@volter/live 0.0.0-stage → 0.5.203

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