@volter/live 0.5.211 → 0.5.213
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/BUNDLED_NOTICES +13 -13
- package/README.md +10 -11
- package/dist/asset-library.d.ts +98 -0
- package/dist/asset-library.js +7 -0
- package/dist/asset-library.js.map +7 -0
- package/dist/chunk-7WKVJGHZ.js +15 -0
- package/dist/chunk-7WKVJGHZ.js.map +7 -0
- package/dist/chunk-B2HUWFTT.js +25 -0
- package/dist/chunk-B2HUWFTT.js.map +7 -0
- package/dist/chunk-IZJOOON2.js +805 -0
- package/dist/chunk-IZJOOON2.js.map +7 -0
- package/dist/chunk-JDOIPALX.js +158 -0
- package/dist/chunk-JDOIPALX.js.map +7 -0
- package/dist/chunk-JFAHP5CC.js +218 -0
- package/dist/chunk-JFAHP5CC.js.map +7 -0
- package/dist/chunk-PC56TDHV.js +189 -0
- package/dist/chunk-PC56TDHV.js.map +7 -0
- package/dist/chunk-WIAH4XZE.js +82 -0
- package/dist/chunk-WIAH4XZE.js.map +7 -0
- package/dist/client.d.ts +376 -0
- package/dist/client.js +10 -0
- package/dist/client.js.map +7 -0
- package/dist/discovery.d.ts +32 -0
- package/dist/discovery.js +19 -0
- package/dist/discovery.js.map +7 -0
- package/dist/editor-document.d.ts +12 -47
- package/dist/editor.d.ts +45 -183
- package/dist/http-transport.d.ts +6 -0
- package/dist/index.d.ts +3 -6
- package/dist/index.js +83 -1762
- package/dist/index.js.map +4 -4
- package/dist/installed-package.d.ts +6 -0
- package/dist/installed-package.js +7 -0
- package/dist/installed-package.js.map +7 -0
- package/dist/lazy-proxy.d.ts +11 -11
- package/dist/member-hints.d.ts +6 -9
- package/dist/product-create.d.ts +29 -0
- package/dist/product-create.js +13 -0
- package/dist/product-create.js.map +7 -0
- package/dist/product-locator.d.ts +100 -0
- package/dist/product-locator.js +25 -0
- package/dist/product-locator.js.map +7 -0
- package/dist/registry-format.d.ts +72 -0
- package/dist/registry-format.js +31 -0
- package/dist/registry-format.js.map +7 -0
- package/dist/session.d.ts +11 -15
- package/dist/singleton.d.ts +1 -13
- package/dist/volter-manifest.d.ts +55 -0
- package/dist/volter-manifest.js +9 -0
- package/dist/volter-manifest.js.map +7 -0
- package/dist/workbench-locator.d.ts +84 -0
- package/dist/workbench-locator.js +416 -0
- package/dist/workbench-locator.js.map +7 -0
- package/package.json +39 -3
- package/src/asset-library.ts +104 -0
- package/src/client.ts +1093 -0
- package/src/discovery.ts +105 -0
- package/src/editor-document.ts +12 -49
- package/src/editor.ts +56 -215
- package/src/http-transport.ts +19 -0
- package/src/index.ts +5 -8
- package/src/installed-package.ts +15 -0
- package/src/lazy-proxy.ts +11 -11
- package/src/member-hints.ts +8 -12
- package/src/product-create.ts +38 -0
- package/src/product-locator.ts +327 -0
- package/src/registry-format.ts +248 -0
- package/src/session.ts +36 -144
- package/src/singleton.ts +1 -16
- package/src/volter-manifest.ts +97 -0
- package/src/workbench-locator.ts +625 -0
- package/dist/tools.d.ts +0 -23
- package/src/tools.ts +0 -45
package/src/client.ts
ADDED
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
import type { Dispatcher } from 'undici';
|
|
2
|
+
import { createDispatcher, dispatchFetch } from './http-transport.js';
|
|
3
|
+
import { refuseWithoutRegisteredToken, withSessionToken } from './registry-format.js';
|
|
4
|
+
import { verbCommandId } from '@volter/sdk/commands';
|
|
5
|
+
import type { DocumentProbeResult, DocumentProbeStep } from '@volter/sdk/document-probe';
|
|
6
|
+
import type {
|
|
7
|
+
ActiveDocumentCapture,
|
|
8
|
+
AssetCompareCapture,
|
|
9
|
+
AssetCompareOptions,
|
|
10
|
+
AssetKind,
|
|
11
|
+
AssetPreviewCapture,
|
|
12
|
+
AssetPreviewOptions,
|
|
13
|
+
AssetPreviewShotSetDefinition,
|
|
14
|
+
AssetPreviewSource,
|
|
15
|
+
CaptureDimensions,
|
|
16
|
+
DocumentCameraPose,
|
|
17
|
+
DocumentLookOutcome,
|
|
18
|
+
StageFrameCostReading,
|
|
19
|
+
DocumentTableProjection,
|
|
20
|
+
EditorChromeCapture,
|
|
21
|
+
EditorChromeCaptureOptions,
|
|
22
|
+
EditorState,
|
|
23
|
+
EditorView,
|
|
24
|
+
EditorWorkspaceName,
|
|
25
|
+
HelperVisibility,
|
|
26
|
+
InspectedFieldWrite,
|
|
27
|
+
InspectedHierarchy,
|
|
28
|
+
InspectedInspection,
|
|
29
|
+
LabeledShotSetCapture,
|
|
30
|
+
PresentedEditorView,
|
|
31
|
+
ProjectInfo,
|
|
32
|
+
RecentProject,
|
|
33
|
+
ShadingMode,
|
|
34
|
+
StructureOp,
|
|
35
|
+
StructureOpOptions,
|
|
36
|
+
StructureOpResult,
|
|
37
|
+
TransformMode,
|
|
38
|
+
TransformSpace,
|
|
39
|
+
Vec3Value,
|
|
40
|
+
ViewPreset,
|
|
41
|
+
ViewportCapture,
|
|
42
|
+
} from '@volter/sdk';
|
|
43
|
+
|
|
44
|
+
// The editor's default origin, spelled out because this package deliberately
|
|
45
|
+
// does not depend on `@volter/project`. `DEFAULT_EDITOR_PORT` in
|
|
46
|
+
// `packages/project/src/manifest/editor-port.ts` is the owner of the number and
|
|
47
|
+
// of the never-`localhost` rule; keep this in step with it.
|
|
48
|
+
const DEFAULT_URL = 'http://127.0.0.1:20173';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A refused editor command, carrying the relay's own STRUCTURED failure code
|
|
52
|
+
* (`/__editor/command`'s `{ ok: false, error, code }`) alongside the prose, so
|
|
53
|
+
* a caller that reacts to a specific refusal matches the code, never the
|
|
54
|
+
* sentence.
|
|
55
|
+
*/
|
|
56
|
+
export class EditorCommandError extends Error {
|
|
57
|
+
readonly code: string | undefined;
|
|
58
|
+
constructor(message: string, code?: string | undefined) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'EditorCommandError';
|
|
61
|
+
this.code = code;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The client's own ceiling on ONE relayed command.
|
|
67
|
+
*
|
|
68
|
+
* A backstop for a LOST server, not a per-command budget: the server already
|
|
69
|
+
* owns per-type budgets (`server/server-utils.ts`'s `relayCommandTimeoutMs`)
|
|
70
|
+
* and its timer must always be the one that fires, because its message names
|
|
71
|
+
* the tab and the remedy while this one can only say "no answer". So this is
|
|
72
|
+
* deliberately ONE number, comfortably above the longest server budget
|
|
73
|
+
* (120s, `play`/`capture-story-variants`) rather than a mirror of that table —
|
|
74
|
+
* a second copy of it would drift silently, and the drift would show up as
|
|
75
|
+
* this timer winning a race it must always lose.
|
|
76
|
+
*
|
|
77
|
+
* Without it a `fetch` with no `AbortSignal` waits on the OS: a dev server
|
|
78
|
+
* that stops answering mid-command holds the CLI open indefinitely, with no
|
|
79
|
+
* output and nothing to read.
|
|
80
|
+
*/
|
|
81
|
+
const COMMAND_DEADLINE_MS = 150_000;
|
|
82
|
+
/** undici's default `headersTimeout`; a command deadline beyond it needs its own dispatcher (see `command`). */
|
|
83
|
+
const UNDICI_DEFAULT_HEADERS_TIMEOUT_MS = 300_000;
|
|
84
|
+
|
|
85
|
+
/** Deadline for console-ledger reads and acknowledgements. */
|
|
86
|
+
const CONSOLE_DRAIN_TIMEOUT_MS = 1_500;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Node's `fetch` collapses EVERY network-layer failure into one two-word
|
|
90
|
+
* `TypeError: fetch failed`. The real reason — `ECONNREFUSED`, `ECONNRESET`,
|
|
91
|
+
* `EPIPE`, a DNS miss — lives only on `error.cause` (sometimes two links down,
|
|
92
|
+
* or inside an `AggregateError`), and nothing prints it unless something walks
|
|
93
|
+
* the chain, so a bare "fetch failed" cannot tell a dead editor from a
|
|
94
|
+
* momentary one. This walks the chain so the message can say which.
|
|
95
|
+
*/
|
|
96
|
+
function describeFetchFailure(error: unknown): { code: string; detail: string } {
|
|
97
|
+
const messages: string[] = [];
|
|
98
|
+
let current: unknown = error;
|
|
99
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
100
|
+
if (!(current instanceof Error)) break;
|
|
101
|
+
if (current.message) messages.push(current.message);
|
|
102
|
+
const code = (current as { code?: unknown }).code;
|
|
103
|
+
if (typeof code === 'string' && code !== '') {
|
|
104
|
+
return { code, detail: messages.join(' <- ') };
|
|
105
|
+
}
|
|
106
|
+
const aggregate = (current as { errors?: unknown }).errors;
|
|
107
|
+
if (Array.isArray(aggregate) && aggregate.length > 0) {
|
|
108
|
+
const inner = describeFetchFailure(aggregate[0]);
|
|
109
|
+
return { code: inner.code, detail: [...messages, inner.detail].join(' <- ') };
|
|
110
|
+
}
|
|
111
|
+
current = (current as { cause?: unknown }).cause;
|
|
112
|
+
}
|
|
113
|
+
return { code: 'UNKNOWN', detail: messages.join(' <- ') || String(error) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Connection failures eligible for one retry when a command opts in. An editor
|
|
118
|
+
* response is not a transport failure and does not use this retry path.
|
|
119
|
+
*/
|
|
120
|
+
const RETRYABLE_TRANSPORT_CODES = new Set([
|
|
121
|
+
'ECONNREFUSED',
|
|
122
|
+
'ECONNRESET',
|
|
123
|
+
'EPIPE',
|
|
124
|
+
'ETIMEDOUT',
|
|
125
|
+
'EHOSTUNREACH',
|
|
126
|
+
'UND_ERR_SOCKET',
|
|
127
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
/** Gap before the one automatic retry — long enough for a dev-server restart's
|
|
131
|
+
* listen socket to come back, short enough to stay invisible. */
|
|
132
|
+
const TRANSPORT_RETRY_DELAY_MS = 400;
|
|
133
|
+
|
|
134
|
+
/** What one undo/redo step reports back — `moved` is false when there was
|
|
135
|
+
* nothing left in that direction, which is an answer, not an error. */
|
|
136
|
+
export interface HistoryStep {
|
|
137
|
+
readonly moved: boolean;
|
|
138
|
+
readonly canUndo: boolean;
|
|
139
|
+
readonly canRedo: boolean;
|
|
140
|
+
readonly undoLabel: string | null;
|
|
141
|
+
readonly redoLabel: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What `open()` acknowledges: the workspace document the scene-table entry
|
|
145
|
+
* resolved to, and the title its tab now carries — the project's own word for
|
|
146
|
+
* that composition, not a filename. */
|
|
147
|
+
export interface OpenedDocument {
|
|
148
|
+
readonly documentId: string;
|
|
149
|
+
readonly title: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class EditorClient {
|
|
153
|
+
private readonly baseUrl: string;
|
|
154
|
+
private readonly fetchOverride: typeof fetch | undefined;
|
|
155
|
+
private readonly transport:
|
|
156
|
+
| ((body: Record<string, unknown>) => Promise<Record<string, unknown>>)
|
|
157
|
+
| null;
|
|
158
|
+
|
|
159
|
+
constructor(opts?: {
|
|
160
|
+
url?: string;
|
|
161
|
+
/** An authenticated transport for the same HTTP routes (e.g. a hosted tab). */
|
|
162
|
+
fetch?: typeof fetch;
|
|
163
|
+
/**
|
|
164
|
+
* An IN-PAGE command channel, for a client that lives inside the editor
|
|
165
|
+
* page itself and has no editor server to reach. Given, every command goes through it instead of
|
|
166
|
+
* `POST /__editor/command`, and answers in the route's own body shape
|
|
167
|
+
* (`{ ok: true, ...data }` / `{ ok: false, error, code? }`).
|
|
168
|
+
*/
|
|
169
|
+
transport?: (body: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
170
|
+
}) {
|
|
171
|
+
if (opts !== undefined && (typeof opts !== 'object' || opts === null || Array.isArray(opts))) {
|
|
172
|
+
throw new TypeError(
|
|
173
|
+
'EditorClient options must be an object. Use new EditorClient({ url: "http://127.0.0.1:20173" }), not new EditorClient("...").',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const unknownOptions = Object.keys(opts ?? {}).filter(
|
|
177
|
+
(key) => key !== 'url' && key !== 'transport' && key !== 'fetch',
|
|
178
|
+
);
|
|
179
|
+
if (unknownOptions.length > 0) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`EditorClient: unknown option${unknownOptions.length === 1 ? '' : 's'} ${unknownOptions.map((key) => `"${key}"`).join(', ')}. Use { url: "http://127.0.0.1:<port>" } to target an editor.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
this.baseUrl = (opts?.url ?? DEFAULT_URL).replace(/\/$/, '');
|
|
185
|
+
this.fetchOverride = opts?.fetch;
|
|
186
|
+
this.transport = opts?.transport ?? null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `retryTransport` opts a command into ONE automatic retry after a transport
|
|
191
|
+
* failure (see {@link RETRYABLE_TRANSPORT_CODES}). It is deliberately
|
|
192
|
+
* OPT-IN and off by default: a socket that died after the request was written
|
|
193
|
+
* cannot prove the editor did not already run the command, so a blanket retry
|
|
194
|
+
* would risk playing/stopping/writing twice. Read-only relays — the captures —
|
|
195
|
+
* have no such hazard and turn it on.
|
|
196
|
+
*/
|
|
197
|
+
private async command<T extends object = Record<string, never>>(
|
|
198
|
+
body: Record<string, unknown>,
|
|
199
|
+
options?: { retryTransport?: boolean; deadlineMs?: number },
|
|
200
|
+
): Promise<T> {
|
|
201
|
+
const type = String(body['type'] ?? 'command');
|
|
202
|
+
const deadlineMs = options?.deadlineMs ?? COMMAND_DEADLINE_MS;
|
|
203
|
+
if (this.transport) {
|
|
204
|
+
const answered = (await this.transport(body)) as {
|
|
205
|
+
ok: boolean;
|
|
206
|
+
error?: string;
|
|
207
|
+
code?: string;
|
|
208
|
+
} & T;
|
|
209
|
+
if (!answered.ok) {
|
|
210
|
+
throw new EditorCommandError(
|
|
211
|
+
answered.error ?? `Editor command "${type}" failed`,
|
|
212
|
+
answered.code,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return answered;
|
|
216
|
+
}
|
|
217
|
+
const url = `${this.baseUrl}/__editor/command`;
|
|
218
|
+
const request = JSON.stringify(body);
|
|
219
|
+
let res: Response | undefined;
|
|
220
|
+
let retried = false;
|
|
221
|
+
let commandDispatcher: Dispatcher | undefined;
|
|
222
|
+
for (;;) {
|
|
223
|
+
try {
|
|
224
|
+
const init: RequestInit = {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'Content-Type': 'application/json' },
|
|
227
|
+
body: request,
|
|
228
|
+
signal: AbortSignal.timeout(deadlineMs),
|
|
229
|
+
};
|
|
230
|
+
// The route answers when the command completes. Undici's default header
|
|
231
|
+
// timeout is 300s, independent of the abort signal; longer command budgets
|
|
232
|
+
// use an explicit dispatcher through undici's fetch to extend that timeout.
|
|
233
|
+
commandDispatcher = deadlineMs > UNDICI_DEFAULT_HEADERS_TIMEOUT_MS
|
|
234
|
+
? createDispatcher(deadlineMs + 30_000)
|
|
235
|
+
: undefined;
|
|
236
|
+
res = this.fetchOverride ? await this.fetchOverride(url, init) : await dispatchFetch(url, withSessionToken(url, init), commandDispatcher);
|
|
237
|
+
break;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
await commandDispatcher?.destroy();
|
|
240
|
+
commandDispatcher = undefined;
|
|
241
|
+
// Only the deadline is reshaped into a "no answer" verdict; everything
|
|
242
|
+
// else is a TRANSPORT failure, and Node hides its reason behind a bare
|
|
243
|
+
// `fetch failed` (see `describeFetchFailure`).
|
|
244
|
+
if ((error as { name?: string } | null)?.name === 'TimeoutError') {
|
|
245
|
+
throw new EditorCommandError(
|
|
246
|
+
`The editor at ${this.baseUrl} never answered "${type}" ` +
|
|
247
|
+
`within ${Math.round(deadlineMs / 1000)}s — past every server-side budget, so ` +
|
|
248
|
+
'the server itself is not answering. Check the terminal that started this editor session.',
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const { code, detail } = describeFetchFailure(error);
|
|
252
|
+
if (options?.retryTransport === true && !retried && RETRYABLE_TRANSPORT_CODES.has(code)) {
|
|
253
|
+
retried = true;
|
|
254
|
+
await new Promise((resolve) => setTimeout(resolve, TRANSPORT_RETRY_DELAY_MS));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
throw new EditorCommandError(
|
|
258
|
+
`POST ${url} ("${type}") never reached the editor: ${code}${detail ? ` (${detail})` : ''}.` +
|
|
259
|
+
(retried
|
|
260
|
+
? ` Retried once after ${TRANSPORT_RETRY_DELAY_MS}ms; it failed the same way.`
|
|
261
|
+
: '') +
|
|
262
|
+
(options?.retryTransport === true
|
|
263
|
+
? ''
|
|
264
|
+
: ' Not retried automatically: this command can change editor state, and a socket that' +
|
|
265
|
+
' died after the request was written cannot prove the editor did not already run it.') +
|
|
266
|
+
' A transport failure means the port stopped answering, not that the editor refused —' +
|
|
267
|
+
' the dev server restarts on any watched source edit, and it shuts itself down after an' +
|
|
268
|
+
' idle window. Check the terminal that started this editor session and confirm the port' +
|
|
269
|
+
' this client resolved.',
|
|
270
|
+
code,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!this.fetchOverride) {
|
|
275
|
+
try {
|
|
276
|
+
refuseWithoutRegisteredToken(url, res.status);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
await commandDispatcher?.destroy();
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
let data: { ok: boolean; error?: string; code?: string } & T;
|
|
283
|
+
try {
|
|
284
|
+
data = (await this.readJson(res)) as typeof data;
|
|
285
|
+
} finally {
|
|
286
|
+
await commandDispatcher?.destroy();
|
|
287
|
+
}
|
|
288
|
+
if (!data.ok) {
|
|
289
|
+
throw new EditorCommandError(
|
|
290
|
+
data.error ?? `Editor command failed: ${res.status}`,
|
|
291
|
+
data.code,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return data;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Refuse shutdown if a document cannot be saved. Does not stop the server. */
|
|
298
|
+
async prepareClose(): Promise<void> {
|
|
299
|
+
await this.command({ type: 'session-prepare-close' });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// --- Selection ---
|
|
303
|
+
|
|
304
|
+
async select(id: string | null): Promise<void> {
|
|
305
|
+
await this.command({ type: 'select', id });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async selectMultiple(ids: string[]): Promise<void> {
|
|
309
|
+
await this.command({ type: 'select-multiple', ids });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async selectAll(): Promise<void> {
|
|
313
|
+
await this.command({ type: 'select-all' });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --- Viewport ---
|
|
317
|
+
|
|
318
|
+
async focusEntity(id: string): Promise<void> {
|
|
319
|
+
await this.command({ type: 'focus-entity', id });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async focusSelection(): Promise<void> {
|
|
323
|
+
await this.command({ type: 'focus-selection' });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Frame the EDIT viewport camera on one entity — the strict sibling of
|
|
328
|
+
* {@link focusEntity}. Same framing; an id the scene does not know is a
|
|
329
|
+
* refusal naming the id (`EditorCommandError`, code `ENTITY_NOT_FOUND`)
|
|
330
|
+
* rather than `focusEntity`'s silent no-op, so a caller that frames an
|
|
331
|
+
* entity before capturing it cannot photograph the wrong thing.
|
|
332
|
+
*/
|
|
333
|
+
async frameEntity(id: string): Promise<void> {
|
|
334
|
+
await this.command({ type: 'frame-entity', id });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async viewPreset(preset: ViewPreset): Promise<void> {
|
|
338
|
+
await this.command({ type: 'view-preset', preset });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* LOOK AROUND THE OPEN MODEL, visibly. Swings the active asset
|
|
343
|
+
* document's camera — the one on the human's screen — by `azimuth`/
|
|
344
|
+
* `elevation` radians, animated over `duration` seconds, and resolves when
|
|
345
|
+
* the move ends. A human drag during the move cancels it where it stands
|
|
346
|
+
* (`cancelledBy: 'human'`); the promise still resolves.
|
|
347
|
+
*/
|
|
348
|
+
async orbitDocument(options: {
|
|
349
|
+
azimuth?: number;
|
|
350
|
+
elevation?: number;
|
|
351
|
+
duration?: number;
|
|
352
|
+
}): Promise<DocumentLookOutcome> {
|
|
353
|
+
return this.command<DocumentLookOutcome>({ type: 'document-orbit', ...options });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** What a frame of a 3D document's stage costs, uncapped (`document-frame-cost`). */
|
|
357
|
+
async frameCostDocument(options?: { readonly frames?: number; readonly stage?: string; readonly quality?: 'full' | 'navigation' }): Promise<StageFrameCostReading> {
|
|
358
|
+
return this.command<StageFrameCostReading>({ type: 'document-frame-cost', ...options });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A slow full revolution around the open document's subject, at a constant rate. */
|
|
362
|
+
async turntableDocument(options?: {
|
|
363
|
+
seconds?: number;
|
|
364
|
+
revolutions?: number;
|
|
365
|
+
}): Promise<DocumentLookOutcome> {
|
|
366
|
+
return this.command<DocumentLookOutcome>({ type: 'document-turntable', ...options });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Frame the open document's subject (its selection if it has one). `fit`
|
|
371
|
+
* scales the fitted distance: 1 is the toolbar Frame button's tight fit.
|
|
372
|
+
*/
|
|
373
|
+
async frameDocument(fit?: number): Promise<DocumentCameraPose> {
|
|
374
|
+
return this.command<DocumentCameraPose>({
|
|
375
|
+
type: 'document-frame',
|
|
376
|
+
...(fit === undefined ? {} : { fit }),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async setCamera(position: Vec3Value, target: Vec3Value, fov?: number): Promise<void> {
|
|
381
|
+
await this.command({
|
|
382
|
+
type: 'set-camera',
|
|
383
|
+
position,
|
|
384
|
+
target,
|
|
385
|
+
...(fov === undefined ? {} : { fov }),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async captureViewport(size?: number): Promise<ViewportCapture> {
|
|
390
|
+
const data = await this.command<ViewportCapture>({
|
|
391
|
+
type: 'capture-viewport',
|
|
392
|
+
...(size === undefined ? {} : { size }),
|
|
393
|
+
});
|
|
394
|
+
return { base64: data.base64, mimeType: data.mimeType };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Capture an isolated, deterministic four-view preview through the editor's
|
|
399
|
+
* native Asset Lab. The SDK delegates rendering to the editor; it never
|
|
400
|
+
* loads, clones, or interprets 3D assets itself.
|
|
401
|
+
*/
|
|
402
|
+
async captureAssetPreview(
|
|
403
|
+
source: AssetPreviewSource,
|
|
404
|
+
options: AssetPreviewOptions = {},
|
|
405
|
+
): Promise<AssetPreviewCapture> {
|
|
406
|
+
const data = await this.command<AssetPreviewCapture>(
|
|
407
|
+
{
|
|
408
|
+
type: 'capture-asset-preview',
|
|
409
|
+
...source,
|
|
410
|
+
...options,
|
|
411
|
+
},
|
|
412
|
+
// Photographing changes nothing, so a momentary transport failure is
|
|
413
|
+
// retried rather than costing the caller its capture.
|
|
414
|
+
{ retryTransport: true },
|
|
415
|
+
);
|
|
416
|
+
return {
|
|
417
|
+
width: data.width,
|
|
418
|
+
height: data.height,
|
|
419
|
+
orientation: data.orientation,
|
|
420
|
+
views: data.views,
|
|
421
|
+
contactSheet: data.contactSheet,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A caller-defined labeled shot set (`live`'s `assetPreviewShots`): the
|
|
427
|
+
* DEFINITION travels with the command (see `AssetPreviewShotSetDefinition`),
|
|
428
|
+
* and the editor's generic
|
|
429
|
+
* capture engine renders it — see the 3D integration's
|
|
430
|
+
* `captureShotSetAssetPreview`. Throws (via `command`'s `{ok:false}`
|
|
431
|
+
* unwrap) with a clear message naming the missing joint(s) when the asset
|
|
432
|
+
* lacks a bone the definition requires.
|
|
433
|
+
*/
|
|
434
|
+
async captureShotSetPreview(
|
|
435
|
+
source: AssetPreviewSource,
|
|
436
|
+
definition: AssetPreviewShotSetDefinition,
|
|
437
|
+
options: AssetPreviewOptions = {},
|
|
438
|
+
): Promise<LabeledShotSetCapture> {
|
|
439
|
+
const data = await this.command<LabeledShotSetCapture>(
|
|
440
|
+
{
|
|
441
|
+
type: 'capture-asset-preview',
|
|
442
|
+
...source,
|
|
443
|
+
...options,
|
|
444
|
+
shotSet: definition,
|
|
445
|
+
},
|
|
446
|
+
{ retryTransport: true },
|
|
447
|
+
);
|
|
448
|
+
return {
|
|
449
|
+
width: data.width,
|
|
450
|
+
height: data.height,
|
|
451
|
+
shots: data.shots,
|
|
452
|
+
warnings: data.warnings,
|
|
453
|
+
contactSheet: data.contactSheet,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Score the asset against a reference GLB (the editor's `screenshot
|
|
459
|
+
* <model.glb> --compare <ref.glb>` command): matched orthographic front + side silhouettes
|
|
460
|
+
* (equal-height bounding-box framing, both yaw-normalized to face the
|
|
461
|
+
* camera), per-view IoU numbers, and overlay evidence images. The
|
|
462
|
+
* reference GLB's raw bytes travel base64 in the command; the editor
|
|
463
|
+
* renders and scores — the SDK never interprets 3D assets itself.
|
|
464
|
+
*/
|
|
465
|
+
async captureAssetComparePreview(
|
|
466
|
+
source: AssetPreviewSource,
|
|
467
|
+
refGlbBase64: string,
|
|
468
|
+
options: AssetCompareOptions = {},
|
|
469
|
+
): Promise<AssetCompareCapture> {
|
|
470
|
+
const { refForward, ...dimensions } = options;
|
|
471
|
+
const data = await this.command<AssetCompareCapture>({
|
|
472
|
+
type: 'capture-asset-preview',
|
|
473
|
+
...source,
|
|
474
|
+
...dimensions,
|
|
475
|
+
compare: { glbBase64: refGlbBase64, ...(refForward ? { forward: refForward } : {}) },
|
|
476
|
+
});
|
|
477
|
+
return { width: data.width, height: data.height, views: data.views };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// --- Panels ---
|
|
481
|
+
|
|
482
|
+
/** Focus a static workspace panel by the key the editor's panel registry
|
|
483
|
+
* holds; an unknown key refuses naming the keys it does hold. */
|
|
484
|
+
async showPanel(panel: string): Promise<void> {
|
|
485
|
+
await this.command({ type: 'show-panel', panel });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Show several instances of the running project split-screen — multiplayer
|
|
489
|
+
* authoring. Pass a total `count` (default "Player N" labels) or an array of
|
|
490
|
+
* `names` (its length is the count; index 0 is the primary). Requires a live
|
|
491
|
+
* play session. */
|
|
492
|
+
async setInstanceCount(countOrNames: number | string[]): Promise<void> {
|
|
493
|
+
await this.command(
|
|
494
|
+
Array.isArray(countOrNames)
|
|
495
|
+
? { type: 'set-instance-count', names: countOrNames }
|
|
496
|
+
: { type: 'set-instance-count', count: countOrNames },
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async openAsset(path: string, kind: AssetKind): Promise<void> {
|
|
501
|
+
await this.command({ type: 'open-asset-tab', path, kind });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** SELECT a project asset — the other half of the browser's
|
|
505
|
+
* selection-vs-open contract (single click selects and fills the
|
|
506
|
+
* Inspector; double click opens a document). */
|
|
507
|
+
async selectAsset(path: string): Promise<void> {
|
|
508
|
+
await this.command({ type: 'select-asset', path });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async closeAsset(key: string): Promise<void> {
|
|
512
|
+
await this.command({ type: 'close-asset-tab', key });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async toggleCommandPalette(): Promise<void> {
|
|
516
|
+
await this.command({ type: 'toggle-command-palette' });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async toggleConsole(): Promise<void> {
|
|
520
|
+
await this.command({ type: 'toggle-console' });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Switch the editor's NAMED WORKSPACE — the task-named layout memory
|
|
524
|
+
* (`design`/`animate`/`look`, or one a package contributes). Answers the workspace now active and whether its dock
|
|
525
|
+
* had rebuilt within the command's wait (`applied`): when it had, a following capture photographs the arrangement
|
|
526
|
+
* that was asked for. */
|
|
527
|
+
async setWorkspace(workspace: EditorWorkspaceName): Promise<{ readonly workspace: EditorWorkspaceName; readonly applied: boolean }> {
|
|
528
|
+
return this.command<{ workspace: EditorWorkspaceName; applied: boolean }>({ type: 'set-workspace', workspace });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Choose a STYLE (`volter.style`) — its material, icon set and type, and the colour theme it
|
|
532
|
+
* was drawn with (`classic`/`glass`/`maya`/`substance`, or one a package the project or
|
|
533
|
+
* product composes carries). */
|
|
534
|
+
async setStyle(style: string): Promise<void> {
|
|
535
|
+
await this.command({ type: 'set-style', style });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** A view's presentation, answered resolved. With a LAYER the person's choice is recorded
|
|
539
|
+
* first. */
|
|
540
|
+
async viewportPresentation(
|
|
541
|
+
documentId: string,
|
|
542
|
+
layer?: import('@volter/sdk/viewport-presentation').PresentationLayer,
|
|
543
|
+
): Promise<{
|
|
544
|
+
presentation: import('@volter/sdk/viewport-presentation').ViewportPresentation;
|
|
545
|
+
binding: { stageKind: string; documentLayer: import('@volter/sdk/viewport-presentation').PresentationLayer | null } | null;
|
|
546
|
+
bound: { viewId: string; stageKind: string }[];
|
|
547
|
+
lastDraw: import('@volter/sdk/viewport-presentation').ViewDrawReport | null;
|
|
548
|
+
presets: string[];
|
|
549
|
+
/** The environment images registered (`./environment-images`), for `lighting.preview.environment.image`. */
|
|
550
|
+
environmentImages: string[];
|
|
551
|
+
}> {
|
|
552
|
+
return this.command({
|
|
553
|
+
type: 'viewport-presentation',
|
|
554
|
+
documentId,
|
|
555
|
+
...(layer ? { layer } : {}),
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async showBuild(): Promise<void> {
|
|
560
|
+
await this.command({ type: 'show-build' });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Atomically present a durable editor view and return its shareable URL. */
|
|
564
|
+
async present(view: EditorView): Promise<PresentedEditorView> {
|
|
565
|
+
const presented = await this.command<PresentedEditorView>({ type: 'present-view', view });
|
|
566
|
+
return { view: presented.view, url: presented.url, warnings: presented.warnings };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
async inspect(): Promise<InspectedInspection> {
|
|
571
|
+
const data = await this.command<{ subject: InspectedInspection }>({ type: 'inspect' });
|
|
572
|
+
return data.subject;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Run one verb exposed by the active Inspector subject, by its id. */
|
|
576
|
+
async runInspectionAction(actionId: string): Promise<InspectedInspection> {
|
|
577
|
+
const data = await this.command<{ subject: InspectedInspection }>({
|
|
578
|
+
type: 'run-inspection-action',
|
|
579
|
+
actionId,
|
|
580
|
+
});
|
|
581
|
+
return data.subject;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Run ONE command by id — the door to everything the command palette lists.
|
|
586
|
+
*
|
|
587
|
+
* Under the Code-OSS frame this is the workbench's own `ICommandService`, so
|
|
588
|
+
* any command id works: a view's `volter.<view>.<verb>`, an editor action's
|
|
589
|
+
* `volter.action.<id>`, or one of VS Code's own. A call made before the
|
|
590
|
+
* command service lands waits for it.
|
|
591
|
+
*
|
|
592
|
+
* The result is whatever the command answered — a view verb's state, or
|
|
593
|
+
* `null` for a command that returns nothing.
|
|
594
|
+
*/
|
|
595
|
+
async runCommand(commandId: string, args?: unknown): Promise<unknown> {
|
|
596
|
+
const data = await this.command<{ result: unknown }>({
|
|
597
|
+
type: 'run-command',
|
|
598
|
+
commandId,
|
|
599
|
+
...(args === undefined ? {} : { args }),
|
|
600
|
+
});
|
|
601
|
+
return data.result;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* One STRUCTURE op on the authored tree — the hierarchy context menu's own
|
|
606
|
+
* verbs, on the same helpers, for a caller with no pointer to right-click
|
|
607
|
+
* with. `id`/`ids` default to the current selection.
|
|
608
|
+
*/
|
|
609
|
+
async structureOp(op: StructureOp, options: StructureOpOptions = {}): Promise<StructureOpResult> {
|
|
610
|
+
return this.command<StructureOpResult>({ type: 'structure-op', op, ...options });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** "Extract Component…" — the hierarchy row's action, as a command. Answers
|
|
614
|
+
* the action's own sentence, which NAMES the files it created. */
|
|
615
|
+
async extractComponent(options: { id?: string; name?: string } = {}): Promise<{ hint: string }> {
|
|
616
|
+
return this.command<{ hint: string }>({ type: 'extract-component', ...options });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** "Fork Component…" — extract's twin: one new file, one callsite retargeted. */
|
|
620
|
+
async forkComponent(options: { id?: string } = {}): Promise<{ hint: string }> {
|
|
621
|
+
return this.command<{ hint: string }>({ type: 'fork-component', ...options });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The HIERARCHY PANEL's actual rendered row tree, as data.
|
|
626
|
+
*
|
|
627
|
+
* The panel projects component marks, document promotion, child caps,
|
|
628
|
+
* collapse state, search and selection scope onto the adapter tree.
|
|
629
|
+
*
|
|
630
|
+
* Deliberately NOT `status().entities`, which walks the raw adapter tree and
|
|
631
|
+
* therefore answers a different question: a panel defect is invisible in it.
|
|
632
|
+
*
|
|
633
|
+
* Each row reports the projected child count and whether the panel draws a disclosure control.
|
|
634
|
+
*
|
|
635
|
+
* Rejects, naming the panel, when no hierarchy panel is mounted: an empty
|
|
636
|
+
* tree would be a fabricated answer about a surface nobody is being shown.
|
|
637
|
+
*/
|
|
638
|
+
async hierarchy(): Promise<InspectedHierarchy> {
|
|
639
|
+
const data = await this.command<{ hierarchy: InspectedHierarchy }>({ type: 'hierarchy' });
|
|
640
|
+
return data.hierarchy;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Run the Hierarchy panel's own Expand All action. */
|
|
644
|
+
async expandHierarchyAll(): Promise<void> {
|
|
645
|
+
await this.command<Record<string, never>>({ type: 'expand-hierarchy-all' });
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Run the Hierarchy panel's own Collapse All action — Expand All's other
|
|
649
|
+
* half, and the only way back to the tree's rest state through the product
|
|
650
|
+
* (see `HierarchyPanelSnapshot.collapseAll`). */
|
|
651
|
+
async collapseHierarchyAll(): Promise<void> {
|
|
652
|
+
await this.command<Record<string, never>>({ type: 'collapse-hierarchy-all' });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Write one editable path through the active Inspector's own IO.
|
|
657
|
+
*
|
|
658
|
+
* The answer carries `write` as well as the subject, because an ack alone
|
|
659
|
+
* cannot be believed: a write with no persistence route open succeeds and
|
|
660
|
+
* changes no byte, and `write.persisted` is how the caller tells the two
|
|
661
|
+
* apart without diffing the tree (`InspectedWriteDestination` in `types.ts`).
|
|
662
|
+
*/
|
|
663
|
+
async setInspectionField(path: string, value: unknown): Promise<InspectedFieldWrite> {
|
|
664
|
+
const data = await this.command<InspectedFieldWrite>({
|
|
665
|
+
type: 'set-inspection-field',
|
|
666
|
+
path,
|
|
667
|
+
value,
|
|
668
|
+
});
|
|
669
|
+
return { subject: data.subject, write: data.write };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* REMOVE one editable path's authored override — the other half of the write
|
|
674
|
+
* door, and the only one that can express byte-ABSENCE.
|
|
675
|
+
*
|
|
676
|
+
* {@link setInspectionField} writes a VALUE, so reverting a property an
|
|
677
|
+
* authoring gesture ADDED puts the default back EXPLICITLY and leaves the
|
|
678
|
+
* source one attribute heavier than it started. This drops the property, so
|
|
679
|
+
* whatever governs it in its absence takes over — the same `io.remove` the
|
|
680
|
+
* Inspector's revert arrow calls, the same persistence pipe, the same awaited
|
|
681
|
+
* `{ destination, persisted }` ack.
|
|
682
|
+
*
|
|
683
|
+
* Rejects with `code: 'REMOVAL_UNAVAILABLE'` when the field does not declare
|
|
684
|
+
* itself removable or the lane implements no removal door. That refusal is a
|
|
685
|
+
* MISSING SEAM, not a failed removal, and it is coded rather than phrased
|
|
686
|
+
* precisely so a caller can grade the two differently.
|
|
687
|
+
*/
|
|
688
|
+
async removeInspectionField(path: string): Promise<InspectedFieldWrite> {
|
|
689
|
+
const data = await this.command<InspectedFieldWrite>({
|
|
690
|
+
type: 'remove-inspection-field',
|
|
691
|
+
path,
|
|
692
|
+
});
|
|
693
|
+
return { subject: data.subject, write: data.write };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* OPEN one piece of the adapter's SCENE TABLE by id — a scene, a prefab, or
|
|
698
|
+
* a story state, because the table makes them siblings (they differ only in
|
|
699
|
+
* instance site). The ids are exactly what `getState().adapter.scenes.entries`
|
|
700
|
+
* reports, so the table is both the menu and the address space. It opens
|
|
701
|
+
* the entry's document in Play as in Edit.
|
|
702
|
+
*
|
|
703
|
+
* Rejects with a coded reason rather than prose: `SCENE_NOT_FOUND` (and it
|
|
704
|
+
* names the ids that DO exist), `SCENE_NOT_OPENABLE` carrying the reason an
|
|
705
|
+
* entry owns no document (the adapter's own, when it declared one),
|
|
706
|
+
* `SCENE_TABLE_UNAVAILABLE` before the adapter has loaded, and
|
|
707
|
+
* `SCENE_DOCUMENT_NOT_MOUNTED` when the host has no document for a piece the
|
|
708
|
+
* table says is openable — a host gap, not a table statement.
|
|
709
|
+
*/
|
|
710
|
+
async open(id: string): Promise<OpenedDocument> {
|
|
711
|
+
return this.command<OpenedDocument>({ type: 'open', id });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Undo / redo one project transaction — the same queue the keyboard shortcut
|
|
716
|
+
* drives. `moved` is false when there was nothing left in that direction.
|
|
717
|
+
*/
|
|
718
|
+
async undo(): Promise<HistoryStep> {
|
|
719
|
+
return this.command<HistoryStep>({ type: 'undo' });
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async redo(): Promise<HistoryStep> {
|
|
723
|
+
return this.command<HistoryStep>({ type: 'redo' });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Read the editor's actual current durable projection. */
|
|
727
|
+
async currentView(): Promise<EditorView> {
|
|
728
|
+
const data = await this.command<{ view: EditorView }>({ type: 'current-view' });
|
|
729
|
+
return data.view;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Photograph the editor PAGE itself — every panel as the person sees it, at
|
|
733
|
+
* `scale` output pixels per CSS pixel (default `devicePixelRatio`), which is
|
|
734
|
+
* what a 1 px border or a glyph edge is judged through. */
|
|
735
|
+
async captureEditorChrome(options?: EditorChromeCaptureOptions): Promise<EditorChromeCapture> {
|
|
736
|
+
return this.command<EditorChromeCapture>({
|
|
737
|
+
type: 'capture-editor-chrome',
|
|
738
|
+
...(options?.scale === undefined ? {} : { scale: options.scale }),
|
|
739
|
+
...(options?.region === undefined ? {} : { region: options.region }),
|
|
740
|
+
...(options?.name === undefined ? {} : { name: options.name }),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Capture the active document's render, excluding editor chrome. A number
|
|
745
|
+
* requests a square; `{width, height}` requests that shape, bounded by
|
|
746
|
+
* {@link CaptureDimensions}. With a view, present and capture it in one request
|
|
747
|
+
* so discovery cannot retarget the capture between client calls. */
|
|
748
|
+
async captureActiveDocument(
|
|
749
|
+
size?: CaptureDimensions,
|
|
750
|
+
view?: EditorView,
|
|
751
|
+
): Promise<ActiveDocumentCapture> {
|
|
752
|
+
return this.command<ActiveDocumentCapture>({
|
|
753
|
+
type: 'capture-active-document',
|
|
754
|
+
...(view ? { view } : {}),
|
|
755
|
+
...(typeof size === 'number' ? { size } : {}),
|
|
756
|
+
...(typeof size === 'object' && size !== null
|
|
757
|
+
? { width: size.width, height: size.height }
|
|
758
|
+
: {}),
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Read or drive the ACTIVE center document's own DOM — the scoped
|
|
764
|
+
* editor-chrome door, and the read/gesture half of the same subject
|
|
765
|
+
* {@link captureActiveDocument} photographs. NOT play-mode gated, and NOT
|
|
766
|
+
* page automation: a target outside the active document's container is
|
|
767
|
+
* refused by name. Design and scope contract:
|
|
768
|
+
* `packages/sdk/src/editor-document-probe.ts`.
|
|
769
|
+
*/
|
|
770
|
+
async documentProbe(step: DocumentProbeStep): Promise<DocumentProbeResult> {
|
|
771
|
+
return this.command<DocumentProbeResult>({ type: 'document-probe', step });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* A verb a package contributes: the Code-OSS command `volter.<type>` its page half registers,
|
|
776
|
+
* waited on as long as its declaration says (`contributes.commands`' `volter.timeoutMs`, which
|
|
777
|
+
* the relay reads from the same manifest), so a package's long verb (a modeling chunk, minutes
|
|
778
|
+
* under Wasm) has its budget in one place. A verb no package declares is refused by name
|
|
779
|
+
* before anything is sent.
|
|
780
|
+
*/
|
|
781
|
+
async contributedVerb<T extends object = Record<string, unknown>>(
|
|
782
|
+
type: string,
|
|
783
|
+
fields: Record<string, unknown> = {},
|
|
784
|
+
): Promise<T> {
|
|
785
|
+
const id = verbCommandId(type);
|
|
786
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/commands/${encodeURIComponent(id)}`);
|
|
787
|
+
const declared = (await this.readJson(res)) as { timeoutMs: number; error?: string };
|
|
788
|
+
if (!res.ok) throw new Error(declared.error ?? `No package declares the command "${id}" (${res.status}).`);
|
|
789
|
+
// Past the relay's own budget, so the relay's answer (its result or its timeout) is what arrives.
|
|
790
|
+
return this.command<T>({ type, ...fields }, { deadlineMs: declared.timeoutMs + 30_000 });
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Run a wire-carried step against the ACTIVE document's published context
|
|
795
|
+
* (`packages/editor-core/src/document-context-registry.ts`) — the REPL door over
|
|
796
|
+
* an open document. `src` is JavaScript source evaluated in the page; references to the caller's
|
|
797
|
+
* closures are unavailable.
|
|
798
|
+
*/
|
|
799
|
+
async documentScript<T = unknown>(src: string): Promise<T> {
|
|
800
|
+
const outcome = await this.command<{ result: T }>({ type: 'document-script', src });
|
|
801
|
+
return outcome.result;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// --- Display (set semantics) ---
|
|
805
|
+
|
|
806
|
+
async setGrid(enabled: boolean): Promise<void> {
|
|
807
|
+
await this.command({ type: 'set-grid', enabled });
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async setHelpers(enabled: boolean): Promise<void> {
|
|
811
|
+
await this.command({ type: 'set-helpers', enabled });
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async setStats(enabled: boolean): Promise<void> {
|
|
815
|
+
await this.command({ type: 'set-stats', enabled });
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async setShadingMode(mode: ShadingMode): Promise<void> {
|
|
819
|
+
await this.command({ type: 'set-shading-mode', mode });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async setHelperType(helperType: keyof HelperVisibility, enabled: boolean): Promise<void> {
|
|
823
|
+
await this.command({ type: 'set-helper-type', helperType, enabled });
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// --- Transform tools (set semantics) ---
|
|
827
|
+
|
|
828
|
+
async setTransformMode(mode: TransformMode): Promise<void> {
|
|
829
|
+
await this.command({ type: 'set-transform-mode', mode });
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async setTransformSpace(space: TransformSpace): Promise<void> {
|
|
833
|
+
await this.command({ type: 'set-transform-space', space });
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async setSnap(enabled: boolean): Promise<void> {
|
|
837
|
+
await this.command({ type: 'set-snap', enabled });
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// --- Project management ---
|
|
841
|
+
|
|
842
|
+
/** A new project from one of the running product's templates or examples (its `/__editor/templates`), or its default
|
|
843
|
+
* where neither is named. */
|
|
844
|
+
async createProject(
|
|
845
|
+
name: string,
|
|
846
|
+
location: string,
|
|
847
|
+
from: { readonly template?: string; readonly example?: string } = {},
|
|
848
|
+
): Promise<ProjectInfo> {
|
|
849
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/create-project`, {
|
|
850
|
+
method: 'POST',
|
|
851
|
+
headers: { 'Content-Type': 'application/json' },
|
|
852
|
+
body: JSON.stringify({ name, location, ...from }),
|
|
853
|
+
});
|
|
854
|
+
const data = (await this.readJson(res)) as {
|
|
855
|
+
ok?: boolean;
|
|
856
|
+
error?: string;
|
|
857
|
+
path?: string;
|
|
858
|
+
config?: ProjectInfo['config'];
|
|
859
|
+
};
|
|
860
|
+
if (!res.ok) {
|
|
861
|
+
throw new Error(data.error ?? `Create project failed: ${res.status}`);
|
|
862
|
+
}
|
|
863
|
+
return { path: data.path as string, config: data.config as ProjectInfo['config'] };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async openProject(path: string): Promise<void> {
|
|
867
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/open-project`, {
|
|
868
|
+
method: 'POST',
|
|
869
|
+
headers: { 'Content-Type': 'application/json' },
|
|
870
|
+
body: JSON.stringify({ path }),
|
|
871
|
+
});
|
|
872
|
+
const body = (await this.readJson(res)) as { error?: string };
|
|
873
|
+
if (!res.ok) {
|
|
874
|
+
throw new Error(body.error ?? `Open project failed: ${res.status}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async getProject(): Promise<ProjectInfo | null> {
|
|
879
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/project`);
|
|
880
|
+
const data = (await this.readJson(res)) as { project: ProjectInfo | null; error?: string };
|
|
881
|
+
if (!res.ok) throw new Error(data.error ?? `Failed to get project: ${res.status}`);
|
|
882
|
+
return data.project;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** One `/__editor/asset-library/<route>` request, for `editor.assetLibrary` (`asset-library.ts`): a GET's fields as
|
|
886
|
+
* its query, a POST's as its JSON body. A refusal throws the route's own error. */
|
|
887
|
+
async assetLibraryRequest<T>(method: 'GET' | 'POST', route: string, fields: object): Promise<T> {
|
|
888
|
+
const url = new URL(`${this.baseUrl}/__editor/asset-library/${route}`);
|
|
889
|
+
if (method === 'GET') {
|
|
890
|
+
for (const [name, value] of Object.entries(fields)) if (value !== undefined) url.searchParams.set(name, String(value));
|
|
891
|
+
}
|
|
892
|
+
// A DOWNLOAD WAITS AS LONG AS THE ROUTE DOES. It answers once the whole file is in the project, and it cancels the
|
|
893
|
+
// import when its caller goes (`res.once('close')` in the route), so undici's default 300 s headers deadline would
|
|
894
|
+
// cancel a large asset on a slow link. Like a command whose budget passes that default (`command`), it gets its
|
|
895
|
+
// own dispatcher, here with no deadline at all (undici sets no timer for 0, `client-h1.js` `setTimeout`): the
|
|
896
|
+
// route sets none. A search or a file listing is one upstream lookup whose route cancels nothing, and keeps the
|
|
897
|
+
// default as its ceiling.
|
|
898
|
+
const dispatcher = route === 'download' ? createDispatcher(0) : undefined;
|
|
899
|
+
try {
|
|
900
|
+
const res = await this.httpFetch(
|
|
901
|
+
url.toString(),
|
|
902
|
+
method === 'GET' ? undefined : { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields) },
|
|
903
|
+
dispatcher,
|
|
904
|
+
);
|
|
905
|
+
const data = (await this.readJson(res)) as T & { error?: string };
|
|
906
|
+
if (!res.ok) throw new Error(data.error ?? `${method} /__editor/asset-library/${route} failed: ${res.status}`);
|
|
907
|
+
return data;
|
|
908
|
+
} finally {
|
|
909
|
+
await dispatcher?.destroy();
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async listRecentProjects(): Promise<RecentProject[]> {
|
|
914
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/recent-projects`);
|
|
915
|
+
const data = (await this.readJson(res)) as { projects: RecentProject[]; error?: string };
|
|
916
|
+
if (!res.ok) throw new Error(data.error ?? `Failed to list projects: ${res.status}`);
|
|
917
|
+
return data.projects;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// --- State ---
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* The document table the host resolved — every scene, prefab, page, model,
|
|
924
|
+
* shot, take … the project's finders produced (`getState().adapter.scenes`
|
|
925
|
+
* is the same projection). A command, so it answers wherever the control
|
|
926
|
+
* channel reaches, not only where `/__editor/state` is served.
|
|
927
|
+
*/
|
|
928
|
+
async documentTable(): Promise<DocumentTableProjection> {
|
|
929
|
+
return this.command<DocumentTableProjection>({ type: 'document-table' });
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
async reloadPage(): Promise<void> {
|
|
934
|
+
const timeoutMs = 60_000;
|
|
935
|
+
const epochs = async () => {
|
|
936
|
+
const { tabs } = await this.getState().catch(() => ({ tabs: [] }));
|
|
937
|
+
return new Map(tabs.map((tab) => [tab.tabId8, tab] as const));
|
|
938
|
+
};
|
|
939
|
+
const pause = () => new Promise((settle) => setTimeout(settle, 250));
|
|
940
|
+
const before = await epochs();
|
|
941
|
+
const deadline = Date.now() + timeoutMs;
|
|
942
|
+
await this.command({ type: 'page-reload' });
|
|
943
|
+
let reloaded: string[] = [];
|
|
944
|
+
while (reloaded.length === 0) {
|
|
945
|
+
if (Date.now() > deadline) throw new Error('The reload was ordered and no tab of this session reported a new page load.');
|
|
946
|
+
await pause();
|
|
947
|
+
const now = await epochs();
|
|
948
|
+
reloaded = [...now].filter(([tabId, tab]) => tab.epochCount > (before.get(tabId)?.epochCount ?? 0)).map(([tabId]) => tabId);
|
|
949
|
+
}
|
|
950
|
+
for (;;) {
|
|
951
|
+
if (Date.now() > deadline) throw new Error('A new page loaded, but its command listener did not become ready.');
|
|
952
|
+
const now = await epochs();
|
|
953
|
+
if (reloaded.some((tabId) => {
|
|
954
|
+
const listener = now.get(tabId)?.commandListener;
|
|
955
|
+
return typeof listener !== 'string' || listener === 'ready';
|
|
956
|
+
})) break;
|
|
957
|
+
await pause();
|
|
958
|
+
}
|
|
959
|
+
for (;;) {
|
|
960
|
+
try {
|
|
961
|
+
await this.command({ type: 'current-view' });
|
|
962
|
+
return;
|
|
963
|
+
} catch (error) {
|
|
964
|
+
if (Date.now() > deadline) throw error;
|
|
965
|
+
await pause();
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async getState(): Promise<EditorState> {
|
|
971
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/state`);
|
|
972
|
+
const state = (await this.readJson(res)) as EditorState;
|
|
973
|
+
if (!res.ok) throw new Error(`Failed to get editor state: ${res.status} ${res.statusText}`);
|
|
974
|
+
return state;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* The complete unresolved console set the session is holding right now.
|
|
979
|
+
*
|
|
980
|
+
* The console endpoint returns named conditions; command envelopes carry
|
|
981
|
+
* only their counts. The request has a bounded header deadline.
|
|
982
|
+
*/
|
|
983
|
+
async getUnresolvedConsole(opts?: { all?: boolean }): Promise<unknown> {
|
|
984
|
+
const res = await this.httpFetch(
|
|
985
|
+
`${this.baseUrl}/__editor/console${opts?.all === true ? '?all=1' : ''}`,
|
|
986
|
+
{ signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS) },
|
|
987
|
+
);
|
|
988
|
+
// Status first: a 404's empty body would otherwise die inside readJson
|
|
989
|
+
// with a parse error that hides the one fact the caller classifies on
|
|
990
|
+
// (does this server SERVE the console route at all?).
|
|
991
|
+
if (!res.ok) {
|
|
992
|
+
throw new Error(`Failed to read unresolved console: ${res.status}`);
|
|
993
|
+
}
|
|
994
|
+
return await this.readJson(res);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Acknowledge one named console condition. */
|
|
998
|
+
async acknowledgeConsole(input: {
|
|
999
|
+
readonly id: string;
|
|
1000
|
+
readonly reason: string;
|
|
1001
|
+
readonly by: string;
|
|
1002
|
+
}): Promise<unknown> {
|
|
1003
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/console/ack`, {
|
|
1004
|
+
method: 'POST',
|
|
1005
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1006
|
+
body: JSON.stringify(input),
|
|
1007
|
+
signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS * 4),
|
|
1008
|
+
});
|
|
1009
|
+
const body = await this.readJson(res);
|
|
1010
|
+
return body;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* `fetch` for this client's plain routes, with the ONE thing Node's `fetch`
|
|
1015
|
+
* will not do: name why it failed.
|
|
1016
|
+
*
|
|
1017
|
+
* `readJson` below already owns "the server answered the wrong thing"; this
|
|
1018
|
+
* owns "nothing answered at all", naming the real code Node buries on
|
|
1019
|
+
* `.cause`. No retry
|
|
1020
|
+
* here — these routes create projects, run tools and acknowledge console
|
|
1021
|
+
* conditions, so repeating one is the caller's decision. The relayed
|
|
1022
|
+
* `command` path above has its own opt-in retry for the read-only captures.
|
|
1023
|
+
*/
|
|
1024
|
+
private async httpFetch(
|
|
1025
|
+
url: string,
|
|
1026
|
+
init?: RequestInit,
|
|
1027
|
+
dispatcher?: Dispatcher,
|
|
1028
|
+
): Promise<Response> {
|
|
1029
|
+
let res: Response;
|
|
1030
|
+
try {
|
|
1031
|
+
res = this.fetchOverride ? await this.fetchOverride(url, init) : await dispatchFetch(url, withSessionToken(url, init), dispatcher);
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
if ((error as { name?: string } | null)?.name === 'TimeoutError') throw error;
|
|
1034
|
+
const { code, detail } = describeFetchFailure(error);
|
|
1035
|
+
throw new EditorCommandError(
|
|
1036
|
+
`${init?.method ?? 'GET'} ${url} never reached the editor: ${code}` +
|
|
1037
|
+
`${detail ? ` (${detail})` : ''}. Nothing answered on that port — check the terminal ` +
|
|
1038
|
+
'that started this editor session and confirm the port this client resolved.',
|
|
1039
|
+
code,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
if (!this.fetchOverride) refuseWithoutRegisteredToken(url, res.status);
|
|
1043
|
+
return res;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** Require a JSON response from the editor server. */
|
|
1047
|
+
private async readJson(res: Response): Promise<unknown> {
|
|
1048
|
+
// The client's JSON response routes funnel through here, so this is
|
|
1049
|
+
// where "did the editor server answer?" is asked — the same question, and
|
|
1050
|
+
// the same JSON-content-type rule, that
|
|
1051
|
+
// `packages/sdk/src/editor-server-response.ts` owns on the browser side.
|
|
1052
|
+
// It is asked again rather than imported because THIS package is published
|
|
1053
|
+
// and depends on neither `@volter/project` nor the editor bundle (see
|
|
1054
|
+
// `DEFAULT_URL` above for that policy).
|
|
1055
|
+
//
|
|
1056
|
+
// Not theoretical here: `baseUrl` is whatever `--url`/`VOLTER_EDITOR_URL`
|
|
1057
|
+
// says, so the CLI can be pointed at a static host, which answers
|
|
1058
|
+
// `200 text/html` for a route nothing serves, and `res.json()` would die as
|
|
1059
|
+
// `Unexpected token '<'`, naming neither the URL nor the cause.
|
|
1060
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
1061
|
+
if (!contentType.includes('application/json')) {
|
|
1062
|
+
throw new Error(
|
|
1063
|
+
`The editor at ${this.baseUrl} answered with its page fallback ` +
|
|
1064
|
+
`(${contentType || 'no content-type'}) rather than JSON, so no editor server handled ` +
|
|
1065
|
+
'the request. Check that this URL is a running editor session.',
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
return await res.json();
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Whether an editor browser tab is connected to the server *right now*.
|
|
1073
|
+
* Unlike {@link getState}, this reflects live SSE connections, not cached
|
|
1074
|
+
* state — use it to check whether commands will actually reach an editor.
|
|
1075
|
+
*/
|
|
1076
|
+
async isConnected(): Promise<boolean> {
|
|
1077
|
+
const state = await this.getState();
|
|
1078
|
+
return state.connected === true;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async waitForState(
|
|
1082
|
+
predicate: (s: EditorState) => boolean,
|
|
1083
|
+
timeoutMs = 10_000,
|
|
1084
|
+
): Promise<EditorState> {
|
|
1085
|
+
const start = Date.now();
|
|
1086
|
+
while (Date.now() - start < timeoutMs) {
|
|
1087
|
+
const state = await this.getState();
|
|
1088
|
+
if (predicate(state)) return state;
|
|
1089
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1090
|
+
}
|
|
1091
|
+
throw new Error(`waitForState timed out after ${timeoutMs}ms`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|