@vgai/editor 0.4.0 → 0.4.1

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 (137) hide show
  1. package/dist/assets/{axe-BJjvBeOZ.js → axe-N4cNyzfs.js} +1 -1
  2. package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
  3. package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
  4. package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
  5. package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
  6. package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
  7. package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
  8. package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
  9. package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
  10. package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
  11. package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
  12. package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
  13. package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
  14. package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
  15. package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
  16. package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
  17. package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
  18. package/dist/assets/index-DiUacMXR.css +1 -0
  19. package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
  20. package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
  21. package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
  22. package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
  23. package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +27125 -1075
  26. package/package.json +2 -2
  27. package/server/asset-history-snapshots.ts +189 -0
  28. package/server/asset-library-routes.ts +404 -53
  29. package/server/catalog-import-recipe.ts +97 -0
  30. package/server/catalog-reimport.ts +141 -0
  31. package/server/cloud-asset-catalog.ts +71 -5
  32. package/server/dev.ts +1 -1
  33. package/server/editor-server.ts +106 -51
  34. package/server/editor-sse.ts +5 -0
  35. package/server/generative-execution-context.ts +45 -0
  36. package/server/model-import-conversion.ts +345 -0
  37. package/server/packaged.ts +1 -1
  38. package/server/project-hmr-files.ts +3 -5
  39. package/server/project-output-writer.ts +440 -36
  40. package/server/project-tools.ts +529 -0
  41. package/server/server-utils.ts +14 -11
  42. package/src/EditorContext.tsx +4 -4
  43. package/src/action-registry.ts +12 -0
  44. package/src/asset-editor-context.tsx +9 -0
  45. package/src/asset-events.ts +3 -15
  46. package/src/asset-selection.ts +3 -0
  47. package/src/asset-workflow/asset-capabilities.ts +40 -0
  48. package/src/asset-workflow/asset-import-jobs.ts +108 -0
  49. package/src/asset-workflow/asset-library-query.ts +8 -1
  50. package/src/asset-workflow/asset-workflow-quality.ts +2 -0
  51. package/src/asset-workflow/import-contract.ts +2 -0
  52. package/src/asset-workflow/model-inspection.ts +92 -1
  53. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  54. package/src/asset-workflow/project-asset-commands.ts +1 -0
  55. package/src/asset-workflow/project-asset-health.ts +3 -6
  56. package/src/asset-workflow/project-asset-operations.ts +119 -4
  57. package/src/command-listener.ts +37 -9
  58. package/src/components/ApplicationMenus.tsx +13 -15
  59. package/src/components/AssetBrowser.tsx +301 -26
  60. package/src/components/AssetImportDetails.tsx +207 -92
  61. package/src/components/AssetImportJobStatus.tsx +55 -0
  62. package/src/components/CenterDocuments.tsx +28 -1
  63. package/src/components/CommandPalette.tsx +2 -2
  64. package/src/components/DefaultEditorLayout.tsx +4 -0
  65. package/src/components/InspectorToolSection.tsx +12 -10
  66. package/src/components/OnlineAssetBrowser.tsx +197 -11
  67. package/src/components/ToolHost.tsx +8 -8
  68. package/src/components/ViewportOverlay.tsx +13 -26
  69. package/src/components/ViewportPanel.tsx +19 -14
  70. package/src/components/ViewportShadingMenu.tsx +103 -0
  71. package/src/components/WorkspaceDock.tsx +103 -17
  72. package/src/components/asset-documents.tsx +38 -11
  73. package/src/components/asset-editor-persistence.ts +300 -5
  74. package/src/components/asset-selection-section.tsx +83 -9
  75. package/src/components/asset-viewers/AudioViewer.tsx +4 -7
  76. package/src/components/asset-viewers/ModelViewer.tsx +168 -5
  77. package/src/components/asset-viewers/OnlineAssetDetail.tsx +52 -12
  78. package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
  79. package/src/components/asset-workflow.css +30 -3
  80. package/src/components/primitives/editor-icons.ts +1 -0
  81. package/src/components/project-tool-documents.tsx +298 -0
  82. package/src/components/status-contributions.tsx +38 -0
  83. package/src/components/tool-documents.tsx +20 -17
  84. package/src/components/tool-schema-form.tsx +258 -0
  85. package/src/components/workspace-dock.css +16 -0
  86. package/src/editor-api.ts +159 -22
  87. package/src/editor-store.ts +6 -5
  88. package/src/editor-viewport.ts +12 -7
  89. package/src/game-viewport-state.ts +28 -0
  90. package/src/model-thumbnail.ts +97 -9
  91. package/src/play-mode.ts +30 -3
  92. package/src/project-tool-discovery.ts +5 -5
  93. package/src/{project-operations.ts → project-tools.ts} +39 -26
  94. package/src/scene-sync.ts +0 -67
  95. package/src/storage/handle-storage.ts +5 -1
  96. package/src/storage/http-storage.ts +7 -2
  97. package/src/storage/mem-storage.ts +7 -1
  98. package/src/storage/types.ts +2 -0
  99. package/src/theme-css.ts +17 -7
  100. package/src/tool-loader.ts +152 -241
  101. package/src/viewport-shading-boundary.ts +15 -0
  102. package/src/widgets/index.ts +2 -1
  103. package/src/workspace-dock-controller.ts +8 -0
  104. package/src/workspace-document-registry.ts +4 -4
  105. package/src/workspace-state-persistence.ts +36 -10
  106. package/template/.claude/skills/editor/SKILL.md +13 -3
  107. package/template/AGENTS.md +100 -83
  108. package/template/package.json +16 -0
  109. package/template/public/scenes/default.vscn.json +1 -2
  110. package/template/scripts/playtest.ts +7 -37
  111. package/template/server/rooms/game-room.ts +2 -2
  112. package/template/src/data/README.md +3 -2
  113. package/template/src/data/tuning.data.json +1 -3
  114. package/template/src/data/tuning.schema.json +0 -12
  115. package/template/src/data/tuning.schema.ts +2 -9
  116. package/template/src/data/tuning.ts +1 -1
  117. package/template/src/scripts/registry.ts +0 -3
  118. package/template/src/tools/README.md +151 -16
  119. package/template/src/tools/autoplay.tool.ts +31 -0
  120. package/template/src/tools/autoplay.ts +15 -0
  121. package/template/src/tools/tuning.document.tsx +107 -0
  122. package/template/src/tools/tuning.tool.ts +24 -0
  123. package/template/src/tools/tuning.ts +9 -0
  124. package/template/src/ui/Button.stories.tsx +1 -1
  125. package/template/tests/acceptance/example.spec.ts +4 -59
  126. package/template/vgai.game.json +1 -1
  127. package/dist/assets/index-bAGhEnnO.css +0 -1
  128. package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
  129. package/dist/project-scripts.bundle.js +0 -3917
  130. package/server/project-operations.ts +0 -343
  131. package/src/components/project-operation-documents.tsx +0 -226
  132. package/template/src/operations/README.md +0 -88
  133. package/template/src/scripts/components/data-spinner.ts +0 -86
  134. package/template/src/scripts/components/spin-lap-math.ts +0 -27
  135. package/template/src/tools/example.tool.tsx +0 -97
  136. package/template/src/tools/spin.tool.tsx +0 -101
  137. package/template/tests/logic/example.test.ts +0 -31
@@ -1,24 +1,34 @@
1
1
  import { faSpinner } from '@fortawesome/free-solid-svg-icons';
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { clearSelectedAsset, getSelectedAsset, setSelectedAsset } from '../asset-selection';
4
+ import { beginAssetImportJob, updateAssetImportJob } from '../asset-workflow/asset-import-jobs';
4
5
  import {
5
6
  type AssetLibrarySort,
6
7
  assetLibraryStateKey,
7
8
  onlineAssetKey,
8
9
  queryAssetLibrary,
9
10
  } from '../asset-workflow/asset-library-query';
11
+ import { confirmAssetAction } from '../asset-workflow/asset-workflow-quality';
10
12
  import { Button, Checkbox, EditorIcon, Select, StateSurface, TextInput } from '../design-system';
11
13
  import { useEditorStore } from '../EditorContext';
12
14
  import {
15
+ type DownloadOnlineAssetResult,
16
+ downloadOnlineAsset,
13
17
  getAssetLibraryCategories,
14
18
  getDownloadedAssetPath,
19
+ getOnlineAssetFiles,
15
20
  loadDownloadedAssets,
16
21
  type OnlineAsset,
17
22
  onAssetDownloaded,
18
23
  searchOnlineAssets,
19
24
  } from '../editor-api';
20
25
  import { getCurrentProject } from '../project-manager';
26
+ import { AssetImportJobStatus } from './AssetImportJobStatus';
21
27
  import { openOnlineAssetDocument } from './asset-documents';
28
+ import {
29
+ downloadOnlineAssetWithHistory,
30
+ recordAppliedAssetImportsWithHistory,
31
+ } from './asset-editor-persistence';
22
32
  import './asset-workflow.css';
23
33
 
24
34
  type Source = 'local' | 'polyhaven' | 'ambientcg';
@@ -107,6 +117,95 @@ function toOnlineInfo(asset: OnlineAsset) {
107
117
  };
108
118
  }
109
119
 
120
+ function retainAvailableSelection(
121
+ current: ReadonlySet<string>,
122
+ assets: readonly OnlineAsset[],
123
+ ): Set<string> {
124
+ const available = new Set(assets.map(onlineAssetKey));
125
+ return new Set([...current].filter((key) => available.has(key)));
126
+ }
127
+
128
+ function nextOnlineSelection(
129
+ assets: readonly OnlineAsset[],
130
+ current: ReadonlySet<string>,
131
+ asset: OnlineAsset,
132
+ anchor: string | null,
133
+ modifiers: { range?: boolean; toggle?: boolean },
134
+ ): { keys: Set<string>; anchor: string } {
135
+ const key = onlineAssetKey(asset);
136
+ if (modifiers.range && anchor) {
137
+ const startIndex = assets.findIndex((candidate) => onlineAssetKey(candidate) === anchor);
138
+ const endIndex = assets.findIndex((candidate) => onlineAssetKey(candidate) === key);
139
+ if (startIndex >= 0 && endIndex >= 0) {
140
+ const [start, end] = startIndex < endIndex ? [startIndex, endIndex] : [endIndex, startIndex];
141
+ return { keys: new Set(assets.slice(start, end + 1).map(onlineAssetKey)), anchor };
142
+ }
143
+ }
144
+ if (!modifiers.toggle) return { keys: new Set([key]), anchor: key };
145
+ const keys = new Set(current);
146
+ if (keys.has(key)) keys.delete(key);
147
+ else keys.add(key);
148
+ return { keys, anchor: key };
149
+ }
150
+
151
+ /** Run one catalog acquisition independently of the Library component's
152
+ * lifetime. A retry journals its successful mutation immediately; the first
153
+ * pass returns history to the caller so a batch can be one transaction. */
154
+ async function importOnlineAssetJob(
155
+ asset: OnlineAsset,
156
+ journalImmediately = false,
157
+ ): Promise<DownloadOnlineAssetResult | null> {
158
+ const jobId = `batch:${onlineAssetKey(asset)}`;
159
+ const job = beginAssetImportJob(jobId, asset.name);
160
+ updateAssetImportJob(jobId, {
161
+ stage: 'fetch',
162
+ loaded: 0,
163
+ total: 0,
164
+ status: 'running',
165
+ });
166
+ try {
167
+ const files = await getOnlineAssetFiles(asset.source, asset.id);
168
+ const file = files[0];
169
+ if (!file) throw new Error('No healthy delivery is available for this asset.');
170
+ const params = {
171
+ source: asset.source,
172
+ id: asset.id,
173
+ name: asset.name,
174
+ url: file.url,
175
+ format: file.format,
176
+ jobId,
177
+ ...(file.includes ? { includes: file.includes } : {}),
178
+ signal: job.controller.signal,
179
+ };
180
+ const result = journalImmediately
181
+ ? await downloadOnlineAssetWithHistory(params, `Import ${asset.name}`)
182
+ : await downloadOnlineAsset(params);
183
+ if (!result.ok) throw new Error(result.error ?? 'The import was rejected.');
184
+ updateAssetImportJob(jobId, {
185
+ stage: 'commit',
186
+ loaded: file.sizeBytes ?? 1,
187
+ total: file.sizeBytes ?? 1,
188
+ status: result.alreadyExists ? 'skipped' : 'imported',
189
+ ...(result.path ? { message: result.path } : {}),
190
+ });
191
+ if (journalImmediately) {
192
+ await loadDownloadedAssets();
193
+ window.dispatchEvent(new CustomEvent('editor:assets-changed'));
194
+ }
195
+ return result;
196
+ } catch (cause) {
197
+ const cancelled = job.controller.signal.aborted;
198
+ updateAssetImportJob(jobId, {
199
+ status: cancelled ? 'cancelled' : 'failed',
200
+ message: `${asset.name} · fetch/convert/validate/commit: ${cause instanceof Error ? cause.message : String(cause)}`,
201
+ retry: () => {
202
+ void importOnlineAssetJob(asset, true);
203
+ },
204
+ });
205
+ return null;
206
+ }
207
+ }
208
+
110
209
  function SourcePane(props: {
111
210
  source: Source;
112
211
  assetType: string;
@@ -320,9 +419,10 @@ function AssetCardThumbnail({ asset }: { asset: OnlineAsset }) {
320
419
  function VirtualAssetList(props: {
321
420
  assets: readonly OnlineAsset[];
322
421
  selectedId: string | null;
422
+ selectedKeys: ReadonlySet<string>;
323
423
  loading: boolean;
324
424
  hasMore: boolean;
325
- onSelect(asset: OnlineAsset): void;
425
+ onSelect(asset: OnlineAsset, modifiers?: { range?: boolean; toggle?: boolean }): void;
326
426
  onOpen(asset: OnlineAsset): void;
327
427
  onLoadMore(): void;
328
428
  }) {
@@ -394,9 +494,10 @@ function VirtualAssetList(props: {
394
494
  aria-busy={props.loading}
395
495
  tabIndex={0}
396
496
  onKeyDown={onKeyDown}
397
- onScroll={(event) =>
398
- setMetrics((current) => ({ ...current, scrollTop: event.currentTarget.scrollTop }))
399
- }
497
+ onScroll={(event) => {
498
+ const scrollTop = event.currentTarget.scrollTop;
499
+ setMetrics((current) => ({ ...current, scrollTop }));
500
+ }}
400
501
  >
401
502
  <div
402
503
  className="vgai-asset-library-navigator__result-space"
@@ -409,11 +510,16 @@ function VirtualAssetList(props: {
409
510
  key={onlineAssetKey(asset)}
410
511
  type="button"
411
512
  role="option"
412
- aria-selected={props.selectedId === asset.id}
513
+ aria-selected={props.selectedKeys.has(onlineAssetKey(asset))}
413
514
  aria-label={`${asset.name}, ${asset.type}, ${asset.source}`}
414
515
  className="vgai-asset-library-row"
415
516
  style={{ top: index * rowHeight }}
416
- onClick={() => props.onSelect(asset)}
517
+ onClick={(event) =>
518
+ props.onSelect(asset, {
519
+ range: event.shiftKey,
520
+ toggle: event.metaKey || event.ctrlKey,
521
+ })
522
+ }
417
523
  onDoubleClick={() => props.onOpen(asset)}
418
524
  onKeyDown={(event) => {
419
525
  if (event.key !== 'Enter') return;
@@ -430,6 +536,9 @@ function VirtualAssetList(props: {
430
536
  {asset.type} · {asset.source === 'local' ? 'Catalog' : asset.source}
431
537
  </span>
432
538
  </span>
539
+ {getDownloadedAssetPath(asset.source, asset.id) && (
540
+ <span className="vgai-asset-badge">In project</span>
541
+ )}
433
542
  </Button>
434
543
  );
435
544
  })}
@@ -446,6 +555,7 @@ function VirtualAssetList(props: {
446
555
  function NavigatorAssetLibrary(props: {
447
556
  assets: readonly OnlineAsset[];
448
557
  selectedId: string | null;
558
+ selectedKeys: ReadonlySet<string>;
449
559
  source: Source;
450
560
  assetType: string;
451
561
  sort: Sort;
@@ -457,11 +567,12 @@ function NavigatorAssetLibrary(props: {
457
567
  filtersOpen: boolean;
458
568
  filters: React.ReactNode;
459
569
  canSaveSearch: boolean;
570
+ onBatchImport(): void;
460
571
  onSource(source: Source): void;
461
572
  onType(type: string): void;
462
573
  onSort(sort: Sort): void;
463
574
  onSearch(query: string): void;
464
- onSelect(asset: OnlineAsset): void;
575
+ onSelect(asset: OnlineAsset, modifiers?: { range?: boolean; toggle?: boolean }): void;
465
576
  onOpen(asset: OnlineAsset): void;
466
577
  onLoadMore(): void;
467
578
  onRetry(): void;
@@ -533,6 +644,12 @@ function NavigatorAssetLibrary(props: {
533
644
  ? 'Loading…'
534
645
  : `${props.assets.length.toLocaleString()} of ${props.total.toLocaleString()}`}
535
646
  </span>
647
+ {props.selectedKeys.size > 1 && (
648
+ <Button variant="solid" size="compact" onClick={props.onBatchImport}>
649
+ Add {props.selectedKeys.size} selected to project
650
+ </Button>
651
+ )}
652
+ <AssetImportJobStatus />
536
653
  </div>
537
654
  {props.filtersOpen && (
538
655
  <div className="vgai-asset-library-navigator__filter-drawer">
@@ -576,6 +693,7 @@ function NavigatorAssetLibrary(props: {
576
693
  <VirtualAssetList
577
694
  assets={props.assets}
578
695
  selectedId={props.selectedId}
696
+ selectedKeys={props.selectedKeys}
579
697
  loading={props.loading}
580
698
  hasMore={props.hasMore}
581
699
  onSelect={props.onSelect}
@@ -613,8 +731,10 @@ export function OnlineAssetBrowser() {
613
731
  const [error, setError] = useState<string | null>(null);
614
732
  const [offset, setOffset] = useState(0);
615
733
  const [selectedId, setSelectedId] = useState<string | null>(null);
734
+ const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
616
735
  const [categories, setCategories] = useState<string[]>([]);
617
736
  const requestId = useRef(0);
737
+ const selectionAnchor = useRef<string | null>(null);
618
738
 
619
739
  useEffect(() => {
620
740
  const projectId = getCurrentProject()?.rootPath ?? '__unscoped__';
@@ -657,6 +777,7 @@ export function OnlineAssetBrowser() {
657
777
  }, [source, assetType]);
658
778
 
659
779
  const doSearch = useCallback(
780
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one request boundary owns stale-response rejection, paging, retained selection, and offline recovery.
660
781
  async (nextOffset: number, append: boolean) => {
661
782
  const currentRequest = ++requestId.current;
662
783
  setLoading(true);
@@ -673,6 +794,19 @@ export function OnlineAssetBrowser() {
673
794
  const result = await searchOnlineAssets(params);
674
795
  if (currentRequest !== requestId.current) return;
675
796
  setAssets((current) => (append ? [...current, ...result.assets] : result.assets));
797
+ if (!append) {
798
+ setSelectedKeys((current) => {
799
+ const retained = retainAvailableSelection(current, result.assets);
800
+ if (retained.size === 0) {
801
+ selectionAnchor.current = null;
802
+ if (getSelectedAsset()?.origin === 'library') clearSelectedAsset();
803
+ }
804
+ return retained;
805
+ });
806
+ setSelectedId((current) =>
807
+ current && result.assets.some((asset) => asset.id === current) ? current : null,
808
+ );
809
+ }
676
810
  setTotal(result.total);
677
811
  } catch {
678
812
  if (!append) setAssets([]);
@@ -689,8 +823,6 @@ export function OnlineAssetBrowser() {
689
823
  useEffect(() => {
690
824
  const timer = window.setTimeout(() => {
691
825
  setOffset(0);
692
- setSelectedId(null);
693
- if (getSelectedAsset()?.origin === 'library') clearSelectedAsset();
694
826
  void doSearch(0, false);
695
827
  }, 250);
696
828
  return () => window.clearTimeout(timer);
@@ -750,8 +882,7 @@ export function OnlineAssetBrowser() {
750
882
  health: count(assets.map((asset) => asset.capabilities?.health ?? 'unsupported')),
751
883
  };
752
884
  }, [assets]);
753
- const onSelect = (asset: OnlineAsset) => {
754
- setSelectedId(asset.id);
885
+ const selectForInspector = (asset: OnlineAsset, selectionCount: number) => {
755
886
  const localPath = getDownloadedAssetPath(asset.source, asset.id);
756
887
  const online = toOnlineInfo(asset);
757
888
  setSelectedAsset({
@@ -765,8 +896,27 @@ export function OnlineAssetBrowser() {
765
896
  : 'image',
766
897
  origin: 'library',
767
898
  online,
899
+ selectionCount,
768
900
  });
769
901
  };
902
+ const onSelect = (asset: OnlineAsset, modifiers: { range?: boolean; toggle?: boolean } = {}) => {
903
+ const key = onlineAssetKey(asset);
904
+ const { keys: next, anchor } = nextOnlineSelection(
905
+ filteredAssets,
906
+ selectedKeys,
907
+ asset,
908
+ selectionAnchor.current,
909
+ modifiers,
910
+ );
911
+ selectionAnchor.current = anchor;
912
+ setSelectedKeys(next);
913
+ const focused = next.has(key)
914
+ ? asset
915
+ : filteredAssets.find((candidate) => next.has(onlineAssetKey(candidate)));
916
+ setSelectedId(focused?.id ?? null);
917
+ if (focused) selectForInspector(focused, next.size);
918
+ else if (getSelectedAsset()?.origin === 'library') clearSelectedAsset();
919
+ };
770
920
  const onOpen = (asset: OnlineAsset) => {
771
921
  openOnlineAssetDocument(store, toOnlineInfo(asset));
772
922
  const key = onlineAssetKey(asset);
@@ -777,6 +927,40 @@ export function OnlineAssetBrowser() {
777
927
  setAssetType(SOURCES.find((entry) => entry.value === nextSource)!.types[0]!.value);
778
928
  setCategory('');
779
929
  };
930
+ const onBatchImport = useCallback(async () => {
931
+ const selectedAssets = filteredAssets.filter((asset) =>
932
+ selectedKeys.has(onlineAssetKey(asset)),
933
+ );
934
+ if (
935
+ selectedAssets.length < 2 ||
936
+ !confirmAssetAction(
937
+ 'batch-import',
938
+ `Add ${selectedAssets.length} selected assets to this project?`,
939
+ )
940
+ )
941
+ return;
942
+ const results: DownloadOnlineAssetResult[] = [];
943
+ for (let index = 0; index < selectedAssets.length; index += 2) {
944
+ const chunk = await Promise.all(
945
+ selectedAssets.slice(index, index + 2).map((asset) => importOnlineAssetJob(asset)),
946
+ );
947
+ results.push(
948
+ ...chunk.filter((result): result is DownloadOnlineAssetResult => result != null),
949
+ );
950
+ }
951
+ const applied = results.flatMap((result) =>
952
+ result.ok && result.history ? [result.history] : [],
953
+ );
954
+ if (applied.length > 0) {
955
+ await recordAppliedAssetImportsWithHistory(
956
+ applied,
957
+ `Import ${applied.length} asset${applied.length === 1 ? '' : 's'}`,
958
+ );
959
+ }
960
+ await loadDownloadedAssets();
961
+ setAssets((current) => [...current]);
962
+ window.dispatchEvent(new CustomEvent('editor:assets-changed'));
963
+ }, [filteredAssets, selectedKeys]);
780
964
  const sourcePane = (
781
965
  <SourcePane
782
966
  source={source}
@@ -817,6 +1001,7 @@ export function OnlineAssetBrowser() {
817
1001
  <NavigatorAssetLibrary
818
1002
  assets={filteredAssets}
819
1003
  selectedId={selectedId}
1004
+ selectedKeys={selectedKeys}
820
1005
  source={source}
821
1006
  assetType={assetType}
822
1007
  sort={sort}
@@ -828,6 +1013,7 @@ export function OnlineAssetBrowser() {
828
1013
  filtersOpen={navigatorFiltersOpen}
829
1014
  filters={sourcePane}
830
1015
  canSaveSearch={Boolean(searchQuery && !savedSearches.includes(searchQuery))}
1016
+ onBatchImport={() => void onBatchImport()}
831
1017
  onSource={onSource}
832
1018
  onType={(value) => {
833
1019
  setAssetType(value);
@@ -11,12 +11,12 @@ import { space, themeVars } from '../design-system';
11
11
  * also clears a tripped boundary — fix the file, save, retry for free.
12
12
  *
13
13
  * Chrome stays editor-styled (§3.4): the host provides the scrollable
14
- * editor-toned container; whatever the tool renders inside is its own
15
- * business (plain React, the project's choice).
14
+ * editor-toned container. Contributions use ordinary React for composition
15
+ * and the public editor widgets for shared interactive controls.
16
16
  */
17
17
 
18
18
  import { Component, type ErrorInfo, type ReactNode } from 'react';
19
- import type { GlobalTool } from '../tool-loader';
19
+ import { type GlobalToolContribution, getToolContributionClient } from '../tool-loader';
20
20
 
21
21
  interface ToolErrorBoundaryProps {
22
22
  /** Project-relative source file, shown in the crash message. */
@@ -76,15 +76,15 @@ export class ToolErrorBoundary extends Component<ToolErrorBoundaryProps, ToolErr
76
76
  /** One project-global tool's content host: editor-styled scroll container +
77
77
  * boundary. Rendered by a center tool DOCUMENT (`tool-documents.tsx`) or a
78
78
  * utility-placement registration (`tool-loader.ts`) since W3. */
79
- export function ToolHost({ tool }: { tool: GlobalTool }) {
80
- const ToolComponent = tool.Component;
79
+ export function ToolHost({ contribution }: { contribution: GlobalToolContribution }) {
80
+ const ToolComponent = contribution.Component;
81
81
  return (
82
82
  <div
83
- data-testid={`tool-panel-${tool.id}`}
83
+ data-testid={`tool-panel-${contribution.id}`}
84
84
  style={{ flex: 1, minHeight: 0, overflow: 'auto', color: themeVars.content.primary }}
85
85
  >
86
- <ToolErrorBoundary file={tool.file}>
87
- <ToolComponent />
86
+ <ToolErrorBoundary file={contribution.file}>
87
+ <ToolComponent tool={contribution.tool} client={getToolContributionClient()} />
88
88
  </ToolErrorBoundary>
89
89
  </div>
90
90
  );
@@ -8,7 +8,6 @@ import {
8
8
  } from '@fortawesome/free-solid-svg-icons';
9
9
  import { useEffect, useRef, useState } from 'react';
10
10
  import {
11
- Button,
12
11
  EditorIcon,
13
12
  FloatingToolbar,
14
13
  IconButton,
@@ -16,21 +15,18 @@ import {
16
15
  MenuItem,
17
16
  MenuSeparator,
18
17
  SplitButtonGroup,
19
- Text,
20
18
  Tooltip,
21
19
  } from '../design-system';
22
20
  import { useEditorStore } from '../EditorContext';
23
- import type { EditorStore, ShadingMode } from '../editor-store';
21
+ import type { EditorStore } from '../editor-store';
22
+ import { ViewportShadingMenu } from './ViewportShadingMenu';
24
23
 
25
- const shadingLabels: Record<ShadingMode, string> = {
26
- solid: 'Solid',
27
- wireframe: 'Wire',
28
- unlit: 'Unlit',
29
- };
30
-
31
- const shadingOrder: ShadingMode[] = ['solid', 'wireframe', 'unlit'];
32
-
33
- /** Viewport-level rendering controls. Three-specific controls hide when no 3D world is active. */
24
+ /**
25
+ * `statsOnly` (spec 28): with no threejs surface showing, the grid, helper,
26
+ * and shading controls are claims about invisible content — only the stats
27
+ * toggle (a property of the page, not of any world) survives. The
28
+ * `ViewportPanel` mount site picks the variant per notify.
29
+ */
34
30
  export function ViewportOverlay({ statsOnly = false }: { statsOnly?: boolean }) {
35
31
  const store = useEditorStore();
36
32
  return (
@@ -51,20 +47,10 @@ export function ViewportOverlay({ statsOnly = false }: { statsOnly?: boolean })
51
47
  </IconButton>
52
48
  </Tooltip>
53
49
  <HelpersButton store={store} />
54
- <Tooltip text={`Shading: ${shadingLabels[store.shadingMode]}`}>
55
- <Button
56
- variant="ghost"
57
- aria-label={`Shading: ${shadingLabels[store.shadingMode]}`}
58
- aria-pressed={store.shadingMode !== 'solid'}
59
- size="comfortable"
60
- onClick={() => {
61
- const index = shadingOrder.indexOf(store.shadingMode);
62
- store.setShadingMode(shadingOrder[(index + 1) % shadingOrder.length]!);
63
- }}
64
- >
65
- <Text variant="label">{shadingLabels[store.shadingMode]}</Text>
66
- </Button>
67
- </Tooltip>
50
+ <ViewportShadingMenu
51
+ mode={store.shadingMode}
52
+ onChange={(mode) => store.setShadingMode(mode)}
53
+ />
68
54
  </>
69
55
  )}
70
56
  <Tooltip text={`Viewport stats: ${store.showStats ? 'On' : 'Off'}`}>
@@ -95,6 +81,7 @@ function HelpersButton({ store }: { store: EditorStore }) {
95
81
  }, [open]);
96
82
 
97
83
  const helperTypes = [
84
+ { key: 'bounds' as const, label: 'Bounds' },
98
85
  { key: 'lights' as const, label: 'Lights' },
99
86
  { key: 'cameras' as const, label: 'Cameras' },
100
87
  { key: 'colliders' as const, label: 'Colliders' },
@@ -1,5 +1,6 @@
1
1
  import { type RenderScope, resolveRenderSettings } from '@engine/render/render-settings';
2
- import { applyRendererSettings, applyScenePostProcessing } from '@engine/setup/setup-renderer';
2
+ import { ViewportShadingRenderer } from '@engine/render/viewport-shading';
3
+ import { applyRendererSettings, applySceneRenderPipeline } from '@engine/setup/setup-renderer';
3
4
  import { EffectComposer } from 'postprocessing';
4
5
  import { useEffect, useRef, useSyncExternalStore } from 'react';
5
6
  import * as THREE from 'three';
@@ -39,6 +40,7 @@ import { loadInitialScene } from '../initial-scene';
39
40
  import { setupNavMeshHandlers } from '../navmesh-handler';
40
41
  import { bindPlayMode } from '../play-mode';
41
42
  import { resolvePreviewBoneAttachments } from '../scene-sync';
43
+ import { isEditorViewportShadingTarget } from '../viewport-shading-boundary';
42
44
  import { CameraInfo } from './CameraInfo';
43
45
  import { StatsOverlay } from './StatsOverlay';
44
46
  import { ToolStrip } from './Toolbar';
@@ -95,10 +97,10 @@ export function ViewportPanel() {
95
97
  applyRendererSettings(renderer, rendering, window.devicePixelRatio);
96
98
  renderer.setSize(Math.max(1, container.clientWidth), Math.max(1, container.clientHeight));
97
99
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
98
- renderer.toneMapping = THREE.NoToneMapping;
99
100
 
100
101
  // --- Post-processing ---
101
102
  const composer = new EffectComposer(renderer);
103
+ const viewportShading = new ViewportShadingRenderer();
102
104
 
103
105
  // --- Scene ---
104
106
  const scene = new THREE.Scene();
@@ -126,14 +128,8 @@ export function ViewportPanel() {
126
128
 
127
129
  // --- Composer rebuild ---
128
130
  function rebuildComposer(): void {
129
- // Re-apply live renderer settings (shadows, resolutionScale) so env edits
130
- // take effect; the post-processing master switch is handled inside
131
- // applyScenePostProcessing via env.rendering.
132
- applyRendererSettings(
133
- renderer,
134
- store.environment?.rendering as RenderScope | undefined,
135
- window.devicePixelRatio,
136
- );
131
+ // Re-apply the complete authored render pipeline so live environment
132
+ // edits use the same renderer configuration as Game.
137
133
  // World-hidden eye: while the focused threejs world is hidden, its
138
134
  // authored post chain (bloom/vignette/…) must not render either — pass
139
135
  // no environment so the composer builds bare. `editor-viewport.ts`
@@ -141,14 +137,17 @@ export function ViewportPanel() {
141
137
  // hide/show edges, which is what re-runs this rebuild.
142
138
  const hiddenWorldId = resolveFocusedViewportWorldId(store);
143
139
  const worldHidden = hiddenWorldId !== null && isWorldHidden(hiddenWorldId);
144
- applyScenePostProcessing(
140
+ applySceneRenderPipeline(
145
141
  composer,
146
142
  renderer,
147
143
  store.scene ?? scene,
148
144
  viewport.camera,
149
145
  worldHidden ? undefined : store.environment,
150
- store.objectMap,
151
- buildEntityTagMap(store),
146
+ {
147
+ basePixelRatio: window.devicePixelRatio,
148
+ objectMap: store.objectMap,
149
+ entityTags: buildEntityTagMap(store),
150
+ },
152
151
  );
153
152
  }
154
153
 
@@ -250,7 +249,12 @@ export function ViewportPanel() {
250
249
  tickEditorAnimations(store.objectMap, dt);
251
250
  }
252
251
  viewport.batchedRenderer.update(dt);
253
- composer.render(dt);
252
+ viewportShading.render(
253
+ viewport.currentScene,
254
+ store.shadingMode,
255
+ () => composer.render(dt),
256
+ isEditorViewportShadingTarget,
257
+ );
254
258
  viewport.renderViewCube(renderer);
255
259
  viewport.renderCameraPreview(renderer);
256
260
 
@@ -361,6 +365,7 @@ export function ViewportPanel() {
361
365
  window.removeEventListener('error', onError);
362
366
  window.removeEventListener('unhandledrejection', onUnhandledRejection);
363
367
  viewport.dispose();
368
+ viewportShading.dispose();
364
369
  // EffectComposer owns input/output WebGLRenderTargets + per-pass resources
365
370
  // that renderer.dispose() does NOT free — dispose it to avoid leaking GPU
366
371
  // render targets on unmount (incl. React StrictMode's mount/unmount/remount).
@@ -0,0 +1,103 @@
1
+ import type { ViewportShadingMode } from '@engine/render/viewport-shading';
2
+ import { faCaretDown, faCheck } from '@fortawesome/free-solid-svg-icons';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Button, EditorIcon, Menu, MenuItem, Tooltip, themeVars, zIndex } from '../design-system';
5
+
6
+ export const viewportShadingModes: ReadonlyArray<{
7
+ mode: ViewportShadingMode;
8
+ label: string;
9
+ description: string;
10
+ }> = [
11
+ { mode: 'solid', label: 'Solid', description: 'Authored materials and lighting' },
12
+ { mode: 'unlit', label: 'Unlit', description: 'Base color without scene lighting' },
13
+ { mode: 'wireframe', label: 'Wireframe', description: 'Renderable triangle edges' },
14
+ { mode: 'normals', label: 'Normals', description: 'Surface-normal direction colors' },
15
+ { mode: 'overdraw', label: 'Overdraw', description: 'Additive overlap visualization' },
16
+ ];
17
+
18
+ export function ViewportShadingMenu({
19
+ mode,
20
+ onChange,
21
+ disabled = false,
22
+ }: {
23
+ mode: ViewportShadingMode;
24
+ onChange: (mode: ViewportShadingMode) => void;
25
+ disabled?: boolean;
26
+ }) {
27
+ const [open, setOpen] = useState(false);
28
+ const ref = useRef<HTMLDivElement>(null);
29
+ const active = viewportShadingModes.find((entry) => entry.mode === mode)!;
30
+
31
+ useEffect(() => {
32
+ if (!open) return;
33
+ const close = (event: PointerEvent) => {
34
+ if (!ref.current?.contains(event.target as Node)) setOpen(false);
35
+ };
36
+ document.addEventListener('pointerdown', close);
37
+ return () => document.removeEventListener('pointerdown', close);
38
+ }, [open]);
39
+
40
+ return (
41
+ <div ref={ref} style={{ position: 'relative' }}>
42
+ <Tooltip text={`Viewport shading: ${active.label}`}>
43
+ <Button
44
+ type="button"
45
+ size="compact"
46
+ variant={mode === 'solid' ? 'ghost' : 'secondary'}
47
+ aria-label={`Viewport shading: ${active.label}`}
48
+ aria-haspopup="menu"
49
+ aria-expanded={open}
50
+ disabled={disabled}
51
+ onClick={() => setOpen((value) => !value)}
52
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 66 }}
53
+ >
54
+ <span>{active.label}</span>
55
+ <EditorIcon icon={faCaretDown} size="xs" />
56
+ </Button>
57
+ </Tooltip>
58
+ {open && !disabled && (
59
+ <Menu
60
+ autoFocusFirst
61
+ aria-label="Viewport shading modes"
62
+ onDismiss={() => setOpen(false)}
63
+ style={{
64
+ position: 'absolute',
65
+ top: 'calc(100% + 4px)',
66
+ right: 0,
67
+ zIndex: zIndex.sticky,
68
+ minWidth: 230,
69
+ }}
70
+ >
71
+ {viewportShadingModes.map((entry) => (
72
+ <MenuItem
73
+ key={entry.mode}
74
+ role="menuitemradio"
75
+ aria-checked={entry.mode === mode}
76
+ onSelect={() => {
77
+ onChange(entry.mode);
78
+ setOpen(false);
79
+ }}
80
+ style={{ display: 'grid', gridTemplateColumns: '14px 1fr', gap: 7 }}
81
+ >
82
+ <span style={{ opacity: entry.mode === mode ? 1 : 0 }}>
83
+ <EditorIcon icon={faCheck} size="xs" />
84
+ </span>
85
+ <span>
86
+ <span style={{ display: 'block' }}>{entry.label}</span>
87
+ <span
88
+ style={{
89
+ display: 'block',
90
+ color: themeVars.content.dim,
91
+ fontSize: 10,
92
+ }}
93
+ >
94
+ {entry.description}
95
+ </span>
96
+ </span>
97
+ </MenuItem>
98
+ ))}
99
+ </Menu>
100
+ )}
101
+ </div>
102
+ );
103
+ }