@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/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @scenoco-three/editor
|
|
2
|
+
|
|
3
|
+
A local **visual editor + headless renderer** for [SceNoCo](https://github.com/) scenes and
|
|
4
|
+
prefabs, with an **MCP server** so a (multimodal) LLM can open a scene, render frames, drive
|
|
5
|
+
gameplay, and read back telemetry — the "eyes and hands" for an agent that authors scenes
|
|
6
|
+
with `@scenoco-three/compiler`. Humans get a browser editor; agents get MCP tools, over **one
|
|
7
|
+
shared live session**.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i -D @scenoco-three/editor
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## CLI
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
scenoco-editor <scene|prefab> [options]
|
|
17
|
+
--components <dirs> Comma-separated component dirs (default: "src")
|
|
18
|
+
--packages <pkgs> Comma-separated tag packages (e.g. @scenoco-three/box2d)
|
|
19
|
+
--port <n> Fixed editor server port (default: a free port)
|
|
20
|
+
--no-open Don't auto-open a browser
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
scenoco-editor scenes/level.scene.xml --components src --packages @scenoco-three/box2d
|
|
25
|
+
# editor: http://localhost:PORT?file=… ← opens in your browser
|
|
26
|
+
# mcp: http://localhost:PORT/mcp ← add to your MCP client (transport: http)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
It serves the editor app through Vite + `@scenoco-three/vite` (so the open scene compiles
|
|
30
|
+
exactly like production), bridges that browser session to an MCP server over a WebSocket, and
|
|
31
|
+
watches the scene's source dependencies for **live reload** (with an unsaved-changes conflict
|
|
32
|
+
prompt).
|
|
33
|
+
|
|
34
|
+
## MCP tools
|
|
35
|
+
|
|
36
|
+
| Tool | Args | Returns |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `open_file` | `path` | Opens a `.scene.xml` / `.prefab.xml` in edit mode. |
|
|
39
|
+
| `get_preview` | `camera?`, `lookAt?`, `size?` | A **PNG** of the current view. |
|
|
40
|
+
| `playback` | `state` | `play` / `pause` / `stop` — enter play mode (components, systems, physics) / freeze / return to the authored scene. |
|
|
41
|
+
| `get_telemetry` | `id` | **JSON** of current property values for the node + components at a scene `id`. |
|
|
42
|
+
| `simulate` | `commands: SimStep[]` | Ordered **images + JSON** — the workhorse below. |
|
|
43
|
+
|
|
44
|
+
### `simulate` — closed-loop driving
|
|
45
|
+
|
|
46
|
+
One call injects input, steps the sim, and captures — *see a game move, not a static scene*.
|
|
47
|
+
Each command is `{ do, … }`:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
type SimStep =
|
|
51
|
+
| { do: 'play' | 'pause' | 'stop' }
|
|
52
|
+
| { do: 'step'; frames?: number; dt?: number }
|
|
53
|
+
| { do: 'key'; code: string; action: 'down' | 'up' | 'tap' }
|
|
54
|
+
| { do: 'mouse'; x?: number; y?: number; dx?: number; dy?: number;
|
|
55
|
+
button?: 'left' | 'right' | 'middle'; action?: 'move' | 'down' | 'up' | 'click' }
|
|
56
|
+
| { do: 'gamepad'; index?: number; buttons?: boolean[]; axes?: number[] }
|
|
57
|
+
| { do: 'preview'; camera?: string; lookAt?: string }
|
|
58
|
+
| { do: 'telemetry'; id: string };
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```jsonc
|
|
62
|
+
// "press Space, advance 90 frames, read the ball, screenshot" — in one tool call
|
|
63
|
+
[ { "do": "play" },
|
|
64
|
+
{ "do": "key", "code": "Space", "action": "tap" },
|
|
65
|
+
{ "do": "step", "frames": 90 },
|
|
66
|
+
{ "do": "telemetry", "id": "ball" },
|
|
67
|
+
{ "do": "preview" } ]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The result is a `multi` payload: each capture's image/JSON in step order.
|
|
71
|
+
|
|
72
|
+
## Programmatic API
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { startEditorServer, startMcpServer } from '@scenoco-three/editor';
|
|
76
|
+
|
|
77
|
+
const editor = await startEditorServer({ cwd, componentRoots: ['src'], packages: [] });
|
|
78
|
+
const mcp = await startMcpServer(editor.bridge); // editor.bridge.send(EditorCommand)
|
|
79
|
+
console.log(editor.url, mcp.url);
|
|
80
|
+
// … later
|
|
81
|
+
await mcp.close(); await editor.close();
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Also exported: `buildMcpServer`, the WebSocket `protocol` types (`EditorCommand`,
|
|
85
|
+
`EditorResponse`, `EditorResult`, `WS_PATH`, `commandId`).
|
|
86
|
+
|
|
87
|
+
## Standalone & dependencies
|
|
88
|
+
|
|
89
|
+
Build-time / Node-side tool — **not** part of the browser runtime; nothing here ships to
|
|
90
|
+
production. It depends on `@scenoco-three/compiler` and `@scenoco-three/vite` (compile +
|
|
91
|
+
serve) and the MCP SDK; `@scenoco-three/core`, `three`, and `vite` are **peers** (the editor
|
|
92
|
+
renders your scene with the same runtime your app uses). You can run it purely for the **human
|
|
93
|
+
browser UI** without ever touching MCP, or wire **only** the MCP server to an agent — the two
|
|
94
|
+
share one live session but neither requires the other.
|
|
95
|
+
|
|
96
|
+
See the [repository](../../README.md), [ARCHITECTURE.md](../../ARCHITECTURE.md), and the
|
|
97
|
+
[agentic guide](../../agentic/SCENOCO-AGENTIC-GUIDE.md).
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
[MIT](./LICENSE)
|
package/app/cameraRig.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Euler, Quaternion, Vector3, type PerspectiveCamera } from 'three';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Owns the editor camera's orientation (yaw/pitch, roll-free) and exposes simple
|
|
5
|
+
* verbs that both the desktop (mouse/keyboard) and touch input adapters call, so
|
|
6
|
+
* the two never fight over the camera transform. Call `sync()` once per frame.
|
|
7
|
+
*/
|
|
8
|
+
export class CameraRig {
|
|
9
|
+
readonly euler = new Euler(0, 0, 0, 'YXZ');
|
|
10
|
+
/** Base movement speed (world units/sec), tuned by the wheel / scene size. */
|
|
11
|
+
speed: number;
|
|
12
|
+
/**
|
|
13
|
+
* Distance to the focus point (Unity scene-camera pivot). Touch pan/dolly scale
|
|
14
|
+
* by this, so motion stays consistent on screen at any zoom — fine near objects,
|
|
15
|
+
* fast when far. Set via `focusOn`; dolly shrinks/grows it.
|
|
16
|
+
*/
|
|
17
|
+
focusDistance: number;
|
|
18
|
+
|
|
19
|
+
private readonly q = new Quaternion();
|
|
20
|
+
private readonly v = new Vector3();
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
readonly camera: PerspectiveCamera,
|
|
24
|
+
target: Vector3,
|
|
25
|
+
) {
|
|
26
|
+
camera.lookAt(target);
|
|
27
|
+
this.euler.setFromQuaternion(camera.quaternion);
|
|
28
|
+
this.speed = Math.max(1, camera.position.distanceTo(target) * 0.5);
|
|
29
|
+
this.focusDistance = Math.max(0.1, camera.position.distanceTo(target));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Re-pivot on a world point (e.g. the selected node) and reset the focus distance. */
|
|
33
|
+
focusOn(point: Vector3): void {
|
|
34
|
+
this.focusDistance = Math.max(0.1, this.camera.position.distanceTo(point));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Add yaw/pitch in radians (pitch clamped to avoid flipping). */
|
|
38
|
+
rotate(dYaw: number, dPitch: number): void {
|
|
39
|
+
this.euler.y += dYaw;
|
|
40
|
+
this.euler.x = clamp(this.euler.x + dPitch, -Math.PI / 2 + 0.01, Math.PI / 2 - 0.01);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Translate in camera-local space: +x right, +y up, +z forward. */
|
|
44
|
+
move(right: number, up: number, forward: number): void {
|
|
45
|
+
this.v.set(right, up, -forward).applyQuaternion(this.q.setFromEuler(this.euler));
|
|
46
|
+
this.camera.position.add(this.v);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Apply the orientation to the camera. Call once per frame, after input. */
|
|
50
|
+
sync(): void {
|
|
51
|
+
this.camera.quaternion.setFromEuler(this.euler);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clamp(v: number, lo: number, hi: number): number {
|
|
56
|
+
return Math.max(lo, Math.min(hi, v));
|
|
57
|
+
}
|
package/app/commands.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stateful edit primitive. Every mutation in the editor is a `Command` so it
|
|
3
|
+
* can be undone and (because each one also mutates the compiled scene) saved as a
|
|
4
|
+
* diff. Commands are deliberately tiny — `apply` redoes, `invert` undoes.
|
|
5
|
+
*/
|
|
6
|
+
export interface Command {
|
|
7
|
+
/** Human label (undo menu / debugging). */
|
|
8
|
+
readonly label: string;
|
|
9
|
+
apply(): void;
|
|
10
|
+
invert(): void;
|
|
11
|
+
/**
|
|
12
|
+
* Edits sharing a key, applied back-to-back, collapse into a single undo step.
|
|
13
|
+
* A gizmo drag or a numeric scrub emits one command per frame; undo should
|
|
14
|
+
* rewind the whole gesture, not each intermediate value.
|
|
15
|
+
*/
|
|
16
|
+
readonly coalesceKey?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Undo/redo history with same-key coalescing. `onChange` fires after every mutation. */
|
|
20
|
+
export class UndoStack {
|
|
21
|
+
private readonly undoables: Command[] = [];
|
|
22
|
+
private readonly redoables: Command[] = [];
|
|
23
|
+
|
|
24
|
+
constructor(private readonly onChange?: () => void) {}
|
|
25
|
+
|
|
26
|
+
/** Apply a command and record it, merging into the previous one if keys match. */
|
|
27
|
+
run(cmd: Command): void {
|
|
28
|
+
cmd.apply();
|
|
29
|
+
const prev = this.undoables[this.undoables.length - 1];
|
|
30
|
+
if (cmd.coalesceKey !== undefined && prev?.coalesceKey === cmd.coalesceKey) {
|
|
31
|
+
// Adopt the latest `apply` (current value) but keep the earliest `invert`
|
|
32
|
+
// (the pre-gesture state), so one undo rewinds the entire gesture.
|
|
33
|
+
this.undoables[this.undoables.length - 1] = {
|
|
34
|
+
label: prev.label,
|
|
35
|
+
coalesceKey: prev.coalesceKey,
|
|
36
|
+
apply: cmd.apply,
|
|
37
|
+
invert: prev.invert,
|
|
38
|
+
};
|
|
39
|
+
} else {
|
|
40
|
+
this.undoables.push(cmd);
|
|
41
|
+
}
|
|
42
|
+
this.redoables.length = 0;
|
|
43
|
+
this.onChange?.();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
undo(): void {
|
|
47
|
+
const cmd = this.undoables.pop();
|
|
48
|
+
if (!cmd) return;
|
|
49
|
+
cmd.invert();
|
|
50
|
+
this.redoables.push(cmd);
|
|
51
|
+
this.onChange?.();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
redo(): void {
|
|
55
|
+
const cmd = this.redoables.pop();
|
|
56
|
+
if (!cmd) return;
|
|
57
|
+
cmd.apply();
|
|
58
|
+
this.undoables.push(cmd);
|
|
59
|
+
this.onChange?.();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get canUndo(): boolean {
|
|
63
|
+
return this.undoables.length > 0;
|
|
64
|
+
}
|
|
65
|
+
get canRedo(): boolean {
|
|
66
|
+
return this.redoables.length > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
clear(): void {
|
|
70
|
+
this.undoables.length = 0;
|
|
71
|
+
this.redoables.length = 0;
|
|
72
|
+
this.onChange?.();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { InputManager } from '@scenoco-three/core';
|
|
2
|
+
import type { CameraRig } from './cameraRig.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Desktop fly navigation via `engine.input` (Unity scene-view style):
|
|
6
|
+
* • hold RIGHT mouse to look · WASD move · Q/E down/up · Shift faster · wheel speed.
|
|
7
|
+
*/
|
|
8
|
+
export class DesktopControls {
|
|
9
|
+
private readonly look = 0.0025; // radians per pixel
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly rig: CameraRig,
|
|
13
|
+
private readonly input: InputManager,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
/** Advance one frame (call after `engine.tick`). */
|
|
17
|
+
update(dt: number): void {
|
|
18
|
+
const { input, rig } = this;
|
|
19
|
+
|
|
20
|
+
const wheel = input.mouseScrollDelta.y;
|
|
21
|
+
if (wheel !== 0) rig.speed = clamp(rig.speed * (wheel < 0 ? 1.1 : 1 / 1.1), 0.05, 1000);
|
|
22
|
+
|
|
23
|
+
if (input.getMouseButton(1)) {
|
|
24
|
+
const d = input.mouseDelta;
|
|
25
|
+
rig.rotate(-d.x * this.look, -d.y * this.look);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const right = axis(input.getKey('D'), input.getKey('A'));
|
|
29
|
+
const up = axis(input.getKey('E'), input.getKey('Q'));
|
|
30
|
+
const forward = axis(input.getKey('W'), input.getKey('S'));
|
|
31
|
+
if (right || up || forward) {
|
|
32
|
+
const len = Math.hypot(right, up, forward);
|
|
33
|
+
const step = (rig.speed * (input.getKey('LeftShift') ? 4 : 1) * dt) / len;
|
|
34
|
+
rig.move(right * step, up * step, forward * step);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function axis(pos: boolean, neg: boolean): number {
|
|
40
|
+
return (pos ? 1 : 0) - (neg ? 1 : 0);
|
|
41
|
+
}
|
|
42
|
+
function clamp(v: number, lo: number, hi: number): number {
|
|
43
|
+
return Math.max(lo, Math.min(hi, v));
|
|
44
|
+
}
|