@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/app/session.ts ADDED
@@ -0,0 +1,294 @@
1
+ import {
2
+ AmbientLight, Box3, Color, DirectionalLight, PerspectiveCamera, Sphere, Vector3, type Object3D,
3
+ } from 'three';
4
+ import { Engine, Registry, bundleKind, type Bundle, type Component } from '@scenoco-three/core';
5
+ import type { SceneProvenance } from '@scenoco-three/compiler';
6
+ import type { EditOp, EditorResult, SimStep } from '../src/protocol.js';
7
+
8
+ export type EditorMode = 'edit' | 'play';
9
+
10
+ /**
11
+ * Owns the engine and the edit ⇄ play lifecycle. In **edit** mode the scene shows
12
+ * its authored pose through an editor-controlled camera (Engine `editor:true`); in
13
+ * **play** mode a fresh `editor:false` engine runs the real gameplay through the
14
+ * scene's own camera. The engine is recreated on each switch.
15
+ */
16
+ export class EditorSession {
17
+ engine: Engine;
18
+ mode: EditorMode = 'edit';
19
+ playing = false;
20
+ /** The editor camera (edit mode only). */
21
+ editorCamera?: PerspectiveCamera;
22
+ /** Fired after the engine/scene changes, so the host rebinds camera + panels. */
23
+ onChange?: () => void;
24
+ /** Fired when a watched file changes on disk (the open file or a `src=` dep). */
25
+ onExternalChange?: (file: string, role: 'open' | 'dep') => void;
26
+
27
+ private bundle?: Bundle;
28
+ /** Source path of the open scene/prefab — the target of `save`. */
29
+ filePath?: string;
30
+ /** Editor provenance for the open file (prefab/material origins), for the document. */
31
+ provenance?: SceneProvenance;
32
+
33
+ constructor(private readonly canvas: HTMLCanvasElement) {
34
+ this.engine = this.makeEngine(true);
35
+ }
36
+
37
+ private makeEngine(editor: boolean): Engine {
38
+ return new Engine({ canvas: this.canvas, autoStart: false, editor, preserveDrawingBuffer: true });
39
+ }
40
+
41
+ /** Fetch + load a scene/prefab (edit mode). Returns a small summary. */
42
+ async open(path: string): Promise<{ file: string; kind: 'scene' | 'prefab'; nodes: number }> {
43
+ const res = await fetch(`/@scenoco/bundle?file=${encodeURIComponent(path)}`);
44
+ const data = (await res.json()) as { bundle: Bundle; provenance: SceneProvenance } | { error: string };
45
+ if (!res.ok || !('bundle' in data) || !Array.isArray(data.bundle)) {
46
+ throw new Error((data as { error?: string }).error ?? 'failed to open file');
47
+ }
48
+ this.bundle = data.bundle;
49
+ this.provenance = data.provenance;
50
+ this.filePath = path;
51
+ this.toEdit();
52
+ return { file: path, kind: bundleKind(this.bundle) === 'prefab' ? 'prefab' : 'scene', nodes: count(this.engine.scene) };
53
+ }
54
+
55
+ /** Write a set of attribute edits back to the source file (surgical diff). */
56
+ async save(edits: EditOp[]): Promise<{ changed: number; unresolved: number }> {
57
+ if (!this.filePath) throw new Error('no file open to save');
58
+ const res = await fetch('/@scenoco/save', {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({ file: this.filePath, edits }),
62
+ });
63
+ const data = (await res.json()) as { changed: number; unresolved: number; error?: string };
64
+ if (!res.ok) throw new Error(data.error ?? 'save failed');
65
+ return data;
66
+ }
67
+
68
+ /** Start (or resume) gameplay. */
69
+ run(): void {
70
+ if (this.mode === 'play') {
71
+ this.playing = true; // resume from pause
72
+ return;
73
+ }
74
+ this.swap(false);
75
+ this.playing = true;
76
+ this.onChange?.();
77
+ }
78
+ pause(): void {
79
+ this.playing = false;
80
+ }
81
+ /** Advance gameplay by a single frame while paused (no-op outside play mode). */
82
+ step(dt = 1 / 60): void {
83
+ if (this.mode !== 'play') return;
84
+ this.engine.tick(dt); // bypass the play+paused gate in tick()
85
+ }
86
+ stop(): void {
87
+ this.toEdit();
88
+ }
89
+
90
+ /** Advance one frame: edit always renders; play advances only while playing. */
91
+ tick(dt: number): void {
92
+ this.engine.tick(this.mode === 'play' && !this.playing ? 0 : dt);
93
+ }
94
+
95
+ /** Capture the current view as a PNG data URL (optionally re-framing the editor camera). */
96
+ preview(opts: { camera?: string; lookAt?: string }): string {
97
+ if (this.mode === 'edit' && this.editorCamera) {
98
+ const cam = vec3(opts.camera);
99
+ const look = vec3(opts.lookAt);
100
+ if (cam) this.editorCamera.position.copy(cam);
101
+ if (look) this.editorCamera.lookAt(look);
102
+ }
103
+ this.engine.tick(0); // render the current frame
104
+ return this.canvas.toDataURL('image/png');
105
+ }
106
+
107
+ /** Current property values for the node + its components at `id`, as plain JSON. */
108
+ telemetry(id: string): unknown {
109
+ const node = this.engine.findObject(id);
110
+ if (!node) return { error: `no object with id "${id}"` };
111
+ const components: Record<string, Record<string, unknown>> = {};
112
+ for (const c of this.engine.getComponents(node)) {
113
+ // Surface the component's scene id (if any), alongside its @property values.
114
+ components[componentName(c) ?? c.constructor.name] = { ...(c.id !== undefined ? { id: c.id } : {}), ...propValues(c) };
115
+ }
116
+ return {
117
+ id,
118
+ type: node.type,
119
+ transform: {
120
+ position: arr(node.position),
121
+ rotation: degArr(node.rotation),
122
+ scale: arr(node.scale),
123
+ visible: node.visible,
124
+ },
125
+ components,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Execute a batch of player-simulation steps in order against the live engine —
131
+ * transport, frame stepping, synthetic key/mouse/gamepad input, and preview/
132
+ * telemetry captures. Runs synchronously (no real-time loop interleaves), so an
133
+ * agent can drive the game to a precise condition and sample it there. Injected
134
+ * input is committed on the next `step`, so the usual order is input → step →
135
+ * preview/telemetry. Returns the captures in step order.
136
+ */
137
+ simulate(steps: SimStep[]): EditorResult[] {
138
+ const out: EditorResult[] = [];
139
+ for (const step of steps) {
140
+ const input = this.engine.input;
141
+ switch (step.do) {
142
+ case 'play':
143
+ this.run();
144
+ break;
145
+ case 'pause':
146
+ this.pause();
147
+ break;
148
+ case 'stop':
149
+ this.stop();
150
+ break;
151
+ case 'step': {
152
+ const frames = Math.max(0, Math.floor(step.frames ?? 1));
153
+ const dt = step.dt ?? 1 / 60;
154
+ for (let i = 0; i < frames; i++) this.engine.tick(dt);
155
+ break;
156
+ }
157
+ case 'tap':
158
+ input.injectKey(step.key, true);
159
+ input.injectKey(step.key, false);
160
+ break;
161
+ case 'keyDown':
162
+ input.injectKey(step.key, true);
163
+ break;
164
+ case 'keyUp':
165
+ input.injectKey(step.key, false);
166
+ break;
167
+ case 'click': {
168
+ if (step.x !== undefined && step.y !== undefined) input.injectMouseMove(step.x, step.y);
169
+ const b = step.button === 'right' ? 1 : step.button === 'middle' ? 2 : 0;
170
+ input.injectMouseButton(b, true);
171
+ input.injectMouseButton(b, false);
172
+ break;
173
+ }
174
+ case 'move':
175
+ input.injectMouseMove(step.x ?? 0, step.y ?? 0, step.dx ?? 0, step.dy ?? 0);
176
+ break;
177
+ case 'gamepad':
178
+ input.injectGamepad(step.index ?? 0, { ...(step.buttons ? { buttons: step.buttons } : {}), ...(step.axes ? { axes: step.axes } : {}) });
179
+ break;
180
+ case 'preview':
181
+ out.push({ kind: 'image', dataUrl: this.preview({ ...(step.camera ? { camera: step.camera } : {}), ...(step.lookAt ? { lookAt: step.lookAt } : {}) }) });
182
+ break;
183
+ case 'telemetry':
184
+ out.push({ kind: 'json', data: this.telemetry(step.id) });
185
+ break;
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ // ── internals ────────────────────────────────────────────────────────────────
192
+
193
+ private toEdit(): void {
194
+ this.swap(true);
195
+ this.mode = 'edit';
196
+ this.playing = false;
197
+ this.editorCamera = this.frameCamera();
198
+ this.onChange?.();
199
+ }
200
+
201
+ private swap(editor: boolean): void {
202
+ this.engine.dispose();
203
+ this.engine = this.makeEngine(editor);
204
+ this.editorCamera = undefined;
205
+ this.mode = editor ? 'edit' : 'play';
206
+ this.applyBundle();
207
+ }
208
+
209
+ private applyBundle(): void {
210
+ if (!this.bundle) return;
211
+ if (bundleKind(this.bundle) === 'prefab') {
212
+ this.engine.instantiate(this.bundle);
213
+ // Editor-only preview lights — flagged so the Hierarchy/picking ignore them.
214
+ const ambient = new AmbientLight(0xffffff, 0.6);
215
+ ambient.userData.scenocoEditorOnly = true;
216
+ this.engine.scene.add(ambient);
217
+ const key = new DirectionalLight(0xffffff, 2);
218
+ key.position.set(3, 5, 4);
219
+ key.userData.scenocoEditorOnly = true;
220
+ this.engine.scene.add(key);
221
+ if (!this.engine.scene.background) this.engine.scene.background = new Color(0x15181f);
222
+ } else {
223
+ this.engine.loadScene(this.bundle);
224
+ }
225
+ }
226
+
227
+ /** Editor camera framing the scene from a 3/4 angle. */
228
+ private frameCamera(): PerspectiveCamera {
229
+ const cam = new PerspectiveCamera(50, this.canvas.clientWidth / this.canvas.clientHeight || 1, 0.01, 50000);
230
+ const box = new Box3().setFromObject(this.engine.scene);
231
+ const center = new Vector3();
232
+ let radius = 1;
233
+ if (!box.isEmpty()) {
234
+ const sphere = box.getBoundingSphere(new Sphere());
235
+ center.copy(sphere.center);
236
+ radius = sphere.radius || 1;
237
+ }
238
+ cam.position.copy(center).addScaledVector(new Vector3(0.6, 0.45, 1).normalize(), (radius / Math.sin((50 * Math.PI) / 180 / 2)) * 1.2);
239
+ cam.lookAt(center);
240
+ this.engine.mainCamera = cam;
241
+ return cam;
242
+ }
243
+ }
244
+
245
+ // ── value helpers (telemetry → JSON) ─────────────────────────────────────────────
246
+
247
+ function propValues(component: Component): Record<string, unknown> {
248
+ const meta = registeredMeta(component);
249
+ const out: Record<string, unknown> = {};
250
+ if (!meta) return out;
251
+ for (const [name] of meta.properties) out[name] = jsonValue((component as unknown as Record<string, unknown>)[name]);
252
+ return out;
253
+ }
254
+ function jsonValue(v: unknown): unknown {
255
+ if (v == null || typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') return v ?? null;
256
+ if (Array.isArray(v)) return v.map(jsonValue);
257
+ const o = v as { isVector3?: boolean; isVector2?: boolean; isColor?: boolean; isEuler?: boolean; isObject3D?: boolean; x?: number; y?: number; z?: number; name?: string; getHexString?: () => string };
258
+ if (o.isVector3) return [o.x, o.y, o.z];
259
+ if (o.isVector2) return [o.x, o.y];
260
+ if (o.isEuler) return [deg(o.x!), deg(o.y!), deg(o.z!)];
261
+ if (o.isColor) return `#${o.getHexString!()}`;
262
+ if (o.isObject3D) return o.name || '(node)';
263
+ if ('node' in (o as object) && 'engine' in (o as object)) return componentName(v as Component) ?? '(component)';
264
+ return String(v);
265
+ }
266
+ const arr = (v: { x: number; y: number; z: number }) => [round(v.x), round(v.y), round(v.z)];
267
+ const degArr = (v: { x: number; y: number; z: number }) => [deg(v.x), deg(v.y), deg(v.z)];
268
+ const deg = (rad: number) => round((rad * 180) / Math.PI);
269
+ const round = (n: number) => Math.round(n * 1000) / 1000;
270
+
271
+ function vec3(s?: string): Vector3 | undefined {
272
+ if (!s) return undefined;
273
+ const p = s.split(/[ ,]+/).map(Number).filter((n) => Number.isFinite(n));
274
+ return p.length === 3 ? new Vector3(p[0], p[1], p[2]) : undefined;
275
+ }
276
+ function count(root: Object3D): number {
277
+ let n = 0;
278
+ root.traverse(() => n++);
279
+ return n;
280
+ }
281
+ function registeredMeta(component: Component) {
282
+ for (const name of Registry.componentNames()) {
283
+ const entry = Registry.getComponent(name);
284
+ if (entry && component instanceof entry.ctor) return entry.meta;
285
+ }
286
+ return undefined;
287
+ }
288
+ function componentName(component: Component): string | undefined {
289
+ for (const name of Registry.componentNames()) {
290
+ const entry = Registry.getComponent(name);
291
+ if (entry && component.constructor === entry.ctor) return name;
292
+ }
293
+ return undefined;
294
+ }
package/app/toolbar.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { GizmoMode } from './gizmo.js';
2
+
3
+ export interface ToolbarHandlers {
4
+ // transport
5
+ play(): void;
6
+ pause(): void;
7
+ step(): void;
8
+ stop(): void;
9
+ // gizmo + edit
10
+ getMode(): GizmoMode;
11
+ setMode(mode: GizmoMode): void;
12
+ undo(): void;
13
+ redo(): void;
14
+ save(): void;
15
+ /** Current state, polled on each `refresh`. */
16
+ state(): {
17
+ mode: 'edit' | 'play';
18
+ playing: boolean;
19
+ canUndo: boolean;
20
+ canRedo: boolean;
21
+ dirty: boolean;
22
+ saving: boolean;
23
+ enabled: boolean;
24
+ };
25
+ }
26
+
27
+ export interface Toolbar {
28
+ /** Re-read handler state into the button styles (transport, active mode, enabled, dirty dot). */
29
+ refresh(): void;
30
+ }
31
+
32
+ // Material-style 24×24 icon paths (filled, `currentColor`).
33
+ const ICON = {
34
+ play: 'M8 5v14l11-7z',
35
+ pause: 'M6 5h4v14H6zm8 0h4v14h-4z',
36
+ step: 'M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z',
37
+ stop: 'M6 6h12v12H6z',
38
+ move: 'M10 9h4V6h3l-5-5-5 5h3v3zm-1 1H6V7l-5 5 5 5v-3h3v-4zm14 2l-5-5v3h-3v4h3v3l5-5zm-9 3h-4v3H8l4 4 4-4h-3v-3z',
39
+ rotate:
40
+ 'M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z',
41
+ scale:
42
+ 'M15 3l2.3 2.3-2.89 2.87 1.42 1.42L18.7 6.7 21 9V3h-6zM3 9l2.3-2.3 2.87 2.89 1.42-1.42L6.7 5.3 9 3H3v6zm6 12l-2.3-2.3 2.89-2.87-1.42-1.42L5.3 17.3 3 15v6h6zm12-6l-2.3 2.3-2.87-2.89-1.42 1.42 2.89 2.87L15 21h6v-6z',
43
+ undo: 'M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z',
44
+ redo: 'M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z',
45
+ save: 'M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z',
46
+ } as const;
47
+
48
+ /**
49
+ * Two floating, touch-reachable toolbars: a **play** panel (play/pause/step/stop)
50
+ * and a **movement** panel (gizmo mode, undo/redo, save). The movement panel is
51
+ * hidden in play mode — there's no editing while the sim runs. All actions defer
52
+ * to `handlers`, which read the live session/document/gizmo.
53
+ */
54
+ export function buildToolbar(handlers: ToolbarHandlers): Toolbar {
55
+ const bars = el('div', 'sc-toolbars');
56
+ const playBar = el('div', 'sc-toolbar');
57
+ const editBar = el('div', 'sc-toolbar');
58
+
59
+ const play = btn('Play', ICON.play, () => handlers.play());
60
+ const pause = btn('Pause', ICON.pause, () => handlers.pause());
61
+ const step = btn('Step one frame', ICON.step, () => handlers.step());
62
+ const stop = btn('Stop', ICON.stop, () => handlers.stop());
63
+ playBar.append(play, pause, step, stop);
64
+
65
+ const move = modeBtn('translate', 'Move', ICON.move);
66
+ const rotate = modeBtn('rotate', 'Rotate', ICON.rotate);
67
+ const scale = modeBtn('scale', 'Scale', ICON.scale);
68
+ const undo = btn('Undo', ICON.undo, () => handlers.undo());
69
+ const redo = btn('Redo', ICON.redo, () => handlers.redo());
70
+ const save = btn('Save', ICON.save, () => handlers.save());
71
+ save.classList.add('sc-save');
72
+ editBar.append(move, rotate, scale, sep(), undo, redo, sep(), save);
73
+
74
+ bars.append(playBar, editBar);
75
+ document.body.append(bars);
76
+
77
+ function modeBtn(mode: GizmoMode, title: string, path: string): HTMLButtonElement {
78
+ return btn(title, path, () => handlers.setMode(mode), mode);
79
+ }
80
+
81
+ function refresh(): void {
82
+ const s = handlers.state();
83
+ const editing = s.mode === 'edit';
84
+ const paused = s.mode === 'play' && !s.playing;
85
+
86
+ // Transport.
87
+ play.disabled = s.playing; // edit → start; paused → resume; playing → disabled
88
+ play.classList.toggle('active', s.playing);
89
+ pause.disabled = !s.playing;
90
+ step.disabled = !paused;
91
+ stop.disabled = editing;
92
+
93
+ // Movement panel: editing only happens in edit mode — hide it during play.
94
+ editBar.style.display = editing ? '' : 'none';
95
+
96
+ const mode = handlers.getMode();
97
+ for (const b of [move, rotate, scale]) {
98
+ b.classList.toggle('active', b.dataset.mode === mode);
99
+ b.disabled = !s.enabled;
100
+ }
101
+ undo.disabled = !s.canUndo;
102
+ redo.disabled = !s.canRedo;
103
+ save.disabled = !s.dirty || s.saving;
104
+ save.classList.toggle('dirty', s.dirty);
105
+ save.classList.toggle('saving', s.saving);
106
+ }
107
+
108
+ refresh();
109
+ return { refresh };
110
+ }
111
+
112
+ // ── DOM helpers ──────────────────────────────────────────────────────────────────
113
+
114
+ function el(tag: string, className: string): HTMLElement {
115
+ const node = document.createElement(tag);
116
+ node.className = className;
117
+ return node;
118
+ }
119
+ function btn(title: string, iconPath: string, onClick: () => void, mode?: GizmoMode): HTMLButtonElement {
120
+ const b = document.createElement('button');
121
+ b.className = 'sc-tbtn';
122
+ b.title = title;
123
+ b.setAttribute('aria-label', title);
124
+ b.innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="${iconPath}"/></svg>`;
125
+ if (mode) b.dataset.mode = mode;
126
+ b.onclick = onClick;
127
+ return b;
128
+ }
129
+ function sep(): HTMLElement {
130
+ return el('div', 'sc-tsep');
131
+ }
@@ -0,0 +1,104 @@
1
+ import { Vector2 } from 'three';
2
+ import type { CameraRig } from './cameraRig.js';
3
+
4
+ /**
5
+ * Touch navigation for mobile 3D editing (HypeHype-style):
6
+ * • one finger drag → pan the camera up/down/left/right (like Q/E/A/D)
7
+ * • two finger drag → rotate (look) with the two-finger movement
8
+ * • two finger pinch → dolly forward / back
9
+ *
10
+ * Event-driven (touchmove drives the rig directly); coexists with the desktop
11
+ * controls because both only ever call `rig` verbs.
12
+ */
13
+ export class TouchControls {
14
+ private readonly points = new Map<number, Vector2>();
15
+ private prevPinch = 0;
16
+ // Per-pixel factors, multiplied by the focus distance (Unity-like): drag/dolly
17
+ // feel the same on screen at any zoom — fine up close, fast when far away.
18
+ private readonly pan = 0.0016;
19
+ private readonly dolly = 0.005;
20
+ private readonly look = 0.005; // radians per pixel
21
+ private detach: Array<() => void> = [];
22
+
23
+ constructor(
24
+ private readonly rig: CameraRig,
25
+ canvas: HTMLElement,
26
+ /** When true, a gizmo handle owns the gesture — leave the camera alone. */
27
+ private readonly isBlocked: () => boolean = () => false,
28
+ ) {
29
+ const on = (type: string, fn: (e: TouchEvent) => void) => {
30
+ const h = fn as EventListener;
31
+ canvas.addEventListener(type, h, { passive: false });
32
+ this.detach.push(() => canvas.removeEventListener(type, h));
33
+ };
34
+ on('touchstart', this.onStart);
35
+ on('touchmove', this.onMove);
36
+ on('touchend', this.onEnd);
37
+ on('touchcancel', this.onEnd);
38
+ }
39
+
40
+ private onStart = (e: TouchEvent): void => {
41
+ e.preventDefault();
42
+ for (const t of Array.from(e.changedTouches)) this.points.set(t.identifier, new Vector2(t.clientX, t.clientY));
43
+ this.prevPinch = this.pinchDistance();
44
+ };
45
+
46
+ private onMove = (e: TouchEvent): void => {
47
+ // A single finger dragging a gizmo handle also emits touch events — yield it.
48
+ if (this.points.size === 1 && this.isBlocked()) return;
49
+ e.preventDefault();
50
+ const k = this.rig.focusDistance;
51
+ if (this.points.size === 1) {
52
+ // One finger → drag the scene: the camera moves *opposite* the finger so the
53
+ // grabbed point follows it. Scaled by focus distance.
54
+ for (const t of Array.from(e.touches)) {
55
+ const prev = this.points.get(t.identifier);
56
+ if (!prev) continue;
57
+ this.rig.move(-(t.clientX - prev.x) * this.pan * k, (t.clientY - prev.y) * this.pan * k, 0);
58
+ prev.set(t.clientX, t.clientY);
59
+ }
60
+ } else if (this.points.size >= 2) {
61
+ // Two fingers → rotate by the average movement, dolly by the pinch change.
62
+ let dx = 0;
63
+ let dy = 0;
64
+ let n = 0;
65
+ for (const t of Array.from(e.touches)) {
66
+ const prev = this.points.get(t.identifier);
67
+ if (!prev) continue;
68
+ dx += t.clientX - prev.x;
69
+ dy += t.clientY - prev.y;
70
+ prev.set(t.clientX, t.clientY);
71
+ n++;
72
+ }
73
+ if (n > 0) this.rig.rotate((-dx / n) * this.look, (-dy / n) * this.look);
74
+
75
+ const pinch = this.pinchDistance();
76
+ if (this.prevPinch > 0) {
77
+ const forward = (pinch - this.prevPinch) * this.dolly * k;
78
+ this.rig.move(0, 0, forward);
79
+ this.rig.focusDistance = Math.max(0.1, this.rig.focusDistance - forward); // dollied closer → finer
80
+ }
81
+ this.prevPinch = pinch;
82
+ }
83
+ };
84
+
85
+ private onEnd = (e: TouchEvent): void => {
86
+ for (const t of Array.from(e.changedTouches)) this.points.delete(t.identifier);
87
+ this.prevPinch = this.pinchDistance();
88
+ };
89
+
90
+ private pinchDistance(): number {
91
+ const pts = [...this.points.values()];
92
+ return pts.length >= 2 ? pts[0]!.distanceTo(pts[1]!) : 0;
93
+ }
94
+
95
+ dispose(): void {
96
+ for (const off of this.detach) off();
97
+ this.detach = [];
98
+ }
99
+ }
100
+
101
+ /** True on a touch-capable device. */
102
+ export function isTouchDevice(): boolean {
103
+ return typeof window !== 'undefined' && ('ontouchstart' in window || (navigator.maxTouchPoints ?? 0) > 0);
104
+ }
@@ -0,0 +1,5 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // The editor server's plugin emits a module that imports every project component,
4
+ // package, and built-in node tag (so any opened scene/prefab has its tags ready).
5
+ declare module 'virtual:scenoco-components';
@@ -0,0 +1,66 @@
1
+ import { WS_PATH, type EditorCommand, type EditorPush, type EditorResponse, type EditorResult } from '../src/protocol.js';
2
+ import type { EditorSession } from './session.js';
3
+
4
+ /**
5
+ * Connect the editor page to the dev server's WebSocket bridge and execute the
6
+ * commands the MCP server relays, replying with the result (id-correlated).
7
+ * Auto-reconnects (e.g. across a dev-server restart).
8
+ */
9
+ export function connectEditor(session: EditorSession): void {
10
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
11
+ const url = `${proto}://${location.host}${WS_PATH}`;
12
+
13
+ const open = (): void => {
14
+ const ws = new WebSocket(url);
15
+ ws.onmessage = async (ev) => {
16
+ let msg: EditorCommand | EditorPush;
17
+ try {
18
+ msg = JSON.parse(ev.data as string) as EditorCommand | EditorPush;
19
+ } catch {
20
+ return;
21
+ }
22
+ // Unsolicited server push (a watched file changed) — not an id-correlated command.
23
+ if ('push' in msg) {
24
+ session.onExternalChange?.(msg.file, msg.role);
25
+ return;
26
+ }
27
+ const cmd = msg;
28
+ let response: EditorResponse;
29
+ try {
30
+ response = { id: cmd.id, ok: true, result: await runCommand(session, cmd) };
31
+ } catch (e) {
32
+ response = { id: cmd.id, ok: false, error: e instanceof Error ? e.message : String(e) };
33
+ }
34
+ ws.send(JSON.stringify(response));
35
+ };
36
+ ws.onclose = () => setTimeout(open, 1000);
37
+ ws.onerror = () => ws.close();
38
+ };
39
+ open();
40
+ }
41
+
42
+ /**
43
+ * Execute one relayed command against the session. Exported so the command
44
+ * dispatch (not just the WS plumbing) can be driven directly in tests.
45
+ */
46
+ export async function runCommand(session: EditorSession, cmd: EditorCommand): Promise<EditorResult> {
47
+ switch (cmd.type) {
48
+ case 'open':
49
+ return { kind: 'json', data: await session.open(cmd.path) };
50
+ case 'preview':
51
+ return { kind: 'image', dataUrl: session.preview({ camera: cmd.camera, lookAt: cmd.lookAt }) };
52
+ case 'run':
53
+ session.run();
54
+ return { kind: 'ok', info: 'playing' };
55
+ case 'pause':
56
+ session.pause();
57
+ return { kind: 'ok', info: 'paused' };
58
+ case 'stop':
59
+ session.stop();
60
+ return { kind: 'ok', info: 'edit mode' };
61
+ case 'telemetry':
62
+ return { kind: 'json', data: session.telemetry(cmd.targetId) };
63
+ case 'simulate':
64
+ return { kind: 'multi', results: session.simulate(cmd.steps) };
65
+ }
66
+ }
package/dist/args.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** Split a comma-separated list, trimming blanks. */
2
+ export declare function parseList(v: string | undefined): string[] | undefined;
3
+ /** Flags that take a value; other `--flags` are booleans (`''`), the rest positionals. */
4
+ export declare const VALUE_FLAGS: Record<string, string>;
5
+ export declare function parseArgs(args: string[]): {
6
+ positionals: string[];
7
+ options: Record<string, string>;
8
+ };
package/dist/args.js ADDED
@@ -0,0 +1,28 @@
1
+ // Pure CLI-arg helpers (no vite imports), so they're unit-testable.
2
+ /** Split a comma-separated list, trimming blanks. */
3
+ export function parseList(v) {
4
+ return v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
5
+ }
6
+ /** Flags that take a value; other `--flags` are booleans (`''`), the rest positionals. */
7
+ export const VALUE_FLAGS = {
8
+ '--components': 'components',
9
+ '--packages': 'packages',
10
+ '--port': 'port',
11
+ };
12
+ export function parseArgs(args) {
13
+ const positionals = [];
14
+ const options = {};
15
+ for (let i = 0; i < args.length; i++) {
16
+ const arg = args[i];
17
+ if (arg in VALUE_FLAGS) {
18
+ options[VALUE_FLAGS[arg]] = args[++i] ?? '';
19
+ }
20
+ else if (arg.startsWith('--')) {
21
+ options[arg.slice(2)] = ''; // boolean flag, e.g. --no-open
22
+ }
23
+ else {
24
+ positionals.push(arg);
25
+ }
26
+ }
27
+ return { positionals, options };
28
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};