@volter/editor-live 0.5.57

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/src/editor.ts ADDED
@@ -0,0 +1,663 @@
1
+ /** Editor and document automation over the shared session command wire.
2
+ * Modeling executes in the editor tab; this client owns no model state. */
3
+
4
+ import type {
5
+ ActiveDocumentCapture,
6
+ AssetKind,
7
+ AssetPreviewCapture,
8
+ AssetPreviewOptions,
9
+ AssetPreviewShotSetDefinition,
10
+ AssetPreviewSource,
11
+ CaptureDimensions,
12
+ DocumentLookOutcome,
13
+ EditorChromeCapture,
14
+ EditorChromeCaptureOptions,
15
+ EditorClient,
16
+ EditorState,
17
+ EditorView,
18
+ EditorWorkspaceName,
19
+ HistoryStep,
20
+ InspectedFieldWrite,
21
+ InspectedHierarchy,
22
+ InspectedInspection,
23
+ LabeledShotSetCapture,
24
+ OpenedDocument,
25
+ PresentedEditorView,
26
+ ShadingMode,
27
+ StructureOp,
28
+ StructureOpOptions,
29
+ StructureOpResult,
30
+ ViewPreset,
31
+ ViewportCapture,
32
+ } from '@volter/editor-sdk';
33
+ import { LiveEditorDocument } from './editor-document.js';
34
+
35
+ /** A panel `showPanel` can focus: the viewport tabs, the console, the build
36
+ * surface, or any key the editor's own static-panel registry holds
37
+ * (`hierarchy`, `assets`, `asset-library`, `inspector`, `history`, …) — which
38
+ * is why this is open: the registry, not this union, is the vocabulary. */
39
+ export type PanelName =
40
+ | 'viewport-edit'
41
+ | 'console'
42
+ // Keeps the literals above in autocomplete while admitting every key the
43
+ // editor's registry holds — the registry answers, this union only hints.
44
+ | (string & {});
45
+
46
+ const EXTENSION_KIND: Record<string, AssetKind> = {
47
+ '.glb': 'model',
48
+ '.gltf': 'model',
49
+ '.png': 'image',
50
+ '.jpg': 'image',
51
+ '.jpeg': 'image',
52
+ '.webp': 'image',
53
+ '.gif': 'image',
54
+ '.svg': 'image',
55
+ '.hdr': 'image',
56
+ '.exr': 'image',
57
+ '.mp4': 'video',
58
+ '.webm': 'video',
59
+ '.mp3': 'audio',
60
+ '.ogg': 'audio',
61
+ '.wav': 'audio',
62
+ '.flac': 'audio',
63
+ '.glsl': 'source',
64
+ '.vert': 'source',
65
+ '.frag': 'source',
66
+ // PROJECT SCRIPTS ARE SOURCE. Without these the guess below falls through to
67
+ // `'json'`, the asset-document router sends the file to the generic JSON
68
+ // viewer (`asset-documents.tsx#assetDocumentViewerRoute`: `spec.kind ===
69
+ // 'json'` is decided before any content routing), and the LIVE MODELING
70
+ // DOCUMENT never mounts — `editor.openAsset('src/lib/fox/fox.model.ts')`
71
+ // silently shows a text pane instead of the model. Only `kind: 'source'`
72
+ // reaches `SourceAssetViewer`, which is what content-routes a project script
73
+ // to `LiveModuleDocument`. The set matches that viewer's own
74
+ // `isProjectScriptPath` regex, `/\.(?:[cm]?[jt]sx?)$/`.
75
+ '.ts': 'source',
76
+ '.tsx': 'source',
77
+ '.mts': 'source',
78
+ '.cts': 'source',
79
+ '.js': 'source',
80
+ '.jsx': 'source',
81
+ '.mjs': 'source',
82
+ '.cjs': 'source',
83
+ };
84
+
85
+ /**
86
+ * Lightweight extension-based `AssetKind` guess for `openAsset`'s optional
87
+ * `kind` argument. Deliberately independent of (not shared with) the
88
+ * editor's own `AssetBrowser.tsx#getAssetKind` — that function is a private,
89
+ * React-component-local helper of a package `@volter/editor-live` has no dependency
90
+ * on. Callers with an unusual extension can always pass `kind` explicitly.
91
+ */
92
+ export function inferAssetKind(path: string): AssetKind {
93
+ // `.prefab.json` is retired (PRs #578/#581/#589) but the `'prefab'`
94
+ // AssetKind itself remains in `@volter/editor-sdk` for view-link compatibility.
95
+ if (path.endsWith('.prefab.json')) return 'prefab';
96
+ const dot = path.lastIndexOf('.');
97
+ const ext = dot >= 0 ? path.slice(dot).toLowerCase() : '';
98
+ return EXTENSION_KIND[ext] ?? 'json';
99
+ }
100
+
101
+ export class LiveEditor {
102
+ /** `#`-private, not `private`: `volter-editor eval --list` enumerates this object's
103
+ * real runtime members, and TypeScript's erased `private` would leave the
104
+ * raw `EditorClient` advertised beside them. */
105
+ readonly #client: EditorClient;
106
+
107
+ /**
108
+ * The ACTIVE center document's own DOM: read it, click it, key it, paste
109
+ * into it. The one door onto editor chrome that is not play-mode gated, and
110
+ * deliberately scoped to that document alone —
111
+ * `packages/editor/src/editor-document-probe.ts` carries the design and the
112
+ * refusal contract. Screenshotting the same subject is
113
+ * {@link LiveEditor.captureActiveDocument}, not a fifth verb here.
114
+ */
115
+ readonly document: LiveEditorDocument;
116
+
117
+ constructor(client: EditorClient) {
118
+ this.#client = client;
119
+ this.document = new LiveEditorDocument(client);
120
+ }
121
+
122
+ /**
123
+ * THE BLENDER LANE'S VERBS, from `volter-editor eval`.
124
+ *
125
+ * Blender runs headless in the editor tab's worker (ARCHITECTURE-CORE, "THE
126
+ * BLENDER IN THE TAB IS BLENDER") and answers `blender-start`,
127
+ * `blender-execute`, `blender-scene-info`, `blender-object-info`,
128
+ * `blender-screenshot-view`, `blender-read-file`, `blender-write-file`,
129
+ * `blender-list-files`, `blender-stop` and `blender-status`. They were
130
+ * reachable from `@volter/editor-sdk` and through `vgai blender-mcp` but from
131
+ * no GENERAL door, so driving a session meant writing an MCP client script
132
+ * per question — the same discovery failure `eval-surface.ts`'s header
133
+ * records, in a lane that had not noticed it yet.
134
+ *
135
+ * volter-editor eval "await editor.blender('blender-execute', { code: 'import bpy; print(len(bpy.data.objects))' })"
136
+ *
137
+ * `blender-status` is the only verb that creates nothing: it answers whether
138
+ * this tab already has a session without starting one.
139
+ */
140
+ async blender<T extends object = Record<string, unknown>>(
141
+ type: `blender-${string}`,
142
+ fields: Record<string, unknown> = {},
143
+ ): Promise<T> {
144
+ return this.#client.blender<T>(type, fields);
145
+ }
146
+
147
+ /**
148
+ * The active authoring adapter's persistence destination — where a save would
149
+ * land (`status().savePath`). A read only: a three root has no scene document
150
+ * to open, and its root is activated instead.
151
+ */
152
+ async scene(): Promise<string | null> {
153
+ const state = await this.#client.getState();
154
+ return state.savePath;
155
+ }
156
+
157
+ /**
158
+ * Make the connected human editor show the same subject/view as the agent.
159
+ * The returned URL is a compact, shareable projection — not a serialized
160
+ * workspace or document payload.
161
+ */
162
+ async present(view: EditorView): Promise<PresentedEditorView> {
163
+ return this.#client.present(view);
164
+ }
165
+
166
+ /** The human editor's actual active document, selection, camera and utility. */
167
+ async currentView(): Promise<EditorView> {
168
+ return this.#client.currentView();
169
+ }
170
+
171
+ /**
172
+ * Capture the same center document the human is currently looking at.
173
+ *
174
+ * A number is a SQUARE of that size — the default, and the right shape for
175
+ * an unstaged look at a model. `{width, height}` asks for a shaped frame, so
176
+ * a video-aspect look needs no crop afterwards. Both are bounded by the
177
+ * relay budget (64-1024 per side, total no larger than a 1024 square); see
178
+ * `@volter/editor-sdk`'s `CaptureDimensions`.
179
+ * Supply a view to present and photograph it in one editor request.
180
+ */
181
+ async captureActiveDocument(
182
+ size?: CaptureDimensions,
183
+ view?: EditorView,
184
+ ): Promise<ActiveDocumentCapture> {
185
+ return this.#client.captureActiveDocument(size, view);
186
+ }
187
+
188
+ /**
189
+ * Photograph the editor PAGE — every panel, tab strip and viewport as the
190
+ * person sees it. `vgai screenshot editor` is this verb from the shell. The
191
+ * one door for judging chrome sighted: a skin, a workspace arrangement or a
192
+ * contributed panel is looked at through this, never guessed at from DOM
193
+ * probes. The page at its own layout, `scale` output pixels per CSS pixel
194
+ * (default `devicePixelRatio`) — a 1 px border or a glyph stroke is only
195
+ * judgeable at the scale the reference it is compared against was captured
196
+ * at, and the result reports its own `size` and `scale`.
197
+ */
198
+ async captureEditorChrome(options?: EditorChromeCaptureOptions): Promise<EditorChromeCapture> {
199
+ return this.#client.captureEditorChrome(options);
200
+ }
201
+
202
+ /** `'all'` -> `EditorClient.selectAll()` (mirrors `vgai select --all`); otherwise `EditorClient.select(id)` (mirrors `vgai select <entityId>`). */
203
+ async select(id: string | 'all'): Promise<void> {
204
+ if (id === 'all') {
205
+ await this.#client.selectAll();
206
+ return;
207
+ }
208
+ await this.#client.select(id);
209
+ }
210
+
211
+ /** Mirrors `vgai deselect`. */
212
+ async deselect(): Promise<void> {
213
+ await this.#client.select(null);
214
+ }
215
+
216
+ /** No `id` -> focus the current selection (mirrors bare `vgai focus`); `id` given -> focus that entity. */
217
+ async focus(id?: string): Promise<void> {
218
+ if (id !== undefined) {
219
+ await this.#client.focusEntity(id);
220
+ return;
221
+ }
222
+ await this.#client.focusSelection();
223
+ }
224
+
225
+ /**
226
+ * Frame the edit viewport camera on one entity. Same framing as
227
+ * `focus(id)`, but an entity id the scene does not know THROWS, naming the
228
+ * id — where `focus` quietly does nothing. Reach for this whenever the next
229
+ * step reads the viewport (`screenshot()`, an
230
+ * `assetPreview(..., { stage: 'scene' })`): a framing that silently missed
231
+ * would otherwise be indistinguishable from one that worked.
232
+ */
233
+ async frame(entityId: string): Promise<void>;
234
+ /**
235
+ * Bare `frame()` frames the OPEN Object3D document's subject instead — its
236
+ * selection if it has one, else the whole model: the toolbar's own Frame
237
+ * button, reachable from a script. `fit` scales the fitted distance (1 is
238
+ * that button's tight fit, 1.5 stands back a little for a shot).
239
+ */
240
+ async frame(options?: { readonly fit?: number }): Promise<void>;
241
+ async frame(target?: string | { readonly fit?: number }): Promise<void> {
242
+ if (typeof target === 'string') {
243
+ await this.#client.frameEntity(target);
244
+ return;
245
+ }
246
+ await this.#client.frameDocument(target?.fit);
247
+ }
248
+
249
+ /**
250
+ * WATCH THE AGENT LOOK AROUND THE MODEL.
251
+ *
252
+ * Swings the open Object3D document's camera — the camera the human's tab is
253
+ * showing — around the framed subject by `azimuth`/`elevation` RADIANS,
254
+ * animated over `duration` seconds (default 0.6), and resolves when the move
255
+ * ends. This is deliberately not a jump cut: the point of the verb is that a
256
+ * person watching sees the agent walk around the thing it is working on.
257
+ *
258
+ * `await editor.orbit({ azimuth: Math.PI / 2 })` — a quarter turn to the right.
259
+ *
260
+ * There is ONE camera, and the human owns it: a drag during the move cancels
261
+ * it exactly where it is, and the resolved outcome says `cancelledBy:
262
+ * 'human'` rather than throwing. A second look verb supersedes the first.
263
+ * The move is drawn by the document's own frame loop, so a document that
264
+ * isn't being drawn (background tab, inactive panel) doesn't orbit.
265
+ */
266
+ async orbit(options: {
267
+ /** RADIANS, relative to where the camera is now. `Math.PI / 2` is a
268
+ * quarter turn; degrees are not accepted and `90` is fourteen turns. */
269
+ readonly azimuth?: number;
270
+ /** RADIANS, relative to where the camera is now. */
271
+ readonly elevation?: number;
272
+ /** SECONDS the move takes (default 0.6). */
273
+ readonly duration?: number;
274
+ }): Promise<DocumentLookOutcome> {
275
+ // AN UNKNOWN KEY IS REFUSED BY NAME. Every member here is optional, so a
276
+ // misspelling — `yaw` for `azimuth`, `pitch` for `elevation` — used to
277
+ // orbit by nothing at all while the outcome still reported a plausible
278
+ // ABSOLUTE azimuth, which reads exactly like a move that happened
279
+ // (measured 2026-09-21, and it cost a round). The keys and their unit are
280
+ // in the refusal because that is the moment the caller needs them.
281
+ const known = ['azimuth', 'elevation', 'duration'];
282
+ const unknown = Object.keys(options ?? {}).filter((key) => !known.includes(key));
283
+ if (unknown.length > 0)
284
+ throw new Error(
285
+ `editor.orbit: ${unknown.join(', ')} ${unknown.length === 1 ? 'is not a key' : 'are not keys'} ` +
286
+ 'this verb takes. It takes azimuth and elevation in RADIANS (relative to where the ' +
287
+ 'camera is now) and duration in SECONDS.',
288
+ );
289
+ return this.#client.orbitDocument(options);
290
+ }
291
+
292
+ /**
293
+ * A slow full revolution of the open document's subject — {@link orbit} with
294
+ * the turns spelled out and a constant angular rate. Resolves at the end of
295
+ * the last revolution.
296
+ */
297
+ async turntable(options?: {
298
+ readonly seconds?: number;
299
+ readonly revolutions?: number;
300
+ }): Promise<DocumentLookOutcome> {
301
+ return this.#client.turntableDocument(options);
302
+ }
303
+
304
+ async view(preset: ViewPreset): Promise<void> {
305
+ await this.#client.viewPreset(preset);
306
+ }
307
+
308
+ /**
309
+ * Switch the editor's NAMED WORKSPACE — `await editor.workspace('model')`.
310
+ *
311
+ * A workspace is a task-named LAYOUT MEMORY over the one dock
312
+ * (ARCHITECTURE-CORE §Editor chrome): `game` (the default, the editor's
313
+ * standing arrangement), `model`, `sculpt`, `texture`, `animate`, `look`.
314
+ * Switching is an EXPLICIT act — nothing in the editor moves chrome on its
315
+ * own, opening a document included — and this is the session door to it,
316
+ * beside `Window → Workspace` and the registered actions.
317
+ *
318
+ * Resolves once the dock has finished rebuilding, so a capture taken
319
+ * immediately after photographs the arrangement that was asked for. Each
320
+ * workspace remembers the user's own hand-tuning per project, so switching
321
+ * away and back is lossless.
322
+ */
323
+ async workspace(id: EditorWorkspaceName): Promise<void> {
324
+ await this.#client.setWorkspace(id);
325
+ }
326
+
327
+ /**
328
+ * Apply a STYLE BUNDLE by id — the chrome's palette, material, icon set and
329
+ * region defaults in one gesture, the session door beside
330
+ * `View → <Style> Style`. A bundle the open project does not offer refuses
331
+ * and names the vocabulary; `currentView().style` reports the one worn.
332
+ */
333
+ async style(id: string): Promise<void> {
334
+ await this.#client.setStyle(id);
335
+ }
336
+
337
+ /**
338
+ * Set the MATERIAL apart from the bundle that usually carries it.
339
+ * Appearance is palette × material, independent axes by ruling, so
340
+ * `style()` alone can never say whether a cost belongs to the blur or to
341
+ * the palette. This is the door that measures them apart; it answers with
342
+ * what the chrome wears afterwards (`style` is `null` when the mix matches
343
+ * no registered bundle).
344
+ */
345
+ async appearance(appearance: {
346
+ readonly material?: string;
347
+ }): Promise<{ material: string; style: string | null }> {
348
+ return this.#client.setAppearance(appearance);
349
+ }
350
+
351
+ /** Focus an editor panel: a viewport tab, the console, the build surface, or
352
+ * any key the editor's static-panel registry holds — an unknown key refuses
353
+ * naming the ones it does. */
354
+ async showPanel(name: PanelName): Promise<void> {
355
+ switch (name) {
356
+ case 'viewport-edit':
357
+ await this.#client.showViewport('edit');
358
+ return;
359
+ case 'console':
360
+ await this.#client.toggleConsole();
361
+ return;
362
+ default:
363
+ // Every other name is the EDITOR's to resolve, against its live panel
364
+ // registry. A list kept here could only ever be a copy going stale.
365
+ await this.#client.showPanel(name);
366
+ return;
367
+ }
368
+ }
369
+
370
+ /** `kind` inferred from `path`'s extension when omitted (`inferAssetKind`) — pass it explicitly to override. */
371
+ async openAsset(path: string, kind?: AssetKind): Promise<void> {
372
+ await this.#client.openAsset(path, kind ?? inferAssetKind(path));
373
+ }
374
+
375
+ /**
376
+ * SELECT a project asset — the browser's single click, which fills the
377
+ * Inspector without opening a document. `openAsset` is the double click.
378
+ *
379
+ * This is how a project's own `asset.inspector` section is reached: select
380
+ * the file it matches, then `inspect()` lists the verbs that section
381
+ * declares and `runAction(id)` runs one. Selecting a path nothing matches
382
+ * is not an error — the Inspector shows what it has, exactly as it does
383
+ * for a human.
384
+ */
385
+ async selectAsset(path: string): Promise<void> {
386
+ await this.#client.selectAsset(path);
387
+ }
388
+
389
+ /**
390
+ * Captures the editor's native four-view preview. A bare string is a
391
+ * project-relative asset path (the common case); an explicit source object
392
+ * targets a path, a LIVE SCENE ENTITY (`assetPreview({ entityId }, …)`) —
393
+ * which is what makes `options.stage: 'scene'`, the entity photographed
394
+ * where it stands under the scene's own lighting, reachable from here — or
395
+ * RAW GLB BYTES (`assetPreview({ glbBase64 }, …)`), for a model that exists
396
+ * only in the calling Node process's memory and has never been written to
397
+ * disk. `stage` defaults to `'lab'`, the neutral Asset Lab staging this has
398
+ * always produced, and the bytes form is lab-only.
399
+ */
400
+ async assetPreview(
401
+ source: string | AssetPreviewSource,
402
+ options?: AssetPreviewOptions,
403
+ ): Promise<AssetPreviewCapture> {
404
+ return this.#client.captureAssetPreview(
405
+ typeof source === 'string' ? { assetPath: source } : source,
406
+ options,
407
+ );
408
+ }
409
+
410
+ /**
411
+ * The same subject photographed as a LABELED SHOT SET instead of the four
412
+ * views — a caller-supplied definition of turntable yaws and bone-anchored
413
+ * crops, rendered against the asset's own skeleton, with a contact sheet.
414
+ * Every source {@link assetPreview} takes works here, GLB bytes included:
415
+ * a shot set stages its own subject, so it needs no place to stand.
416
+ *
417
+ * Sole in-repo caller today: `project.bake.preview`'s `--orbit` lane.
418
+ */
419
+ async assetPreviewShots(
420
+ source: string | AssetPreviewSource,
421
+ definition: AssetPreviewShotSetDefinition,
422
+ options?: AssetPreviewOptions,
423
+ ): Promise<LabeledShotSetCapture> {
424
+ return this.#client.captureShotSetPreview(
425
+ typeof source === 'string' ? { assetPath: source } : source,
426
+ definition,
427
+ options,
428
+ );
429
+ }
430
+
431
+ async grid(on: boolean): Promise<void> {
432
+ await this.#client.setGrid(on);
433
+ }
434
+
435
+ async helpers(on: boolean): Promise<void> {
436
+ await this.#client.setHelpers(on);
437
+ }
438
+
439
+ async stats(on: boolean): Promise<void> {
440
+ await this.#client.setStats(on);
441
+ }
442
+
443
+ async shading(mode: ShadingMode): Promise<void> {
444
+ await this.#client.setShadingMode(mode);
445
+ }
446
+
447
+ /**
448
+ * READ the inspector, as data — the serialized inspection subject
449
+ * (`editor.inspect()`; design: `docs/ARCHITECTURE-CORE.md` §Editor chrome,
450
+ * "The Inspection Model"). This is the Figma-Inspect analog: whatever a
451
+ * human would see in the inspector right now — the subject's identity, its
452
+ * verbs, and every identified section in display order, with a `fields`
453
+ * section's CURRENT VALUES at their scriptable `path`s.
454
+ *
455
+ * Reach for it whenever the next step depends on what an object actually
456
+ * IS: `await editor.select(id)` then `await editor.inspect()` answers "what
457
+ * properties does this thing have, and what are they set to" in one call,
458
+ * against the same model the panel renders — no scene-graph reads, no
459
+ * guessing at property names.
460
+ *
461
+ * When the inspector is showing NOTHING — nothing selected on a surface
462
+ * with no empty-state subject of its own, which is most of them — the answer
463
+ * is `{none: true}`, so "the human sees no inspector" and "the read failed"
464
+ * are never the same value. A surface whose empty space IS a real thing (an
465
+ * open Asset Lab document) still answers with that subject, and never with
466
+ * another surface's.
467
+ *
468
+ * A `custom` section body is a named opaque: the editor renders it with
469
+ * React, so the wire reports its identity rather than pretending to describe
470
+ * its rendering — plus, when the section can say what it DISPLAYS, a `data`
471
+ * payload in its own vocabulary (`transform` carries
472
+ * `{position, rotation, scale}`, rotation in Euler XYZ degrees).
473
+ */
474
+ async inspect(): Promise<InspectedInspection> {
475
+ return this.#client.inspect();
476
+ }
477
+
478
+ /** Run one verb listed by `inspect().quickActions`, through the same action
479
+ * the human Inspector button invokes. */
480
+ async runAction(actionId: string): Promise<InspectedInspection> {
481
+ return this.#client.runInspectionAction(actionId);
482
+ }
483
+
484
+ /**
485
+ * Run ONE command by id — the door to everything the command palette lists.
486
+ *
487
+ * ONE NAME (orchestrator ruling 2026-09-19). There were briefly TWO doors
488
+ * onto the one view-verb table — this one and `editor.viewVerb(view, verb)`,
489
+ * which addressed the same registry by its two halves. A second addressing
490
+ * of one table is a second name for one thing, and an agent reading
491
+ * `--list` had to choose between them with nothing to choose on. This door
492
+ * stays because it is strictly wider: it addresses a COMMAND ID, so under
493
+ * the frame it reaches everything the workbench knows — a `vgai.action.<id>`
494
+ * editor action, one of VS Code's own — and not only a view. A VIEW is
495
+ * reached by spelling its verb's command id:
496
+ *
497
+ * await editor.command('vgai.blender-uv-view.state')
498
+ * await editor.command('vgai.blender-uv-view.zoom', { to: 600 })
499
+ *
500
+ * await editor.command('vgai.blender-node-view.view-all')
501
+ * await editor.command('vgai.blender-node-view.look', { node: 'Principled BSDF' })
502
+ *
503
+ * Under the Code-OSS frame this is the workbench's own command service, so
504
+ * any command id works — ours and VS Code's alike. Standalone `vgai edit`
505
+ * has no command service and answers the `vgai.<view>.<verb>` shape off the
506
+ * SAME verb table the frame's commands call, refusing any other id by name.
507
+ * One table, two doors, exactly like the keymap's.
508
+ *
509
+ * Answers with whatever the command returned — a view verb's own state, or
510
+ * `null` for a command that returns nothing.
511
+ */
512
+ async command(commandId: string, args?: unknown): Promise<unknown> {
513
+ return this.#client.runCommand(commandId, args);
514
+ }
515
+
516
+ /**
517
+ * RESTRUCTURE the authored tree — the hierarchy context menu's own verbs.
518
+ *
519
+ * `create`, `delete`, `duplicate`, `reparent`, `reorder`, `wrap`, `unwrap`,
520
+ * `group`, `ungroup`, `copy`, `cut`, `paste`; `extractComponent` and
521
+ * `forkComponent` are the two that write whole new files and have their own
522
+ * doors below. All of them run the SAME `authoring/consumer-actions.ts`
523
+ * helpers the menu items call, so there is one implementation of each op and
524
+ * not a second that can disagree with what a human gets.
525
+ *
526
+ * It exists because the menu is a POINTER surface: every one of these ops was
527
+ * reachable only by right-clicking a hierarchy row, which is nothing an agent
528
+ * can do — so for an ingest root, whose only authoring surface IS the editor,
529
+ * structure was closed entirely.
530
+ *
531
+ * `id`/`ids` default to the current selection. The answer carries the same
532
+ * per-edit `write` ack `setField` does, so `write.persisted` tells a saved
533
+ * restructure from a live-only one. An op the active adapter does not provide
534
+ * REJECTS by name — never a silent no-op.
535
+ */
536
+ async structure(op: StructureOp, options?: StructureOpOptions): Promise<StructureOpResult> {
537
+ return this.#client.structureOp(op, options ?? {});
538
+ }
539
+
540
+ /**
541
+ * "Extract Component…" — lift the selected native subtree into its own
542
+ * component file (plus a story) and replace the callsite with it.
543
+ *
544
+ * Answers the action's own sentence, which NAMES both new files, because
545
+ * undo owns the callsite edit and will not remove them.
546
+ */
547
+ async extractComponent(options?: { id?: string; name?: string }): Promise<string> {
548
+ return (await this.#client.extractComponent(options ?? {})).hint;
549
+ }
550
+
551
+ /**
552
+ * "Fork Component…" — copy the selected instance's component definition to a
553
+ * new file and retarget THIS CALLSITE at it.
554
+ *
555
+ * One callsite is the unit of the edit; when that callsite sits inside a
556
+ * component rendered many times, every one of those renders now renders the
557
+ * fork.
558
+ */
559
+ async forkComponent(options?: { id?: string }): Promise<string> {
560
+ return (await this.#client.forkComponent(options ?? {})).hint;
561
+ }
562
+
563
+ /**
564
+ * READ the hierarchy panel, as data — the rows a human is looking at right
565
+ * now, nested exactly as the panel nests them.
566
+ *
567
+ * The companion to {@link inspect}: that one answers "what IS the selected
568
+ * thing", this one answers "what does the tree LOOK LIKE". It is the panel's
569
+ * own output, not a fresh walk of the scene — the adapter's tree after the
570
+ * component marks fold implementation subtrees (bones, particle renderers,
571
+ * instanced pools), after the internals reveal, the document promotion, the
572
+ * child cap, the collapse state, the search filter and the selection scope.
573
+ *
574
+ * Works in play mode and edit mode; the answer says which (`playState`,
575
+ * `activeViewportTab`), because the two are different adapters and a tree
576
+ * that looks wrong is very often the wrong adapter's tree.
577
+ *
578
+ * Prefer this over `status().entities`, which is deliberately a different
579
+ * question — the RAW adapter tree, unprojected. A panel that renders the
580
+ * wrong rows looks perfectly healthy in that facet.
581
+ *
582
+ * Each row carries `childCount` (what its caret opens), `internalChildCount`
583
+ * (what is folded behind "Reveal Internals") and `expandable` (whether the
584
+ * panel draws a caret at all), so "this subtree exists but nothing in the UI
585
+ * opens it" is a fact you can read rather than one you have to notice.
586
+ *
587
+ * Rejects, naming the panel, when no hierarchy panel is mounted — an empty
588
+ * tree would be a fabricated answer about a surface nobody is being shown.
589
+ */
590
+ async hierarchy(): Promise<InspectedHierarchy> {
591
+ return this.#client.hierarchy();
592
+ }
593
+
594
+ /** Expand every branch through the Hierarchy panel's own action. */
595
+ async expandHierarchyAll(): Promise<void> {
596
+ await this.#client.expandHierarchyAll();
597
+ }
598
+
599
+ /** Collapse every branch through the same panel action. Expanding is
600
+ * persisted per project, so without this the tree's REST STATE — what a
601
+ * person sees on opening the project — is unreachable once any reader has
602
+ * expanded it. */
603
+ async collapseHierarchyAll(): Promise<void> {
604
+ await this.#client.collapseHierarchyAll();
605
+ }
606
+
607
+ /**
608
+ * Write one editable field from `inspect()` by its stable path, through the
609
+ * same Inspector IO and persistence boundary the human control uses.
610
+ *
611
+ * The answer is `{ subject, write }`, and `write` is the half worth reading
612
+ * first: a write with no persistence route open still succeeds — it lands on
613
+ * the live object and journals live-only — so `write.persisted` is how you
614
+ * tell a saved edit from one that will not survive the session, without
615
+ * diffing the tree. `write.destination` is the adapter's own words for where
616
+ * it went ("live-only (not saved)" is a destination, never silence).
617
+ */
618
+ async setField(path: string, value: unknown): Promise<InspectedFieldWrite> {
619
+ return this.#client.setInspectionField(path, value);
620
+ }
621
+
622
+ /**
623
+ * REMOVE one field's authored override — the revert arrow, as a command.
624
+ *
625
+ * Reach for this instead of `setField` whenever you are UNDOING an edit that
626
+ * added a property the source did not carry: `setField` can only write a
627
+ * value, so setting the default back leaves `position={[0, 0, 0]}` in the
628
+ * file where there was nothing before. Only this door restores the bytes.
629
+ *
630
+ * The answer is the same `{ subject, write }` shape, awaited past the bytes.
631
+ * It rejects with `code: 'REMOVAL_UNAVAILABLE'` when the field is not
632
+ * declared removable or the lane has no removal door — which is a missing
633
+ * seam to report, not a removal that failed.
634
+ */
635
+ async removeField(path: string): Promise<InspectedFieldWrite> {
636
+ return this.#client.removeInspectionField(path);
637
+ }
638
+
639
+ /** Open a document by its adapter-declared id, through its registered owner. */
640
+ async open(id: string): Promise<OpenedDocument> {
641
+ return this.#client.open(id);
642
+ }
643
+
644
+ /** Undo / redo one project transaction, through the session's own history
645
+ * queue — the same one the keyboard shortcut drives. */
646
+ async undo(): Promise<HistoryStep> {
647
+ return this.#client.undo();
648
+ }
649
+
650
+ async redo(): Promise<HistoryStep> {
651
+ return this.#client.redo();
652
+ }
653
+
654
+ /** Mirrors `vgai status` — the full live editor state as JSON. */
655
+ async status(): Promise<EditorState> {
656
+ return this.#client.getState();
657
+ }
658
+
659
+ /** A live viewport PNG (`EditorClient.captureViewport`) — no direct CLI verb exists; this is the closest wire read. */
660
+ async screenshot(size?: number): Promise<ViewportCapture> {
661
+ return this.#client.captureViewport(size);
662
+ }
663
+ }