@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/README.md +101 -0
- package/app/cameraRig.ts +57 -0
- package/app/commands.ts +74 -0
- package/app/desktopControls.ts +44 -0
- package/app/document.ts +500 -0
- package/app/gizmo.ts +75 -0
- package/app/index.html +103 -0
- package/app/main.ts +302 -0
- package/app/panels.ts +553 -0
- package/app/session.ts +294 -0
- package/app/toolbar.ts +131 -0
- package/app/touchControls.ts +104 -0
- package/app/vite-env.d.ts +5 -0
- package/app/wsClient.ts +66 -0
- package/dist/args.d.ts +8 -0
- package/dist/args.js +28 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +74 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.js +150 -0
- package/dist/protocol.d.ts +157 -0
- package/dist/protocol.js +9 -0
- package/dist/server.d.ts +44 -0
- package/dist/server.js +264 -0
- package/package.json +53 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createServer as createNetServer } from 'node:net';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import chokidar from 'chokidar';
|
|
6
|
+
import { createServer } from 'vite';
|
|
7
|
+
import { WebSocketServer } from 'ws';
|
|
8
|
+
import { bundlePrefab, bundleScene, compilePrefabXml, compileSceneXml, loadComponents, patchSceneXml, scanComponents, } from '@scenoco-three/compiler';
|
|
9
|
+
import { WS_PATH } from './protocol.js';
|
|
10
|
+
const appDir = fileURLToPath(new URL('../app', import.meta.url));
|
|
11
|
+
/** Loads `src=` resources from disk, relative to the referencing file. */
|
|
12
|
+
const fsResolver = (request, referrer) => {
|
|
13
|
+
const path = resolve(referrer ? dirname(referrer) : '.', request);
|
|
14
|
+
return existsSync(path) ? { path, contents: readFileSync(path, 'utf8') } : null;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Start the editor's Vite dev server: serves the app, registers all project
|
|
18
|
+
* components/packages, compiles any scene/prefab on demand, and bridges the page
|
|
19
|
+
* to the MCP server over a WebSocket.
|
|
20
|
+
*/
|
|
21
|
+
export async function startEditorServer(opts = {}) {
|
|
22
|
+
const componentRoots = opts.componentRoots ?? ['src'];
|
|
23
|
+
const packages = opts.packages ?? [];
|
|
24
|
+
const port = opts.port ?? (await freePort());
|
|
25
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
26
|
+
const watcher = createFileWatcher();
|
|
27
|
+
const server = await createServer({
|
|
28
|
+
configFile: false,
|
|
29
|
+
root: appDir,
|
|
30
|
+
logLevel: 'warn',
|
|
31
|
+
server: { host: true, port, strictPort: true, fs: { strict: false } },
|
|
32
|
+
plugins: [allComponentsPlugin(componentRoots, packages, cwd), bundleEndpointPlugin(componentRoots, packages, cwd, watcher)],
|
|
33
|
+
});
|
|
34
|
+
await server.listen();
|
|
35
|
+
const bridge = attachBridge(server);
|
|
36
|
+
watcher.setPush((msg) => bridge.push(msg));
|
|
37
|
+
const url = server.resolvedUrls?.local[0] ?? `http://localhost:${port}/`;
|
|
38
|
+
const networkUrl = server.resolvedUrls?.network[0];
|
|
39
|
+
return {
|
|
40
|
+
url,
|
|
41
|
+
...(networkUrl ? { networkUrl } : {}),
|
|
42
|
+
bridge,
|
|
43
|
+
close: async () => {
|
|
44
|
+
await watcher.close();
|
|
45
|
+
await server.close();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A push-based file watcher: tracks the open file plus its `src=` dependencies,
|
|
51
|
+
* and notifies the editor when one changes on disk. Changes whose content matches
|
|
52
|
+
* what we last compiled/wrote are ignored — so the editor's own save (and no-op
|
|
53
|
+
* touches) don't masquerade as external edits.
|
|
54
|
+
*/
|
|
55
|
+
function createFileWatcher() {
|
|
56
|
+
const w = chokidar.watch([], { ignoreInitial: true, awaitWriteFinish: true });
|
|
57
|
+
const content = new Map(); // abs path → last-known content
|
|
58
|
+
let openFile = '';
|
|
59
|
+
let push = () => { };
|
|
60
|
+
const onChange = (changed) => {
|
|
61
|
+
const abs = resolve(changed);
|
|
62
|
+
if (!content.has(abs))
|
|
63
|
+
return; // not in our set
|
|
64
|
+
let text;
|
|
65
|
+
try {
|
|
66
|
+
text = readFileSync(abs, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return; // deleted mid-event; ignore
|
|
70
|
+
}
|
|
71
|
+
if (text === content.get(abs))
|
|
72
|
+
return; // our own write or a no-op touch
|
|
73
|
+
content.set(abs, text);
|
|
74
|
+
push({ push: 'fileChanged', file: abs, role: abs === openFile ? 'open' : 'dep' });
|
|
75
|
+
};
|
|
76
|
+
w.on('change', onChange);
|
|
77
|
+
w.on('add', onChange);
|
|
78
|
+
return {
|
|
79
|
+
arm(file, openContents, deps) {
|
|
80
|
+
const abs = resolve(file);
|
|
81
|
+
const next = new Map([[abs, openContents]]);
|
|
82
|
+
for (const d of deps)
|
|
83
|
+
next.set(resolve(d.path), d.contents);
|
|
84
|
+
for (const old of content.keys())
|
|
85
|
+
if (!next.has(old))
|
|
86
|
+
w.unwatch(old);
|
|
87
|
+
for (const p of next.keys())
|
|
88
|
+
if (!content.has(p))
|
|
89
|
+
w.add(p);
|
|
90
|
+
content.clear();
|
|
91
|
+
for (const [k, v] of next)
|
|
92
|
+
content.set(k, v);
|
|
93
|
+
openFile = abs;
|
|
94
|
+
},
|
|
95
|
+
note(file, contents) {
|
|
96
|
+
content.set(resolve(file), contents);
|
|
97
|
+
},
|
|
98
|
+
setPush(fn) {
|
|
99
|
+
push = fn;
|
|
100
|
+
},
|
|
101
|
+
close: () => w.close(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** `virtual:scenoco-components` → import every project component + package + built-in nodes. */
|
|
105
|
+
function allComponentsPlugin(roots, packages, cwd) {
|
|
106
|
+
const id = 'virtual:scenoco-components';
|
|
107
|
+
const resolved = '\0' + id;
|
|
108
|
+
return {
|
|
109
|
+
name: 'scenoco:all-components',
|
|
110
|
+
resolveId: (source) => (source === id ? resolved : null),
|
|
111
|
+
load(source) {
|
|
112
|
+
if (source !== resolved)
|
|
113
|
+
return null;
|
|
114
|
+
const files = [...scanComponents(roots, cwd).files.keys()].map((f) => `import ${JSON.stringify(`/@fs${f}`)};`);
|
|
115
|
+
const pkgs = packages.map((p) => `import ${JSON.stringify(p)};`);
|
|
116
|
+
return ['import "@scenoco-three/core/nodes";', ...pkgs, ...files].join('\n');
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** `GET /@scenoco/bundle?file=<abs>` → the compiled runtime bundle (JSON). */
|
|
121
|
+
function bundleEndpointPlugin(roots, packages, cwd, watcher) {
|
|
122
|
+
return {
|
|
123
|
+
name: 'scenoco:bundle-endpoint',
|
|
124
|
+
async configureServer(server) {
|
|
125
|
+
// Register components in this Node process so compilation/validation works.
|
|
126
|
+
await loadComponents(roots, cwd, { packages }).catch(() => { });
|
|
127
|
+
// POST /@scenoco/save — surgical, attribute-level write-back to the source.
|
|
128
|
+
server.middlewares.use((req, res, next) => {
|
|
129
|
+
if (req.method !== 'POST' || !req.url || !req.url.startsWith('/@scenoco/save'))
|
|
130
|
+
return next();
|
|
131
|
+
void readJsonBody(req)
|
|
132
|
+
.then((body) => {
|
|
133
|
+
const { file, edits } = (body ?? {});
|
|
134
|
+
if (!file || !Array.isArray(edits))
|
|
135
|
+
throw new Error('expected { file, edits }');
|
|
136
|
+
const abs = resolve(cwd, file);
|
|
137
|
+
const { text, changed, unresolved } = patchSceneXml(readFileSync(abs, 'utf8'), edits);
|
|
138
|
+
if (changed > 0)
|
|
139
|
+
writeFileSync(abs, text);
|
|
140
|
+
watcher.note(abs, text); // our own write — don't report it back as an external change
|
|
141
|
+
res.setHeader('Content-Type', 'application/json');
|
|
142
|
+
res.end(JSON.stringify({ ok: true, changed, unresolved: unresolved.length }));
|
|
143
|
+
})
|
|
144
|
+
.catch((e) => {
|
|
145
|
+
res.statusCode = 400;
|
|
146
|
+
res.setHeader('Content-Type', 'application/json');
|
|
147
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
server.middlewares.use((req, res, next) => {
|
|
151
|
+
if (!req.url || !req.url.startsWith('/@scenoco/bundle'))
|
|
152
|
+
return next();
|
|
153
|
+
try {
|
|
154
|
+
const file = new URL(req.url, 'http://x').searchParams.get('file');
|
|
155
|
+
if (!file)
|
|
156
|
+
throw new Error('missing ?file=');
|
|
157
|
+
const abs = resolve(cwd, file);
|
|
158
|
+
const xml = readFileSync(abs, 'utf8');
|
|
159
|
+
const isPrefab = abs.endsWith('.prefab.xml');
|
|
160
|
+
// Track which src= files the compile touches, so we can watch them too.
|
|
161
|
+
const deps = [];
|
|
162
|
+
const trackingResolver = (request, referrer) => {
|
|
163
|
+
const r = fsResolver(request, referrer);
|
|
164
|
+
if (r)
|
|
165
|
+
deps.push({ path: r.path, contents: r.contents });
|
|
166
|
+
return r;
|
|
167
|
+
};
|
|
168
|
+
const result = isPrefab
|
|
169
|
+
? compilePrefabXml(xml, { path: abs, resolve: trackingResolver })
|
|
170
|
+
: compileSceneXml(xml, { path: abs, resolve: trackingResolver });
|
|
171
|
+
if (!result.ok) {
|
|
172
|
+
res.statusCode = 400;
|
|
173
|
+
res.setHeader('Content-Type', 'application/json');
|
|
174
|
+
res.end(JSON.stringify({ error: 'compile failed', diagnostics: result.diagnostics }));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
watcher.arm(abs, xml, deps); // watch this file + its src deps for changes
|
|
178
|
+
const bundle = isPrefab ? bundlePrefab(result.scene) : bundleScene(result.scene);
|
|
179
|
+
res.setHeader('Content-Type', 'application/json');
|
|
180
|
+
res.end(JSON.stringify({ bundle, provenance: result.provenance }));
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
res.statusCode = 400;
|
|
184
|
+
res.setHeader('Content-Type', 'application/json');
|
|
185
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** WebSocket bridge: one editor page ↔ the MCP server, with id-correlated replies. */
|
|
192
|
+
function attachBridge(server) {
|
|
193
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
194
|
+
let editor;
|
|
195
|
+
const pending = new Map();
|
|
196
|
+
server.httpServer?.on('upgrade', (req, socket, head) => {
|
|
197
|
+
// Only our path; Vite's HMR upgrades (vite-hmr subprotocol) fall through to it.
|
|
198
|
+
if (!req.url || !req.url.startsWith(WS_PATH))
|
|
199
|
+
return;
|
|
200
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
201
|
+
editor = ws;
|
|
202
|
+
ws.on('message', (data) => {
|
|
203
|
+
try {
|
|
204
|
+
const r = JSON.parse(String(data));
|
|
205
|
+
pending.get(r.id)?.(r);
|
|
206
|
+
pending.delete(r.id);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
/* ignore malformed */
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
ws.on('close', () => {
|
|
213
|
+
if (editor === ws)
|
|
214
|
+
editor = undefined;
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
connected: () => !!editor && editor.readyState === editor.OPEN,
|
|
220
|
+
send: (cmd) => new Promise((res, rej) => {
|
|
221
|
+
if (!editor || editor.readyState !== editor.OPEN) {
|
|
222
|
+
rej(new Error('no editor connected — open the editor page in a browser first'));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
pending.set(cmd.id, res);
|
|
226
|
+
editor.send(JSON.stringify(cmd));
|
|
227
|
+
setTimeout(() => {
|
|
228
|
+
if (pending.delete(cmd.id))
|
|
229
|
+
rej(new Error('editor did not respond in time'));
|
|
230
|
+
}, 15000);
|
|
231
|
+
}),
|
|
232
|
+
push: (msg) => {
|
|
233
|
+
if (editor && editor.readyState === editor.OPEN)
|
|
234
|
+
editor.send(JSON.stringify(msg));
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/** Read a request's JSON body (for the save endpoint). */
|
|
239
|
+
function readJsonBody(req) {
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
let data = '';
|
|
242
|
+
req.on('data', (c) => (data += c));
|
|
243
|
+
req.on('end', () => {
|
|
244
|
+
try {
|
|
245
|
+
resolve(data ? JSON.parse(data) : undefined);
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
req.on('error', reject);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/** Ask the OS for a free TCP port (avoids Vite's 5173-default collisions). */
|
|
255
|
+
function freePort() {
|
|
256
|
+
return new Promise((res, rej) => {
|
|
257
|
+
const probe = createNetServer();
|
|
258
|
+
probe.on('error', rej);
|
|
259
|
+
probe.listen(0, () => {
|
|
260
|
+
const port = probe.address().port;
|
|
261
|
+
probe.close(() => res(port));
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scenoco-three/editor",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Local preview + headless frame render for SceNoCo scenes/prefabs (for multimodal LLMs)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"scenoco-editor": "./dist/cli/index.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"app"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"three",
|
|
24
|
+
"threejs",
|
|
25
|
+
"scenoco",
|
|
26
|
+
"editor",
|
|
27
|
+
"preview",
|
|
28
|
+
"render",
|
|
29
|
+
"gamedev"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"typecheck": "npm --prefix ../core run build && npm --prefix ../compiler run build && npm --prefix ../vite run build && tsc --noEmit -p tsconfig.json"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
40
|
+
"@scenoco-three/compiler": "^0.2.0",
|
|
41
|
+
"@scenoco-three/vite": "^0.2.0",
|
|
42
|
+
"chokidar": "^4.0.3",
|
|
43
|
+
"ws": "^8.18.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@scenoco-three/core": "^0.2.0",
|
|
47
|
+
"three": ">=0.160",
|
|
48
|
+
"vite": ">=5"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/ws": "^8.5.13"
|
|
52
|
+
}
|
|
53
|
+
}
|