@volter/editor-sdk 0.5.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +19 -0
  4. package/package.json +90 -0
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +83 -0
  7. package/src/client.ts +1547 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +985 -0
  10. package/src/document-probe.ts +237 -0
  11. package/src/editor-view.ts +220 -0
  12. package/src/extension.ts +40 -0
  13. package/src/generations.ts +178 -0
  14. package/src/host.ts +1167 -0
  15. package/src/http-transport.browser.ts +14 -0
  16. package/src/http-transport.node.ts +19 -0
  17. package/src/index.ts +128 -0
  18. package/src/layout-arrangements.ts +5 -0
  19. package/src/layouts.tsx +108 -0
  20. package/src/looks.ts +14 -0
  21. package/src/project/output-roots.ts +73 -0
  22. package/src/project/tab-census.ts +149 -0
  23. package/src/project-tool-catalog.ts +96 -0
  24. package/src/selection.tsx +108 -0
  25. package/src/services.ts +18 -0
  26. package/src/session/build-report.ts +19 -0
  27. package/src/session/collaboration-types.ts +262 -0
  28. package/src/session/command-table.ts +333 -0
  29. package/src/session/discovery.ts +90 -0
  30. package/src/session/editor-brand.ts +73 -0
  31. package/src/session/editor-compatibility.ts +248 -0
  32. package/src/session/editor-control-lifecycle.ts +68 -0
  33. package/src/session/editor-control-protocol.ts +5 -0
  34. package/src/session/entrypoint-selection-readers.ts +66 -0
  35. package/src/session/entrypoint-selection-source.ts +120 -0
  36. package/src/session/game-css-scope.ts +30 -0
  37. package/src/session/product-create.ts +24 -0
  38. package/src/session/product-locator.ts +389 -0
  39. package/src/session/project-module-url.ts +245 -0
  40. package/src/session/registry-format.ts +203 -0
  41. package/src/session/relative-path-guard.ts +56 -0
  42. package/src/session/source-glob.ts +15 -0
  43. package/src/session/tool-contribution-convention.ts +116 -0
  44. package/src/session/workbench-locator.ts +650 -0
  45. package/src/session.ts +41 -0
  46. package/src/share.ts +160 -0
  47. package/src/tools/errors.ts +91 -0
  48. package/src/tools/provider-execution.ts +70 -0
  49. package/src/tools/registry.ts +341 -0
  50. package/src/tools/types.ts +159 -0
  51. package/src/transport.ts +97 -0
  52. package/src/types.ts +1581 -0
  53. package/src/views.ts +164 -0
  54. package/src/widgets/design-system.ts +93 -0
  55. package/src/widgets/editor-appearance.ts +149 -0
  56. package/src/widgets/editor-material.ts +83 -0
  57. package/src/widgets/icon-set-registry.ts +105 -0
  58. package/src/widgets/index.ts +71 -0
  59. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  60. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  61. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  62. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  63. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  64. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  65. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  66. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  67. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  68. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  69. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  70. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  71. package/src/widgets/inspector-widgets/index.ts +24 -0
  72. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  73. package/src/widgets/interactive-edit-scope.ts +33 -0
  74. package/src/widgets/patterns/Dialog.tsx +129 -0
  75. package/src/widgets/patterns/Fields.tsx +44 -0
  76. package/src/widgets/patterns/List.tsx +25 -0
  77. package/src/widgets/patterns/StateSurface.tsx +40 -0
  78. package/src/widgets/patterns/Surfaces.tsx +122 -0
  79. package/src/widgets/patterns/Tabs.tsx +80 -0
  80. package/src/widgets/patterns/Toolbar.tsx +72 -0
  81. package/src/widgets/patterns/Tree.tsx +72 -0
  82. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  83. package/src/widgets/primitives/Button.tsx +62 -0
  84. package/src/widgets/primitives/ColorInput.tsx +78 -0
  85. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  86. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  87. package/src/widgets/primitives/FormControls.tsx +88 -0
  88. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  89. package/src/widgets/primitives/JsonInput.tsx +113 -0
  90. package/src/widgets/primitives/Layout.tsx +100 -0
  91. package/src/widgets/primitives/Menu.tsx +140 -0
  92. package/src/widgets/primitives/NumberInput.tsx +169 -0
  93. package/src/widgets/primitives/Panel.tsx +80 -0
  94. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  95. package/src/widgets/primitives/Text.tsx +54 -0
  96. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  97. package/src/widgets/primitives/Tooltip.tsx +204 -0
  98. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  99. package/src/widgets/primitives/banner-tones.ts +32 -0
  100. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  101. package/src/widgets/primitives/editor-icons.ts +245 -0
  102. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  103. package/src/widgets/theme.ts +2633 -0
  104. package/src/widgets/z-index.ts +25 -0
@@ -0,0 +1,985 @@
1
+ /**
2
+ * Props for ordinary React components registered at editor contribution
3
+ * points. There is deliberately no extension class or lifecycle: package
4
+ * metadata names a module and the editor renders its default export.
5
+ */
6
+
7
+ import type { AuthoringAdapter } from '@volter/editor-project/adapter';
8
+ import type { GenerationAccountProjection } from '@volter/editor-sdk/account';
9
+ import type { GenerationJob } from '@volter/editor-sdk/generations';
10
+ import type {
11
+ AnimationClip,
12
+ Camera,
13
+ ColorRepresentation,
14
+ Group,
15
+ Intersection,
16
+ Object3D,
17
+ Ray,
18
+ Scene,
19
+ ToneMapping,
20
+ WebGLRenderer,
21
+ } from 'three';
22
+ import type { EditorClient } from './client.js';
23
+ import type { ProjectToolCatalogEntry } from './types.js';
24
+
25
+ /** Stable, format-neutral selection data available to inspector contributions. */
26
+ export interface ToolContributionNode {
27
+ readonly id: string;
28
+ readonly label: string;
29
+ readonly role?: string;
30
+ readonly secondaryLabel?: string;
31
+ readonly kind: string;
32
+ readonly parentId: string | null;
33
+ readonly childIds: string[];
34
+ }
35
+
36
+ /**
37
+ * One disposable native Three.js build supplied to the editor's generic
38
+ * preview surface. This is a host-lifetime boundary, not a model format or
39
+ * procedural-asset base class: project code still constructs ordinary
40
+ * Object3D and AnimationClip instances directly.
41
+ */
42
+ export interface ToolObject3DPreviewSource {
43
+ readonly root: Object3D;
44
+ /**
45
+ * Optional authored roots for the document hierarchy when `root` is a
46
+ * presentation/lifetime container rather than authored content itself.
47
+ * Framing, ticking and disposal still own `root`; this list only scopes the
48
+ * shared Hierarchy. Omitted means `root`, preserving the native tree.
49
+ */
50
+ readonly hierarchyRoots?: readonly Object3D[];
51
+ readonly animations?: readonly AnimationClip[];
52
+ /**
53
+ * The source's OWN tick, and the host drives it EXACTLY ONCE per mount — for
54
+ * the bounded design-time settle that gives a physics-owned body the pose it
55
+ * actually rests in (`authoring/design-time-settle.ts`). It is never a frame
56
+ * loop: content time does not advance on a design-time surface, so a source
57
+ * that animates from here shows its settled pose and then holds it. Play is
58
+ * where a world runs.
59
+ */
60
+ update?(deltaSeconds: number): void;
61
+ dispose(): void;
62
+ }
63
+
64
+ /** Direct native handles for optional project-owned preview presentation. */
65
+ export interface ToolObject3DPreviewContext {
66
+ readonly scene: Scene;
67
+ readonly camera: Camera;
68
+ readonly renderer: WebGLRenderer;
69
+ /**
70
+ * A cloned Object3D graph, never the source root itself. Geometry,
71
+ * materials, and textures are shared read-only with the disposable source;
72
+ * clone a resource before applying preview-only mutations to it.
73
+ */
74
+ readonly model: Object3D;
75
+ }
76
+
77
+ export interface ToolObject3DPreviewExtension {
78
+ /** When present, replaces the viewer's default render call for this frame. */
79
+ render?(deltaSeconds: number): void;
80
+ /** Resize project-owned composers and render targets with the host viewport. */
81
+ resize?(width: number, height: number, pixelRatio: number): void;
82
+ /** Release every light, ground mesh, pass, target, and listener added here. */
83
+ dispose(): void;
84
+ }
85
+
86
+ export interface ToolObject3DPreviewProps {
87
+ /** Build a fresh source. The host disposes it after its preview snapshot. */
88
+ readonly build: () => ToolObject3DPreviewSource;
89
+ readonly displayName?: string;
90
+ readonly height?: number | string;
91
+ readonly background?: ColorRepresentation;
92
+ readonly showSkeleton?: boolean;
93
+ readonly cameraDirection?: readonly [number, number, number];
94
+ /** Host diagnostic grid. Defaults off with setupPreview, on otherwise. */
95
+ readonly showGrid?: boolean;
96
+ /** Host studio lights. Defaults off with setupPreview, on otherwise. */
97
+ readonly useDefaultLighting?: boolean;
98
+ /** Explicit renderer exposure. Custom presentation otherwise owns it. */
99
+ readonly exposure?: number;
100
+ /**
101
+ * Optional raw Three.js presentation hook for project-specific lights,
102
+ * ground, or postprocessing. Host grid/studio lights default off when this
103
+ * is present. It is preview chrome and is never exported.
104
+ */
105
+ readonly setupPreview?: (
106
+ context: ToolObject3DPreviewContext,
107
+ ) => ToolObject3DPreviewExtension | undefined;
108
+ readonly active?: boolean;
109
+ }
110
+
111
+ /**
112
+ * Per-document overrides of the editor's standard viewport dressing — explicit
113
+ * opt-outs and opt-ins, never re-implementations. Omitted means the standard
114
+ * look (`standard-viewport-dressing.ts`).
115
+ */
116
+ export interface ToolViewportDressing {
117
+ /** `false` skips the RoomEnvironment IBL — and, with it, the stage's
118
+ * environment INTENSITY: a document that opts out of image-based lighting
119
+ * is lit by its own lights alone. Default on. */
120
+ readonly environment?: boolean;
121
+ /** `false` skips the gradient backdrop. Default on (an explicit flat
122
+ * `background` color or the asset studio stage also skips it). */
123
+ readonly background?: boolean;
124
+ /** `false` skips the key light — and the editor's own design-time light
125
+ * rig with it, because both answer the same question and a document that
126
+ * says "I light myself" is answering it. Default on; a source that
127
+ * authors its own lights skips the key regardless — authored lighting
128
+ * always wins. */
129
+ readonly keyLight?: boolean;
130
+ /** `true` adds the ground grid (content standing on y=0). Default off. */
131
+ readonly grid?: boolean;
132
+ /**
133
+ * An object the STAGE parents to its own camera, so everything under it
134
+ * turns with the view instead of standing still in the world.
135
+ *
136
+ * It exists for VIEW-SPACE LIGHTING — Blender's Solid mode has no world
137
+ * light at all, only four studio lights stated in view space, which is why
138
+ * its shading reads the same however the model is orbited. A directional
139
+ * light here states its direction in the camera's own space (three's camera
140
+ * looks down −Z) and keeps its `target` under this object at the origin.
141
+ *
142
+ * The stage owns the parenting AND the teardown: a document hands the
143
+ * object over and its own `dispose` never touches the camera.
144
+ */
145
+ readonly viewLocked?: Object3D;
146
+ /**
147
+ * THE STAGE'S VIEW TRANSFORM — three's `WebGLRenderer.toneMapping`. Omitted
148
+ * means the editor's own, ACES.
149
+ *
150
+ * A document whose source system states its own transform passes that one
151
+ * instead: Blender's factory scene is AgX (`view_settings.view_transform`),
152
+ * and this product's Blender RENDER path already photographs through
153
+ * `AgXToneMapping` — so before this existed the viewport was the one
154
+ * surface in the chain running a different curve from the thing it frames.
155
+ * It is not a look preference: the curve decides how a shading range lands
156
+ * on screen, and two curves over one radiance are two different pictures.
157
+ */
158
+ readonly toneMapping?: ToneMapping;
159
+ }
160
+
161
+ /**
162
+ * A native source graph hosted by the editor's normal Three.js authoring
163
+ * viewport, hierarchy, selection, and Inspector. Unlike Object3DPreview this
164
+ * surface exposes the live graph as the active workspace document context.
165
+ */
166
+ export interface ToolObject3DAuthoringProps {
167
+ readonly documentId: string;
168
+ readonly sourcePath: string;
169
+ /**
170
+ * Produce the graph to author. Called ONCE PER ACTIVATION, not once per
171
+ * document: the host tears its scene down whenever the document goes inactive
172
+ * (an ordinary tab switch) and calls this again on the way back, disposing
173
+ * whatever the previous call returned. So a caller must pick one of two
174
+ * shapes, and there is no third:
175
+ *
176
+ * - a FACTORY — build a fresh graph every call, and let the returned
177
+ * `dispose` free it (what the model/entity asset documents do); or
178
+ * - an OWNED graph — return the same root every call with an EMPTY `dispose`,
179
+ * and free it from the caller's own lifetime instead (what the 3D
180
+ * components board and the story turntable do, because their graph is
181
+ * async to create).
182
+ *
183
+ * Returning a graph you cannot rebuild synchronously AND a `dispose` that
184
+ * really frees it is the third shape, and it renders a black panel on the
185
+ * second activation.
186
+ */
187
+ readonly build: () => ToolObject3DPreviewSource;
188
+ readonly displayName?: string;
189
+ /** Explicit flat scene background. Omit it to inherit the editor's standard
190
+ * viewport dressing (environment, gradient backdrop, key light). */
191
+ readonly background?: ColorRepresentation;
192
+ /**
193
+ * The Asset Lab's IMPORT AUDIT on this document's inspector — the Geometry
194
+ * block (triangle counts, GPU estimate, LODs, collision, invalid values)
195
+ * and the Source row. Default on: it is what an imported model's inspector
196
+ * is for. A modeling document that carries its own data panel (the mesh
197
+ * document's Data / Modifiers sections) passes `false`, the way Blender's
198
+ * Object Data tab is the mesh's own, not an importer's report.
199
+ */
200
+ readonly audit?: boolean;
201
+ readonly cameraDirection?: readonly [number, number, number];
202
+ /** Per-document overrides of the standard viewport dressing. */
203
+ readonly dressing?: ToolViewportDressing;
204
+ /** How far the OPENING view stands back from a fit of the content: `1`
205
+ * fills the view (the default); Blender's startup view has its cube at
206
+ * about a third of that, so the Model document opens at `3`. Frame
207
+ * (numpad .) still fits exactly. */
208
+ readonly openingFit?: number;
209
+ /**
210
+ * Optional binding from live native clips back to ordinary project source.
211
+ * Project code only serializes its own TypeScript shape; the editor owns the
212
+ * checksum-guarded write and records it in the canonical project history.
213
+ * Without this binding the animation workspace remains honestly read-only.
214
+ */
215
+ /**
216
+ * Project-owned serialization for this authored document. Each serializer
217
+ * returns the exact bytes of one ordinary project file; the host writes all
218
+ * resources as one checksum-guarded, failure-atomic history transaction.
219
+ * The project owns the format and the host never interprets its contents.
220
+ */
221
+ readonly persistence?: ToolObject3DDocumentPersistence;
222
+ /** Project-owned serialization of this document into ONE source module,
223
+ * written through the editor's source seam (see the binding's docs). */
224
+ readonly documentSource?: ToolDocumentSourceBinding;
225
+ /** Replace the default native-tree projection with a semantic adapter. The
226
+ * default adapter is provided for delegation, so a project can add terrain
227
+ * layers, bones, or mesh elements without rebuilding Object3D projection. */
228
+ readonly authoring?: ToolObject3DDocumentAuthoringFactory;
229
+ /** Project-owned direct manipulation hosted by the editor's input and
230
+ * transient-overlay lifecycle. Completed gestures commit `persistence`; an
231
+ * Escape, unmount, thrown callback, or failed write calls `cancel`. */
232
+ readonly interaction?: ToolObject3DDocumentInteraction;
233
+ /**
234
+ * The editor's orange SELECTION SILHOUETTE around whatever the hierarchy
235
+ * has selected. Default on. A document that owns a sub-object editing mode
236
+ * passes `false` while that mode is live: the silhouette is an OBJECT-level
237
+ * affordance, and Blender draws none in Edit Mode. It is live — a document
238
+ * may flip it as its own mode changes, and the outline appears or goes with
239
+ * the next frame.
240
+ */
241
+ readonly selectionOutline?: boolean;
242
+ readonly active?: boolean;
243
+ /**
244
+ * This document's own counts, drawn in the VIEWPORT OVERLAY under the view
245
+ * and subject lines — Blender's home for them (`sculpting.png`: `Vertices
246
+ * 8` / `Faces 6` below `User Perspective` / `(1) Cube | Cube`, never in the
247
+ * header band). The host owns the block's geometry and ink; the document
248
+ * owns which counts exist and what they are called, because only it knows
249
+ * what it is counting. Omit it and no block is drawn.
250
+ */
251
+ readonly statistics?: readonly ToolViewportStatistic[];
252
+ }
253
+
254
+ /** One row of the viewport overlay's statistics block: Blender's label column
255
+ * and its value column. */
256
+ export interface ToolViewportStatistic {
257
+ /** Stable row identity — the React key, never drawn. */
258
+ readonly id: string;
259
+ /** Blender's own noun where one exists (`Vertices`, `Faces`). */
260
+ readonly label: string;
261
+ readonly value: string;
262
+ }
263
+
264
+ /** The native document state handed back to project-owned serializers. */
265
+ export interface ToolObject3DDocumentState {
266
+ readonly root: Object3D;
267
+ readonly animations: readonly AnimationClip[];
268
+ }
269
+
270
+ /** One project-owned artifact participating in an Asset Lab commit. */
271
+ export interface ToolObject3DDocumentResource {
272
+ /** Project-root-relative destination. */
273
+ readonly path: string;
274
+ /** MIME type recorded in canonical history. */
275
+ readonly contentType?: string;
276
+ /** `null` removes the file in the same atomic transaction. */
277
+ readonly serialize: (
278
+ document: ToolObject3DDocumentState,
279
+ ) => string | Uint8Array | null | Promise<string | Uint8Array | null>;
280
+ }
281
+
282
+ export interface ToolObject3DDocumentPersistence {
283
+ /** Default undo/redo label. A completed gesture may supply a narrower one. */
284
+ readonly label?: string;
285
+ /** Non-empty, path-unique set of files owned by this document. */
286
+ readonly resources: readonly ToolObject3DDocumentResource[];
287
+ }
288
+
289
+ export interface ToolObject3DDocumentAuthoringContext extends ToolObject3DDocumentState {
290
+ readonly documentId: string;
291
+ readonly sourcePath: string;
292
+ readonly scene: Scene;
293
+ readonly defaultAdapter: AuthoringAdapter;
294
+ /**
295
+ * Serialize this project-owned document through its declared persistence
296
+ * resources and record the change in canonical history. Rejects when the
297
+ * document is read-only or the atomic write fails.
298
+ */
299
+ readonly commit: (label?: string) => Promise<boolean>;
300
+ }
301
+
302
+ export interface ToolObject3DDocumentAuthoring {
303
+ readonly adapter: AuthoringAdapter;
304
+ dispose?(): void;
305
+ }
306
+
307
+ export type ToolObject3DDocumentAuthoringFactory = (
308
+ context: ToolObject3DDocumentAuthoringContext,
309
+ ) => ToolObject3DDocumentAuthoring;
310
+
311
+ /** Adapter-native hit data for a project-owned Asset Lab gesture. */
312
+ export interface ToolObject3DPointerEvent {
313
+ readonly pointerId: number;
314
+ readonly button: number;
315
+ readonly buttons: number;
316
+ readonly clientX: number;
317
+ readonly clientY: number;
318
+ readonly altKey: boolean;
319
+ readonly ctrlKey: boolean;
320
+ readonly metaKey: boolean;
321
+ readonly shiftKey: boolean;
322
+ readonly ray: Ray;
323
+ readonly hits: readonly Intersection<Object3D>[];
324
+ }
325
+
326
+ /** One gesture owns exact rollback to its pre-begin document state. */
327
+ export interface ToolObject3DGesture {
328
+ readonly label?: string;
329
+ update(event: ToolObject3DPointerEvent): void;
330
+ /** Finalize the project-owned native state before the host serializes it. */
331
+ commit(event: ToolObject3DPointerEvent): void | Promise<void>;
332
+ /** Restore exact pre-begin native state. Must be safe after partial commit. */
333
+ cancel(): void | Promise<void>;
334
+ }
335
+
336
+ export interface ToolObject3DInteractionContext {
337
+ readonly root: Object3D;
338
+ readonly scene: Scene;
339
+ readonly renderer: WebGLRenderer;
340
+ /** Host-owned group removed on document teardown. Project code owns GPU
341
+ * resources it adds and releases them from the extension's `dispose`. */
342
+ readonly overlay: Group;
343
+ /** Live camera getter; projection can change while the document is open. */
344
+ readonly camera: () => Camera;
345
+ /** False means gestures will not begin because no persistence binding exists. */
346
+ readonly writable: boolean;
347
+ }
348
+
349
+ export interface ToolObject3DInteractionExtension {
350
+ /** Return null to yield this pointer to the ordinary viewport controls. */
351
+ begin(event: ToolObject3DPointerEvent): ToolObject3DGesture | null;
352
+ /**
353
+ * Optional pre-sample phase. Restore any transforms changed by the previous
354
+ * frame's refinement here; the host advances content time after this call
355
+ * and invokes {@link update} with the resulting native pose.
356
+ */
357
+ prepareFrame?(deltaSeconds: number): void;
358
+ /**
359
+ * Optional per-frame refinement after the host has advanced content time.
360
+ * Use this for project-owned constraints and overlay presentation.
361
+ */
362
+ update?(deltaSeconds: number): void;
363
+ dispose(): void;
364
+ }
365
+
366
+ export interface ToolObject3DDocumentInteraction {
367
+ setup(context: ToolObject3DInteractionContext): ToolObject3DInteractionExtension;
368
+ }
369
+
370
+ /**
371
+ * Project-owned serialization of a WHOLE Asset Lab document into one ordinary
372
+ * source file, for a
373
+ * document whose truth is a TypeScript module (a modeling session's mesh
374
+ * module). The editor owns the checksum-guarded whole-file write through the
375
+ * source seam and records it in canonical history; executable source never
376
+ * travels through {@link ToolObject3DAuthoringProps.persistence}'s resource
377
+ * route, which the server refuses by rule. A document declares either this or
378
+ * `persistence`, never both. MUST be referentially stable across renders.
379
+ */
380
+ export interface ToolDocumentSourceBinding {
381
+ /** Project-relative source file below src/. */
382
+ readonly path: string;
383
+ /** Undo/redo label shown by the editor history. */
384
+ readonly label?: string;
385
+ /** The exact bytes of the module for this document state — or `null` for
386
+ * "nothing changed, write nothing", so a gesture that ended without an
387
+ * edit leaves the file byte-for-byte alone. */
388
+ readonly serialize: (document: ToolObject3DDocumentState) => string | null;
389
+ }
390
+
391
+ /** Format-neutral identity for a project-owned Asset Lab document.
392
+ *
393
+ * This surface deliberately accepts no React children. Project tools may run
394
+ * a different React major than the editor, so their native UI stays in their
395
+ * own runtime while this editor-owned subject publishes the standard Asset
396
+ * Lab context, selection floor, and Inspector identity row beside it.
397
+ */
398
+ export interface ToolAssetDocumentProps {
399
+ /** Stable id of the open workspace document. */
400
+ readonly documentId: string;
401
+ /** Subject name shown by the Inspector. */
402
+ readonly title: string;
403
+ /** Ecosystem-neutral asset kind shown by the Inspector. */
404
+ readonly type: string;
405
+ /** Optional provenance or concise state shown with the identity row. */
406
+ readonly status?: string;
407
+ /** Whether this is the active center document. */
408
+ readonly active?: boolean;
409
+ }
410
+
411
+ /** Editor-owned native surfaces available to ordinary project React tools. */
412
+ export interface ToolContributionSurfaces {
413
+ readonly AssetDocument: import('react').ComponentType<ToolAssetDocumentProps>;
414
+ readonly Object3DPreview: import('react').ComponentType<ToolObject3DPreviewProps>;
415
+ readonly Object3DAuthoring: import('react').ComponentType<ToolObject3DAuthoringProps>;
416
+ }
417
+
418
+ /** One bounded visual-history sample from the live Play recording. */
419
+ export interface ToolContributionRecordingFrame {
420
+ /** Monotonic within this recording, including after older frames are evicted. */
421
+ readonly sequence: number;
422
+ /** Position on the recording clock, measured from `startedAt`. */
423
+ readonly mediaTimeMs: number;
424
+ /** Wall-clock capture time, for correlating project-owned event streams. */
425
+ readonly capturedAt: string;
426
+ /** Borrowed object URL. It remains valid until this frame is evicted or Play ends. */
427
+ readonly src: string;
428
+ readonly width: number;
429
+ readonly height: number;
430
+ }
431
+
432
+ /** Stable snapshot returned by the live recording transport. */
433
+ export interface ToolContributionRecordingSnapshot {
434
+ readonly startedAt: string;
435
+ readonly width: number;
436
+ readonly height: number;
437
+ /** Latest sampled position on the live recording clock. */
438
+ readonly liveEdgeMs: number;
439
+ readonly sampleFps: number;
440
+ readonly retentionMs: number;
441
+ readonly frames: readonly ToolContributionRecordingFrame[];
442
+ /** A preview failure never stops the full WebM recording. */
443
+ readonly previewError: string | null;
444
+ }
445
+
446
+ /**
447
+ * Read-only visual history for the current Play recording.
448
+ *
449
+ * `getSnapshot` is referentially stable between notifications, so it can be
450
+ * passed directly to React's `useSyncExternalStore`. A `null` snapshot means
451
+ * Play is not currently recording (for example, a human-started Play session
452
+ * that did not explicitly start recording).
453
+ */
454
+ export interface ToolContributionRecording {
455
+ readonly getSnapshot: () => ToolContributionRecordingSnapshot | null;
456
+ readonly subscribe: (listener: () => void) => () => void;
457
+ }
458
+
459
+ /** One structured row from a Gameplay Session's durable JSONL record. */
460
+ export interface ToolGameplaySessionEntry {
461
+ readonly t?: number;
462
+ readonly level?: string;
463
+ readonly source?: string;
464
+ readonly sub?: string;
465
+ readonly msg?: string;
466
+ readonly meta?: Readonly<Record<string, unknown>>;
467
+ readonly tick?: number;
468
+ readonly simT?: number;
469
+ readonly world?: string;
470
+ readonly simSpeed?: number;
471
+ readonly [field: string]: unknown;
472
+ }
473
+
474
+ export interface ToolGameplaySessionRecording {
475
+ readonly format: 'composite-webm' | 'canvas-dom';
476
+ readonly replay: string | null;
477
+ readonly file: string;
478
+ readonly url: string;
479
+ readonly startedAt: number | null;
480
+ readonly finalized: boolean;
481
+ readonly bytes: number | null;
482
+ }
483
+
484
+ /** One Play interval, durable after Stop and editor reload. */
485
+ export interface ToolGameplaySession {
486
+ readonly id: string;
487
+ readonly run: string | null;
488
+ readonly status: 'live' | 'completed';
489
+ readonly startedAt: number;
490
+ readonly endedAt: number | null;
491
+ /** Real elapsed wall time. Simulation time remains an entry field. */
492
+ readonly durationMs: number;
493
+ readonly logFile: string;
494
+ readonly entries: readonly ToolGameplaySessionEntry[];
495
+ readonly recording: ToolGameplaySessionRecording | null;
496
+ }
497
+
498
+ export interface ToolGameplaySessionsSnapshot {
499
+ readonly sessions: readonly ToolGameplaySession[];
500
+ readonly selectedSessionId: string | null;
501
+ readonly selectedSession: ToolGameplaySession | null;
502
+ /** Shared analytics cursor, measured in real milliseconds from Play start. */
503
+ readonly cursorMs: number;
504
+ readonly liveEdgeMs: number;
505
+ readonly loading: boolean;
506
+ readonly error: string | null;
507
+ }
508
+
509
+ /** Stable external store supplied to the built-in Analytics contribution. */
510
+ export interface ToolGameplaySessions {
511
+ readonly getSnapshot: () => ToolGameplaySessionsSnapshot;
512
+ readonly subscribe: (listener: () => void) => () => void;
513
+ readonly select: (sessionId: string) => void;
514
+ readonly seek: (realTimeMs: number) => void;
515
+ readonly refresh: () => Promise<void>;
516
+ }
517
+
518
+ /**
519
+ * The live game a contribution is looking at, or `null` when nothing is
520
+ * playing.
521
+ *
522
+ * A dev-GUI contribution's whole subject is the RUNNING game — its stats,
523
+ * cheats, tuning handles and event stream all hang off the live `Game`, and
524
+ * there is no other door to it from a contribution (the editor's own state is
525
+ * not the game's). It is `unknown` deliberately: `@vgai/game-runtime`'s `Game` is
526
+ * the project's dependency, not this package's, so a contribution narrows it
527
+ * with its own import rather than making every consumer of this SDK carry the
528
+ * engine's types.
529
+ *
530
+ * `instanceId` is the mount id the editor's runtime instruments are pointed
531
+ * at (the Inspect selector). With several seats live that is the ONLY thing
532
+ * distinguishing two mounts of the same project, so a contribution that
533
+ * caches per-game state keys it on this and re-reads when it changes.
534
+ */
535
+ export interface ToolContributionPlay {
536
+ readonly game: unknown;
537
+ readonly instanceId: string;
538
+ /** The editor-owned recording transport shared by every project tool. */
539
+ readonly recording: ToolContributionRecording;
540
+ }
541
+
542
+ /**
543
+ * THE DOCUMENT HEADER REGION, as a project contribution sees it.
544
+ *
545
+ * A `workspace.document` module may `export const Toolbar` beside its default
546
+ * component. The editor renders it in the host-owned header strip above the
547
+ * document — the same strip the Game and Story documents use — with the SAME
548
+ * props the content receives, so a header and its body read one state. The
549
+ * host draws the strip identically for every document (height, divider,
550
+ * island treatment over a backdrop document); the contribution supplies
551
+ * only what goes in it. A document that hand-draws a bar inside its own body
552
+ * instead is a document whose header no skin, preset or region rule can
553
+ * reach — which is the whole reason the strip is the host's.
554
+ */
555
+ export type ToolDocumentToolbar = import('react').ComponentType<ToolContributionProps>;
556
+
557
+ /**
558
+ * THE DOCUMENT SHELF REGION — Blender's tool shelf — as a project sees it:
559
+ * `export const Shelf` on a `workspace.document` module. The editor draws it
560
+ * as a vertical rail over the leading edge of the document's content box,
561
+ * with the same props as the body. Same contract as {@link ToolDocumentToolbar}.
562
+ */
563
+ export type ToolDocumentShelf = import('react').ComponentType<ToolContributionProps>;
564
+
565
+ /**
566
+ * A document the adapter's table lists — a model, a page — as handed to the
567
+ * editor registered for its kind (ARCHITECTURE-CORE §The project model,
568
+ * "Documents, not scenes"). A `workspace.document` contribution declares the
569
+ * kind it edits with `export const documentKind = 'model'`; the host then
570
+ * opens every table entry of that kind as its OWN document — titled by the
571
+ * entry's label, one per entry, restored across reloads by the entry's id —
572
+ * and mounts the contribution with the entry here. Nothing else is looked
573
+ * up: the entry names the module (`source`) and the contribution does the
574
+ * rest through `importProjectModule`.
575
+ */
576
+ export interface ToolDocumentEntry {
577
+ /** The table entry's id (`model:src/models/cage.ts`). */
578
+ readonly id: string;
579
+ readonly kind: string;
580
+ /** The entry's display label — the document's title (`cage`). */
581
+ readonly label: string;
582
+ /** The module that IS the document, when the entry has one. */
583
+ readonly source?: { readonly path: string; readonly export?: string };
584
+ }
585
+
586
+ export interface ToolContributionProps {
587
+ /** The exact registered callable this contribution presents. */
588
+ readonly tool: ProjectToolCatalogEntry;
589
+ /** Present when mounted as the editor of a table document of the kind this
590
+ * contribution declared (`export const documentKind`). */
591
+ readonly document?: ToolDocumentEntry;
592
+ /** Stable id of this presentation within the registered callable. */
593
+ readonly contributionId?: string;
594
+ /** Direct editor SDK client; invoke with `client.runProjectTool(tool.name, ...)`. */
595
+ readonly client: EditorClient;
596
+ /** Generic editor-owned presentation surfaces; project source remains native. */
597
+ readonly surfaces: ToolContributionSurfaces;
598
+ /** Sanitized product-account projection. Never contains an access token. */
599
+ readonly account: GenerationAccountProjection;
600
+ /** The inspected play instance, or `null` while nothing is playing. */
601
+ readonly play: ToolContributionPlay | null;
602
+ /** Present when mounted as a workspace document contribution. */
603
+ readonly documentId?: string;
604
+ /** Whether that workspace document is the active center subject. */
605
+ readonly active?: boolean;
606
+ /**
607
+ * Present with `documentId`. Hand the host the ONE object this document
608
+ * edits through — its live session — and `editor.document.run(ctx => …)`
609
+ * (`vgai eval`) runs a step against it in Edit mode, without play: the
610
+ * agent's REPL over the document. Re-publish whenever that object changes
611
+ * (a reload that swaps a session); the return value unpublishes.
612
+ */
613
+ readonly publishContext?: (context: unknown) => () => void;
614
+ /**
615
+ * Raise a card on the editor's bottom-right notification stack — the ONE
616
+ * shape an event takes (ARCHITECTURE-CORE §Editor chrome, "Notices take
617
+ * VS Code's shape"): a refusal, a failed write, a no-op the user should
618
+ * hear about. Never paint these into a document's own chrome. Plain
619
+ * `info` hides itself; warnings and errors stay until dismissed. The
620
+ * return value dismisses the card early.
621
+ */
622
+ readonly notify?: (notice: ToolNotice) => () => void;
623
+ /**
624
+ * Tell the host what this contribution is about to do on the page's main
625
+ * thread — `work('building src/models/x.ts')` before running a module's
626
+ * `build()`, `work(null)` after. A command that times out meanwhile is
627
+ * refused naming that work instead of "the page never answered".
628
+ */
629
+ readonly work?: (label: string | null) => void;
630
+ }
631
+
632
+ /** A card for {@link ToolContributionProps.notify}. */
633
+ export interface ToolNotice {
634
+ readonly tone: 'info' | 'warning' | 'error';
635
+ /** One line, bold — what happened. */
636
+ readonly title: string;
637
+ /** The rest, plain — what it means, what to do. */
638
+ readonly detail?: string;
639
+ }
640
+
641
+ /**
642
+ * A `workspace.utility`'s props — the shared shape, except that `tool` may be
643
+ * absent.
644
+ *
645
+ * It is its own type rather than a relaxation of {@link ToolContributionProps}
646
+ * because only the two PRESENTING points earn the relaxation (this one and
647
+ * {@link ToolInspectorContributionProps}): a record-reading drawer panel (a
648
+ * log, a run timeline) presents what HAPPENED rather than one callable's
649
+ * output, so there is no honest name to put there and the loader stopped
650
+ * demanding a false one. Widening the shared props instead would hand every
651
+ * RUNNING contribution a `tool` it must now null-check while its own point
652
+ * still guarantees one.
653
+ */
654
+ export interface ToolUtilityContributionProps extends Omit<ToolContributionProps, 'tool'> {
655
+ /** The callable this utility declared, when it declared one at all. */
656
+ readonly tool?: ProjectToolCatalogEntry;
657
+ }
658
+
659
+ /**
660
+ * A game's analytics body. The editor owns session selection, real-time
661
+ * transport, and optional video preview; this component owns only the charts
662
+ * and readouts that give the selected session meaning for this game.
663
+ *
664
+ * `play` is intentionally absent. Historical analysis consumes the durable
665
+ * Gameplay Session record, so the type itself prevents an Analytics surface
666
+ * from accidentally depending on a live module graph.
667
+ */
668
+ export interface ToolAnalyticsContributionProps
669
+ extends Omit<ToolContributionProps, 'tool' | 'play'> {
670
+ readonly tool?: ProjectToolCatalogEntry;
671
+ readonly gameplaySessions: ToolGameplaySessions;
672
+ }
673
+
674
+ /**
675
+ * A `selection.inspector`'s props. `tool` is OPTIONAL here for the same reason
676
+ * it is on a utility: a section may present state the editor already has —
677
+ * readings, verbs a game registered as debug commands — and drive no single
678
+ * registered callable at all. A section that DOES commit through one still
679
+ * declares it and still gets the resolved entry.
680
+ *
681
+ * A module may also `export const icon = '<glyph name>'` — the NAME of the
682
+ * glyph that stands for this section wherever the Inspector shows one (the
683
+ * Properties tab rail, the section-icon strip). A name, never an icon
684
+ * object: the active icon set paints it, and a set may give that name its
685
+ * own category ink (`IconSetContribution` `tone`). Give a section that
686
+ * means something its OWN name (`properties-data`), the way the Outliner's
687
+ * rows do, because a tone is keyed by name and a shared name would tint
688
+ * every section that borrowed it. Omitted, the host draws its generic tool
689
+ * glyph.
690
+ */
691
+ export interface ToolInspectorContributionProps extends Omit<ToolContributionProps, 'tool'> {
692
+ /** The callable this section declared, when it declared one at all. */
693
+ readonly tool?: ProjectToolCatalogEntry;
694
+ readonly node: ToolContributionNode | null;
695
+ readonly nodeId: string | null;
696
+ /** The matched adapter — the same value `match` was handed. Adapter-native
697
+ * API; import its concrete type when a section reads more than the node
698
+ * (a model section reads the document's source path and root). */
699
+ readonly adapter: unknown;
700
+ }
701
+
702
+ /** Stable editor projection of one selected project or external-library asset. */
703
+ export interface ToolContributionAsset {
704
+ readonly path: string;
705
+ readonly name: string;
706
+ readonly kind: string;
707
+ readonly origin: 'project' | 'library';
708
+ readonly sourcePath?: string;
709
+ }
710
+
711
+ /** An `asset.inspector`'s props — the same relaxation as the two other
712
+ * PRESENTING points ({@link ToolUtilityContributionProps},
713
+ * {@link ToolInspectorContributionProps}): it presents what an ASSET is, not
714
+ * one callable's output, so `tool` may be absent. */
715
+ /**
716
+ * A verb an `asset.inspector` contribution offers for the asset it is
717
+ * showing — the SAME thing a built-in Inspector button is: it appears in the
718
+ * panel's identity row, `editor.inspect().quickActions` lists it, and
719
+ * `editor.runAction(id)` runs exactly this `run`. A section publishes its
720
+ * verbs with {@link ToolAssetInspectorContributionProps.setActions} when it
721
+ * knows them, which is what gives a project's own Inspector door a place in
722
+ * the control API instead of a mouse only.
723
+ */
724
+ export interface ToolAssetInspectorAction {
725
+ /** Stable id, unique within the contribution (`shot.open`). */
726
+ readonly id: string;
727
+ /** Accessible name and tooltip. */
728
+ readonly title: string;
729
+ /** Visible button text. */
730
+ readonly label?: string;
731
+ readonly disabled?: boolean;
732
+ readonly run: () => void | Promise<void>;
733
+ }
734
+
735
+ export interface ToolAssetInspectorContributionProps extends Omit<ToolContributionProps, 'tool'> {
736
+ readonly tool?: ProjectToolCatalogEntry;
737
+ readonly asset: ToolContributionAsset | null;
738
+ /**
739
+ * Publish the verbs this section offers for the asset it is showing — see
740
+ * {@link ToolAssetInspectorAction}. Call it with the list when the answer
741
+ * is known (a section that probes asynchronously calls it from the effect
742
+ * that resolves), and with `[]` when the answer is no. The editor renders
743
+ * them in the identity row and serves them to
744
+ * `editor.inspect().quickActions` / `editor.runAction(id)`, so the panel
745
+ * and the control API always show one list. Not calling it means "no
746
+ * verbs", which is what a section that only displays should do.
747
+ */
748
+ readonly setActions: (actions: readonly ToolAssetInspectorAction[]) => void;
749
+ }
750
+
751
+ /** Optional named export required by `asset.inspector` contributions. */
752
+ export type ToolAssetInspectorContributionMatch = (asset: ToolContributionAsset | null) => boolean;
753
+
754
+ /**
755
+ * Provider-owned presentation mounted inside the editor-owned generation
756
+ * result document. The host owns job state, billing, and acceptance; this
757
+ * component only interprets the native poll result.
758
+ */
759
+ export interface ToolGenerationResultContributionProps extends ToolContributionProps {
760
+ readonly job: GenerationJob;
761
+ readonly result: unknown;
762
+ }
763
+
764
+ /**
765
+ * Required named export for `generation.result` contributions. A single poll
766
+ * callable may serve many native operations, so presentation selection is
767
+ * based on the durable job and the provider's unmodified poll result rather
768
+ * than registration order or a host-owned media taxonomy.
769
+ */
770
+ export type ToolGenerationResultContributionMatch = (
771
+ job: GenerationJob,
772
+ result: unknown,
773
+ ) => boolean;
774
+
775
+ /**
776
+ * What the inspector is showing AROUND the node a `selection.inspector`
777
+ * contribution is being matched against.
778
+ *
779
+ * It exists for one question a `node`/`adapter` pair cannot answer: with
780
+ * NOTHING selected, `node` is `null` on every surface alike — an open Asset
781
+ * Lab document's empty state and the play surface's Game subject are the same
782
+ * two arguments. A contribution that matches `node === null` therefore matched
783
+ * BOTH, and the empty-state subject of whatever document happened to be open
784
+ * grew sections belonging to another surface entirely.
785
+ *
786
+ * `nullSubjectId` is the id of the empty-state subject actually being composed
787
+ * (`inspection/null-subject.ts`), so a contribution scopes itself POSITIVELY —
788
+ * `ctx.nullSubjectId === 'game'` — rather than by guessing from the adapter.
789
+ * It is `null` whenever a node IS selected, which is the honest answer: there
790
+ * is no empty-state subject in that composition.
791
+ */
792
+ export interface ToolInspectorContributionMatchContext {
793
+ readonly nullSubjectId: string | null;
794
+ }
795
+
796
+ /** Optional named export required by `selection.inspector` contributions. */
797
+ export type ToolInspectorContributionMatch = (
798
+ node: ToolContributionNode | null,
799
+ /** Adapter-native API. Import its concrete type when a contribution needs it. */
800
+ adapter: unknown,
801
+ context: ToolInspectorContributionMatchContext,
802
+ ) => boolean;
803
+
804
+ // ---------------------------------------------------------------------------
805
+ // Project modules, through the host's own door
806
+ // ---------------------------------------------------------------------------
807
+
808
+ /**
809
+ * How THIS HOST loads a project module for a contribution: the dev/packaged
810
+ * host serves it through its own Vite (`/@fs/…`, with a URL a live re-import
811
+ * can stamp). The host registers one loader per project (`tool-loader.ts`); a
812
+ * contribution that needs a project module — a model's Edit Mesh door
813
+ * importing `build()` — asks {@link importProjectModule} and never spells a
814
+ * tier's mechanics itself.
815
+ */
816
+ export interface ProjectModuleLoader {
817
+ readonly import: (path: string) => Promise<Record<string, unknown>>;
818
+ /** The module's servable URL, or `null` on a tier that serves none. */
819
+ readonly url: (path: string) => URL | null;
820
+ /** The module's SOURCE TEXT as it is on disk (or in storage) right now —
821
+ * what a contribution that writes the module back (a model's mesh
822
+ * editor appending a line) patches. */
823
+ readonly source: (path: string) => Promise<string>;
824
+ }
825
+
826
+ /** ONE loader across every copy of this module — see `host.ts` for why the
827
+ * packaged runtime holds two SDK instances, and `layouts.tsx` for the
828
+ * `Symbol.for` precedent. */
829
+ const LOADER_KEY = Symbol.for('vgai.editor.project-module-loader');
830
+ const loaders = globalThis as typeof globalThis & {
831
+ [LOADER_KEY]?: ProjectModuleLoader | null;
832
+ };
833
+
834
+ /** Host side: install (or clear) the active project's module loader. */
835
+ export function registerProjectModuleLoader(loader: ProjectModuleLoader | null): void {
836
+ loaders[LOADER_KEY] = loader;
837
+ }
838
+
839
+ /** Contribution side: a project module (`src/models/cage.ts`), loaded the
840
+ * way this tier loads project modules. Refuses by name with no host. */
841
+ export function importProjectModule(path: string): Promise<Record<string, unknown>> {
842
+ if (!loaders[LOADER_KEY]) {
843
+ return Promise.reject(
844
+ new Error(`importProjectModule(${path}): no host has registered a project module loader`),
845
+ );
846
+ }
847
+ return loaders[LOADER_KEY].import(path);
848
+ }
849
+
850
+ /** The module's servable URL on this host, or `null` when it has none. */
851
+ export function projectModuleUrl(path: string): URL | null {
852
+ return loaders[LOADER_KEY]?.url(path) ?? null;
853
+ }
854
+
855
+ /** Contribution side: the module's current source text. Refuses by name with no host. */
856
+ export function readProjectModuleSource(path: string): Promise<string> {
857
+ if (!loaders[LOADER_KEY]) {
858
+ return Promise.reject(
859
+ new Error(`readProjectModuleSource(${path}): no host has registered a project module loader`),
860
+ );
861
+ }
862
+ return loaders[LOADER_KEY].source(path);
863
+ }
864
+
865
+ // ---------------------------------------------------------------------------
866
+ // The transform a stage transforms through
867
+ // ---------------------------------------------------------------------------
868
+
869
+ /**
870
+ * THE MODAL TRANSFORM A STAGE OWNS — the door the host's transform tools drive
871
+ * on a stage that is not the editor viewport's own.
872
+ *
873
+ * WHY IT EXISTS, measured rather than argued (2026-09-19, cold `--template
874
+ * models` scaffold): the host's `ToolStrip` draws Move / Rotate / Scale /
875
+ * Transform over every stage that paints a three surface, and on a Model
876
+ * document all four lit and nothing happened — `Transform` lit at boot,
877
+ * offering a tool that could never act. Eight controls in the two places a
878
+ * person looks first, one of them lit.
879
+ *
880
+ * TWO SEPARATE CAUSES, and only the second one is this door's:
881
+ * 1 THE TOOLS WROTE THE WRONG STORE. Every stage owns an `EditorShellStore`
882
+ * (`stage-store-registry.ts`); the strip wrote the SHELL's, which no
883
+ * document stage reads. That is fixed in the host (`ToolStrip`'s own note
884
+ * carries the prefab-story measurement) and it is why a session-painted
885
+ * stage now takes the ordinary GIZMO arm.
886
+ * 2 A MESH MODULE DOES NOT TRANSFORM THROUGH A GIZMO AT ALL. Blender's Edit
887
+ * Mode Move/Rotate/Scale act on the element selection and are its
888
+ * `G`/`R`/`S`; ours already do, through `MeshEditSession.beginTransform`
889
+ * from the Mesh menu and the keys, while the document's own adapter is a
890
+ * read-only projection of the datablock that a gizmo could not write. So
891
+ * the stage DECLARES how it transforms and the host's three single-channel
892
+ * tools run that, instead of the host guessing from the document's kind.
893
+ *
894
+ * THERE IS NO `combined` KIND, and that is the tool shelf's own rule applied
895
+ * to the fourth button: Blender's all-handles Transform IS a gizmo, and a
896
+ * modal transform has no twin for it, so the host does not draw it over a
897
+ * door (the same reason ten of Blender's twenty-one tools are absent; the
898
+ * 21-row table that said which lived in the deleted Edit Mesh document and is
899
+ * in git — `packages/mesh/contributions/mesh-edit-document.tsx` before
900
+ * 2026-09-19).
901
+ *
902
+ * NEITHER ARE THE HEADER'S TRANSFORM WELLS (orientation, pivot/anchor, snap,
903
+ * options) over a door. Every one of them configures a GIZMO — measured
904
+ * 2026-09-19: `transformSpace`'s only functional reader is the viewport's
905
+ * `controls.setSpace`, and `pivotMode`/`gizmoAnchor`/`snapEnabled` are read
906
+ * only by that viewport and the 2D canvas overlay. A modal door has no
907
+ * orientation, no pivot choice and no snapping to configure (the mesh kit
908
+ * transforms about the selection's median on global axes, always), so the host
909
+ * draws no wells over one. Wiring a well here means giving the door a
910
+ * parameter first.
911
+ */
912
+ export type StageTransformKind = 'translate' | 'rotate' | 'scale';
913
+
914
+ export interface StageTransformDoor {
915
+ /** Arm this stage's own modal transform. The stage narrates its own refusal
916
+ * (nothing selected, another gesture running); the host never paraphrases
917
+ * it. */
918
+ readonly begin: (kind: StageTransformKind) => void;
919
+ /** The kind armed right now, or `null`. This is what LIGHTS a tool: these
920
+ * are modal operators, not persistent tool modes, so between gestures
921
+ * nothing is lit. */
922
+ readonly armed: () => StageTransformKind | null;
923
+ /** `useSyncExternalStore` pair with {@link armed}. */
924
+ readonly subscribe: (listener: () => void) => () => void;
925
+ }
926
+
927
+ interface StageTransformRegistry {
928
+ readonly doors: Map<string, StageTransformDoor>;
929
+ version: number;
930
+ readonly listeners: Set<() => void>;
931
+ }
932
+
933
+ /** ONE registry across every copy of this module, for the same reason
934
+ * {@link registerProjectModuleLoader} needs one — see its note. */
935
+ const STAGE_TRANSFORM_KEY = Symbol.for('vgai.editor.stage-transform-doors');
936
+ const stageTransforms = globalThis as typeof globalThis & {
937
+ [STAGE_TRANSFORM_KEY]?: StageTransformRegistry;
938
+ };
939
+
940
+ function stageTransformRegistry(): StageTransformRegistry {
941
+ const existing = stageTransforms[STAGE_TRANSFORM_KEY];
942
+ if (existing) return existing;
943
+ const created: StageTransformRegistry = { doors: new Map(), version: 0, listeners: new Set() };
944
+ stageTransforms[STAGE_TRANSFORM_KEY] = created;
945
+ return created;
946
+ }
947
+
948
+ function notifyStageTransforms(registry: StageTransformRegistry): void {
949
+ registry.version++;
950
+ for (const listener of [...registry.listeners]) listener();
951
+ }
952
+
953
+ /** A mounted stage declares the transform it transforms through — the shape
954
+ * `stage-store-registry.ts` uses for the store a stage runs on. Re-registering
955
+ * the same document replaces it; the returned unregister drops it. */
956
+ export function registerStageTransform(documentId: string, door: StageTransformDoor): () => void {
957
+ const registry = stageTransformRegistry();
958
+ registry.doors.set(documentId, door);
959
+ notifyStageTransforms(registry);
960
+ return () => {
961
+ if (registry.doors.get(documentId) !== door) return;
962
+ registry.doors.delete(documentId);
963
+ notifyStageTransforms(registry);
964
+ };
965
+ }
966
+
967
+ /** The transform that document's stage owns, or `null` when it declares none
968
+ * (every stage whose transform is the editor viewport's own gizmo). */
969
+ export function stageTransformDoor(documentId: string | null): StageTransformDoor | null {
970
+ return documentId === null ? null : (stageTransformRegistry().doors.get(documentId) ?? null);
971
+ }
972
+
973
+ /** `useSyncExternalStore` shape — a stage declaring or dropping its door
974
+ * changes what the shelf and the header draw. */
975
+ export function subscribeStageTransforms(listener: () => void): () => void {
976
+ const registry = stageTransformRegistry();
977
+ registry.listeners.add(listener);
978
+ return () => {
979
+ registry.listeners.delete(listener);
980
+ };
981
+ }
982
+
983
+ export function stageTransformsVersion(): number {
984
+ return stageTransformRegistry().version;
985
+ }