@scenoco-three/editor 0.3.2 → 0.5.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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -17
  3. package/app/cameraRig.ts +73 -12
  4. package/app/commands.ts +5 -1
  5. package/app/desktopControls.ts +13 -10
  6. package/app/document.ts +474 -427
  7. package/app/editLog.ts +127 -0
  8. package/app/editor.css +51 -0
  9. package/app/gizmo.ts +100 -6
  10. package/app/index.html +6 -122
  11. package/app/main.ts +488 -293
  12. package/app/meta.ts +24 -0
  13. package/app/picking.ts +47 -0
  14. package/app/session.ts +487 -210
  15. package/app/touchControls.ts +85 -62
  16. package/app/ui-react/AddMemberPopup.tsx +114 -0
  17. package/app/ui-react/BottomLauncher.tsx +15 -0
  18. package/app/ui-react/EditorUI.tsx +22 -0
  19. package/app/ui-react/FilePicker.tsx +80 -0
  20. package/app/ui-react/HeadBtn.tsx +21 -0
  21. package/app/ui-react/InspectorSheet.tsx +475 -0
  22. package/app/ui-react/NodeListSheet.tsx +56 -0
  23. package/app/ui-react/TopBar.tsx +137 -0
  24. package/app/ui-react/Tree.tsx +106 -0
  25. package/app/ui-react/cx.ts +4 -0
  26. package/app/ui-react/fields.tsx +407 -0
  27. package/app/ui-react/format.ts +30 -0
  28. package/app/ui-react/mount.tsx +31 -0
  29. package/app/ui-react/store.ts +80 -0
  30. package/app/ui-react/types.ts +88 -0
  31. package/app/ui-react/ui.module.css +250 -0
  32. package/app/ui-react/useSize.ts +20 -0
  33. package/app/values.ts +137 -0
  34. package/app/vite-env.d.ts +4 -2
  35. package/dist/cli/index.js +93 -70
  36. package/dist/index.d.ts +10 -3
  37. package/dist/index.js +9 -5
  38. package/dist/mcp.d.ts +28 -12
  39. package/dist/mcp.js +172 -131
  40. package/dist/protocol.d.ts +74 -65
  41. package/dist/protocol.js +17 -4
  42. package/dist/server.d.ts +106 -39
  43. package/dist/server.js +387 -324
  44. package/package.json +26 -11
  45. package/app/filePicker.ts +0 -64
  46. package/app/panels.ts +0 -553
  47. package/app/toolbar.ts +0 -131
  48. package/app/wsClient.ts +0 -92
  49. package/dist/args.d.ts +0 -8
  50. package/dist/args.js +0 -28
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SceNoCo contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # @scenoco-three/editor
2
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**.
3
+ A local **visual editor** for [SceNoCo](https://github.com/) scenes and prefabs, with an
4
+ **MCP server** so a (multimodal) LLM can open a scene, render frames, drive gameplay, and
5
+ read back telemetry — the "eyes and hands" for an agent that authors scenes with
6
+ `@scenoco-three/compiler`. Humans and agents share the **same live browser tab**: the MCP
7
+ tools drive it over a WebSocket bridge, there's no separate headless render path.
8
8
 
9
9
  ```bash
10
10
  npm i -D @scenoco-three/editor
@@ -19,7 +19,6 @@ scenoco-editor [scene|prefab] [options] Omit the file to choose from a start
19
19
  dependency in the project's package.json)
20
20
  --port <n> Fixed MCP port — pin it so your MCP client survives restarts
21
21
  (the HTTP editor server always takes a free port and prints its URL)
22
- --no-open Don't auto-open a browser
23
22
  ```
24
23
 
25
24
  ```bash
@@ -39,10 +38,16 @@ prompt).
39
38
  | Tool | Args | Returns |
40
39
  | --- | --- | --- |
41
40
  | `open_file` | `path` | Opens a `.scene.xml` / `.prefab.xml` in edit mode. |
42
- | `get_preview` | `camera?`, `lookAt?`, `size?` | A **PNG** of the current view. |
41
+ | `screenshot` | `camera`, `lookAt?`, `width`, `height`, `fit`, `ui` | A **PNG** of the current view, at exactly the size and camera fit you state, with or without the HUD. |
43
42
  | `playback` | `state` | `play` / `pause` / `stop` — enter play mode (components, systems, physics) / freeze / return to the authored scene. |
44
- | `get_telemetry` | `id` | **JSON** of current property values for the node + components at a scene `id`. |
45
- | `simulate` | `commands: SimStep[]` | Ordered **images + JSON** — the workhorse below. |
43
+ | `inspect` | `id` | **JSON** of current property values at a scene `id` — a **node** (its transform + every component on it) or a **component** (its authored field values + its host node's transform). Nodes and components share one id namespace. |
44
+
45
+ Values read back in the same vocabulary the scene XML is written in: vectors are
46
+ named by axis (`{"x": 0, "y": 1.5, "z": 0}`), eulers are **degrees**, colours are
47
+ `"#rrggbb"`, and a reference is `"#id"`. Points you pass *in* (`camera`, `lookAt`)
48
+ take the DSL's own vector grammar — `"y: 6; z: 12"`, an omitted axis being 0, with
49
+ the positional `"0 6 12"` still accepted.
50
+ | `simulate` | `commands: SimStep[]` + the same `width`/`height`/`fit`/`ui` | Ordered **images + JSON** — the workhorse below. The capture format is stated once for the batch, not per shot. |
46
51
 
47
52
  ### `simulate` — closed-loop driving
48
53
 
@@ -53,23 +58,24 @@ Each command is a self-contained verb `{ do, … }`:
53
58
  type SimStep =
54
59
  | { do: 'play' | 'pause' | 'stop' }
55
60
  | { do: 'step'; frames?: number; dt?: number } // advance `frames` ticks (default 1)
56
- | { do: 'tap'; key: string } // key press + release
61
+ | { do: 'press'; key: string } // key press + release
57
62
  | { do: 'keyDown'; key: string }
58
63
  | { do: 'keyUp'; key: string }
59
64
  | { do: 'click'; x?: number; y?: number; button?: 'left' | 'right' | 'middle' }
60
65
  | { do: 'move'; x?: number; y?: number; dx?: number; dy?: number }
61
66
  | { do: 'gamepad'; index?: number; buttons?: boolean[]; axes?: number[] }
62
- | { do: 'preview'; camera?: string; lookAt?: string }
67
+ | { do: 'preview'; camera: string; lookAt?: string } // size/fit/ui come from the call
63
68
  | { do: 'telemetry'; id: string };
64
69
  ```
65
70
 
66
71
  ```jsonc
67
72
  // "press Space, advance 90 frames, read the ball, screenshot" — in one tool call
68
- [ { "do": "play" },
69
- { "do": "tap", "key": "Space" },
70
- { "do": "step", "frames": 90 },
71
- { "do": "telemetry", "id": "ball" },
72
- { "do": "preview" } ]
73
+ { "width": 960, "height": 540, "fit": "contain", "ui": true,
74
+ "commands": [ { "do": "play" },
75
+ { "do": "press", "key": "Space" },
76
+ { "do": "step", "frames": 90 },
77
+ { "do": "telemetry", "id": "ball" },
78
+ { "do": "preview", "camera": "fit" } ] }
73
79
  ```
74
80
 
75
81
  The result is a `multi` payload: each capture's image/JSON in step order.
@@ -99,7 +105,7 @@ browser UI** without ever touching MCP, or wire **only** the MCP server to an ag
99
105
  share one live session but neither requires the other.
100
106
 
101
107
  See the [repository](../../README.md), [ARCHITECTURE.md](../../ARCHITECTURE.md), and the
102
- [agentic guide](../../agentic/SCENOCO-AGENTIC-GUIDE.md).
108
+ [measurements](../../bench/README.md).
103
109
 
104
110
  ## License
105
111
 
package/app/cameraRig.ts CHANGED
@@ -12,46 +12,107 @@ export class CameraRig {
12
12
  /**
13
13
  * Distance to the focus point (Unity scene-camera pivot). Touch pan/dolly scale
14
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.
15
+ * fast when far. Set by `frameOn`; dolly shrinks/grows it.
16
16
  */
17
17
  focusDistance: number;
18
18
 
19
19
  private readonly q = new Quaternion();
20
20
  private readonly v = new Vector3();
21
21
 
22
- constructor(
23
- readonly camera: PerspectiveCamera,
24
- target: Vector3,
25
- ) {
22
+ /** Where a focus glide is taking the camera, while one is running. */
23
+ private glideTo: Vector3 | null = null;
24
+
25
+ readonly camera: PerspectiveCamera;
26
+
27
+ constructor(camera: PerspectiveCamera, target: Vector3) {
28
+ this.camera = camera;
26
29
  camera.lookAt(target);
27
30
  this.euler.setFromQuaternion(camera.quaternion);
28
31
  this.speed = Math.max(1, camera.position.distanceTo(target) * 0.5);
29
32
  this.focusDistance = Math.max(0.1, camera.position.distanceTo(target));
30
33
  }
31
34
 
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
+ * Frame a point: keep looking exactly the way you are, and move until it is centred
37
+ * `distance` away.
38
+ *
39
+ * This is Unity's F, and the angle is the whole of it. Turning to face the selection instead
40
+ * throws away the view the person spent their time arranging — they lined the scene up from
41
+ * one side, pressed F to get closer to a node, and now they are looking at it from wherever
42
+ * the pivot maths decided. Keeping the orientation means F only ever answers "put that in
43
+ * front of me", which is the question it was asked.
44
+ *
45
+ * The camera glides rather than teleports: a cut leaves you working out where you ended up,
46
+ * and the motion is what tells you the scene did not change — only your seat.
47
+ */
48
+ frameOn(point: Vector3, distance: number): void {
49
+ this.focusDistance = Math.max(0.1, distance);
50
+ // Straight back along the current view direction — `-Z` in camera space, which is where the
51
+ // camera already looks, so nothing about the orientation moves.
52
+ this.glideTo = point.clone().addScaledVector(this.forward(this.v), -this.focusDistance);
53
+ this.speed = Math.max(1, this.focusDistance * 0.5); // fly speed scales with how far out we sit
54
+ }
55
+
56
+ /** The unit vector the camera currently looks along. */
57
+ private forward(into: Vector3): Vector3 {
58
+ return into.set(0, 0, -1).applyQuaternion(this.q.setFromEuler(this.euler));
35
59
  }
36
60
 
37
61
  /** Add yaw/pitch in radians (pitch clamped to avoid flipping). */
38
62
  rotate(dYaw: number, dPitch: number): void {
63
+ this.glideTo = null; // the person is driving now
39
64
  this.euler.y += dYaw;
40
65
  this.euler.x = clamp(this.euler.x + dPitch, -Math.PI / 2 + 0.01, Math.PI / 2 - 0.01);
41
66
  }
42
67
 
43
- /** Translate in camera-local space: +x right, +y up, +z forward. */
68
+ /**
69
+ * Translate: `right` and `forward` in camera-local space, `up` along **world** up.
70
+ *
71
+ * Local up is the tempting reading and the wrong one. With the camera pitched down at the
72
+ * scene — which is where an editor camera spends its life — a local "up" also carries you
73
+ * backwards, so Q and E slide the view around instead of raising and lowering it. Unity's scene
74
+ * camera uses world up for the same reason.
75
+ */
44
76
  move(right: number, up: number, forward: number): void {
45
- this.v.set(right, up, -forward).applyQuaternion(this.q.setFromEuler(this.euler));
77
+ this.glideTo = null; // any input cancels a focus glide — the camera has one owner at a time
78
+ this.v.set(right, 0, -forward).applyQuaternion(this.q.setFromEuler(this.euler));
79
+ this.v.y += up;
46
80
  this.camera.position.add(this.v);
47
81
  }
48
82
 
49
- /** Apply the orientation to the camera. Call once per frame, after input. */
50
- sync(): void {
83
+ /**
84
+ * Apply the orientation, and advance a focus glide. Call once per frame, after input.
85
+ *
86
+ * Frame-rate independent easing: the *fraction of the remaining distance* covered per second
87
+ * is fixed, so the same glide takes the same time at 60Hz and at 144Hz. `1 - k ** dt` is the
88
+ * exponential form of that — the naive `lerp(a, b, 0.1)` per frame is twice as fast on a
89
+ * 120Hz display, which is how a smooth camera on one machine becomes a snap on another.
90
+ */
91
+ sync(dt = 0): void {
51
92
  this.camera.quaternion.setFromEuler(this.euler);
93
+ if (!this.glideTo) return;
94
+ const remaining = this.v.subVectors(this.glideTo, this.camera.position);
95
+ // Close enough that another frame of easing would be invisible: land exactly and stop, so
96
+ // the camera is never left a hair off with a live tween attached to it.
97
+ if (remaining.lengthSq() < 1e-8) {
98
+ this.camera.position.copy(this.glideTo);
99
+ this.glideTo = null;
100
+ return;
101
+ }
102
+ this.camera.position.addScaledVector(remaining, 1 - GLIDE_LEFT_PER_SECOND ** dt);
52
103
  }
53
104
  }
54
105
 
106
+ /**
107
+ * The fraction of the distance to the focus target still left after one second.
108
+ *
109
+ * Measured rather than guessed: at 1e-6 the camera is 94% of the way there in 0.2s and 98.5% in
110
+ * 0.3s, so the move reads as quick and decisive and is visually over in about a third of a
111
+ * second. Ten times gentler (1e-5) drifts long enough to feel laggy on a phone; ten times
112
+ * sharper is indistinguishable from a cut.
113
+ */
114
+ const GLIDE_LEFT_PER_SECOND = 1e-6;
115
+
55
116
  function clamp(v: number, lo: number, hi: number): number {
56
117
  return Math.max(lo, Math.min(hi, v));
57
118
  }
package/app/commands.ts CHANGED
@@ -21,7 +21,11 @@ export class UndoStack {
21
21
  private readonly undoables: Command[] = [];
22
22
  private readonly redoables: Command[] = [];
23
23
 
24
- constructor(private readonly onChange?: () => void) {}
24
+ private readonly onChange: (() => void) | undefined;
25
+
26
+ constructor(onChange?: () => void) {
27
+ this.onChange = onChange;
28
+ }
25
29
 
26
30
  /** Apply a command and record it, merging into the previous one if keys match. */
27
31
  run(cmd: Command): void {
@@ -1,4 +1,4 @@
1
- import type { InputManager } from '@scenoco-three/core';
1
+ import { Input, KeyCode, MouseButton } from '@scenoco-three/core';
2
2
  import type { CameraRig } from './cameraRig.js';
3
3
 
4
4
  /**
@@ -8,10 +8,13 @@ import type { CameraRig } from './cameraRig.js';
8
8
  export class DesktopControls {
9
9
  private readonly look = 0.0025; // radians per pixel
10
10
 
11
- constructor(
12
- private readonly rig: CameraRig,
13
- private readonly input: InputManager,
14
- ) {}
11
+ private readonly rig: CameraRig;
12
+ /** v2's input is one singleton attached to the canvas, not a per-engine manager. */
13
+ private readonly input = Input;
14
+
15
+ constructor(rig: CameraRig) {
16
+ this.rig = rig;
17
+ }
15
18
 
16
19
  /** Advance one frame (call after `engine.tick`). */
17
20
  update(dt: number): void {
@@ -20,17 +23,17 @@ export class DesktopControls {
20
23
  const wheel = input.mouseScrollDelta.y;
21
24
  if (wheel !== 0) rig.speed = clamp(rig.speed * (wheel < 0 ? 1.1 : 1 / 1.1), 0.05, 1000);
22
25
 
23
- if (input.getMouseButton(1)) {
26
+ if (input.getMouseButton(MouseButton.Right)) {
24
27
  const d = input.mouseDelta;
25
28
  rig.rotate(-d.x * this.look, -d.y * this.look);
26
29
  }
27
30
 
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
+ const right = axis(input.getKey(KeyCode.D), input.getKey(KeyCode.A));
32
+ const up = axis(input.getKey(KeyCode.E), input.getKey(KeyCode.Q));
33
+ const forward = axis(input.getKey(KeyCode.W), input.getKey(KeyCode.S));
31
34
  if (right || up || forward) {
32
35
  const len = Math.hypot(right, up, forward);
33
- const step = (rig.speed * (input.getKey('LeftShift') ? 4 : 1) * dt) / len;
36
+ const step = (rig.speed * (input.getKey(KeyCode.LeftShift) ? 4 : 1) * dt) / len;
34
37
  rig.move(right * step, up * step, forward * step);
35
38
  }
36
39
  }