@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.
- package/dist/assets/{axe-BJjvBeOZ.js → axe-N4cNyzfs.js} +1 -1
- package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
- package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
- package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
- package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
- package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
- package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
- package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
- package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
- package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
- package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
- package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
- package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
- package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
- package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
- package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
- package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
- package/dist/assets/index-DiUacMXR.css +1 -0
- package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
- package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
- package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
- package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
- package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/packaged.mjs +27125 -1075
- package/package.json +2 -2
- package/server/asset-history-snapshots.ts +189 -0
- package/server/asset-library-routes.ts +404 -53
- package/server/catalog-import-recipe.ts +97 -0
- package/server/catalog-reimport.ts +141 -0
- package/server/cloud-asset-catalog.ts +71 -5
- package/server/dev.ts +1 -1
- package/server/editor-server.ts +106 -51
- package/server/editor-sse.ts +5 -0
- package/server/generative-execution-context.ts +45 -0
- package/server/model-import-conversion.ts +345 -0
- package/server/packaged.ts +1 -1
- package/server/project-hmr-files.ts +3 -5
- package/server/project-output-writer.ts +440 -36
- package/server/project-tools.ts +529 -0
- package/server/server-utils.ts +14 -11
- package/src/EditorContext.tsx +4 -4
- package/src/action-registry.ts +12 -0
- package/src/asset-editor-context.tsx +9 -0
- package/src/asset-events.ts +3 -15
- package/src/asset-selection.ts +3 -0
- package/src/asset-workflow/asset-capabilities.ts +40 -0
- package/src/asset-workflow/asset-import-jobs.ts +108 -0
- package/src/asset-workflow/asset-library-query.ts +8 -1
- package/src/asset-workflow/asset-workflow-quality.ts +2 -0
- package/src/asset-workflow/import-contract.ts +2 -0
- package/src/asset-workflow/model-inspection.ts +92 -1
- package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/asset-workflow/project-asset-commands.ts +1 -0
- package/src/asset-workflow/project-asset-health.ts +3 -6
- package/src/asset-workflow/project-asset-operations.ts +119 -4
- package/src/command-listener.ts +37 -9
- package/src/components/ApplicationMenus.tsx +13 -15
- package/src/components/AssetBrowser.tsx +301 -26
- package/src/components/AssetImportDetails.tsx +207 -92
- package/src/components/AssetImportJobStatus.tsx +55 -0
- package/src/components/CenterDocuments.tsx +28 -1
- package/src/components/CommandPalette.tsx +2 -2
- package/src/components/DefaultEditorLayout.tsx +4 -0
- package/src/components/InspectorToolSection.tsx +12 -10
- package/src/components/OnlineAssetBrowser.tsx +197 -11
- package/src/components/ToolHost.tsx +8 -8
- package/src/components/ViewportOverlay.tsx +13 -26
- package/src/components/ViewportPanel.tsx +19 -14
- package/src/components/ViewportShadingMenu.tsx +103 -0
- package/src/components/WorkspaceDock.tsx +103 -17
- package/src/components/asset-documents.tsx +38 -11
- package/src/components/asset-editor-persistence.ts +300 -5
- package/src/components/asset-selection-section.tsx +83 -9
- package/src/components/asset-viewers/AudioViewer.tsx +4 -7
- package/src/components/asset-viewers/ModelViewer.tsx +168 -5
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +52 -12
- package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
- package/src/components/asset-workflow.css +30 -3
- package/src/components/primitives/editor-icons.ts +1 -0
- package/src/components/project-tool-documents.tsx +298 -0
- package/src/components/status-contributions.tsx +38 -0
- package/src/components/tool-documents.tsx +20 -17
- package/src/components/tool-schema-form.tsx +258 -0
- package/src/components/workspace-dock.css +16 -0
- package/src/editor-api.ts +159 -22
- package/src/editor-store.ts +6 -5
- package/src/editor-viewport.ts +12 -7
- package/src/game-viewport-state.ts +28 -0
- package/src/model-thumbnail.ts +97 -9
- package/src/play-mode.ts +30 -3
- package/src/project-tool-discovery.ts +5 -5
- package/src/{project-operations.ts → project-tools.ts} +39 -26
- package/src/scene-sync.ts +0 -67
- package/src/storage/handle-storage.ts +5 -1
- package/src/storage/http-storage.ts +7 -2
- package/src/storage/mem-storage.ts +7 -1
- package/src/storage/types.ts +2 -0
- package/src/theme-css.ts +17 -7
- package/src/tool-loader.ts +152 -241
- package/src/viewport-shading-boundary.ts +15 -0
- package/src/widgets/index.ts +2 -1
- package/src/workspace-dock-controller.ts +8 -0
- package/src/workspace-document-registry.ts +4 -4
- package/src/workspace-state-persistence.ts +36 -10
- package/template/.claude/skills/editor/SKILL.md +13 -3
- package/template/AGENTS.md +100 -83
- package/template/package.json +16 -0
- package/template/public/scenes/default.vscn.json +1 -2
- package/template/scripts/playtest.ts +7 -37
- package/template/server/rooms/game-room.ts +2 -2
- package/template/src/data/README.md +3 -2
- package/template/src/data/tuning.data.json +1 -3
- package/template/src/data/tuning.schema.json +0 -12
- package/template/src/data/tuning.schema.ts +2 -9
- package/template/src/data/tuning.ts +1 -1
- package/template/src/scripts/registry.ts +0 -3
- package/template/src/tools/README.md +151 -16
- package/template/src/tools/autoplay.tool.ts +31 -0
- package/template/src/tools/autoplay.ts +15 -0
- package/template/src/tools/tuning.document.tsx +107 -0
- package/template/src/tools/tuning.tool.ts +24 -0
- package/template/src/tools/tuning.ts +9 -0
- package/template/src/ui/Button.stories.tsx +1 -1
- package/template/tests/acceptance/example.spec.ts +4 -59
- package/template/vgai.game.json +1 -1
- package/dist/assets/index-bAGhEnnO.css +0 -1
- package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
- package/dist/project-scripts.bundle.js +0 -3917
- package/server/project-operations.ts +0 -343
- package/src/components/project-operation-documents.tsx +0 -226
- package/template/src/operations/README.md +0 -88
- package/template/src/scripts/components/data-spinner.ts +0 -86
- package/template/src/scripts/components/spin-lap-math.ts +0 -27
- package/template/src/tools/example.tool.tsx +0 -97
- package/template/src/tools/spin.tool.tsx +0 -101
- 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
|
-
|
|
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.
|
|
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={() =>
|
|
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
|
|
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
|
|
15
|
-
*
|
|
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
|
|
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({
|
|
80
|
-
const ToolComponent =
|
|
79
|
+
export function ToolHost({ contribution }: { contribution: GlobalToolContribution }) {
|
|
80
|
+
const ToolComponent = contribution.Component;
|
|
81
81
|
return (
|
|
82
82
|
<div
|
|
83
|
-
data-testid={`tool-panel-${
|
|
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={
|
|
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
|
|
21
|
+
import type { EditorStore } from '../editor-store';
|
|
22
|
+
import { ViewportShadingMenu } from './ViewportShadingMenu';
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 {
|
|
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
|
|
130
|
-
//
|
|
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
|
-
|
|
140
|
+
applySceneRenderPipeline(
|
|
145
141
|
composer,
|
|
146
142
|
renderer,
|
|
147
143
|
store.scene ?? scene,
|
|
148
144
|
viewport.camera,
|
|
149
145
|
worldHidden ? undefined : store.environment,
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
+
}
|