@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.
- package/README.md +31 -106
- package/bin/serve.js +37 -0
- package/dist/editor.js +12521 -0
- package/package.json +29 -41
- package/app/cameraRig.ts +0 -118
- package/app/commands.ts +0 -78
- package/app/desktopControls.ts +0 -47
- package/app/document.ts +0 -547
- package/app/editLog.ts +0 -127
- package/app/editor.css +0 -51
- package/app/gizmo.ts +0 -169
- package/app/globals.d.ts +0 -9
- package/app/index.html +0 -14
- package/app/main.ts +0 -546
- package/app/meta.ts +0 -24
- package/app/picking.ts +0 -47
- package/app/session.ts +0 -612
- package/app/touchControls.ts +0 -127
- package/app/ui-react/AddMemberPopup.tsx +0 -114
- package/app/ui-react/BottomLauncher.tsx +0 -15
- package/app/ui-react/EditorUI.tsx +0 -22
- package/app/ui-react/FilePicker.tsx +0 -80
- package/app/ui-react/HeadBtn.tsx +0 -21
- package/app/ui-react/InspectorSheet.tsx +0 -475
- package/app/ui-react/NodeListSheet.tsx +0 -56
- package/app/ui-react/TopBar.tsx +0 -137
- package/app/ui-react/Tree.tsx +0 -106
- package/app/ui-react/cx.ts +0 -4
- package/app/ui-react/fields.tsx +0 -407
- package/app/ui-react/format.ts +0 -30
- package/app/ui-react/mount.tsx +0 -31
- package/app/ui-react/store.ts +0 -80
- package/app/ui-react/types.ts +0 -88
- package/app/ui-react/ui.module.css +0 -250
- package/app/ui-react/useSize.ts +0 -20
- package/app/values.ts +0 -137
- package/app/vite-env.d.ts +0 -7
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -122
- package/dist/index.d.ts +0 -10
- package/dist/index.js +0 -10
- package/dist/mcp.d.ts +0 -32
- package/dist/mcp.js +0 -191
- package/dist/protocol.d.ts +0 -170
- package/dist/protocol.js +0 -22
- package/dist/server.d.ts +0 -124
- package/dist/server.js +0 -440
package/app/ui-react/types.ts
DELETED
|
@@ -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; }
|
package/app/ui-react/useSize.ts
DELETED
|
@@ -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';
|
package/dist/cli/index.d.ts
DELETED
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';
|