@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
|
@@ -95,6 +95,22 @@
|
|
|
95
95
|
min-width: 0;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
.vgai-dock-tab-close {
|
|
99
|
+
width: 22px;
|
|
100
|
+
min-width: 22px;
|
|
101
|
+
height: 22px;
|
|
102
|
+
margin-left: 2px;
|
|
103
|
+
padding: 0;
|
|
104
|
+
border-radius: var(--vgai-radius-sm);
|
|
105
|
+
color: var(--vgai-text-3);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.vgai-dock-tab-close:hover,
|
|
109
|
+
.vgai-dock-tab-close:focus-visible {
|
|
110
|
+
color: var(--vgai-text-1);
|
|
111
|
+
background: var(--vgai-bg-3);
|
|
112
|
+
}
|
|
113
|
+
|
|
98
114
|
/* Runtime/world surfaces and file-backed resource documents are different
|
|
99
115
|
navigation families. The adjacent-family selector makes the seam follow
|
|
100
116
|
the actual Dockview rail when the user moves tabs. */
|
package/src/editor-api.ts
CHANGED
|
@@ -175,6 +175,7 @@ export interface AssetEntry {
|
|
|
175
175
|
name: string;
|
|
176
176
|
type: 'file' | 'directory';
|
|
177
177
|
size: number;
|
|
178
|
+
modifiedAt: number;
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
/** List files and folders in a directory under public/. */
|
|
@@ -186,6 +187,7 @@ export async function listAssets(root: string, dir: string): Promise<AssetEntry[
|
|
|
186
187
|
name: e.name,
|
|
187
188
|
type: e.type === 'dir' ? 'directory' : 'file',
|
|
188
189
|
size: e.size ?? 0,
|
|
190
|
+
modifiedAt: e.mtime ?? 0,
|
|
189
191
|
}));
|
|
190
192
|
} catch {
|
|
191
193
|
return [];
|
|
@@ -387,17 +389,38 @@ export function connectHarnessChatEvents(
|
|
|
387
389
|
};
|
|
388
390
|
}
|
|
389
391
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
392
|
+
// State reports can be triggered by several browser-owned transitions at once
|
|
393
|
+
// (a Play click changes both play state and the active viewport, for example).
|
|
394
|
+
// Keep only the newest waiting snapshot and serialize requests so an older,
|
|
395
|
+
// slower POST can never land after a newer one and roll `/__editor/state` back.
|
|
396
|
+
let pendingEditorState: Record<string, unknown> | null = null;
|
|
397
|
+
let editorStateReportDrain: Promise<void> | null = null;
|
|
398
|
+
|
|
399
|
+
async function drainEditorStateReports(): Promise<void> {
|
|
400
|
+
while (pendingEditorState) {
|
|
401
|
+
const state = pendingEditorState;
|
|
402
|
+
pendingEditorState = null;
|
|
403
|
+
try {
|
|
404
|
+
await fetch(`${BASE}/state`, {
|
|
405
|
+
method: 'POST',
|
|
406
|
+
headers: { 'Content-Type': 'application/json' },
|
|
407
|
+
body: JSON.stringify({ ...state, _clientId: EDITOR_CLIENT_ID }),
|
|
408
|
+
});
|
|
409
|
+
} catch {
|
|
410
|
+
/* best-effort */
|
|
411
|
+
}
|
|
400
412
|
}
|
|
413
|
+
editorStateReportDrain = null;
|
|
414
|
+
// A caller cannot interleave with the synchronous block above, but keep
|
|
415
|
+
// this defensive restart so future refactors cannot strand a late report.
|
|
416
|
+
if (pendingEditorState) editorStateReportDrain = drainEditorStateReports();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Report the current editor state to the server (called by command-listener). */
|
|
420
|
+
export function reportEditorState(state: Record<string, unknown>): Promise<void> {
|
|
421
|
+
pendingEditorState = state;
|
|
422
|
+
editorStateReportDrain ??= drainEditorStateReports();
|
|
423
|
+
return editorStateReportDrain;
|
|
401
424
|
}
|
|
402
425
|
|
|
403
426
|
/** Report a command result back to the server so the SDK/CLI gets the response.
|
|
@@ -775,7 +798,7 @@ export async function flushLogEntries(entries: LogEntry[]): Promise<boolean> {
|
|
|
775
798
|
// SSRF-allowlist reasons to stay server-side) that writes into
|
|
776
799
|
// `<project>/public/asset-library/...` via raw `node:fs`, not
|
|
777
800
|
// `StorageBackend` — there is no browser-mode equivalent. The server-only
|
|
778
|
-
// Asset Library
|
|
801
|
+
// Asset Library document is absent in browser mode; the functions below are a
|
|
779
802
|
// defensive backstop for any other caller: read-only lookups degrade to an
|
|
780
803
|
// honest empty result (no fetch attempted), the one mutating call
|
|
781
804
|
// (`downloadOnlineAsset`) throws instead.
|
|
@@ -818,6 +841,33 @@ export interface AssetFileOption {
|
|
|
818
841
|
includes?: { relativePath: string; url: string; size: number }[];
|
|
819
842
|
}
|
|
820
843
|
|
|
844
|
+
export type AssetDeliveryIssueCode =
|
|
845
|
+
| 'offline'
|
|
846
|
+
| 'quota-limited'
|
|
847
|
+
| 'license-blocked'
|
|
848
|
+
| 'unavailable'
|
|
849
|
+
| 'provider-error';
|
|
850
|
+
|
|
851
|
+
export class AssetDeliveryError extends Error {
|
|
852
|
+
constructor(
|
|
853
|
+
readonly code: AssetDeliveryIssueCode,
|
|
854
|
+
message: string,
|
|
855
|
+
) {
|
|
856
|
+
super(message);
|
|
857
|
+
this.name = 'AssetDeliveryError';
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export function assetDeliveryIssueMessage(code: AssetDeliveryIssueCode): string {
|
|
862
|
+
return {
|
|
863
|
+
offline: 'Network delivery is offline. SSD assets remain available.',
|
|
864
|
+
'quota-limited': 'The remote provider quota is exhausted. Retry after the provider resets it.',
|
|
865
|
+
'license-blocked': 'This delivery is blocked because its license terms are not accepted.',
|
|
866
|
+
unavailable: 'No healthy SSD, cloud, or remote delivery is available for this variant.',
|
|
867
|
+
'provider-error': 'The remote provider could not return delivery metadata. Retry the request.',
|
|
868
|
+
}[code];
|
|
869
|
+
}
|
|
870
|
+
|
|
821
871
|
export interface OnlineAssetPreview {
|
|
822
872
|
path: string;
|
|
823
873
|
format: string;
|
|
@@ -861,6 +911,7 @@ export async function loadDownloadedAssets(): Promise<void> {
|
|
|
861
911
|
const data = (await res.json()) as {
|
|
862
912
|
downloaded: { source: string; id: string; path: string }[];
|
|
863
913
|
};
|
|
914
|
+
downloadedAssetPaths.clear();
|
|
864
915
|
for (const entry of data.downloaded) {
|
|
865
916
|
downloadedAssetPaths.set(assetCacheKey(entry.source, entry.id), `/${entry.path}`);
|
|
866
917
|
}
|
|
@@ -889,7 +940,10 @@ export async function searchOnlineAssets(params: {
|
|
|
889
940
|
if (params.limit != null) qs.set('limit', String(params.limit));
|
|
890
941
|
|
|
891
942
|
const res = await fetch(`${BASE}/asset-library/search?${qs}`);
|
|
892
|
-
if (!res.ok)
|
|
943
|
+
if (!res.ok) {
|
|
944
|
+
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
945
|
+
throw new Error(body?.error ?? `Asset Library search failed (${res.status}).`);
|
|
946
|
+
}
|
|
893
947
|
return (await res.json()) as { assets: OnlineAsset[]; total: number };
|
|
894
948
|
}
|
|
895
949
|
|
|
@@ -897,10 +951,26 @@ export async function searchOnlineAssets(params: {
|
|
|
897
951
|
export async function getOnlineAssetFiles(source: string, id: string): Promise<AssetFileOption[]> {
|
|
898
952
|
if (isBrowserMode()) return [];
|
|
899
953
|
const qs = new URLSearchParams({ source, id });
|
|
900
|
-
|
|
954
|
+
let res: Response;
|
|
955
|
+
try {
|
|
956
|
+
res = await fetch(`${BASE}/asset-library/files?${qs}`);
|
|
957
|
+
} catch (error) {
|
|
958
|
+
throw new AssetDeliveryError(
|
|
959
|
+
'offline',
|
|
960
|
+
error instanceof Error ? error.message : assetDeliveryIssueMessage('offline'),
|
|
961
|
+
);
|
|
962
|
+
}
|
|
901
963
|
if (!res.ok) {
|
|
902
964
|
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
903
|
-
|
|
965
|
+
const code: AssetDeliveryIssueCode =
|
|
966
|
+
res.status === 403
|
|
967
|
+
? 'license-blocked'
|
|
968
|
+
: res.status === 429
|
|
969
|
+
? 'quota-limited'
|
|
970
|
+
: res.status === 404
|
|
971
|
+
? 'unavailable'
|
|
972
|
+
: 'provider-error';
|
|
973
|
+
throw new AssetDeliveryError(code, body?.error ?? assetDeliveryIssueMessage(code));
|
|
904
974
|
}
|
|
905
975
|
const data = (await res.json()) as { files: AssetFileOption[] };
|
|
906
976
|
return data.files;
|
|
@@ -918,6 +988,21 @@ export async function getOnlineAssetPreview(
|
|
|
918
988
|
return (await res.json()) as OnlineAssetPreview;
|
|
919
989
|
}
|
|
920
990
|
|
|
991
|
+
export interface AppliedAssetImportHistory {
|
|
992
|
+
assetPath: string;
|
|
993
|
+
beforeToken: string;
|
|
994
|
+
afterToken: string;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
export interface DownloadOnlineAssetResult {
|
|
998
|
+
ok: boolean;
|
|
999
|
+
path?: string;
|
|
1000
|
+
alreadyExists?: boolean;
|
|
1001
|
+
provenanceOperationId?: string;
|
|
1002
|
+
history?: AppliedAssetImportHistory;
|
|
1003
|
+
error?: string;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
921
1006
|
/** Download an online asset into the project's public/asset-library/ folder. */
|
|
922
1007
|
export async function downloadOnlineAsset(params: {
|
|
923
1008
|
source: string;
|
|
@@ -925,8 +1010,10 @@ export async function downloadOnlineAsset(params: {
|
|
|
925
1010
|
name: string;
|
|
926
1011
|
url: string;
|
|
927
1012
|
format: string;
|
|
1013
|
+
jobId?: string;
|
|
928
1014
|
includes?: { relativePath: string; url: string; size: number }[];
|
|
929
|
-
|
|
1015
|
+
signal?: AbortSignal;
|
|
1016
|
+
}): Promise<DownloadOnlineAssetResult> {
|
|
930
1017
|
if (isBrowserMode()) {
|
|
931
1018
|
throw new Error(
|
|
932
1019
|
'Downloading online assets needs the Node dev server (Poly Haven/ambientcg access is proxied ' +
|
|
@@ -936,14 +1023,10 @@ export async function downloadOnlineAsset(params: {
|
|
|
936
1023
|
const res = await fetch(`${BASE}/asset-library/download`, {
|
|
937
1024
|
method: 'POST',
|
|
938
1025
|
headers: { 'Content-Type': 'application/json' },
|
|
939
|
-
body: JSON.stringify(params),
|
|
1026
|
+
body: JSON.stringify({ ...params, signal: undefined }),
|
|
1027
|
+
...(params.signal ? { signal: params.signal } : {}),
|
|
940
1028
|
});
|
|
941
|
-
const result = (await res.json()) as
|
|
942
|
-
ok: boolean;
|
|
943
|
-
path?: string;
|
|
944
|
-
alreadyExists?: boolean;
|
|
945
|
-
error?: string;
|
|
946
|
-
};
|
|
1029
|
+
const result = (await res.json()) as DownloadOnlineAssetResult;
|
|
947
1030
|
// Cache the local path on success
|
|
948
1031
|
if (result.ok && result.path) {
|
|
949
1032
|
downloadedAssetPaths.set(assetCacheKey(params.source, params.id), `/${result.path}`);
|
|
@@ -952,6 +1035,60 @@ export async function downloadOnlineAsset(params: {
|
|
|
952
1035
|
return result;
|
|
953
1036
|
}
|
|
954
1037
|
|
|
1038
|
+
/** Re-run a saved catalog import plan and atomically replace its project directory. */
|
|
1039
|
+
export async function reimportProjectAsset(
|
|
1040
|
+
assetPath: string,
|
|
1041
|
+
plan: import('./asset-workflow/import-contract').AssetImportPlan,
|
|
1042
|
+
signal?: AbortSignal,
|
|
1043
|
+
): Promise<{ ok: boolean; path?: string; provenanceOperationId?: string; error?: string }> {
|
|
1044
|
+
if (isBrowserMode()) {
|
|
1045
|
+
throw new Error('Reimport requires the Node editor server and the original catalog delivery.');
|
|
1046
|
+
}
|
|
1047
|
+
const response = await fetch(`${BASE}/asset-library/reimport`, {
|
|
1048
|
+
method: 'POST',
|
|
1049
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1050
|
+
body: JSON.stringify({ assetPath, plan }),
|
|
1051
|
+
...(signal ? { signal } : {}),
|
|
1052
|
+
});
|
|
1053
|
+
const result = (await response.json()) as {
|
|
1054
|
+
ok: boolean;
|
|
1055
|
+
path?: string;
|
|
1056
|
+
provenanceOperationId?: string;
|
|
1057
|
+
error?: string;
|
|
1058
|
+
};
|
|
1059
|
+
if (!response.ok) throw new Error(result.error ?? `Reimport failed (${response.status}).`);
|
|
1060
|
+
return result;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Capture a content-addressed server snapshot for one imported asset directory. */
|
|
1064
|
+
export async function captureProjectAssetHistory(assetPath: string): Promise<string> {
|
|
1065
|
+
if (isBrowserMode()) throw new Error('Imported asset history requires the Node editor server.');
|
|
1066
|
+
const response = await fetch(`${BASE}/asset-library/history/capture`, {
|
|
1067
|
+
method: 'POST',
|
|
1068
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1069
|
+
body: JSON.stringify({ assetPath }),
|
|
1070
|
+
});
|
|
1071
|
+
const result = (await response.json()) as { ok?: boolean; token?: string; error?: string };
|
|
1072
|
+
if (!response.ok || !result.token) {
|
|
1073
|
+
throw new Error(result.error ?? `Asset history capture failed (${response.status}).`);
|
|
1074
|
+
}
|
|
1075
|
+
return result.token;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Restore an exact imported-directory + provenance snapshot during undo/redo. */
|
|
1079
|
+
export async function restoreProjectAssetHistory(assetPath: string, token: string): Promise<void> {
|
|
1080
|
+
if (isBrowserMode()) throw new Error('Imported asset history requires the Node editor server.');
|
|
1081
|
+
const response = await fetch(`${BASE}/asset-library/history/restore`, {
|
|
1082
|
+
method: 'POST',
|
|
1083
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1084
|
+
body: JSON.stringify({ assetPath, token }),
|
|
1085
|
+
});
|
|
1086
|
+
if (!response.ok) {
|
|
1087
|
+
const result = (await response.json().catch(() => null)) as { error?: string } | null;
|
|
1088
|
+
throw new Error(result?.error ?? `Asset history restore failed (${response.status}).`);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
955
1092
|
/** Get Poly Haven categories for a given asset type. */
|
|
956
1093
|
export async function getAssetLibraryCategories(type: string, source?: string): Promise<string[]> {
|
|
957
1094
|
if (isBrowserMode()) return [];
|
package/src/editor-store.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { PersistenceProvider } from '@engine/adapter';
|
|
18
|
+
import type { ViewportShadingMode } from '@engine/render/viewport-shading';
|
|
18
19
|
import { applyAssetRename } from '@engine/scene/asset-paths';
|
|
19
20
|
import { parseSceneFile } from '@engine/scene/parse';
|
|
20
21
|
import {
|
|
@@ -50,7 +51,6 @@ import type { SceneMetadata } from './scene-serializer';
|
|
|
50
51
|
import { cleanDocument, serializeScene } from './scene-serializer';
|
|
51
52
|
import {
|
|
52
53
|
applyEnvironment,
|
|
53
|
-
applyShadingOverride,
|
|
54
54
|
buildPreview,
|
|
55
55
|
clearMaterialRefCache,
|
|
56
56
|
registerParticles,
|
|
@@ -65,7 +65,7 @@ export type TransformMode = 'translate' | 'rotate' | 'scale';
|
|
|
65
65
|
export type TransformSpace = 'world' | 'local';
|
|
66
66
|
export type PivotMode = 'active-element' | 'median-point' | 'individual-origins';
|
|
67
67
|
|
|
68
|
-
export type ShadingMode =
|
|
68
|
+
export type ShadingMode = ViewportShadingMode;
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Which persistence regime the current play session exposes. D19 makes play
|
|
@@ -98,6 +98,7 @@ export interface OnlineAssetInfo {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export interface HelperVisibility {
|
|
101
|
+
bounds: boolean;
|
|
101
102
|
lights: boolean;
|
|
102
103
|
cameras: boolean;
|
|
103
104
|
colliders: boolean;
|
|
@@ -205,6 +206,7 @@ export class EditorStore {
|
|
|
205
206
|
private _showGrid = true;
|
|
206
207
|
private _showHelpers = true;
|
|
207
208
|
private _helperVisibility: HelperVisibility = {
|
|
209
|
+
bounds: false,
|
|
208
210
|
lights: true,
|
|
209
211
|
cameras: true,
|
|
210
212
|
colliders: true,
|
|
@@ -1887,8 +1889,8 @@ export class EditorStore {
|
|
|
1887
1889
|
|
|
1888
1890
|
setShadingMode(mode: ShadingMode): void {
|
|
1889
1891
|
this._shadingMode = mode;
|
|
1890
|
-
//
|
|
1891
|
-
|
|
1892
|
+
// View-only state. The viewport applies it during its own render and
|
|
1893
|
+
// restores native materials before returning to editor/game code.
|
|
1892
1894
|
this._notify();
|
|
1893
1895
|
}
|
|
1894
1896
|
|
|
@@ -2474,7 +2476,6 @@ export class EditorStore {
|
|
|
2474
2476
|
this._objectMap = buildPreview(
|
|
2475
2477
|
doc,
|
|
2476
2478
|
this._scene,
|
|
2477
|
-
this._shadingMode,
|
|
2478
2479
|
this._renderer ?? undefined,
|
|
2479
2480
|
this._batchedRenderer,
|
|
2480
2481
|
);
|
package/src/editor-viewport.ts
CHANGED
|
@@ -20,8 +20,9 @@ import {
|
|
|
20
20
|
suppressWorldEnvironment,
|
|
21
21
|
} from './authoring/world-hidden-viewport';
|
|
22
22
|
import { isWorldHidden } from './authoring/world-session-state';
|
|
23
|
+
import { downloadOnlineAssetWithHistory } from './components/asset-editor-persistence';
|
|
23
24
|
import { themeVars, zIndex } from './design-system';
|
|
24
|
-
import {
|
|
25
|
+
import { getDownloadedAssetPath, getOnlineAssetFiles } from './editor-api';
|
|
25
26
|
import { EDITOR_LAYER } from './editor-layers';
|
|
26
27
|
import type { EditorStore } from './editor-store';
|
|
27
28
|
import { applyEntityGizmos } from './gizmo-registry';
|
|
@@ -519,8 +520,12 @@ export class EditorViewport {
|
|
|
519
520
|
const MIN = 0.5;
|
|
520
521
|
const color = new THREE.Color(0x579eff);
|
|
521
522
|
|
|
522
|
-
//
|
|
523
|
-
|
|
523
|
+
// Selection bounds are always visible. The Bounds diagnostic expands the
|
|
524
|
+
// same native BoxHelper path to every authored/runtime entity.
|
|
525
|
+
const ids = this._store.helperVisibility.bounds
|
|
526
|
+
? this._objectMap.keys()
|
|
527
|
+
: this._store.selectedEntityIds;
|
|
528
|
+
for (const id of ids) {
|
|
524
529
|
const obj = this._objectMap.get(id);
|
|
525
530
|
if (!obj) continue;
|
|
526
531
|
|
|
@@ -1946,8 +1951,8 @@ export class EditorViewport {
|
|
|
1946
1951
|
});
|
|
1947
1952
|
};
|
|
1948
1953
|
|
|
1949
|
-
const doDownload = (dlParams: Parameters<typeof
|
|
1950
|
-
|
|
1954
|
+
const doDownload = (dlParams: Parameters<typeof downloadOnlineAssetWithHistory>[0]) => {
|
|
1955
|
+
downloadOnlineAssetWithHistory(dlParams)
|
|
1951
1956
|
.then((result) => {
|
|
1952
1957
|
if (result.ok && result.path) {
|
|
1953
1958
|
resolveEntity(result.path);
|
|
@@ -1969,7 +1974,7 @@ export class EditorViewport {
|
|
|
1969
1974
|
};
|
|
1970
1975
|
|
|
1971
1976
|
if (online.url) {
|
|
1972
|
-
const dlParams: Parameters<typeof
|
|
1977
|
+
const dlParams: Parameters<typeof downloadOnlineAssetWithHistory>[0] = {
|
|
1973
1978
|
source: online.source,
|
|
1974
1979
|
id: online.id,
|
|
1975
1980
|
name: online.name,
|
|
@@ -1988,7 +1993,7 @@ export class EditorViewport {
|
|
|
1988
1993
|
});
|
|
1989
1994
|
return;
|
|
1990
1995
|
}
|
|
1991
|
-
const params: Parameters<typeof
|
|
1996
|
+
const params: Parameters<typeof downloadOnlineAssetWithHistory>[0] = {
|
|
1992
1997
|
source: online.source,
|
|
1993
1998
|
id: online.id,
|
|
1994
1999
|
name: online.name,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ViewportShadingMode } from '@engine/render/viewport-shading';
|
|
2
|
+
import { setGameViewportShadingMode } from './play-mode';
|
|
3
|
+
|
|
4
|
+
let mode: ViewportShadingMode = 'solid';
|
|
5
|
+
let version = 0;
|
|
6
|
+
const listeners = new Set<() => void>();
|
|
7
|
+
|
|
8
|
+
export function gameViewportShadingMode(): ViewportShadingMode {
|
|
9
|
+
return mode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function setGameDocumentShadingMode(next: ViewportShadingMode): void {
|
|
13
|
+
const changed = mode !== next;
|
|
14
|
+
mode = next;
|
|
15
|
+
setGameViewportShadingMode(next);
|
|
16
|
+
if (!changed) return;
|
|
17
|
+
version++;
|
|
18
|
+
for (const listener of listeners) listener();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function subscribeGameViewportShading(listener: () => void): () => void {
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
return () => listeners.delete(listener);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function gameViewportShadingVersion(): number {
|
|
27
|
+
return version;
|
|
28
|
+
}
|
package/src/model-thumbnail.ts
CHANGED
|
@@ -138,22 +138,22 @@ export class ModelThumbnailRenderer {
|
|
|
138
138
|
this.scene.add(group);
|
|
139
139
|
|
|
140
140
|
// Auto-frame the camera around the model
|
|
141
|
-
const box =
|
|
141
|
+
const box = modelPreviewBounds(group);
|
|
142
142
|
if (box.isEmpty()) {
|
|
143
143
|
this.scene.remove(group);
|
|
144
144
|
throw new Error('Model contains no previewable geometry.');
|
|
145
145
|
}
|
|
146
146
|
const center = box.getCenter(new THREE.Vector3());
|
|
147
|
-
const
|
|
148
|
-
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
|
147
|
+
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
|
149
148
|
const fovRad = (this.camera.fov * Math.PI) / 180;
|
|
150
|
-
const dist = (
|
|
149
|
+
const dist = (Math.max(sphere.radius, 0.0001) / Math.sin(fovRad / 2)) * 1.18;
|
|
150
|
+
const direction = new THREE.Vector3(0.65, 0.45, 0.65).normalize();
|
|
151
151
|
|
|
152
152
|
// Position camera at a 3/4 angle (slightly above, rotated 30° from front)
|
|
153
153
|
this.camera.position.set(
|
|
154
|
-
center.x + dist *
|
|
155
|
-
center.y + dist *
|
|
156
|
-
center.z + dist *
|
|
154
|
+
center.x + dist * direction.x,
|
|
155
|
+
center.y + dist * direction.y,
|
|
156
|
+
center.z + dist * direction.z,
|
|
157
157
|
);
|
|
158
158
|
this.camera.lookAt(center);
|
|
159
159
|
this.camera.near = dist * 0.01;
|
|
@@ -165,10 +165,14 @@ export class ModelThumbnailRenderer {
|
|
|
165
165
|
else this.renderer.setClearColor(0x000000, 0);
|
|
166
166
|
this.renderer.clear();
|
|
167
167
|
this.renderer.render(this.scene, this.camera);
|
|
168
|
+
const outputCanvas = strengthenThinThumbnailSilhouette(
|
|
169
|
+
this.renderer.domElement,
|
|
170
|
+
spec.background === 'neutral',
|
|
171
|
+
);
|
|
168
172
|
const dataUrl =
|
|
169
173
|
spec.output === 'webp'
|
|
170
|
-
?
|
|
171
|
-
:
|
|
174
|
+
? outputCanvas.toDataURL('image/webp', 0.86)
|
|
175
|
+
: outputCanvas.toDataURL('image/png');
|
|
172
176
|
|
|
173
177
|
// Clean up the loaded GLTF to free GPU memory
|
|
174
178
|
this.scene.remove(group);
|
|
@@ -207,6 +211,90 @@ export class ModelThumbnailRenderer {
|
|
|
207
211
|
}
|
|
208
212
|
}
|
|
209
213
|
|
|
214
|
+
function strengthenThinThumbnailSilhouette(
|
|
215
|
+
source: HTMLCanvasElement,
|
|
216
|
+
neutralBackground: boolean,
|
|
217
|
+
): HTMLCanvasElement {
|
|
218
|
+
const canvas = document.createElement('canvas');
|
|
219
|
+
canvas.width = source.width;
|
|
220
|
+
canvas.height = source.height;
|
|
221
|
+
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
222
|
+
if (!context) return source;
|
|
223
|
+
context.drawImage(source, 0, 0);
|
|
224
|
+
const image = context.getImageData(0, 0, canvas.width, canvas.height);
|
|
225
|
+
const visible = new Uint8Array(canvas.width * canvas.height);
|
|
226
|
+
let visibleCount = 0;
|
|
227
|
+
for (let pixel = 0; pixel < visible.length; pixel++) {
|
|
228
|
+
const offset = pixel * 4;
|
|
229
|
+
const isVisible = neutralBackground
|
|
230
|
+
? Math.abs(image.data[offset]! - 32) > 3 ||
|
|
231
|
+
Math.abs(image.data[offset + 1]! - 36) > 3 ||
|
|
232
|
+
Math.abs(image.data[offset + 2]! - 42) > 3
|
|
233
|
+
: image.data[offset + 3]! > 8;
|
|
234
|
+
if (isVisible) {
|
|
235
|
+
visible[pixel] = 1;
|
|
236
|
+
visibleCount++;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const ratio = visibleCount / visible.length;
|
|
240
|
+
if (ratio < 0.0001) return canvas;
|
|
241
|
+
const original = new Uint8ClampedArray(image.data);
|
|
242
|
+
if (neutralBackground) {
|
|
243
|
+
for (let pixel = 0; pixel < visible.length; pixel++) {
|
|
244
|
+
if (!visible[pixel]) continue;
|
|
245
|
+
const offset = pixel * 4;
|
|
246
|
+
const luminance =
|
|
247
|
+
original[offset]! * 0.2126 +
|
|
248
|
+
original[offset + 1]! * 0.7152 +
|
|
249
|
+
original[offset + 2]! * 0.0722;
|
|
250
|
+
if (luminance >= 88) continue;
|
|
251
|
+
const lift = 88 - luminance;
|
|
252
|
+
image.data[offset] = Math.min(255, original[offset]! + lift);
|
|
253
|
+
image.data[offset + 1] = Math.min(255, original[offset + 1]! + lift);
|
|
254
|
+
image.data[offset + 2] = Math.min(255, original[offset + 2]! + lift);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const dilationPasses = ratio < 0.004 ? 2 : ratio < 0.02 ? 1 : 0;
|
|
258
|
+
let currentVisible = visible;
|
|
259
|
+
for (let pass = 0; pass < dilationPasses; pass++) {
|
|
260
|
+
const nextVisible = currentVisible.slice();
|
|
261
|
+
const passPixels = new Uint8ClampedArray(image.data);
|
|
262
|
+
for (let y = 1; y < canvas.height - 1; y++) {
|
|
263
|
+
for (let x = 1; x < canvas.width - 1; x++) {
|
|
264
|
+
const pixel = y * canvas.width + x;
|
|
265
|
+
if (currentVisible[pixel]) continue;
|
|
266
|
+
const neighbor = [pixel - 1, pixel + 1, pixel - canvas.width, pixel + canvas.width].find(
|
|
267
|
+
(candidate) => currentVisible[candidate],
|
|
268
|
+
);
|
|
269
|
+
if (neighbor === undefined) continue;
|
|
270
|
+
const targetOffset = pixel * 4;
|
|
271
|
+
const sourceOffset = neighbor * 4;
|
|
272
|
+
image.data.set(passPixels.subarray(sourceOffset, sourceOffset + 4), targetOffset);
|
|
273
|
+
nextVisible[pixel] = 1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
currentVisible = nextVisible;
|
|
277
|
+
}
|
|
278
|
+
context.putImageData(image, 0, 0);
|
|
279
|
+
return canvas;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Bind-pose geometry bounds avoid malformed/outlier skin transforms shrinking a preview to a dot. */
|
|
283
|
+
export function modelPreviewBounds(model: THREE.Object3D): THREE.Box3 {
|
|
284
|
+
model.updateWorldMatrix(true, true);
|
|
285
|
+
const bounds = new THREE.Box3();
|
|
286
|
+
const candidate = new THREE.Box3();
|
|
287
|
+
model.traverseVisible((object) => {
|
|
288
|
+
const renderable = object as THREE.Mesh | THREE.Line | THREE.Points;
|
|
289
|
+
if (!renderable.geometry) return;
|
|
290
|
+
if (!renderable.geometry.boundingBox) renderable.geometry.computeBoundingBox();
|
|
291
|
+
if (!renderable.geometry.boundingBox) return;
|
|
292
|
+
candidate.copy(renderable.geometry.boundingBox).applyMatrix4(object.matrixWorld);
|
|
293
|
+
bounds.union(candidate);
|
|
294
|
+
});
|
|
295
|
+
return bounds;
|
|
296
|
+
}
|
|
297
|
+
|
|
210
298
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This format dispatcher keeps each Three.js loader path visible in one place.
|
|
211
299
|
export async function loadModelThumbnailObject(
|
|
212
300
|
spec: ModelThumbnailSpec & { format: ModelThumbnailFormat },
|
package/src/play-mode.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { PerformanceProfiler } from '@engine/dev/performance-profiler';
|
|
|
18
18
|
import type { GameComponentClass } from '@engine/ecs/game-component';
|
|
19
19
|
import * as gameComponentModule from '@engine/ecs/game-component';
|
|
20
20
|
import { logHmrSwapMiss } from '@engine/ecs/hmr-swap-report';
|
|
21
|
+
import type { ViewportShadingMode } from '@engine/render/viewport-shading';
|
|
21
22
|
import type { GameSession } from '@engine/runtime/create-runtime';
|
|
22
23
|
import {
|
|
23
24
|
type DebugVirtualInputTarget,
|
|
@@ -688,6 +689,23 @@ export function getGameAudio() {
|
|
|
688
689
|
return null;
|
|
689
690
|
}
|
|
690
691
|
|
|
692
|
+
/** Apply a render-only diagnostic mode to every compatible live Three root. */
|
|
693
|
+
export function setGameViewportShadingMode(mode: ViewportShadingMode): number {
|
|
694
|
+
if (!_session?.game) return 0;
|
|
695
|
+
let applied = 0;
|
|
696
|
+
for (const world of _session.game.roots) {
|
|
697
|
+
if (!isFirstPartyMounted(world.mounted)) continue;
|
|
698
|
+
world.mounted.setViewportShadingMode(mode);
|
|
699
|
+
applied++;
|
|
700
|
+
}
|
|
701
|
+
return applied;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Honest capability check for the Game toolbar's first-party Three diagnostic views. */
|
|
705
|
+
export function gameViewportShadingSupported(): boolean {
|
|
706
|
+
return Boolean(_session?.game.roots.some((world) => isFirstPartyMounted(world.mounted)));
|
|
707
|
+
}
|
|
708
|
+
|
|
691
709
|
/** Get the running game's scene (for editor Scene tab rendering). */
|
|
692
710
|
export function getGameScene(): THREE.Scene | null {
|
|
693
711
|
return _session?.scene ?? null;
|
|
@@ -829,11 +847,20 @@ async function enterPlayModeInner(explicitSeed?: number): Promise<void> {
|
|
|
829
847
|
// Patch console → editorConsole tagged [game]
|
|
830
848
|
patchConsole();
|
|
831
849
|
|
|
832
|
-
// Start log session and register sink for JSONL persistence
|
|
833
|
-
|
|
850
|
+
// Start log session and register sink for JSONL persistence. Start, every
|
|
851
|
+
// flush, and end share ONE permanent promise tail. `exitPlayMode()` stays
|
|
852
|
+
// synchronous for UI callers, but a restart's start is queued after the
|
|
853
|
+
// prior run's final flush/end; otherwise that late end closes the NEW
|
|
854
|
+
// server session and every later 200ms flush receives a 409. Keeping start
|
|
855
|
+
// on the tail also handles Stop arriving while start itself is in flight:
|
|
856
|
+
// Stop's end queues behind it, then the epoch check below prevents the
|
|
857
|
+
// cancelled run from installing a sink/interval after Stop.
|
|
858
|
+
const logSessionStart = _logFlushChain.then(() => startLogSession());
|
|
859
|
+
_logFlushChain = logSessionStart;
|
|
860
|
+
await logSessionStart;
|
|
861
|
+
if (epoch !== _playEpoch) return;
|
|
834
862
|
_pendingEntries = [];
|
|
835
863
|
_lastPersistedDebugEventSeq = 0;
|
|
836
|
-
_logFlushChain = Promise.resolve();
|
|
837
864
|
editorConsole.setSink((entry: ConsoleEntry) => {
|
|
838
865
|
// Wave 5 (AC-A1.5's play-log half): stamp {tick, simT} from the live
|
|
839
866
|
// session's built-in `time` provider so log entries correlate
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { connectToolFileEvents } from './asset-events';
|
|
26
26
|
import { onProjectChange } from './project-manager';
|
|
27
|
-
import {
|
|
27
|
+
import { refreshProjectToolContributions } from './tool-loader';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Start the project-tool discovery lifecycle: one immediate discovery pass,
|
|
@@ -32,10 +32,10 @@ import { refreshProjectTools } from './tool-loader';
|
|
|
32
32
|
* Returns a stop function that disconnects both subscriptions (the pass in
|
|
33
33
|
* flight is left to finish — `refreshProjectTools` never throws).
|
|
34
34
|
*/
|
|
35
|
-
export function
|
|
36
|
-
void
|
|
37
|
-
const stopProjectWatch = onProjectChange(() => void
|
|
38
|
-
const stopToolFileWatch = connectToolFileEvents(() => void
|
|
35
|
+
export function startProjectToolContributionDiscovery(): () => void {
|
|
36
|
+
void refreshProjectToolContributions();
|
|
37
|
+
const stopProjectWatch = onProjectChange(() => void refreshProjectToolContributions());
|
|
38
|
+
const stopToolFileWatch = connectToolFileEvents(() => void refreshProjectToolContributions());
|
|
39
39
|
return () => {
|
|
40
40
|
stopProjectWatch();
|
|
41
41
|
stopToolFileWatch();
|