@vgai/editor-sdk 0.5.2 → 0.5.3

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/README.md CHANGED
@@ -11,8 +11,8 @@ to the caller.
11
11
  ```ts
12
12
  import { EditorClient } from '@vgai/editor-sdk';
13
13
 
14
- const editor = new EditorClient(); // default http://localhost:5173
15
- const projectEditor = new EditorClient({ url: 'http://localhost:25786' });
14
+ const editor = new EditorClient(); // default http://127.0.0.1:20173
15
+ const projectEditor = new EditorClient({ url: 'http://127.0.0.1:25786' });
16
16
  await editor.play();
17
17
 
18
18
  // Present the same editor subject/view to the connected human and get a
@@ -69,7 +69,7 @@ import type { ToolContributionProps } from '@vgai/editor-sdk/contributions';
69
69
  export default function MapBuilder({ tool, client, account }: ToolContributionProps) {
70
70
  return (
71
71
  <Button
72
- variant="solid"
72
+ variant="primary"
73
73
  onClick={() => void client.runProjectTool(tool.name, { seed: 42 }, { confirm: true })}
74
74
  title={`Default execution: ${account.preferredRoute}`}
75
75
  >
@@ -99,7 +99,7 @@ utilities. Badges are for short statuses and counts, not headings.
99
99
  ### Extension contract (`@vgai/editor-sdk/extension`)
100
100
 
101
101
  The typed contract for everything a game project contributes TO the editor —
102
- four surfaces, one degradation ladder (`ExtensionContributionTier`:
102
+ three surfaces, one degradation ladder (`ExtensionContributionTier`:
103
103
  `active` / `absent` / `failed`). An absent contribution hides its surface
104
104
  (the editor never fabricates placeholder data); a failing one is contained
105
105
  per-contribution and reported loudly on the editor console — never a crashed
@@ -110,36 +110,7 @@ editor, never silent fake output.
110
110
  there is no parallel rail.
111
111
  2. **Inspector sections** — `selection.inspector` / `asset.inspector` tool
112
112
  contributions with an exported `match`.
113
- 3. **Per-component viewport gizmos** — a `GameComponent` subclass declares
114
- `static editorGizmo: ComponentGizmoHook` (the Unity `OnDrawGizmos`
115
- analog). The editor renders its objects for every scene entity carrying
116
- that component — editor-only layer, Helpers ▸ Components toggle,
117
- raycast-excluded, draw-only. Props arrive schema-parsed through the
118
- component's own Zod `static schema`; build objects with `ctx.three` (the
119
- editor's three module):
120
-
121
- ```ts
122
- import type { ComponentGizmoHook } from '@vgai/editor-sdk/extension';
123
-
124
- export class TriggerZone extends GameComponent {
125
- static schema = z.object({ radius: z.number().default(2) });
126
- static editorGizmo: ComponentGizmoHook = ({ props, three }) => {
127
- const geo = new three.RingGeometry(0.98 * (props.radius as number), props.radius as number);
128
- return new three.Mesh(geo, new three.MeshBasicMaterial({ wireframe: true }));
129
- };
130
- update() {}
131
- }
132
- ```
133
-
134
- The scaffold template's `SceneCamera` (`src/scripts/components/
135
- scene-camera.ts`) is the worked reference.
136
-
137
- Editing a component file in edit mode re-captures the registry and
138
- redraws contributed gizmos (script-HMR); registry restructures may need a
139
- project reopen. Recorded deferral: in the HOSTED (browser) editor,
140
- contributed component gizmos resolve `absent` — example registries are
141
- evaluated for play but not captured at design time.
142
- 4. **System adapters** — runtime capabilities registered from game code via
113
+ 3. **System adapters** — runtime capabilities registered from game code via
143
114
  `ctx.registerSystemAdapter?.(kind, impl)` (`SystemAdapters` in
144
115
  `@vgai/engine`). Deliberately not re-exported here: the engine already
145
116
  publishes that seam and every consumer of it also imports the engine.
@@ -168,7 +139,7 @@ buffers and images must resolve on the same project origin. The thin CLI
168
139
  equivalent writes the four views and contact sheet to disk:
169
140
 
170
141
  ```bash
171
- npm run vgai -- asset-preview --asset /models/storefront.glb --out artifacts/storefront
142
+ npm run vgai -- screenshot /models/storefront.glb --out artifacts/storefront
172
143
  ```
173
144
 
174
145
  ## Limitations
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/editor-sdk",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.2",
5
+ "version": "0.5.3",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@types/three": "^0.180.0",
25
- "@vgai/sdk": "0.5.2"
25
+ "@vgai/sdk": "0.5.3"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": "^19.0.0",
package/src/client.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  EditorView,
13
13
  GameCapture,
14
14
  HelperVisibility,
15
+ InspectedInspection,
15
16
  LabeledShotSetCapture,
16
17
  PresentedEditorView,
17
18
  ProjectInfo,
@@ -20,31 +21,111 @@ import type {
20
21
  ProjectToolOutcome,
21
22
  RecentProject,
22
23
  ShadingMode,
24
+ StoryCaptureOptions,
25
+ StoryVariantCapture,
23
26
  TransformMode,
24
27
  TransformSpace,
25
28
  Vec3Value,
26
29
  ViewPreset,
27
30
  ViewportCapture,
31
+ ViewportTab,
28
32
  } from './types.js';
29
33
 
30
- const DEFAULT_URL = 'http://localhost:5173';
34
+ // The editor's default origin, spelled out because this package deliberately
35
+ // does not depend on `@vgai/engine`. `DEFAULT_EDITOR_PORT` in
36
+ // `packages/engine/src/manifest/editor-port.ts` is the owner of the number and
37
+ // of the never-`localhost` rule; keep this in step with it.
38
+ const DEFAULT_URL = 'http://127.0.0.1:20173';
39
+
40
+ /**
41
+ * A refused editor command, carrying the relay's own STRUCTURED failure code
42
+ * alongside the prose.
43
+ *
44
+ * `/__editor/command` has always answered `{ ok: false, error, code }` and
45
+ * this client has always dropped the `code` on the floor, so every caller that
46
+ * wanted to react to a specific refusal had to substring-match an English
47
+ * sentence. `vgai screenshot`'s hidden-tab fallback is the first caller that
48
+ * genuinely must branch (`BRIDGE_SCREENSHOT_STALE` has a working recovery;
49
+ * "not in play mode" does not), and a fallback keyed on prose would fire on
50
+ * the wrong failure the first time someone rewords the message.
51
+ */
52
+ export class EditorCommandError extends Error {
53
+ readonly code: string | undefined;
54
+ constructor(message: string, code?: string | undefined) {
55
+ super(message);
56
+ this.name = 'EditorCommandError';
57
+ this.code = code;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The GAME DEBUG PLANE, as a contribution's client sees it.
63
+ *
64
+ * Deliberately the same two words `@vgai/live`'s session binding uses
65
+ * (`game.state(name)` / `game.command(name, ...args)`), because it is the same
66
+ * plane: whatever the running game registered through `ctx.debug` — a provider
67
+ * read by name, a command invoked by name. A tool contribution that wants the
68
+ * game's own vitals in its panel has this door and no other; there is
69
+ * deliberately no per-capability method, because a game names its own
70
+ * providers and commands.
71
+ *
72
+ * Both legs reject LOUDLY (`EditorCommandError`) rather than answer with a
73
+ * placeholder: play not running is `'not in play mode — start play before
74
+ * using the debug seam'`, and an unregistered command carries
75
+ * `code: 'DEBUG_COMMAND_NOT_REGISTERED'`. A panel decides what to show for
76
+ * those; the client never invents one.
77
+ */
78
+ export interface GameDebugDoor {
79
+ /**
80
+ * Read ONE registered state provider by name (`'bot.tester'`). `undefined`
81
+ * when the running game registered no such provider — or no debug adapter at
82
+ * all, which is an honest answer rather than a refusal.
83
+ */
84
+ state(name: string): Promise<unknown>;
85
+ /** Invoke ONE registered debug command by name, with its own arguments. */
86
+ command(name: string, ...args: unknown[]): Promise<unknown>;
87
+ }
31
88
 
32
89
  export class EditorClient {
33
90
  private readonly baseUrl: string;
34
91
 
92
+ /**
93
+ * The running game's debug plane — see {@link GameDebugDoor}. It rides the
94
+ * SAME `/__editor/command` relay every other method here uses (relay cases
95
+ * `inspect-gameplay-state` / `invoke-debug-command`, `command-listener.ts`),
96
+ * so a tool contribution reaches the game through the client it already has
97
+ * rather than a second channel of its own.
98
+ */
99
+ readonly game: GameDebugDoor;
100
+
35
101
  constructor(opts?: { url?: string }) {
36
102
  if (opts !== undefined && (typeof opts !== 'object' || opts === null || Array.isArray(opts))) {
37
103
  throw new TypeError(
38
- 'EditorClient options must be an object. Use new EditorClient({ url: "http://localhost:5173" }), not new EditorClient("...").',
104
+ 'EditorClient options must be an object. Use new EditorClient({ url: "http://127.0.0.1:20173" }), not new EditorClient("...").',
39
105
  );
40
106
  }
41
107
  const unknownOptions = Object.keys(opts ?? {}).filter((key) => key !== 'url');
42
108
  if (unknownOptions.length > 0) {
43
109
  throw new Error(
44
- `EditorClient: unknown option${unknownOptions.length === 1 ? '' : 's'} ${unknownOptions.map((key) => `"${key}"`).join(', ')}. Use { url: "http://localhost:<port>" } to target an editor.`,
110
+ `EditorClient: unknown option${unknownOptions.length === 1 ? '' : 's'} ${unknownOptions.map((key) => `"${key}"`).join(', ')}. Use { url: "http://127.0.0.1:<port>" } to target an editor.`,
45
111
  );
46
112
  }
47
113
  this.baseUrl = (opts?.url ?? DEFAULT_URL).replace(/\/$/, '');
114
+ this.game = {
115
+ state: async (name: string): Promise<unknown> => {
116
+ // `keys` narrows the relay to the one provider asked for, so a panel
117
+ // polling one vital never drags the whole plane's `stateAll()` across
118
+ // the wire. A game with no debug adapter answers `{ state: null }`.
119
+ const data = await this.command<{ state: Record<string, unknown> | null }>({
120
+ type: 'inspect-gameplay-state',
121
+ keys: [name],
122
+ });
123
+ return data.state?.[name];
124
+ },
125
+ command: async (name: string, ...args: unknown[]): Promise<unknown> =>
126
+ (await this.command<{ result: unknown }>({ type: 'invoke-debug-command', name, args }))
127
+ .result,
128
+ };
48
129
  }
49
130
 
50
131
  private async command<T extends object = Record<string, never>>(
@@ -55,9 +136,9 @@ export class EditorClient {
55
136
  headers: { 'Content-Type': 'application/json' },
56
137
  body: JSON.stringify(body),
57
138
  });
58
- const data = (await res.json()) as { ok: boolean; error?: string } & T;
139
+ const data = (await res.json()) as { ok: boolean; error?: string; code?: string } & T;
59
140
  if (!data.ok) {
60
- throw new Error(data.error ?? `Editor command failed: ${res.status}`);
141
+ throw new EditorCommandError(data.error ?? `Editor command failed: ${res.status}`, data.code);
61
142
  }
62
143
  return data;
63
144
  }
@@ -118,6 +199,17 @@ export class EditorClient {
118
199
  await this.command({ type: 'focus-selection' });
119
200
  }
120
201
 
202
+ /**
203
+ * Frame the EDIT viewport camera on one entity — the strict sibling of
204
+ * {@link focusEntity}. Same framing; an id the scene does not know is a
205
+ * refusal naming the id (`EditorCommandError`, code `ENTITY_NOT_FOUND`)
206
+ * rather than `focusEntity`'s silent no-op, so a caller that frames an
207
+ * entity before capturing it cannot photograph the wrong thing.
208
+ */
209
+ async frameEntity(id: string): Promise<void> {
210
+ await this.command({ type: 'frame-entity', id });
211
+ }
212
+
121
213
  async viewPreset(preset: ViewPreset): Promise<void> {
122
214
  await this.command({ type: 'view-preset', preset });
123
215
  }
@@ -142,7 +234,7 @@ export class EditorClient {
142
234
  /**
143
235
  * Unit 4 (live-front-door wave) — capture the RUNNING GAME (`vgai
144
236
  * screenshot`'s wire leg). Sends the SAME `bridge-screenshot` relay op
145
- * `@vgai/e2e`'s `RelayTransport.screenshot` (and therefore
237
+ * `@vgai/live`'s `RelayTransport.screenshot` (and therefore
146
238
  * `game.screenshot()` on the relay path) already sends, so all three
147
239
  * surfaces composite the identical full game stack — canvas(es) plus the
148
240
  * HUD/react DOM layers — rather than any of them inventing a second,
@@ -154,10 +246,34 @@ export class EditorClient {
154
246
  * mode isn't running ("not in play mode — start play before using the
155
247
  * debug seam") or no game canvas is mounted yet. Never returns a blank or
156
248
  * editor-only frame as a stand-in.
249
+ *
250
+ * `opts.refreshHiddenFrame` is the hidden-tab leg: a backgrounded tab
251
+ * pauses the loop, so the canvas holds a provably stale frame and the relay
252
+ * refuses it with `BRIDGE_SCREENSHOT_STALE` rather than pass it off as
253
+ * current. Setting this asks the relay to render exactly ONE deterministic
254
+ * tick (`runTicks(1, {render:'last'})`) first — the same escape
255
+ * `@vgai/live`'s `RelayTransport.screenshot` has always used, which is why
256
+ * `vgai eval` could capture hidden frames while `vgai screenshot` could not.
257
+ * Off by default: a caller who does not ask must never be handed a frame
258
+ * that only exists because the capture drove the game.
259
+ *
260
+ * `opts.devLayers` puts the game's DEV LAYERS (`dev: true` roots — its own
261
+ * in-game dev GUI) INTO the frame. Also off by default, for the mirror-image
262
+ * reason: a panel frame at the moment of a failure is strong evidence, and a
263
+ * "does the game look right" frame with the panel painted over it is
264
+ * worthless — so the contamination only happens when someone asks for it.
157
265
  */
158
- async captureGame(): Promise<GameCapture> {
159
- const data = await this.command<GameCapture>({ type: 'bridge-screenshot' });
266
+ async captureGame(opts?: {
267
+ refreshHiddenFrame?: boolean;
268
+ devLayers?: boolean;
269
+ }): Promise<GameCapture> {
270
+ const data = await this.command<GameCapture>({
271
+ type: 'bridge-screenshot',
272
+ ...(opts?.refreshHiddenFrame === true ? { refreshHiddenFrame: true } : {}),
273
+ ...(opts?.devLayers === true ? { devLayers: true } : {}),
274
+ });
160
275
  const layers = data.layers;
276
+ const flatness = data.flatness;
161
277
  return {
162
278
  base64: data.base64,
163
279
  mimeType: data.mimeType,
@@ -165,6 +281,11 @@ export class EditorClient {
165
281
  ...(layers && Number.isInteger(layers.canvases) && Number.isInteger(layers.domOverlays)
166
282
  ? { layers }
167
283
  : {}),
284
+ // Pass the pixel-honesty fields through as the page reported them: the
285
+ // warning sentence is written where the pixels are, so nothing here
286
+ // re-derives (or softens) it.
287
+ ...(flatness && typeof flatness.dominantFraction === 'number' ? { flatness } : {}),
288
+ ...(data.hiddenFrame === true ? { hiddenFrame: true } : {}),
168
289
  };
169
290
  }
170
291
 
@@ -191,7 +312,7 @@ export class EditorClient {
191
312
  }
192
313
 
193
314
  /**
194
- * A project-defined labeled shot set (`vgai asset-preview --shots <set>`):
315
+ * A project-defined labeled shot set (`vgai screenshot <target> --shots <set>`):
195
316
  * the DEFINITION travels with the command (project data — see
196
317
  * `AssetPreviewShotSetDefinition`; the CLI resolves it from the registered
197
318
  * `project.<set>.previewShots` tool), and the editor's generic
@@ -215,13 +336,15 @@ export class EditorClient {
215
336
  width: data.width,
216
337
  height: data.height,
217
338
  shots: data.shots,
339
+ // An older editor predates the empty-frame guard and sends none.
340
+ warnings: data.warnings ?? [],
218
341
  contactSheet: data.contactSheet,
219
342
  };
220
343
  }
221
344
 
222
345
  /**
223
- * B8.4 — score the asset against a reference GLB (`vgai asset-preview
224
- * --compare <ref.glb>`): matched orthographic front + side silhouettes
346
+ * B8.4 — score the asset against a reference GLB (`vgai screenshot
347
+ * <model.glb> --compare <ref.glb>`): matched orthographic front + side silhouettes
225
348
  * (equal-height bounding-box framing, both yaw-normalized to face the
226
349
  * camera), per-view IoU numbers, and overlay evidence images. The
227
350
  * reference GLB's raw bytes travel base64 in the command; the editor
@@ -242,9 +365,38 @@ export class EditorClient {
242
365
  return { width: data.width, height: data.height, views: data.views };
243
366
  }
244
367
 
368
+ /**
369
+ * The STORY lane (`vgai screenshot <file>.stories.tsx`): every CSF export of
370
+ * one project story file rendered in the live session's DOM and captured
371
+ * through the same composite leg {@link captureGame} uses, returned as
372
+ * per-export images plus one variant sheet. `options.story` narrows to a
373
+ * single export.
374
+ *
375
+ * Rendering happens in the EDITOR — the SDK never imports, composes or
376
+ * mounts a CSF module itself; the session already owns that machinery for
377
+ * its Stories panel and this drives it.
378
+ */
379
+ async captureStoryVariants(
380
+ modulePath: string,
381
+ options: StoryCaptureOptions = {},
382
+ ): Promise<StoryVariantCapture> {
383
+ const data = await this.command<StoryVariantCapture>({
384
+ type: 'capture-story-variants',
385
+ modulePath,
386
+ ...options,
387
+ });
388
+ return {
389
+ modulePath: data.modulePath,
390
+ width: data.width,
391
+ height: data.height,
392
+ variants: data.variants,
393
+ contactSheet: data.contactSheet,
394
+ };
395
+ }
396
+
245
397
  // --- Panels ---
246
398
 
247
- async showViewport(tab: 'scene' | 'game'): Promise<void> {
399
+ async showViewport(tab: ViewportTab): Promise<void> {
248
400
  await this.command({ type: 'viewport-tab', tab });
249
401
  }
250
402
 
@@ -252,6 +404,18 @@ export class EditorClient {
252
404
  await this.command({ type: 'active-tab', key: '__inspector__' });
253
405
  }
254
406
 
407
+ /** Show several instances of the running game split-screen — multiplayer
408
+ * authoring. Pass a total `count` (default "Player N" labels) or an array of
409
+ * `names` (its length is the count; index 0 is the primary). Requires a live
410
+ * play session. */
411
+ async setInstanceCount(countOrNames: number | string[]): Promise<void> {
412
+ await this.command(
413
+ Array.isArray(countOrNames)
414
+ ? { type: 'set-instance-count', names: countOrNames }
415
+ : { type: 'set-instance-count', count: countOrNames },
416
+ );
417
+ }
418
+
255
419
  async openAsset(path: string, kind: AssetKind): Promise<void> {
256
420
  await this.command({ type: 'open-asset-tab', path, kind });
257
421
  }
@@ -278,6 +442,27 @@ export class EditorClient {
278
442
  return { view: presented.view, url: presented.url, warnings: presented.warnings };
279
443
  }
280
444
 
445
+ /**
446
+ * The INSPECTION SUBJECT the editor is showing right now, as data — the
447
+ * serialized projection of the inspection model (design:
448
+ * `docs/ARCHITECTURE-CORE.md` §Editor chrome, "The Inspection Model").
449
+ *
450
+ * The same subject a human reads in the inspector: identity, presentation,
451
+ * verbs, and the identified sections in display order — with a `fields`
452
+ * section's CURRENT VALUES read through the same io the field rows edit
453
+ * through. With nothing selected it answers the active surface's own
454
+ * no-selection subject when it has one, exactly as the panel does; it never
455
+ * reports another surface's, and when the panel itself is unmounted it
456
+ * answers `{none: true}` rather than a subject nobody is looking at. A
457
+ * `custom` section body is a named opaque (`{kind, id, title}`) — the editor
458
+ * renders those with React — plus its displayed values under `data` when it
459
+ * has any (the Transform section's position/rotation/scale).
460
+ */
461
+ async inspect(): Promise<InspectedInspection> {
462
+ const data = await this.command<{ subject: InspectedInspection }>({ type: 'inspect' });
463
+ return data.subject;
464
+ }
465
+
281
466
  /** Read the editor's actual current durable projection. */
282
467
  async currentView(): Promise<EditorView> {
283
468
  const data = await this.command<{ view: EditorView }>({ type: 'current-view' });
@@ -329,10 +514,10 @@ export class EditorClient {
329
514
  }
330
515
 
331
516
  // --- Scene ---
332
-
333
- async openScene(path: string): Promise<void> {
334
- await this.command({ type: 'open-scene', path });
335
- }
517
+ //
518
+ // `openScene(path)` lived here, relaying `{type:'open-scene', path}`. That
519
+ // verb now rejects — the `.vscn.json` format it opened is deleted, and a three
520
+ // root is opened by activating its root.
336
521
 
337
522
  // --- Project management ---
338
523
 
@@ -397,12 +582,20 @@ export class EditorClient {
397
582
  async runProjectTool(
398
583
  name: string,
399
584
  input: unknown = {},
400
- options: { confirm?: boolean } = {},
585
+ options: { confirm?: boolean; instance?: string } = {},
401
586
  ): Promise<ProjectToolOutcome> {
402
587
  const res = await fetch(`${this.baseUrl}/__editor/project-tools/run`, {
403
588
  method: 'POST',
404
589
  headers: { 'Content-Type': 'application/json' },
405
- body: JSON.stringify({ name, input, confirm: options.confirm === true }),
590
+ body: JSON.stringify({
591
+ name,
592
+ input,
593
+ confirm: options.confirm === true,
594
+ // Omitted (not null) when unset — the wire body is JSON and the tool
595
+ // host reads absence as "the sole instance", same convention as the
596
+ // relay's `instance`.
597
+ ...(options.instance !== undefined ? { instance: options.instance } : {}),
598
+ }),
406
599
  });
407
600
  const body = (await res.json()) as ProjectToolOutcome;
408
601
  if (!body || typeof body !== 'object' || typeof body.ok !== 'boolean') {
@@ -26,7 +26,6 @@ export interface ToolContributionNode {
26
26
  readonly kind: string;
27
27
  readonly parentId: string | null;
28
28
  readonly childIds: string[];
29
- readonly flags: object;
30
29
  }
31
30
 
32
31
  /**
@@ -97,8 +96,28 @@ export interface ToolObject3DPreviewProps {
97
96
  export interface ToolObject3DAuthoringProps {
98
97
  readonly documentId: string;
99
98
  readonly sourcePath: string;
99
+ /**
100
+ * Produce the graph to author. Called ONCE PER ACTIVATION, not once per
101
+ * document: the host tears its scene down whenever the document goes inactive
102
+ * (an ordinary tab switch) and calls this again on the way back, disposing
103
+ * whatever the previous call returned. So a caller must pick one of two
104
+ * shapes, and there is no third:
105
+ *
106
+ * - a FACTORY — build a fresh graph every call, and let the returned
107
+ * `dispose` free it (what the model/entity asset documents do); or
108
+ * - an OWNED graph — return the same root every call with an EMPTY `dispose`,
109
+ * and free it from the caller's own lifetime instead (what the 3D
110
+ * components board and the story turntable do, because their graph is
111
+ * async to create).
112
+ *
113
+ * Returning a graph you cannot rebuild synchronously AND a `dispose` that
114
+ * really frees it is the third shape, and it renders a black panel on the
115
+ * second activation.
116
+ */
100
117
  readonly build: () => ToolObject3DPreviewSource;
101
118
  readonly displayName?: string;
119
+ /** Explicit flat scene background. Omit it to inherit the editor's standard
120
+ * viewport dressing (environment, gradient backdrop, key light). */
102
121
  readonly background?: ColorRepresentation;
103
122
  readonly cameraDirection?: readonly [number, number, number];
104
123
  readonly autoplay?: boolean;
package/src/extension.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Extension contract (W3a) — the published surface a game project uses to
3
3
  * contribute to the editor, with the degradation ladder made API.
4
4
  *
5
- * A project extends the editor through exactly four surfaces:
5
+ * A project extends the editor through exactly three surfaces:
6
6
  *
7
7
  * (a) **Editor panels** — registered into the Dockview workspace as documents
8
8
  * or utilities via a tool contribution (`package.json#vgai.tools` →
@@ -12,11 +12,7 @@
12
12
  * (b) **Inspector sections** — `point: 'selection.inspector'` /
13
13
  * `'asset.inspector'` tool contributions with an exported `match`.
14
14
  * Types: `ToolInspectorContributionProps` and friends, same module.
15
- * (c) **Per-component viewport gizmos** (this module) a `GameComponent`
16
- * subclass declares `static editorGizmo: ComponentGizmoHook`; the editor
17
- * renders its objects for every scene entity carrying that component,
18
- * on the editor-only layer, alongside the built-in gizmos.
19
- * (d) **System adapters** — runtime capabilities (networking, debug, …)
15
+ * (c) **System adapters** runtime capabilities (networking, debug, …)
20
16
  * registered from game code via `ctx.registerSystemAdapter?.(kind, impl)`
21
17
  * (`SystemAdapters` in `@vgai/engine`'s `runtime/types` /
22
18
  * `adapter/system-adapter`). Deliberately NOT re-exported here: the
@@ -36,74 +32,8 @@
36
32
  * console) — never silently swallowed into fake output.
37
33
  */
38
34
 
39
- import type * as THREE from 'three';
40
-
41
35
  /**
42
36
  * Capability tier of a single extension contribution — the degradation
43
37
  * ladder made API (see the module doc above for the exact semantics).
44
38
  */
45
39
  export type ExtensionContributionTier = 'active' | 'absent' | 'failed';
46
-
47
- /** Resolved status of one per-component gizmo contribution. */
48
- export interface ComponentGizmoStatus {
49
- tier: ExtensionContributionTier;
50
- /** Present only for `'failed'` — the contained error's message. */
51
- error?: string;
52
- }
53
-
54
- /**
55
- * What a component gizmo hook receives. Draw with `ctx.three` — the EDITOR's
56
- * three module — so returned objects render in the editor viewport
57
- * regardless of which physical `three` copy the project itself resolves.
58
- */
59
- export interface ComponentGizmoContext {
60
- /** The component's registry name (the key in `entity.components`). */
61
- componentName: string;
62
- /**
63
- * The entity's authored component data. When the component class declares
64
- * a `static schema` (Zod), this is the schema-parsed value — defaults
65
- * applied, invalid data already rejected (a parse failure fails the
66
- * contribution loudly instead of fabricating defaults).
67
- */
68
- props: Record<string, unknown>;
69
- /** The editor's `three` module — build all gizmo objects with this. */
70
- three: typeof THREE;
71
- }
72
-
73
- /**
74
- * Per-component viewport gizmo contribution (critique O11 — the
75
- * `OnDrawGizmos` half of the extension contract). Declared as a STATIC on a
76
- * `GameComponent` subclass:
77
- *
78
- * ```ts
79
- * import type { ComponentGizmoHook } from '@vgai/editor-sdk/extension';
80
- *
81
- * export class TriggerZone extends GameComponent {
82
- * static schema = z.object({ radius: z.number().default(2) });
83
- * static editorGizmo: ComponentGizmoHook = ({ props, three }) => {
84
- * const ring = new three.LineLoop(circleGeometry(props.radius as number),
85
- * new three.LineBasicMaterial({ color: 0x44ff88 }));
86
- * return ring;
87
- * };
88
- * // …
89
- * }
90
- * ```
91
- *
92
- * Contract:
93
- * - Runs at DESIGN TIME in the editor (never in the running game). Called
94
- * whenever the editor (re)builds gizmos for an entity carrying the
95
- * component; treat it as pure — read `props`, return fresh objects.
96
- * - Returned objects are parented to the entity's `Object3D` (local space),
97
- * tagged as editor helpers on the editor-only render layer, excluded from
98
- * raycast selection, and toggled by the viewport's Helpers ▸ Components
99
- * filter. The editor disposes them with the entity's other gizmos.
100
- * - Return `null` (or an empty array) to draw nothing for this entity.
101
- * - Draw-only, v1: gizmos visualize; they do not edit. Interactive handles
102
- * stay editor-internal until a consumer needs them (no dead surface).
103
- * - Throwing (or returning a non-Object3D) marks the contribution
104
- * `'failed'`: contained, hidden, reported loudly. The editor never
105
- * substitutes a fallback drawing.
106
- */
107
- export type ComponentGizmoHook = (
108
- ctx: ComponentGizmoContext,
109
- ) => THREE.Object3D | readonly THREE.Object3D[] | null;
package/src/index.ts CHANGED
@@ -4,7 +4,8 @@ export type {
4
4
  GenerationJobStatus,
5
5
  GenerationJobsDocument,
6
6
  } from '@vgai/sdk/generations';
7
- export { EditorClient } from './client.js';
7
+ export type { GameDebugDoor } from './client.js';
8
+ export { EditorClient, EditorCommandError } from './client.js';
8
9
  export type {
9
10
  ToolAssetInspectorContributionMatch,
10
11
  ToolAssetInspectorContributionProps,
@@ -16,12 +17,7 @@ export type {
16
17
  ToolInspectorContributionProps,
17
18
  } from './contributions.js';
18
19
  export { editorViewFromUrl, editorViewUrl } from './editor-view.js';
19
- export type {
20
- ComponentGizmoContext,
21
- ComponentGizmoHook,
22
- ComponentGizmoStatus,
23
- ExtensionContributionTier,
24
- } from './extension.js';
20
+ export type { ExtensionContributionTier } from './extension.js';
25
21
  export type {
26
22
  ActiveDocumentCapture,
27
23
  AssetCompareCapture,
@@ -32,7 +28,9 @@ export type {
32
28
  AssetPreviewCapture,
33
29
  AssetPreviewOptions,
34
30
  AssetPreviewShotSetDefinition,
31
+ AssetPreviewShotWarning,
35
32
  AssetPreviewSource,
33
+ AssetPreviewStage,
36
34
  AssetPreviewView,
37
35
  EditorCameraState,
38
36
  EditorEntitySummary,
@@ -41,6 +39,15 @@ export type {
41
39
  EditorViewDocument,
42
40
  GameCapture,
43
41
  HelperVisibility,
42
+ InspectedAction,
43
+ InspectedField,
44
+ InspectedInspection,
45
+ InspectedNothing,
46
+ InspectedSection,
47
+ InspectedSectionBody,
48
+ InspectedSubject,
49
+ InspectionPresentationKind,
50
+ InspectionSurface,
44
51
  LabeledShotSetCapture,
45
52
  PresentedEditorView,
46
53
  ProjectInfo,
@@ -53,9 +60,13 @@ export type {
53
60
  ShadingMode,
54
61
  ShotSetPoseRotation,
55
62
  ShotSetShot,
63
+ StoryCaptureOptions,
64
+ StoryVariantCapture,
65
+ StoryVariantImage,
56
66
  TransformMode,
57
67
  TransformSpace,
58
68
  Vec3Value,
59
69
  ViewPreset,
60
70
  ViewportCapture,
71
+ ViewportTab,
61
72
  } from './types.js';
package/src/types.ts CHANGED
@@ -1,3 +1,14 @@
1
+ /**
2
+ * The two viewport tabs — the editor's two MODES, named for what the user is
3
+ * doing rather than for what happens to be mounted (ARCHITECTURE-CORE
4
+ * §Vocabulary, decided 2026-08-02). They were `'scene' | 'game'` until WO-LR1:
5
+ * `'scene'` outlived the `.vscn` scene format that named it (a three root is
6
+ * TSX now, and asset/story/tool documents live on that tab too), and `'game'`
7
+ * named the mounted artifact rather than the mode. Not to be confused with the
8
+ * PLAY TRANSPORT (the five stable controls) — this is which tab is showing.
9
+ */
10
+ export type ViewportTab = 'edit' | 'play';
11
+
1
12
  export type AssetKind =
2
13
  | 'model'
3
14
  | 'image'
@@ -19,8 +30,6 @@ export interface HelperVisibility {
19
30
  splines: boolean;
20
31
  navmesh: boolean;
21
32
  skeletons: boolean;
22
- /** Per-component gizmo contributions (W3a extension contract). */
23
- components: boolean;
24
33
  }
25
34
 
26
35
  export interface Vec3Value {
@@ -60,25 +69,74 @@ export interface ViewportCapture {
60
69
  * container, no DOM `Image`/`XMLSerializer`, or a rasterization failure) —
61
70
  * never a silently HUD-less image passed off as the whole game. `layers` is
62
71
  * only present on the composite leg.
72
+ *
73
+ * `flatness` and `hiddenFrame` are the same honesty contract applied to the
74
+ * PIXELS: how much of the frame is one flat surface (a near-blank capture
75
+ * carries its own "weak evidence" warning), and whether the frame exists only
76
+ * because the capture asked a hidden tab's runtime for one deterministic tick.
63
77
  */
64
78
  export interface GameCapture {
65
79
  base64: string;
66
80
  mimeType: 'image/png';
67
81
  composite: boolean;
68
82
  layers?: { canvases: number; domOverlays: number };
83
+ /** Degeneracy measure — `packages/editor/src/composite-screenshot.ts`'s
84
+ * `measureFlatness`. Absent when pixel readback was unavailable. */
85
+ flatness?: {
86
+ dominantFraction: number;
87
+ dominantColor: string;
88
+ distinctRegions: number;
89
+ degenerate: boolean;
90
+ /** Present iff `degenerate`; the sentence to show the caller verbatim. */
91
+ warning?: string;
92
+ };
93
+ /** True when the editor tab was hidden-paused and the runtime rendered one
94
+ * deterministic tick to produce this frame. */
95
+ hiddenFrame?: boolean;
69
96
  }
70
97
 
71
98
  export type AssetPreviewView = 'front' | 'right' | 'top' | 'perspective';
72
99
  export type AssetPreviewBackground = 'neutral' | 'transparent';
73
100
 
101
+ /**
102
+ * What the Asset Lab is being asked to photograph: a same-origin project
103
+ * model path, a live scene entity, or RAW GLB BYTES that travel with the
104
+ * command.
105
+ *
106
+ * The bytes form exists because rasterization only happens where there is a
107
+ * GPU. A Node host that built a model in memory — the module-look lane's
108
+ * `project.bake.preview`, which compiles a project TS module's `Object3D` and
109
+ * exports it to an in-memory GLB without writing anything — has pixels
110
+ * nowhere until the live editor session renders them. It is four-view,
111
+ * lab-stage only: bytes stand nowhere, so `stage: 'scene'`, the shot-set /
112
+ * source-review modes and compare are all refused by name at the relay.
113
+ */
74
114
  export type AssetPreviewSource =
75
- | { assetPath: string; entityId?: never }
76
- | { entityId: string; assetPath?: never };
115
+ | { assetPath: string; entityId?: never; glbBase64?: never }
116
+ | { entityId: string; assetPath?: never; glbBase64?: never }
117
+ | { glbBase64: string; assetPath?: never; entityId?: never };
118
+
119
+ /**
120
+ * Where an ENTITY capture is staged.
121
+ *
122
+ * `'lab'` (the default on every surface) is the editor's neutral Asset Lab
123
+ * stage: an isolated, yaw-normalized snapshot under fixed studio lighting,
124
+ * identical whatever the entity's surroundings are. `'scene'` photographs the
125
+ * entity where it stands in the live scene, under the scene's own lighting,
126
+ * with the editor's own grid/gizmos/helpers excluded — see
127
+ * `packages/editor/src/asset-preview.ts`'s `captureSceneStageAssetPreview`.
128
+ *
129
+ * `'scene'` is an ENTITY-only, four-view option: the relay refuses it by name
130
+ * for an `assetPath` source (a model loaded from disk stands nowhere) and for
131
+ * the shot-set / source-review / compare modes (each stages its own subject).
132
+ */
133
+ export type AssetPreviewStage = 'lab' | 'scene';
77
134
 
78
135
  export interface AssetPreviewOptions {
79
136
  width?: number;
80
137
  height?: number;
81
138
  background?: AssetPreviewBackground;
139
+ stage?: AssetPreviewStage;
82
140
  }
83
141
 
84
142
  export interface AssetPreviewCapture {
@@ -89,8 +147,39 @@ export interface AssetPreviewCapture {
89
147
  }
90
148
 
91
149
  /**
92
- * B8.4 the asset-preview compare mode (`vgai asset-preview --compare
93
- * <ref.glb>`): the asset and a caller-supplied reference GLB rendered with
150
+ * The STORY lane (`vgai screenshot <file>.stories.tsx`): a project CSF file's
151
+ * exports rendered in the live session's DOM and captured through the same
152
+ * composite leg the game lane uses, as ONE variant sheet per file. `story`
153
+ * narrows to a single export. See `packages/editor/src/story-capture.ts`.
154
+ */
155
+ export interface StoryCaptureOptions {
156
+ /** Narrow the sheet to one CSF export name (`--story <export>`). */
157
+ story?: string;
158
+ /** Per-variant cell size in CSS pixels; defaults to a 960x540 rectangle,
159
+ * because a UI story is not the Asset Lab's square 3D view. */
160
+ width?: number;
161
+ height?: number;
162
+ }
163
+
164
+ export interface StoryVariantImage extends ViewportCapture {
165
+ /** The CSF export name — the file name each variant PNG is written under. */
166
+ name: string;
167
+ /** Storybook's human-facing story name. */
168
+ label: string;
169
+ }
170
+
171
+ export interface StoryVariantCapture {
172
+ /** Project-relative path of the CSF module that was photographed. */
173
+ modulePath: string;
174
+ width: number;
175
+ height: number;
176
+ variants: StoryVariantImage[];
177
+ contactSheet: ViewportCapture & { width: number; height: number };
178
+ }
179
+
180
+ /**
181
+ * B8.4 — the Asset Lab compare mode (`vgai screenshot <model.glb>
182
+ * --compare <ref.glb>`): the asset and a caller-supplied reference GLB rendered with
94
183
  * matched orthographic front + side framing (equal-height bounding-box
95
184
  * normalization, both yaw-normalized to face the camera), scored by
96
185
  * silhouette IoU with per-view overlay evidence (orange asset / cyan
@@ -121,7 +210,7 @@ export interface AssetCompareCapture {
121
210
  }
122
211
 
123
212
  /**
124
- * A project-defined labeled shot set (`vgai asset-preview --shots <set>`).
213
+ * A project-defined labeled shot set (`vgai screenshot <target> --shots <set>`).
125
214
  * The DEFINITION is project data: a registered project tool named
126
215
  * `project.<set>.previewShots` returns it (installed capabilities register
127
216
  * theirs — e.g. the humanoid capability contributes its canonical verify set),
@@ -135,6 +224,20 @@ export interface ShotSetPoseRotation {
135
224
  radians: number;
136
225
  }
137
226
 
227
+ /** The other half of a pose: a named morph target driven to an influence.
228
+ * A rig whose meshes carry no such morph is posed by the rotations alone —
229
+ * the same degrade-don't-throw rule the rotations follow for a missing
230
+ * joint. */
231
+ export interface ShotSetPoseMorph {
232
+ morph: string;
233
+ influence: number;
234
+ }
235
+
236
+ /** One step of a named pose. A pose is a flat list so a single expression
237
+ * (a jaw ROTATION plus a brow MORPH) is one entry in `poses`, not two
238
+ * parallel lists the capture engine would have to zip. */
239
+ export type ShotSetPoseStep = ShotSetPoseRotation | ShotSetPoseMorph;
240
+
138
241
  export type ShotSetShot =
139
242
  | { label: string; view: 'turntable'; yaw: number; pose?: string | undefined }
140
243
  | {
@@ -142,6 +245,13 @@ export type ShotSetShot =
142
245
  view: 'bone-zoom';
143
246
  bones: string[];
144
247
  spanFraction: number;
248
+ /** The angle the crop is taken FROM, in the same radian convention as
249
+ * a turntable shot's `yaw` (0 the front camera, -PI/2 the subject's
250
+ * left). Omitted means 0. The front camera is not a general answer
251
+ * for a long subject: a crop anchored on the tail root of an 8 m
252
+ * quadruped otherwise photographs the hind legs standing in front of
253
+ * it. */
254
+ yaw?: number | undefined;
145
255
  pose?: string | undefined;
146
256
  };
147
257
 
@@ -155,16 +265,37 @@ export interface AssetPreviewShotSetDefinition {
155
265
  /** Optional clause appended to rig-requirement errors,
156
266
  * e.g. "a Mixamo-named humanoid skeleton". */
157
267
  rigRequirementHint?: string;
158
- /** Named poses (bone rotations applied to a disposable snapshot only);
159
- * shots opt in via their `pose` field. */
160
- poses?: Record<string, ShotSetPoseRotation[]>;
268
+ /** Named poses (bone rotations and morph influences applied to a disposable
269
+ * snapshot only); shots opt in via their `pose` field. */
270
+ poses?: Record<string, ShotSetPoseStep[]>;
161
271
  shots: ShotSetShot[];
162
272
  }
163
273
 
274
+ /**
275
+ * A shot the capture engine rendered but does not vouch for.
276
+ *
277
+ * `'empty-frame'` — the shot's frame contained no renderable geometry, so
278
+ * the PNG is background only. It is reported rather than thrown because one
279
+ * mis-aimed crop must not kill a 20-shot render; it is reported LOUDLY
280
+ * because a background tile on a contact sheet otherwise reads as coverage.
281
+ */
282
+ export interface AssetPreviewShotWarning {
283
+ label: string;
284
+ reason: 'empty-frame';
285
+ /** Already names the shot, its anchor joints and its pose — surfaces print
286
+ * this string rather than re-composing one. */
287
+ message: string;
288
+ bones?: string[];
289
+ pose?: string;
290
+ }
291
+
164
292
  export interface LabeledShotSetCapture {
165
293
  width: number;
166
294
  height: number;
167
295
  shots: Array<ViewportCapture & { label: string }>;
296
+ /** Empty when every shot framed geometry. Absent against an editor that
297
+ * predates the empty-frame guard. */
298
+ warnings: AssetPreviewShotWarning[];
168
299
  contactSheet: ViewportCapture & { width: number; height: number };
169
300
  }
170
301
 
@@ -182,18 +313,29 @@ export interface EditorState {
182
313
  */
183
314
  loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null;
184
315
  /**
185
- * R1 (docs/R3F-FOLLOW-THROUGH-SPEC.md) the pending-restart reason when
186
- * source changed while the game was RUNNING and the running session is now
187
- * stale (e.g. an R3F entry-file write-back during play, a registry.ts
188
- * edit). The editor's Restart button surfaces the same reason; one restart
189
- * (`vgai play`, or the button) remounts every root from fresh source and
190
- * clears it. `null` when the running session is fresh; absent against an
191
- * older server that predates the field.
316
+ * The connected editor tab's OWN reported visibility/focus
317
+ * (`command-listener.ts`'s `collectPresence`, straight off
318
+ * `document.visibilityState`/`document.hasFocus()`). This is the only field
319
+ * that distinguishes a usable session from a merely attached one:
320
+ * `connected` answers "is an SSE client holding the session open", which a
321
+ * BACKGROUNDED tab satisfies perfectly while the engine hidden-pauses its
322
+ * loop underneath. `null` when the page has no `document` at all; absent
323
+ * against an older server that predates the field.
324
+ */
325
+ presence?: { visibility: 'visible' | 'hidden' | 'prerender'; focused: boolean } | null;
326
+ /**
327
+ * The pending-restart reason when source changed while the game was
328
+ * RUNNING and the running session is now stale (e.g. an R3F entry-file
329
+ * write-back during play, a registry.ts edit). The editor's Restart button
330
+ * surfaces the same reason; one restart (`vgai play`, or the button)
331
+ * remounts every root from fresh source and clears it. `null` when the
332
+ * running session is fresh; absent against an older server that predates
333
+ * the field.
192
334
  */
193
335
  restartRequired?: string | null;
194
336
  selectedEntityId: string | null;
195
337
  selectedEntityIds: string[];
196
- activeViewportTab: 'scene' | 'game';
338
+ activeViewportTab: ViewportTab;
197
339
  /** The actual active center document, including tool/source documents. */
198
340
  activeDocumentId?: string | null;
199
341
  activeTabKey: string;
@@ -233,7 +375,8 @@ export interface EditorState {
233
375
  stateUpdatedAt?: number;
234
376
  /**
235
377
  * Validate-on-change (#103): per-file validation status for every
236
- * `*.vscn.json`/`*.prefab.json`/`vgai.game.json` the dev server has seen
378
+ * Project `src/**` source / `vgai.project.json` the dev
379
+ * server has seen
237
380
  * change since it booted (or since the last project switch). Server-
238
381
  * computed — unlike the rest of `EditorState`, it is NOT part of the
239
382
  * browser-POSTed snapshot, so it is always current. A file appears here
@@ -243,6 +386,32 @@ export interface EditorState {
243
386
  * read it unconditionally.
244
387
  */
245
388
  projectValidation?: Record<string, { errors: string[]; at: number }>;
389
+ /**
390
+ * PD-13: the WARNING half of the same server-computed validation pass —
391
+ * authoring-convention findings (the R3F00x codes, OID surface conflicts)
392
+ * that do not make a file invalid but do make it unauthorable. Same shape,
393
+ * same lifecycle and same freshness guarantee as `projectValidation` above:
394
+ * whole-project (not just the open document), keyed by project-relative
395
+ * path, present only while the file currently warns, `{}` when clean.
396
+ *
397
+ * The server has sent this since the R3F authoring diagnostics landed; it
398
+ * was missing from this interface, so every typed consumer — `vgai status`
399
+ * included — could only reach it through a cast. Declared here so a caller
400
+ * that wants to react to authoring warnings can see they exist.
401
+ */
402
+ projectWarnings?: Record<string, { warnings: string[]; at: number }>;
403
+ /**
404
+ * PD-14: whether the SOURCE half of the validation pass above is running at
405
+ * all. `'active'` is the normal state; `'awaiting-src'` means the project
406
+ * has no `src/` directory yet, so nothing under `src/` is being validated —
407
+ * an empty `projectValidation`/`projectWarnings` says nothing about source
408
+ * files while this reads `'awaiting-src'`. It is not terminal: the watcher
409
+ * is armed on the not-yet-existing path and flips to `'active'` (running
410
+ * the boot-equivalent scan) the moment `src/` appears, with no restart.
411
+ * `'no-project'` when no project is open. Server-computed, like its
412
+ * neighbors above.
413
+ */
414
+ sourceValidation?: 'active' | 'awaiting-src' | 'no-project';
246
415
  /**
247
416
  * #124: the absolute path of the project this editor server currently has
248
417
  * open — server-computed (never part of the browser-POSTed snapshot,
@@ -257,7 +426,7 @@ export interface EditorState {
257
426
  projectRoot?: string | null;
258
427
  /**
259
428
  * #124: the open project's declared name, alongside `projectRoot` above
260
- * (`vgai.game.json`'s `name`). `null` when no project is open, or the open
429
+ * (`vgai.project.json`'s `name`). `null` when no project is open, or the open
261
430
  * project has no readable manifest name.
262
431
  */
263
432
  projectName?: string | null;
@@ -366,3 +535,104 @@ export type {
366
535
  ProjectToolContribution,
367
536
  ToolContributionPoint,
368
537
  } from '@vgai/sdk/project-tool-catalog';
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // Inspection — the serialized inspection subject (`EditorClient.inspect`)
541
+ // ---------------------------------------------------------------------------
542
+ //
543
+ // The wire mirror of the editor's own `SerializedInspectionSubject`
544
+ // (`packages/editor/src/inspection/serialize.ts`, which owns the contract and
545
+ // carries the reasoning). `command-listener.ts` annotates its `inspect`
546
+ // payload with this type, so `tsc` checks the two sides against each other on
547
+ // every build rather than letting them drift silently.
548
+
549
+ /** Where the inspector's subject lives; `asset-lab` is an open asset
550
+ * document — the three paradigm scoped to a subtree, inspected in the same
551
+ * box as the scene. */
552
+ export type InspectionSurface = 'three' | 'canvas' | 'dom' | 'asset-lab';
553
+
554
+ /** Which LAYOUT the one inspector box is in: the compact box over the
555
+ * viewport, or the same sections stacked in the dock column. */
556
+ export type InspectionPresentationKind = 'card' | 'column';
557
+
558
+ /** One inspected field: a stable scriptable `path` and the value at it. */
559
+ export interface InspectedField {
560
+ path: string;
561
+ label: string;
562
+ type: 'string' | 'number' | 'boolean' | 'vec3' | 'color' | 'enum' | 'asset' | 'json';
563
+ /** Absent when nothing is at that address, or when `mixed` is set. */
564
+ value?: unknown;
565
+ /** The inspected subjects disagree about this field. */
566
+ mixed?: true;
567
+ /** The value shown is the declared default — the document does not carry it. */
568
+ defaulted?: boolean;
569
+ readonly?: boolean;
570
+ resettable?: boolean;
571
+ revertsTo?: string;
572
+ group?: string;
573
+ options?: readonly unknown[];
574
+ }
575
+
576
+ /** A section's content. An opaque body is a NAMED OPAQUE — the editor renders
577
+ * it with a React component the wire deliberately does not describe. The two
578
+ * opaque kinds are distinguished because "this subject has a live preview"
579
+ * is a real fact about it: `custom` is a contributed block, `preview` is the
580
+ * subject's own square view of itself.
581
+ *
582
+ * A custom body carries `data` when it can say what it DISPLAYS — the keys
583
+ * are the section's own vocabulary, not a shared schema. The shipped case is
584
+ * `transform`: `{position, rotation, scale}`, three numbers each, with
585
+ * rotation in Euler XYZ DEGREES exactly as the rotation inputs show it (the
586
+ * quaternion behind them is not on this wire). */
587
+ export type InspectedSectionBody =
588
+ | { kind: 'fields'; fields: readonly InspectedField[] }
589
+ | { kind: 'custom'; id: string; title: string; data?: Record<string, unknown> }
590
+ | { kind: 'preview'; id: string; title: string };
591
+
592
+ export interface InspectedSection {
593
+ id: string;
594
+ title: string;
595
+ order: number;
596
+ description?: string;
597
+ body: InspectedSectionBody;
598
+ }
599
+
600
+ /** A verb on the subject (the visibility eye, the Asset Editor jump). */
601
+ export interface InspectedAction {
602
+ id: string;
603
+ title: string;
604
+ label?: string;
605
+ /** Toggle state, for verbs that have one — how visibility is read. */
606
+ pressed?: boolean;
607
+ disabled?: boolean;
608
+ }
609
+
610
+ /** The whole inspection subject, as data — what a human sees in the
611
+ * inspector, for an agent (`vgai eval 'editor.inspect()'`). */
612
+ export interface InspectedSubject {
613
+ id: string;
614
+ title: string;
615
+ kindLabel?: string;
616
+ /** The quiet line a subject with nothing to edit explains itself with. */
617
+ hint?: string;
618
+ presentation: {
619
+ preferred: InspectionPresentationKind;
620
+ resolved?: InspectionPresentationKind;
621
+ surface?: InspectionSurface;
622
+ };
623
+ quickActions: readonly InspectedAction[];
624
+ /** Already in display order. */
625
+ sections: readonly InspectedSection[];
626
+ }
627
+
628
+ /** NOTHING is being inspected: the inspector is unmounted, so the honest
629
+ * answer is not an empty subject but the absence of one. Distinct from a
630
+ * missing reply, which means nobody answered
631
+ * (`editor.inspection.get`'s `INSPECTION_UNAVAILABLE`). */
632
+ export interface InspectedNothing {
633
+ none: true;
634
+ }
635
+
636
+ /** What `editor.inspect()` answers: the subject showing, or nothing at all.
637
+ * Narrow with `'none' in result`. */
638
+ export type InspectedInspection = InspectedSubject | InspectedNothing;