@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 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)
@@ -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
+ }
@@ -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
+ }