@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,215 @@
1
+ import { faBorderAll, faLightbulb } from '@fortawesome/free-solid-svg-icons';
2
+ import { EditorIcon, FloatingToolbar, IconButton, Tooltip } from '@volter/editor-sdk/widgets';
3
+ import type { AuthoringAdapter } from '@volter/editor-project/adapter';
4
+ import { memo, useEffect, useId, useMemo, useReducer, useState, useSyncExternalStore } from 'react';
5
+ import { activeAuthoringVersion, subscribeActiveAuthoring } from '@volter/editor-sdk/kit/authoring/active-adapter';
6
+ import {
7
+ object3DDocumentSession,
8
+ object3DDocumentSessionsVersion,
9
+ subscribeObject3DDocumentSessions,
10
+ } from '../authoring/object3d-document-session-registry';
11
+ import { resolvePanelAuthoring } from '@volter/editor-sdk/kit/authoring/panel-authoring';
12
+ import type { EditorShellStore } from '../editor-shell-store';
13
+ import { activeLightCount } from '@volter/editor-sdk/kit/light-explorer-model';
14
+ import { CORE_WORKSPACE_UTILITIES } from '@volter/editor-sdk/kit/workspace-core-utilities';
15
+ import {
16
+ subscribeWorkspaceDocuments,
17
+ workspaceDocumentRegistryVersion,
18
+ } from '@volter/editor-sdk/kit/workspace-document-registry';
19
+ import { showWorkspaceUtility } from '@volter/editor-sdk/kit/workspace-host-commands';
20
+ import { ViewportOverlaysMenu } from '@volter/editor-sdk/kit/components/ViewportOverlaysMenu';
21
+ import {
22
+ setViewGridVisible,
23
+ subscribeViewportPresentation,
24
+ viewGridVisible,
25
+ viewportPresentationVersion,
26
+ } from '@volter/editor-sdk/kit/viewport-presentation';
27
+ import { ViewportShadingMenu } from './ViewportShadingMenu';
28
+ import type { ViewportShadingMode } from '@volter/editor-threejs/render/viewport-shading';
29
+ import { ViewportViewMenu } from './ViewportViewMenu';
30
+ import { useViewportChrome, useViewportWords } from '@volter/editor-sdk/kit/native-selection-style';
31
+ import { createPortal } from 'react-dom';
32
+ import { stageViewName } from './stage-view-name';
33
+
34
+ /**
35
+ * Three-specific viewport display controls, mounted over ONE STAGE and driving
36
+ * THAT stage (ARCHITECTURE-CORE §One stage unit 4): `store` is the stage's own
37
+ * store, so a prefab's grid button toggles the prefab's grid rather than the
38
+ * world's. Performance moved to the global header telemetry so Scene and Play
39
+ * expose one source-following instrument.
40
+ *
41
+ * GRID is the stage's VIEW's switch (`overlays.grid.visible` in
42
+ * `kit/viewport-presentation`, keyed by `documentId`): the one flag this
43
+ * button, the document's header, the `toggle.grid` action, `set-grid` and
44
+ * `vgai status`'s `showGrid` all read and write.
45
+ */
46
+ export function ViewportOverlay({
47
+ store,
48
+ documentId,
49
+ }: {
50
+ readonly store: EditorShellStore;
51
+ readonly documentId: string;
52
+ }) {
53
+ useSyncExternalStore(store.shell.subscribe, store.shell.getSnapshot);
54
+ useSyncExternalStore(subscribeWorkspaceDocuments, workspaceDocumentRegistryVersion);
55
+ useSyncExternalStore(subscribeActiveAuthoring, activeAuthoringVersion);
56
+ useSyncExternalStore(subscribeObject3DDocumentSessions, object3DDocumentSessionsVersion);
57
+ const { adapter } = resolvePanelAuthoring(store.shell);
58
+ const session = object3DDocumentSession(documentId);
59
+ useSyncExternalStore(session?.subscribe ?? NO_SESSION_SUBSCRIBE, session?.getSnapshot ?? ZERO);
60
+ useSyncExternalStore(subscribeViewportPresentation, viewportPresentationVersion);
61
+ const grid = viewGridVisible(documentId);
62
+ // THE VIEW'S NAME ON THE BAR (the look's `stage.chrome.viewName` `bar`, Unreal's
63
+ // "Perspective" pill) leads these controls and opens the view menu; wherever the name opens
64
+ // that menu itself (`bar`, `menu`) the camera icon is not drawn a second time.
65
+ const chrome = useViewportChrome();
66
+ const words = useViewportWords();
67
+ const namedView = chrome.viewName === 'bar' || chrome.viewName === 'menu';
68
+ // ON THE LOOK'S BAR these controls sit in the bar's display slot, which the document's surface
69
+ // draws beside the tools (`WorkspaceDocumentSurface`); they are portalled there so the row
70
+ // flows as one. Found from this stage's own place in the page.
71
+ const [anchor, setAnchor] = useState<HTMLSpanElement | null>(null);
72
+ const findSlot = (): HTMLElement | null =>
73
+ anchor
74
+ ?.closest('.vgai-dock-document-content')
75
+ ?.querySelector<HTMLElement>(':scope > .vgai-stage-bar [data-stage-bar-slot="display"]') ?? null;
76
+ // ONE OVERLAY PER SLOT: a document hosting two stages has one bar, so the first overlay to
77
+ // claim its slot draws there and any other keeps its own place.
78
+ const owner = useId();
79
+ const found = findSlot();
80
+ const slot = found && (found.dataset['owner'] === undefined || found.dataset['owner'] === owner) ? found : null;
81
+ // The surface's bar can commit after this render (a change of look reaches both at once), so
82
+ // the slot is looked for again once the page has settled.
83
+ const [, reslot] = useReducer((value: number) => value + 1, 0);
84
+ useEffect(() => {
85
+ const now = findSlot();
86
+ if (now && now.dataset['owner'] === undefined) now.dataset['owner'] = owner;
87
+ const claimable = now && now.dataset['owner'] === owner ? now : null;
88
+ if (claimable !== slot) reslot();
89
+ });
90
+ useEffect(
91
+ () => () => {
92
+ if (slot?.dataset['owner'] === owner) delete slot.dataset['owner'];
93
+ },
94
+ [slot, owner],
95
+ );
96
+ const toolbar = (
97
+ <FloatingToolbar
98
+ label="Viewport display"
99
+ className="vgai-viewport-toolbar vgai-viewport-toolbar-right vgai-stage-display"
100
+ >
101
+ {chrome.viewName === 'bar' && session ? (
102
+ <ViewportViewMenu
103
+ shell={store.shell}
104
+ documentId={documentId}
105
+ label={stageViewName(session.viewport, session.projection(), 'long')}
106
+ />
107
+ ) : null}
108
+ <Tooltip text={`Grid: ${grid ? 'On' : 'Off'}`}>
109
+ <IconButton
110
+ aria-label="Toggle grid"
111
+ aria-pressed={grid}
112
+ size="comfortable"
113
+ onClick={() => setViewGridVisible(documentId, !grid)}
114
+ >
115
+ <EditorIcon icon={faBorderAll} size="md" />
116
+ </IconButton>
117
+ </Tooltip>
118
+ <HelpersButton store={store} word={words.helpers} />
119
+ <ViewportShadingMenu
120
+ mode={sessionShading(session) ?? store.shadingMode}
121
+ // A document stage paints the mode its SESSION holds, and the store's is only what this
122
+ // menu shows: the session's `setMode` is what `set-shading-mode` drives, and writing the
123
+ // store alone relabelled the menu over an unchanged picture (measured under Unreal's look:
124
+ // "Wireframe" over the shaded box).
125
+ onChange={(mode) => (session ? session.setMode(mode) : store.setShadingMode(mode))}
126
+ words={words.shading}
127
+ />
128
+ {namedView ? null : <ViewportViewMenu shell={store.shell} documentId={documentId} />}
129
+ <LightExplorerButton adapter={adapter} store={store} />
130
+ </FloatingToolbar>
131
+ );
132
+ return (
133
+ <>
134
+ <span ref={setAnchor} hidden />
135
+ {slot ? createPortal(toolbar, slot) : toolbar}
136
+ </>
137
+ );
138
+ }
139
+
140
+ /** The shading mode a document stage is painting (the menu names one it does not offer). */
141
+ function sessionShading(
142
+ session: ReturnType<typeof object3DDocumentSession>,
143
+ ): ViewportShadingMode | 'uv' | 'vertex-colors' | null {
144
+ return session?.presentation().mode ?? null;
145
+ }
146
+
147
+ const NO_SESSION_SUBSCRIBE = () => () => {};
148
+ const ZERO = () => 0;
149
+
150
+ /** Keep the hierarchy walk off ordinary viewport-store renders. The adapter
151
+ * notifies when its projected tree changes; a stable adapter prop lets React
152
+ * skip this child for unrelated grid/shading/selection updates. */
153
+ const LightExplorerButton = memo(function LightExplorerButton({
154
+ adapter,
155
+ store,
156
+ }: {
157
+ readonly adapter: AuthoringAdapter;
158
+ readonly store: EditorShellStore;
159
+ }) {
160
+ const [, refresh] = useReducer((value: number) => value + 1, 0);
161
+ useEffect(() => adapter.subscribe?.(refresh), [adapter]);
162
+ // `adapter.subscribe` is `store.subscribe` on every shipping adapter, so the
163
+ // `memo` above cannot stop this button re-rendering on a bare selection
164
+ // click — and `activeLightCount` walks the WHOLE tree. Key the walk on
165
+ // `contentVersion`, which a selection-only notify leaves alone.
166
+ const lightCount = useMemo(() => activeLightCount(adapter), [adapter, store.shell.contentVersion]);
167
+ if (lightCount === 0) return null;
168
+ return (
169
+ <Tooltip text={`Open Light Explorer · ${lightCount} ${lightCount === 1 ? 'light' : 'lights'}`}>
170
+ <IconButton
171
+ aria-label="Open Light Explorer"
172
+ size="comfortable"
173
+ onClick={() => showWorkspaceUtility(CORE_WORKSPACE_UTILITIES.lightExplorer.id)}
174
+ >
175
+ <EditorIcon icon={faLightbulb} size="md" />
176
+ </IconButton>
177
+ </Tooltip>
178
+ );
179
+ });
180
+
181
+ function HelpersButton({ store, word }: { store: EditorShellStore; word?: string | undefined }) {
182
+ const helperTypes = [
183
+ { key: 'bounds' as const, label: 'Bounds' },
184
+ { key: 'lights' as const, label: 'Lights' },
185
+ { key: 'cameras' as const, label: 'Cameras' },
186
+ { key: 'colliders' as const, label: 'Colliders' },
187
+ { key: 'joints' as const, label: 'Joints' },
188
+ { key: 'particles' as const, label: 'Particle Emitters' },
189
+ { key: 'lod' as const, label: 'LOD' },
190
+ { key: 'audio' as const, label: 'Audio' },
191
+ { key: 'splines' as const, label: 'Splines' },
192
+ { key: 'navmesh' as const, label: 'NavMesh' },
193
+ { key: 'constraints' as const, label: 'Constraints' },
194
+ { key: 'reflectionProbes' as const, label: 'Reflection Probes' },
195
+ { key: 'triggerVolumes' as const, label: 'Trigger Volumes' },
196
+ { key: 'skeletons' as const, label: 'Skeletons' },
197
+ { key: 'weights' as const, label: 'Weights' },
198
+ { key: 'cursor' as const, label: '3D Cursor' },
199
+ { key: 'empties' as const, label: 'Empties' },
200
+ ];
201
+
202
+ return (
203
+ <ViewportOverlaysMenu
204
+ label="Helpers"
205
+ {...(word !== undefined ? { word } : {})}
206
+ master={{ enabled: store.shell.showHelpers, onToggle: () => store.shell.toggleHelpers() }}
207
+ choices={helperTypes.map((helper) => ({
208
+ id: helper.key,
209
+ label: helper.label,
210
+ enabled: store.shell.helperVisibility[helper.key],
211
+ onToggle: () => store.shell.toggleHelperType(helper.key),
212
+ }))}
213
+ />
214
+ );
215
+ }
@@ -0,0 +1,340 @@
1
+ import { faCaretDown, faCheck } from '@fortawesome/free-solid-svg-icons';
2
+ import {
3
+ AnchoredMenu,
4
+ Button,
5
+ EditorIcon,
6
+ IconButton,
7
+ MenuItem,
8
+ MenuSeparator,
9
+ Tooltip,
10
+ themeVars,
11
+ } from '@volter/editor-sdk/widgets';
12
+ import type { ViewportShadingMode } from '@volter/editor-threejs/render/viewport-shading';
13
+ import { type ReactNode, useMemo, useRef, useState } from 'react';
14
+
15
+ export const viewportShadingModes: ReadonlyArray<{
16
+ mode: ViewportShadingMode;
17
+ label: string;
18
+ description: string;
19
+ }> = [
20
+ { mode: 'solid', label: 'Material', description: 'Authored materials and lighting' },
21
+ { mode: 'clay', label: 'Solid', description: 'Neutral flat-shaded geometry' },
22
+ { mode: 'unlit', label: 'Unlit', description: 'Base color without scene lighting' },
23
+ { mode: 'wireframe', label: 'Wireframe', description: 'Renderable triangle edges' },
24
+ { mode: 'matcap', label: 'Matcap', description: 'Neutral clay sphere — form without color' },
25
+ { mode: 'normals', label: 'Normals', description: 'Surface-normal direction colors' },
26
+ { mode: 'overdraw', label: 'Overdraw', description: 'Additive overlap visualization' },
27
+ ];
28
+
29
+ /** The names of the modes a stage may paint that this menu does not offer. */
30
+ const OWN_MODE_NAMES: Readonly<Record<string, string>> = {
31
+ preview: 'Material Preview',
32
+ rendered: 'Rendered',
33
+ uv: 'UV',
34
+ 'vertex-colors': 'Vertex Colors',
35
+ };
36
+
37
+ export interface ViewportDisplayModeChoice<T extends string> {
38
+ readonly mode: T;
39
+ readonly label: string;
40
+ readonly description: string;
41
+ }
42
+
43
+ /**
44
+ * A SHADING SEGMENT — one cell of Blender's four-button shading group, which
45
+ * is what its 3D View header spends on shading instead of a word dropdown.
46
+ * `glyph` names the MARK, never the mode: the same sphere means "solid" in
47
+ * any vocabulary, and a caller's mode ids are its own.
48
+ */
49
+ export interface ViewportShadingSegment<T extends string> {
50
+ readonly mode: T;
51
+ readonly glyph: 'wireframe' | 'sphere' | 'preview' | 'rendered';
52
+ readonly label: string;
53
+ }
54
+
55
+ /**
56
+ * THREE OF BLENDER'S FOUR, and the fourth stands only where it has a mode.
57
+ * Blender's group is Wireframe / Solid / Material Preview / Rendered; this
58
+ * viewport implements the first three (`viewportShadingModes` above:
59
+ * `wireframe`, `clay` — its own label is "Solid" — and `solid`, labelled
60
+ * "Material"). A stage that keeps its lighting per draw mode draws Blender's
61
+ * own four instead ({@link perModeShadingSegments}); elsewhere a Rendered cell
62
+ * would be a shim promising a render the stage does not produce.
63
+ * Every other mode this viewport HAS (Unlit, Matcap, Normals, Overdraw, and
64
+ * the document's UV / Vertex-colors when the asset carries them) stays in the
65
+ * popover behind the chevron, which is also where Blender keeps the rest of
66
+ * its shading controls.
67
+ */
68
+ export const viewportShadingSegments: ReadonlyArray<ViewportShadingSegment<ViewportShadingMode>> = [
69
+ { mode: 'wireframe', glyph: 'wireframe', label: 'Wireframe' },
70
+ { mode: 'clay', glyph: 'sphere', label: 'Solid' },
71
+ { mode: 'solid', glyph: 'preview', label: 'Material' },
72
+ ];
73
+
74
+ /**
75
+ * BLENDER'S OWN FOUR CELLS, for a stage that keeps lighting per draw mode as Blender's shading
76
+ * types do: Wireframe, Solid (`solid` — materials' colours under its studio lights), Material
77
+ * Preview (`preview`) and Rendered (`rendered`). A cell stands only where the stage says how its
78
+ * mode is lit, so none is a shim; Solid and Wireframe are lit by the stage's own default.
79
+ */
80
+ export function perModeShadingSegments(
81
+ declared: ReadonlySet<string>,
82
+ ): ReadonlyArray<ViewportShadingSegment<ViewportShadingMode>> {
83
+ return [
84
+ { mode: 'wireframe', glyph: 'wireframe', label: 'Wireframe' },
85
+ { mode: 'solid', glyph: 'sphere', label: 'Solid' },
86
+ ...(declared.has('preview') ? [{ mode: 'preview' as const, glyph: 'preview' as const, label: 'Material Preview' }] : []),
87
+ ...(declared.has('rendered') ? [{ mode: 'rendered' as const, glyph: 'rendered' as const, label: 'Rendered' }] : []),
88
+ ];
89
+ }
90
+
91
+ /**
92
+ * The three marks, transcribed from `modeling-edit-none.png` at its native 2x
93
+ * — the 3D View header's shading group, cells at x 2628 / 2666 / 2704, y
94
+ * 60..95, each a 36x36 device cell carrying 28..32 device px of ink. On the
95
+ * 16-unit box here (the icon set's own grid, so these sit at the same optical
96
+ * size as every `EditorIcon` beside them):
97
+ *
98
+ * - WIREFRAME is a wire globe: a ring, two latitude chords and ONE meridian,
99
+ * and the frame settles the spacing — measured off the circle's own centre
100
+ * (device rows 64..91, so centre row 77.5, radius 14) the chords sit at
101
+ * ±5 device px and the meridian 5 device px LEFT of centre, i.e. all three
102
+ * at 0.357 of the radius. The off-centre meridian is what makes it read as
103
+ * a globe seen from three-quarters rather than a crosshair.
104
+ * - SPHERE is a plain filled disc. Blender's carries a near-white body
105
+ * inside a lighter rim; at 14 CSS px the rim is below this instrument's
106
+ * floor and a flat disc is the honest reduction.
107
+ * - PREVIEW is the ring with its upper-left lobe filled: the fill's right
108
+ * edge is a near-vertical chord just left of centre (device offset 13 of
109
+ * 28, held from row 68 down) and its lower edge a cut from the ring at
110
+ * row 78.5 down-right to row 82 — measured, because the first cut put
111
+ * that edge two device rows high and the mark read as a pie chart rather
112
+ * than a lit sphere. Blender hatches the remaining lower-right quadrant;
113
+ * three hatch strokes cannot survive 14 px and are deliberately not
114
+ * drawn, so this mark is Blender's silhouette without its texture.
115
+ *
116
+ * These are drawn HERE rather than fetched through `EditorIcon` because the
117
+ * icon-set seam is keyed by Font Awesome NAME, and the three names that would
118
+ * carry these marks are already spent on other jobs (`globe` is the
119
+ * transform-space toggle, `circle` is the Outliner's generic object, and
120
+ * `circle-half-stroke` is the LUT document's before/after). Re-drawing a
121
+ * shared name would move those marks too. The precedent is the sibling
122
+ * segmented group in this same header — `@volter/editor-blender`'s select-mode cells draw
123
+ * their own glyphs inline for the same reason.
124
+ */
125
+ function ViewportShadingGlyph({
126
+ glyph,
127
+ }: {
128
+ readonly glyph: ViewportShadingSegment<string>['glyph'];
129
+ }) {
130
+ return (
131
+ <svg
132
+ viewBox="0 0 16 16"
133
+ className="vgai-viewport-shading-glyph"
134
+ aria-hidden="true"
135
+ focusable="false"
136
+ >
137
+ {glyph === 'sphere' || glyph === 'rendered' ? (
138
+ <circle cx="8" cy="8" r="7.3" fill="currentColor" />
139
+ ) : (
140
+ <circle cx="8" cy="8" r="7.3" fill="none" stroke="currentColor" strokeWidth="1.05" />
141
+ )}
142
+ {glyph === 'wireframe' ? (
143
+ <g stroke="currentColor" strokeWidth="1.05">
144
+ <line x1="1.7" y1="5.4" x2="14.3" y2="5.4" />
145
+ <line x1="1.7" y1="10.6" x2="14.3" y2="10.6" />
146
+ <line x1="5.4" y1="1.7" x2="5.4" y2="14.3" />
147
+ </g>
148
+ ) : null}
149
+ {glyph === 'rendered' ? (
150
+ // Blender's Rendered mark is a lit ball: the filled disc with a highlight up and left.
151
+ <circle cx="5.6" cy="5.6" r="2.3" fill="var(--vgai-surface-overlay, #ffffff)" fillOpacity="0.7" />
152
+ ) : null}
153
+ {glyph === 'preview' ? (
154
+ <path
155
+ d="M7.73 1.23 L7.73 10.43 L1.22 8.04 A6.78 6.78 0 0 1 7.73 1.23 Z"
156
+ fill="currentColor"
157
+ />
158
+ ) : null}
159
+ </svg>
160
+ );
161
+ }
162
+
163
+ export function ViewportDisplayModeMenu<T extends string>({
164
+ mode,
165
+ onChange,
166
+ choices,
167
+ disabled = false,
168
+ segments,
169
+ children,
170
+ }: {
171
+ mode: T;
172
+ onChange: (mode: T) => void;
173
+ choices: ReadonlyArray<ViewportDisplayModeChoice<T>>;
174
+ disabled?: boolean;
175
+ /** Draw Blender's segmented group instead of a labelled trigger. */
176
+ segments?: ReadonlyArray<ViewportShadingSegment<T>>;
177
+ /** Extra rows for the popover — Blender keeps its lighting controls here. */
178
+ children?: ReactNode;
179
+ }) {
180
+ const [open, setOpen] = useState(false);
181
+ const ref = useRef<HTMLButtonElement>(null);
182
+ const active = choices.find((entry) => entry.mode === mode) ?? choices[0];
183
+ if (!active) return null;
184
+
185
+ const menu =
186
+ open && !disabled ? (
187
+ <AnchoredMenu
188
+ id="vgai-viewport-shading-menu"
189
+ anchorRef={ref}
190
+ aria-label="Viewport shading modes"
191
+ onDismiss={() => setOpen(false)}
192
+ style={{
193
+ minWidth: 230,
194
+ }}
195
+ >
196
+ {choices.map((entry) => (
197
+ <MenuItem
198
+ key={entry.mode}
199
+ role="menuitemradio"
200
+ aria-checked={entry.mode === mode}
201
+ onSelect={() => {
202
+ onChange(entry.mode);
203
+ setOpen(false);
204
+ }}
205
+ style={{ display: 'grid', gridTemplateColumns: '14px 1fr', gap: 7 }}
206
+ >
207
+ <span style={{ opacity: entry.mode === mode ? 1 : 0 }}>
208
+ <EditorIcon icon={faCheck} size="xs" />
209
+ </span>
210
+ <span>
211
+ <span style={{ display: 'block' }}>{entry.label}</span>
212
+ <span
213
+ style={{
214
+ display: 'block',
215
+ color: themeVars.content.dim,
216
+ fontSize: 10,
217
+ }}
218
+ >
219
+ {entry.description}
220
+ </span>
221
+ </span>
222
+ </MenuItem>
223
+ ))}
224
+ {children ? (
225
+ <>
226
+ <MenuSeparator />
227
+ <div className="vgai-viewport-shading-options">{children}</div>
228
+ </>
229
+ ) : null}
230
+ </AnchoredMenu>
231
+ ) : null;
232
+
233
+ if (segments) {
234
+ // BLENDER'S SHADING CONTROL: the modes it HAS as joined cells, then the
235
+ // chevron whose popover holds everything else. The cells join the way the
236
+ // sibling select-mode group in this same header joins — square inner
237
+ // corners and a shared border — so the five read as one widget with one
238
+ // cell lit, and `aria-pressed` is the only thing that paints the lit one.
239
+ return (
240
+ <div className="vgai-viewport-popover-anchor">
241
+ <span className="vgai-viewport-shading-segments" role="group" aria-label="Viewport shading">
242
+ {segments.map((segment) => (
243
+ <Tooltip key={segment.mode} text={`Viewport shading: ${segment.label}`}>
244
+ <IconButton
245
+ size="compact"
246
+ shape="segment"
247
+ variant="secondary"
248
+ aria-pressed={segment.mode === mode}
249
+ aria-label={`Viewport shading: ${segment.label}`}
250
+ disabled={disabled}
251
+ onClick={() => onChange(segment.mode)}
252
+ >
253
+ <ViewportShadingGlyph glyph={segment.glyph} />
254
+ </IconButton>
255
+ </Tooltip>
256
+ ))}
257
+ <Tooltip text="Shading options">
258
+ <IconButton
259
+ ref={ref}
260
+ size="compact"
261
+ shape="segment"
262
+ variant="secondary"
263
+ aria-label="Shading options"
264
+ aria-haspopup="menu"
265
+ aria-expanded={open}
266
+ aria-controls={open ? 'vgai-viewport-shading-menu' : undefined}
267
+ disabled={disabled}
268
+ onClick={() => setOpen((value) => !value)}
269
+ >
270
+ <EditorIcon icon={faCaretDown} size="xs" />
271
+ </IconButton>
272
+ </Tooltip>
273
+ </span>
274
+ {menu}
275
+ </div>
276
+ );
277
+ }
278
+
279
+ return (
280
+ <div style={{ position: 'relative' }}>
281
+ <Tooltip text={`Viewport shading: ${active.label}`}>
282
+ {/* A control's CHROME never encodes its VALUE. The variant used to be
283
+ `mode === 'solid' ? 'ghost' : 'secondary'`, so the trigger's well
284
+ appeared and disappeared as you used it — and at rest (`solid`,
285
+ labelled "Material") it was a transparent slab where Blender paints
286
+ a bordered well: measured in `modeling-object-none.png`, `Object
287
+ Mode v` is #272727 inside a 1px #3c3c3c border, 20px tall, on a
288
+ #343434 header. `secondary` is this estate's select-like trigger
289
+ already (FontPicker's `.vgai-field-trigger`), so no new variant. */}
290
+ <Button
291
+ ref={ref}
292
+ type="button"
293
+ size="compact"
294
+ variant="secondary"
295
+ aria-label={`Viewport shading: ${active.label}`}
296
+ aria-haspopup="menu"
297
+ aria-expanded={open}
298
+ aria-controls={open ? 'vgai-viewport-shading-menu' : undefined}
299
+ disabled={disabled}
300
+ onClick={() => setOpen((value) => !value)}
301
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 66 }}
302
+ >
303
+ <span>{active.label}</span>
304
+ <EditorIcon icon={faCaretDown} size="xs" />
305
+ </Button>
306
+ </Tooltip>
307
+ {menu}
308
+ </div>
309
+ );
310
+ }
311
+
312
+ export function ViewportShadingMenu({
313
+ mode,
314
+ onChange,
315
+ disabled = false,
316
+ words,
317
+ }: {
318
+ /** The mode the stage paints. One this menu does not offer (a stage's Material Preview or
319
+ * Rendered, a Model document's UV or vertex colours) is still shown by its own name. */
320
+ mode: ViewportShadingMode | 'uv' | 'vertex-colors';
321
+ onChange: (mode: ViewportShadingMode) => void;
322
+ disabled?: boolean;
323
+ /** Each mode's name in the target's own words (the look's `stage.words.shading`). */
324
+ words?: Readonly<Partial<Record<string, string>>>;
325
+ }) {
326
+ const choices = useMemo(() => {
327
+ const offered = viewportShadingModes.map((choice) => ({ ...choice, label: words?.[choice.mode] ?? choice.label }));
328
+ if (offered.some((choice) => choice.mode === mode)) return offered;
329
+ const label = words?.[mode] ?? OWN_MODE_NAMES[mode] ?? mode;
330
+ return [...offered, { mode: mode as ViewportShadingMode, label, description: 'The mode this view is drawn in' }];
331
+ }, [words, mode]);
332
+ return (
333
+ <ViewportDisplayModeMenu
334
+ mode={mode as ViewportShadingMode}
335
+ onChange={onChange}
336
+ choices={choices}
337
+ disabled={disabled}
338
+ />
339
+ );
340
+ }