@volter/editor-threejs 0.5.65 → 0.5.67

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 (112) hide show
  1. package/NOTICE +2 -0
  2. package/contributions/animation-mixers.service.ts +20 -0
  3. package/contributions/animation-timeline.utility.tsx +44 -0
  4. package/contributions/three-integration.service.ts +13 -0
  5. package/dist-node/serving.mjs +405 -0
  6. package/package.json +113 -5
  7. package/serving/animation-live-module.ts +70 -0
  8. package/serving/animation-stamp.ts +88 -0
  9. package/serving/index.ts +14 -0
  10. package/serving/model-import-conversion.ts +344 -0
  11. package/src/adapter/ingest/scene-capture.ts +1 -23
  12. package/src/adapter/renderer-config.ts +3 -4
  13. package/src/adapter/three-contract.ts +72 -0
  14. package/src/animation/live-mixers.ts +55 -0
  15. package/src/ecs/object-marks.ts +1 -1
  16. package/src/ecs/user-data.ts +0 -16
  17. package/src/host-hierarchy-objects.ts +31 -0
  18. package/src/kit/animation/three-clips-subject.ts +190 -0
  19. package/src/kit/asset-compare.ts +294 -0
  20. package/src/kit/asset-preview-command.ts +265 -0
  21. package/src/kit/asset-preview-framing.ts +357 -0
  22. package/src/kit/asset-preview.ts +2802 -0
  23. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  24. package/src/kit/authoring/component-instance-root.ts +171 -0
  25. package/src/kit/authoring/design-time-settle.ts +343 -0
  26. package/src/kit/authoring/live-object-transform.ts +62 -0
  27. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  28. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  29. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  30. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  31. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  32. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  33. package/src/kit/authoring/three-projection-core.ts +226 -0
  34. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  35. package/src/kit/authoring/viewport-raycast.ts +240 -0
  36. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  37. package/src/kit/camera-authoring.ts +175 -0
  38. package/src/kit/components/CameraInfo.tsx +56 -0
  39. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  40. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  41. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  42. package/src/kit/components/StageHost.tsx +2547 -0
  43. package/src/kit/components/StageOverlays.tsx +21 -0
  44. package/src/kit/components/StatsOverlay.tsx +78 -0
  45. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  46. package/src/kit/components/ViewportFurniture.tsx +655 -0
  47. package/src/kit/components/ViewportOverlay.tsx +215 -0
  48. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  49. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  50. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  51. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  52. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  53. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  54. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  55. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  56. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  57. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  58. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  59. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  60. package/src/kit/components/stage-keyboard.tsx +40 -0
  61. package/src/kit/components/stage-overlay-set.tsx +105 -0
  62. package/src/kit/components/stage-presence-markers.ts +482 -0
  63. package/src/kit/components/stage-transform-chrome.ts +30 -0
  64. package/src/kit/components/stage-transform-tools.tsx +73 -0
  65. package/src/kit/components/stage-view-name.ts +30 -0
  66. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  67. package/src/kit/components/world-root-binding.ts +64 -0
  68. package/src/kit/constraint-helper.ts +338 -0
  69. package/src/kit/editor-shell-store.ts +814 -0
  70. package/src/kit/editor-viewport.ts +6621 -0
  71. package/src/kit/entity-lod.ts +31 -0
  72. package/src/kit/entity-object.ts +92 -0
  73. package/src/kit/hierarchy-mark-reader.ts +74 -0
  74. package/src/kit/instanced-presentation.ts +164 -0
  75. package/src/kit/live-module-source.ts +230 -0
  76. package/src/kit/model-thumbnail.ts +539 -0
  77. package/src/kit/play-camera-flight.ts +300 -0
  78. package/src/kit/projection/three.ts +898 -0
  79. package/src/kit/reflection-probe-helper.ts +142 -0
  80. package/src/kit/scene-document-viewport.ts +51 -0
  81. package/src/kit/scene-framing.ts +315 -0
  82. package/src/kit/scene-view-fog.ts +89 -0
  83. package/src/kit/spatial-handle-visuals.ts +332 -0
  84. package/src/kit/stories/three-story-model.ts +66 -0
  85. package/src/kit/three-canvas-render.ts +44 -0
  86. package/src/kit/three-hierarchy-row-media.ts +26 -0
  87. package/src/kit/three-inspection-media.ts +73 -0
  88. package/src/kit/three-integration.ts +86 -0
  89. package/src/kit/three-state.ts +33 -0
  90. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  91. package/src/kit/three-viewport/camera-fit.ts +41 -0
  92. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  93. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  94. package/src/kit/three-viewport/selection-outline.ts +333 -0
  95. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  96. package/src/kit/three-viewport/source-color.ts +197 -0
  97. package/src/kit/three-viewport/studio-environment.ts +96 -0
  98. package/src/kit/trigger-volume-helper.ts +116 -0
  99. package/src/kit/viewport-actions.ts +128 -0
  100. package/src/kit/viewport-authoring-policy.ts +154 -0
  101. package/src/kit/viewport-commands.ts +318 -0
  102. package/src/kit/viewport-hotkeys.ts +119 -0
  103. package/src/kit/viewport-shading-boundary.ts +12 -0
  104. package/src/kit/viewport-status-facet.ts +53 -0
  105. package/src/object3d-contributions.ts +494 -0
  106. package/src/render/viewport-shading.ts +6 -2
  107. package/src/viewport/content-bounds.ts +38 -4
  108. package/src/viewport/environment.ts +16 -0
  109. package/src/viewport-api.ts +92 -0
  110. package/src/viewport-door.ts +237 -0
  111. package/src/animation/animation-clock.ts +0 -479
  112. package/src/animation/runtime-inspection.ts +0 -45
@@ -0,0 +1,549 @@
1
+ import {
2
+ faBone,
3
+ faCamera,
4
+ faCaretDown,
5
+ faCheck,
6
+ faCube,
7
+ faEllipsis,
8
+ faExpand,
9
+ faRotateLeft,
10
+ } from '@fortawesome/free-solid-svg-icons';
11
+ import {
12
+ AnchoredMenu,
13
+ Button,
14
+ EditorBanner,
15
+ EditorIcon,
16
+ EditorPopover,
17
+ EditorToolbar,
18
+ IconButton,
19
+ MenuItem,
20
+ MenuSeparator,
21
+ Select,
22
+ Text,
23
+ TextInput,
24
+ Tooltip,
25
+ } from '@volter/editor-sdk/widgets';
26
+ import { useRef, useState, useSyncExternalStore } from 'react';
27
+ import { captureModelAssetPreview, captureObjectAssetPreview } from '../asset-preview';
28
+ import {
29
+ type ModelCameraPreset,
30
+ supportedModelDiagnosticModes,
31
+ } from '../asset-workflow/model-inspection';
32
+ import type { Object3DDocumentViewMode } from '../authoring/object3d-document-session';
33
+ import {
34
+ object3DDocumentSession,
35
+ object3DDocumentSessionsVersion,
36
+ subscribeObject3DDocumentSessions,
37
+ } from '../authoring/object3d-document-session-registry';
38
+ import {
39
+ contributedChromeVersion,
40
+ contributedHeaderItems,
41
+ subscribeContributedChrome,
42
+ } from '@volter/editor-sdk/kit/chrome-registry';
43
+ import type { HelperVisibility } from '@volter/editor-sdk/kit/shell-store';
44
+ import { object3DDocumentWritePolicy } from '@volter/editor-sdk/kit/object3d-document-write-policy';
45
+ import { stageStore, stageStoresVersion, subscribeStageStores } from '@volter/editor-sdk/kit/stage-store-registry';
46
+ import { viewportStageHelperKinds } from '../../viewport-door';
47
+ import {
48
+ type PresentationLayer,
49
+ applyViewPreset,
50
+ DOCUMENT_STUDIO_PRESET,
51
+ setViewGridVisible,
52
+ viewGridVisible,
53
+ setViewPresentation,
54
+ stageLightsPerMode,
55
+ startingPresentation,
56
+ studioPresets,
57
+ viewPresets,
58
+ viewPresentationBinding,
59
+ subscribeViewportPresentation,
60
+ viewPresentation,
61
+ viewportPresentationVersion,
62
+ } from '@volter/editor-sdk/kit/viewport-presentation';
63
+ import { ViewportOverlaysGlyph, ViewportOverlaysMenu } from '@volter/editor-sdk/kit/components/ViewportOverlaysMenu';
64
+ import {
65
+ type ViewportDisplayModeChoice,
66
+ ViewportDisplayModeMenu,
67
+ perModeShadingSegments,
68
+ viewportShadingModes,
69
+ viewportShadingSegments,
70
+ } from './ViewportShadingMenu';
71
+
72
+ const subscribeToNothing = () => () => undefined;
73
+ const noSessionVersion = () => 0;
74
+
75
+ /**
76
+ * THE DOCUMENT'S OWN OVERLAY ROWS, beside Grid and Bounds.
77
+ *
78
+ * Blender's viewport overlay popover carries a BONES checkbox
79
+ * (`View3DOverlay.show_bones`, `makesrna/intern/rna_space.cc:5125-5129`, drawn
80
+ * by `scripts/startup/bl_ui/space_view3d.py:7161` with `text="Bones"`), and it
81
+ * is Blender's noun that is used here rather than the Helpers vocabulary's
82
+ * `Skeletons`, because this menu IS that popover — the comment on the eye below
83
+ * says so. `Weights` has no Blender checkbox at all: Blender reaches weight
84
+ * colours through Weight Paint MODE, and an inspection surface has no brushes
85
+ * to enter one, so it is a display toggle here (WORK.md §Blender in the tab is
86
+ * Blender, "Inspection parity", I4).
87
+ *
88
+ * A ROW STANDS ONLY WHERE THE DOCUMENT PUT A HELPER OF THAT KIND on this stage
89
+ * (`viewportStageHelperKinds`) — the same rule the Outliner's restriction
90
+ * columns follow: a control follows what the thing behind it answers, never a
91
+ * knob. So an imported GLB's Asset Lab document, which shows no bones, draws
92
+ * neither row.
93
+ */
94
+ const DOCUMENT_HELPER_ROWS: readonly { key: keyof HelperVisibility; label: string }[] = [
95
+ { key: 'skeletons', label: 'Bones' },
96
+ { key: 'weights', label: 'Weights' },
97
+ { key: 'cursor', label: '3D Cursor' },
98
+ // Blender's overlay extras: the objects that have no surface.
99
+ { key: 'cameras', label: 'Cameras' },
100
+ { key: 'lights', label: 'Lights' },
101
+ { key: 'empties', label: 'Empties' },
102
+ ];
103
+ export function Object3DDocumentToolbar({
104
+ documentId,
105
+ assetPath,
106
+ }: {
107
+ readonly documentId: string;
108
+ readonly assetPath?: string;
109
+ }) {
110
+ useSyncExternalStore(
111
+ subscribeObject3DDocumentSessions,
112
+ object3DDocumentSessionsVersion,
113
+ object3DDocumentSessionsVersion,
114
+ );
115
+ const session = object3DDocumentSession(documentId);
116
+ useSyncExternalStore(
117
+ subscribeContributedChrome,
118
+ contributedChromeVersion,
119
+ contributedChromeVersion,
120
+ );
121
+ useSyncExternalStore(
122
+ session?.subscribe ?? subscribeToNothing,
123
+ session?.getSnapshot ?? noSessionVersion,
124
+ session?.getSnapshot ?? noSessionVersion,
125
+ );
126
+ // THIS STAGE's own store and its own helpers: the shared panels read
127
+ // whichever store the FOCUSED document's stage runs on (ARCHITECTURE-CORE
128
+ // §One stage), and a toolbar is a control over ITS document, never over the
129
+ // world root's.
130
+ useSyncExternalStore(subscribeStageStores, stageStoresVersion, stageStoresVersion);
131
+ const stage = stageStore(documentId);
132
+ useSyncExternalStore(
133
+ stage?.subscribe ?? subscribeToNothing,
134
+ stage?.getSnapshot ?? noSessionVersion,
135
+ stage?.getSnapshot ?? noSessionVersion,
136
+ );
137
+ const helperKinds = viewportStageHelperKinds(documentId);
138
+ const [capture, setCapture] = useState<string | null>(null);
139
+ const [error, setError] = useState<string | null>(null);
140
+ const [viewOpen, setViewOpen] = useState(false);
141
+ const [moreOpen, setMoreOpen] = useState(false);
142
+ const viewRef = useRef<HTMLButtonElement>(null);
143
+ const moreRef = useRef<HTMLButtonElement>(null);
144
+ useSyncExternalStore(subscribeViewportPresentation, viewportPresentationVersion, viewportPresentationVersion);
145
+ if (!session) return null;
146
+ const presentation = session.presentation();
147
+ const { lighting } = viewPresentation(documentId);
148
+ const binding = viewPresentationBinding(documentId);
149
+ const starting = binding ? startingPresentation(binding.stageKind) : null;
150
+ const documentStudioOffered =
151
+ binding?.documentLayer?.all?.lighting?.studioPreset === DOCUMENT_STUDIO_PRESET.id ||
152
+ starting?.all?.lighting?.studioPreset === DOCUMENT_STUDIO_PRESET.id;
153
+ // WHERE A LIGHTING CHOICE GOES: the current draw mode's, on a stage that keeps lighting per
154
+ // mode (Blender's Solid, Material Preview and Rendered each keep theirs), else every mode's.
155
+ const perMode = binding ? stageLightsPerMode(binding.stageKind) : false;
156
+ const lightingChoice = (choice: NonNullable<NonNullable<PresentationLayer['all']>['lighting']>): PresentationLayer =>
157
+ // Keyed by the mode the view resolves its lighting by (UV and vertex colours draw under it).
158
+ perMode ? { modes: { [viewPresentation(documentId).drawMode]: { lighting: choice } } } : { all: { lighting: choice } };
159
+ // THE MODES THE STAGE LIGHTS ITS OWN WAY, which are the cells it draws (`perModeShadingSegments`).
160
+ const declaredModes = new Set(
161
+ Object.entries(starting?.modes ?? {})
162
+ .filter(([, mode]) => mode?.lighting !== undefined)
163
+ .map(([mode]) => mode),
164
+ );
165
+ const availableModes = new Set(
166
+ supportedModelDiagnosticModes(session.root)
167
+ .filter((item) => item.available)
168
+ .map((item) => item.mode),
169
+ );
170
+ const modes: Array<ViewportDisplayModeChoice<Object3DDocumentViewMode>> = [
171
+ // On a per-mode stage `solid` IS Blender's Solid (materials' colours under its studio), so the
172
+ // neutral override is named for what it is.
173
+ ...(perMode
174
+ ? viewportShadingModes.map((choice) =>
175
+ choice.mode === 'solid'
176
+ ? { ...choice, label: 'Solid', description: "Materials' colours under the studio lights" }
177
+ : choice.mode === 'clay'
178
+ ? { ...choice, label: 'Clay' }
179
+ : choice,
180
+ )
181
+ : viewportShadingModes),
182
+ ...(declaredModes.has('preview')
183
+ ? [{ mode: 'preview' as const, label: 'Material Preview', description: 'Materials lit by a preview environment' }]
184
+ : []),
185
+ ...(declaredModes.has('rendered')
186
+ ? [{ mode: 'rendered' as const, label: 'Rendered', description: "Lit as a render is, by the scene's own lights" }]
187
+ : []),
188
+ ...(availableModes.has('uv')
189
+ ? [{ mode: 'uv' as const, label: 'UV', description: 'UV coordinates as surface color' }]
190
+ : []),
191
+ ...(availableModes.has('vertex-colors')
192
+ ? [
193
+ {
194
+ mode: 'vertex-colors' as const,
195
+ label: 'Vertex colors',
196
+ description: 'Authored color attributes without textures',
197
+ },
198
+ ]
199
+ : []),
200
+ ];
201
+
202
+ const saveThumbnailFraming = async (reset: boolean) => {
203
+ if (!assetPath) return;
204
+ setError(null);
205
+ try {
206
+ // The shell owns where a framing is RECORDED (`.vgai/thumbnails.json`,
207
+ // written through the project-file route); this surface only knows the
208
+ // asset and the pose. See `object3d-document-write-policy.ts`.
209
+ await object3DDocumentWritePolicy().saveThumbnailFraming(
210
+ assetPath,
211
+ reset ? undefined : session.cameraPose(),
212
+ );
213
+ } catch (cause) {
214
+ setError(cause instanceof Error ? cause.message : String(cause));
215
+ }
216
+ };
217
+
218
+ const capturePreview = async () => {
219
+ setError(null);
220
+ try {
221
+ const options = { background: presentation.background } as const;
222
+ const result = assetPath?.toLowerCase().endsWith('.spz')
223
+ ? await captureModelAssetPreview(assetPath, options)
224
+ : captureObjectAssetPreview(session.root, options);
225
+ setCapture(`data:image/png;base64,${result.contactSheet.base64}`);
226
+ } catch (cause) {
227
+ setError(cause instanceof Error ? cause.message : String(cause));
228
+ }
229
+ };
230
+
231
+ const frameLabel = session.selection().length > 0 ? 'Frame selection' : 'Frame all';
232
+ return (
233
+ <div className="vgai-model-document-header">
234
+ <EditorToolbar
235
+ compact
236
+ label="Viewport view controls"
237
+ className="vgai-model-viewport-toolbar vgai-model-viewport-toolbar--view"
238
+ >
239
+ {/* THE BUTTON SAYS WHAT IT DOES. `session.frame()` frames the
240
+ selection when there is one and the whole subject when there is
241
+ not, but it was labelled "Frame selection" unconditionally — so a
242
+ tester hunting a fit-the-board control on the 3D board reported
243
+ finding none, then discovered this button does exactly that
244
+ (runhuman passes 87 and 89). One control, named for the job it is
245
+ about to do. */}
246
+ <Tooltip text={frameLabel}>
247
+ <IconButton aria-label={frameLabel} onClick={() => session.frame()}>
248
+ <EditorIcon icon={faExpand} size="md" />
249
+ </IconButton>
250
+ </Tooltip>
251
+ <div className="vgai-viewport-popover-anchor">
252
+ <Tooltip text="Camera orientation and projection">
253
+ <Button
254
+ ref={viewRef}
255
+ type="button"
256
+ size="compact"
257
+ // A dropdown TRIGGER is a widget, not a label: Blender's
258
+ // `Object Mode v` in the same row of chrome is a 20px well
259
+ // (#272727 in a 1px #3c3c3c border) while its plain menu words
260
+ // — View/Select/Add — carry no chrome at all. `ghost` said this
261
+ // was the second kind; it is the first.
262
+ variant="secondary"
263
+ aria-label={`Camera view: ${presentation.projection}`}
264
+ aria-haspopup="menu"
265
+ aria-expanded={viewOpen}
266
+ onClick={() => setViewOpen((open) => !open)}
267
+ className="vgai-model-toolbar-labelled-control"
268
+ >
269
+ <EditorIcon icon={faCube} size="sm" />
270
+ <span>
271
+ {presentation.projection === 'perspective' ? 'Perspective' : 'Orthographic'}
272
+ </span>
273
+ <EditorIcon icon={faCaretDown} size="xs" />
274
+ </Button>
275
+ </Tooltip>
276
+ {viewOpen && (
277
+ <AnchoredMenu
278
+ anchorRef={viewRef}
279
+ align="start"
280
+ clamp
281
+ aria-label="Camera view"
282
+ onDismiss={() => setViewOpen(false)}
283
+ style={{ minWidth: 210 }}
284
+ >
285
+ {(['front', 'right', 'top', 'isometric'] as ModelCameraPreset[]).map((preset) => (
286
+ <MenuItem
287
+ key={preset}
288
+ onSelect={() => {
289
+ session.setViewPreset(preset, 'view');
290
+ setViewOpen(false);
291
+ }}
292
+ >
293
+ {preset === 'isometric'
294
+ ? 'Perspective 3/4'
295
+ : `${preset[0]?.toUpperCase()}${preset.slice(1)} view`}
296
+ </MenuItem>
297
+ ))}
298
+ <MenuSeparator />
299
+ {(['perspective', 'orthographic'] as const).map((projection) => (
300
+ <MenuItem
301
+ key={projection}
302
+ role="menuitemradio"
303
+ aria-checked={presentation.projection === projection}
304
+ onSelect={() => {
305
+ session.setProjection(projection);
306
+ setViewOpen(false);
307
+ }}
308
+ >
309
+ <span className="vgai-menu-check">
310
+ {presentation.projection === projection && (
311
+ <EditorIcon icon={faCheck} size="xs" />
312
+ )}
313
+ </span>
314
+ {projection === 'perspective' ? 'Perspective' : 'Orthographic'}
315
+ </MenuItem>
316
+ ))}
317
+ </AnchoredMenu>
318
+ )}
319
+ </div>
320
+ {availableModes.has('skeleton') ? (
321
+ <Tooltip text={`Rig: ${presentation.skeleton ? 'Visible' : 'Hidden'}`}>
322
+ <IconButton
323
+ aria-label="Toggle rig overlay"
324
+ aria-pressed={presentation.skeleton}
325
+ onClick={() => session.setSkeleton(!presentation.skeleton)}
326
+ >
327
+ <EditorIcon icon={faBone} size="md" />
328
+ </IconButton>
329
+ </Tooltip>
330
+ ) : null}
331
+ {/* A package's own controls on this document (`@volter/editor-sdk/chrome`,
332
+ `placement: 'object3d-document'`): handed the document's id; the
333
+ contribution resolves its subject through its own integration. */}
334
+ {contributedHeaderItems('object3d-document').map(({ id, Component }) => (
335
+ <Component key={id} documentId={documentId} />
336
+ ))}
337
+ </EditorToolbar>
338
+ <EditorToolbar
339
+ compact
340
+ label="Viewport preview controls"
341
+ className="vgai-model-viewport-toolbar vgai-model-viewport-toolbar--preview"
342
+ >
343
+ {/* THE DISPLAY CONTROLS SIT AT THE TRAILING EDGE, which is where
344
+ Blender's are: its 3D View header packs Visibility, Gizmo,
345
+ Overlays, X-Ray and Shading hard against the region's right border
346
+ and leaves the middle empty. Ours used to clump them after the
347
+ view controls with three hundred pixels of bare header to their
348
+ right and this group's `...` alone at the edge. */}
349
+ {/* BLENDER'S SHADING CONTROL, one widget: the segments it has, and the
350
+ chevron's popover carrying every other mode AND the lighting rows
351
+ that used to be a second `Studio v` dropdown further along the row.
352
+ Blender's own shading popover is where Lighting lives (Studio /
353
+ MatCap / Flat), so the two controls were always one control drawn
354
+ twice — measured in `modeling-edit-none.png`, its header's trailing
355
+ cluster is five icon groups and no words at all. */}
356
+ <ViewportDisplayModeMenu
357
+ mode={presentation.mode}
358
+ onChange={(mode) => session.setMode(mode)}
359
+ choices={modes}
360
+ segments={perMode ? perModeShadingSegments(declaredModes) : viewportShadingSegments}
361
+ >
362
+ {/* A NAMED VIEW, first: a whole presentation (a target engine's default viewport —
363
+ its light, backdrop, overlays and tool) that the packages here contribute
364
+ (`*.view.ts`). Choosing one replaces the view's own choices; the rows below then
365
+ adjust it. Shown only where a package contributes one. */}
366
+ {viewPresets().length > 0 ? (
367
+ <label>
368
+ <span>View</span>
369
+ <Select
370
+ aria-label="View preset"
371
+ value=""
372
+ onChange={(event) => {
373
+ if (event.target.value) applyViewPreset(documentId, event.target.value);
374
+ }}
375
+ >
376
+ <option value="">Choose…</option>
377
+ {viewPresets().map((preset) => (
378
+ <option key={preset.id} value={preset.id}>
379
+ {preset.title}
380
+ </option>
381
+ ))}
382
+ </Select>
383
+ </label>
384
+ ) : null}
385
+ {/* LIGHTING AND EXPOSURE ARE THE VIEW'S PRESENTATION (`kit/viewport-presentation`):
386
+ a studio preset, or the scene's own lights, and the tone's exposure. */}
387
+ <label>
388
+ <span>Lighting</span>
389
+ <Select
390
+ aria-label="Lighting"
391
+ value={lighting.source === 'studio' ? `studio:${lighting.studioPreset}` : lighting.source}
392
+ onChange={(event) => {
393
+ const value = event.target.value;
394
+ setViewPresentation(
395
+ documentId,
396
+ lightingChoice(
397
+ value.startsWith('studio:')
398
+ ? { source: 'studio', studioPreset: value.slice('studio:'.length) }
399
+ : { source: value as 'scene' },
400
+ ),
401
+ );
402
+ }}
403
+ >
404
+ {studioPresets()
405
+ // The document's own studio is a choice only where the document brought one.
406
+ .filter((preset) => preset.id !== DOCUMENT_STUDIO_PRESET.id || documentStudioOffered)
407
+ .map((preset) => (
408
+ <option key={preset.id} value={`studio:${preset.id}`}>
409
+ {preset.title}
410
+ </option>
411
+ ))}
412
+ <option value="scene">Scene lights</option>
413
+ </Select>
414
+ </label>
415
+ <label>
416
+ <span>Exposure</span>
417
+ <TextInput
418
+ aria-label="Preview exposure"
419
+ type="range"
420
+ min="0.2"
421
+ max="2"
422
+ step="0.1"
423
+ value={lighting.tone.exposure}
424
+ onChange={(event) =>
425
+ setViewPresentation(documentId, lightingChoice({ tone: { exposure: Number(event.target.value) } }))
426
+ }
427
+ />
428
+ <Text as="span" variant="caption">
429
+ {lighting.tone.exposure.toFixed(1)}
430
+ </Text>
431
+ </label>
432
+ </ViewportDisplayModeMenu>
433
+ {/* THE EYE WAS THE OVERLAYS CONTROL ALL ALONG — measured by what it
434
+ toggles, which is Grid and Bounds, exactly Blender's Show Overlays
435
+ subject. It takes Blender's mark. It does NOT take Blender's split
436
+ toggle+chevron: that toggle binds to a MASTER overlays flag this
437
+ document's session does not have, and deriving one from "any
438
+ overlay is on" would silently drop the per-overlay state on the
439
+ first click. One button, one job, until the session has the flag. */}
440
+ <ViewportOverlaysMenu
441
+ glyph={<ViewportOverlaysGlyph />}
442
+ choices={[
443
+ {
444
+ id: 'grid',
445
+ label: 'Grid',
446
+ // The VIEW's grid switch (`kit/viewport-presentation`), one per view.
447
+ enabled: viewGridVisible(documentId),
448
+ onToggle: () => setViewGridVisible(documentId, !viewGridVisible(documentId)),
449
+ },
450
+ {
451
+ id: 'bounds',
452
+ label: 'Bounds',
453
+ enabled: presentation.bounds,
454
+ onToggle: () => session.setBounds(!presentation.bounds),
455
+ },
456
+ ...(stage === null
457
+ ? []
458
+ : DOCUMENT_HELPER_ROWS.filter((row) => helperKinds.includes(row.key)).map((row) => ({
459
+ id: row.key,
460
+ label: row.label,
461
+ enabled: stage.helperVisibility[row.key],
462
+ onToggle: () => stage.toggleHelperType(row.key),
463
+ }))),
464
+ ]}
465
+ />
466
+ <div className="vgai-viewport-popover-anchor">
467
+ <Tooltip text="Asset preview actions">
468
+ <IconButton
469
+ ref={moreRef}
470
+ aria-label="Asset preview actions"
471
+ aria-haspopup="menu"
472
+ aria-expanded={moreOpen}
473
+ onClick={() => setMoreOpen((open) => !open)}
474
+ >
475
+ <EditorIcon icon={faEllipsis} size="md" />
476
+ </IconButton>
477
+ </Tooltip>
478
+ {moreOpen && (
479
+ <AnchoredMenu
480
+ anchorRef={moreRef}
481
+ clamp
482
+ aria-label="Asset preview actions"
483
+ onDismiss={() => setMoreOpen(false)}
484
+ style={{ minWidth: 210 }}
485
+ >
486
+ <MenuItem
487
+ onSelect={() => {
488
+ setMoreOpen(false);
489
+ void capturePreview();
490
+ }}
491
+ >
492
+ <EditorIcon icon={faCamera} size="sm" />
493
+ Capture preview
494
+ </MenuItem>
495
+ {assetPath ? (
496
+ <>
497
+ <MenuItem
498
+ onSelect={() => {
499
+ setMoreOpen(false);
500
+ void saveThumbnailFraming(false);
501
+ }}
502
+ >
503
+ Save thumbnail framing
504
+ </MenuItem>
505
+ <MenuItem
506
+ onSelect={() => {
507
+ setMoreOpen(false);
508
+ void saveThumbnailFraming(true);
509
+ }}
510
+ >
511
+ Reset thumbnail framing
512
+ </MenuItem>
513
+ </>
514
+ ) : null}
515
+ <MenuSeparator />
516
+ <MenuItem
517
+ onSelect={() => {
518
+ session.resetPresentation();
519
+ setMoreOpen(false);
520
+ }}
521
+ >
522
+ <EditorIcon icon={faRotateLeft} size="sm" />
523
+ Reset viewport settings
524
+ </MenuItem>
525
+ </AnchoredMenu>
526
+ )}
527
+ </div>
528
+ </EditorToolbar>
529
+ {capture ? (
530
+ <EditorPopover className="vgai-model-capture-preview">
531
+ <div>
532
+ <Text as="strong" variant="label">
533
+ Deterministic views
534
+ </Text>
535
+ <Button type="button" variant="ghost" onClick={() => setCapture(null)}>
536
+ Close
537
+ </Button>
538
+ </div>
539
+ <img src={capture} alt="Front, right, top, and three-quarter model preview" />
540
+ </EditorPopover>
541
+ ) : null}
542
+ {error ? (
543
+ <EditorBanner className="vgai-model-toolbar-error" tone="error">
544
+ {error}
545
+ </EditorBanner>
546
+ ) : null}
547
+ </div>
548
+ );
549
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Lazy document boundary for the shared Object3D authoring surface.
3
+ *
4
+ * Registries and open-document planners need to be able to name this surface
5
+ * during editor boot, but the implementation carries Asset Lab's animation,
6
+ * capture, model-inspection, and source-authoring graph. Importing that graph
7
+ * merely to register unopened document kinds put it on Scene's cold path.
8
+ * Keep the public component shape here and evaluate the implementation only
9
+ * when React actually renders an Object3D document.
10
+ */
11
+
12
+ import { lazy, Suspense, useEffect } from 'react';
13
+ import { announceDocumentStage } from '@volter/editor-sdk/kit/document-viewports';
14
+ import type { Object3DDocumentViewportProps } from './StageHost';
15
+ import { OBJECT3D_SURFACE_BUILDING, ViewportSurfaceStatus } from '@volter/editor-sdk/kit/viewport-surface-status';
16
+
17
+ const LazyObject3DDocumentViewport = lazy(async () => {
18
+ const module = await import('./StageHost');
19
+ return { default: module.Object3DDocumentViewport };
20
+ });
21
+
22
+ export function Object3DDocumentViewport(props: Object3DDocumentViewportProps) {
23
+ // ANNOUNCE BEFORE THE CHUNK RESOLVES. An opener's `ready` asks whether this
24
+ // document is mounting a stage at a moment when the implementation import is
25
+ // still in flight, and this boundary is the only thing rendered by then —
26
+ // announcing from inside `StageHost` would answer "no stage" for exactly the
27
+ // window the wait exists to cover.
28
+ useAnnouncedObject3DDocumentStage(props.documentId, props.chromeless);
29
+ return (
30
+ <Suspense
31
+ fallback={
32
+ <ViewportSurfaceStatus testId="object3d-document-loading">
33
+ {OBJECT3D_SURFACE_BUILDING}
34
+ </ViewportSurfaceStatus>
35
+ }
36
+ >
37
+ <LazyObject3DDocumentViewport {...props} />
38
+ </Suspense>
39
+ );
40
+ }
41
+
42
+ /**
43
+ * The announcement, spelled here and again at `StageHost.tsx`'s own
44
+ * `Object3DDocumentViewport` — deliberately NOT a shared hook, because
45
+ * importing one across these two files is the static edge that would put this
46
+ * boundary inside the implementation's own pinned closure and defeat the
47
+ * laziness the boundary exists for.
48
+ *
49
+ * A CHROMELESS mount announces nothing, for the same reason it registers no
50
+ * session (`StageHost.tsx:2235`): it is a thumbnail of a document, several can
51
+ * be alive for one id at once, and an opener waiting on one would wait forever.
52
+ */
53
+ function useAnnouncedObject3DDocumentStage(documentId: string, chromeless = false): void {
54
+ useEffect(() => {
55
+ if (chromeless) return;
56
+ return announceDocumentStage(documentId);
57
+ }, [documentId, chromeless]);
58
+ }