@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
@@ -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
- /** Report the current editor state to the server (called by command-listener). */
391
- export async function reportEditorState(state: Record<string, unknown>): Promise<void> {
392
- try {
393
- await fetch(`${BASE}/state`, {
394
- method: 'POST',
395
- headers: { 'Content-Type': 'application/json' },
396
- body: JSON.stringify({ ...state, _clientId: EDITOR_CLIENT_ID }),
397
- });
398
- } catch {
399
- /* best-effort */
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 navigator is unavailable in browser mode; the functions below are a
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) return { assets: [], total: 0 };
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
- const res = await fetch(`${BASE}/asset-library/files?${qs}`);
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
- throw new Error(body?.error ?? `Asset delivery lookup failed (${res.status}).`);
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
- }): Promise<{ ok: boolean; path?: string; alreadyExists?: boolean; error?: string }> {
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 [];
@@ -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 = 'solid' | 'wireframe' | 'unlit';
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
- // Re-shade live meshes immediately (ED1) don't wait for a full rebuild.
1891
- if (this._scene) applyShadingOverride(this._scene, mode);
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
  );
@@ -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 { downloadOnlineAsset, getDownloadedAssetPath, getOnlineAssetFiles } from './editor-api';
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
- // Add new
523
- for (const id of this._store.selectedEntityIds) {
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 downloadOnlineAsset>[0]) => {
1950
- downloadOnlineAsset(dlParams)
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 downloadOnlineAsset>[0] = {
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 downloadOnlineAsset>[0] = {
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
+ }
@@ -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 = new THREE.Box3().setFromObject(group);
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 size = box.getSize(new THREE.Vector3());
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 = (maxDim / (2 * Math.tan(fovRad / 2))) * 1.3; // 1.3× padding
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 * 0.65,
155
- center.y + dist * 0.45,
156
- center.z + dist * 0.65,
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
- ? this.renderer.domElement.toDataURL('image/webp', 0.86)
171
- : this.renderer.domElement.toDataURL('image/png');
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
- await startLogSession();
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 { refreshProjectTools } from './tool-loader';
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 startProjectToolDiscovery(): () => void {
36
- void refreshProjectTools();
37
- const stopProjectWatch = onProjectChange(() => void refreshProjectTools());
38
- const stopToolFileWatch = connectToolFileEvents(() => void refreshProjectTools());
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();