@scenoco-three/editor 0.4.0 → 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.
- package/LICENSE +21 -0
- package/README.md +23 -17
- package/app/cameraRig.ts +73 -12
- package/app/commands.ts +5 -1
- package/app/desktopControls.ts +13 -10
- package/app/document.ts +474 -427
- package/app/editLog.ts +127 -0
- package/app/editor.css +51 -0
- package/app/gizmo.ts +100 -6
- package/app/index.html +6 -122
- package/app/main.ts +488 -304
- package/app/meta.ts +24 -0
- package/app/picking.ts +47 -0
- package/app/session.ts +476 -311
- package/app/touchControls.ts +85 -62
- package/app/ui-react/AddMemberPopup.tsx +114 -0
- package/app/ui-react/BottomLauncher.tsx +15 -0
- package/app/ui-react/EditorUI.tsx +22 -0
- package/app/ui-react/FilePicker.tsx +80 -0
- package/app/ui-react/HeadBtn.tsx +21 -0
- package/app/ui-react/InspectorSheet.tsx +475 -0
- package/app/ui-react/NodeListSheet.tsx +56 -0
- package/app/ui-react/TopBar.tsx +137 -0
- package/app/ui-react/Tree.tsx +106 -0
- package/app/ui-react/cx.ts +4 -0
- package/app/ui-react/fields.tsx +407 -0
- package/app/ui-react/format.ts +30 -0
- package/app/ui-react/mount.tsx +31 -0
- package/app/ui-react/store.ts +80 -0
- package/app/ui-react/types.ts +88 -0
- package/app/ui-react/ui.module.css +250 -0
- package/app/ui-react/useSize.ts +20 -0
- package/app/values.ts +137 -0
- package/dist/cli/index.js +93 -70
- package/dist/index.d.ts +10 -3
- package/dist/index.js +9 -5
- package/dist/mcp.d.ts +28 -12
- package/dist/mcp.js +172 -131
- package/dist/protocol.d.ts +74 -65
- package/dist/protocol.js +17 -4
- package/dist/server.d.ts +106 -39
- package/dist/server.js +386 -361
- package/package.json +25 -10
- package/app/filePicker.ts +0 -64
- package/app/panels.ts +0 -553
- package/app/toolbar.ts +0 -131
- package/app/wsClient.ts +0 -92
- package/dist/args.d.ts +0 -8
- 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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
45
|
-
|
|
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: '
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
[
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
/**
|
|
50
|
-
|
|
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
|
-
|
|
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 {
|
package/app/desktopControls.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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(
|
|
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(
|
|
29
|
-
const up = axis(input.getKey(
|
|
30
|
-
const forward = axis(input.getKey(
|
|
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(
|
|
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
|
}
|