@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
@@ -21,8 +21,11 @@ import {
21
21
  type ModelCameraPreset,
22
22
  type ModelDiagnosticMode,
23
23
  type ModelInspection,
24
+ modelSelectionObjects,
25
+ rootMotionTrailPoints,
24
26
  supportedModelDiagnosticModes,
25
27
  } from '../../asset-workflow/model-inspection';
28
+ import { registerPreviewResource } from '../../asset-workflow/preview-resource-lifetime';
26
29
  import {
27
30
  loadProjectThumbnailManifest,
28
31
  resetProjectThumbnailManifestCache,
@@ -40,6 +43,7 @@ import {
40
43
  text,
41
44
  } from '../../design-system';
42
45
  import { useEditorStore } from '../../EditorContext';
46
+ import { revealInFinder } from '../../editor-api';
43
47
  import {
44
48
  createModelPreviewSource,
45
49
  loadModelThumbnailObject,
@@ -97,8 +101,13 @@ export function ModelViewer({
97
101
  const [lighting, setLighting] = useState<'studio' | 'outdoor' | 'flat'>('studio');
98
102
  const [inspection, setInspection] = useState<ModelInspection | null>(null);
99
103
  const [diagnosticMode, setDiagnosticMode] = useState<ModelDiagnosticMode>('lit');
104
+ const [selectedStructure, setSelectedStructure] = useState<
105
+ { kind: 'node' | 'material'; id: string } | undefined
106
+ >();
100
107
  const [playing, setPlaying] = useState(false);
101
108
  const [loop, setLoop] = useState(true);
109
+ const [showSkeleton, setShowSkeleton] = useState(false);
110
+ const [showRootMotionTrail, setShowRootMotionTrail] = useState(false);
102
111
  const [speed, setSpeed] = useState(1);
103
112
  const [time, setTime] = useState(0);
104
113
  const [selectedClip, setSelectedClip] = useState(0);
@@ -126,6 +135,9 @@ export function ModelViewer({
126
135
  const width = container.clientWidth;
127
136
  const height = width;
128
137
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
138
+ const releaseContext = registerPreviewResource('webglContext');
139
+ const releaseAnimationFrame = registerPreviewResource('animationFrame');
140
+ const releaseResizeObserver = registerPreviewResource('resizeObserver');
129
141
  renderer.setSize(width, height);
130
142
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
131
143
  renderer.outputColorSpace = THREE.SRGBColorSpace;
@@ -283,6 +295,9 @@ export function ModelViewer({
283
295
  runtimeRef.current = null;
284
296
  renderer.dispose();
285
297
  renderer.forceContextLoss();
298
+ releaseResizeObserver();
299
+ releaseAnimationFrame();
300
+ releaseContext();
286
301
  if (ownedSnapshot) disposeAssetPreviewSnapshot(ownedSnapshot);
287
302
  if (ownedModel) disposeModel(ownedModel);
288
303
  if (container.contains(renderer.domElement)) container.removeChild(renderer.domElement);
@@ -367,6 +382,21 @@ export function ModelViewer({
367
382
  if (runtimeRef.current) applyModelDiagnosticMode(runtimeRef.current, diagnosticMode);
368
383
  }, [diagnosticMode]);
369
384
 
385
+ useEffect(() => {
386
+ const runtime = runtimeRef.current;
387
+ if (runtime) applyModelSelectionHighlight(runtime, selectedStructure);
388
+ }, [inspection, selectedStructure]);
389
+
390
+ useEffect(() => {
391
+ const runtime = runtimeRef.current;
392
+ if (runtime)
393
+ applyAnimationDiagnosticHelpers(runtime, {
394
+ selectedClip,
395
+ showSkeleton,
396
+ showRootMotionTrail,
397
+ });
398
+ }, [inspection, selectedClip, showRootMotionTrail, showSkeleton]);
399
+
370
400
  useEffect(() => {
371
401
  const runtime = runtimeRef.current;
372
402
  if (!runtime?.mixer || runtime.model.animations.length === 0) return;
@@ -478,7 +508,32 @@ export function ModelViewer({
478
508
  Retry
479
509
  </Button>
480
510
  )}
481
- {issue?.recovery.includes('reimport') && <Button type="button">Reimport</Button>}
511
+ {issue?.recovery.includes('reveal-source') && assetPath && (
512
+ <Button
513
+ type="button"
514
+ onClick={() => {
515
+ void revealInFinder(assetPath).catch((cause) =>
516
+ setError(cause instanceof Error ? cause.message : String(cause)),
517
+ );
518
+ }}
519
+ >
520
+ Reveal source
521
+ </Button>
522
+ )}
523
+ {issue?.recovery.includes('reimport') && assetPath && (
524
+ <Button
525
+ type="button"
526
+ onClick={() =>
527
+ window.dispatchEvent(
528
+ new CustomEvent('editor:reimport-project-asset', {
529
+ detail: { path: assetPath },
530
+ }),
531
+ )
532
+ }
533
+ >
534
+ Reimport
535
+ </Button>
536
+ )}
482
537
  </div>
483
538
  )}
484
539
  </div>
@@ -490,7 +545,11 @@ export function ModelViewer({
490
545
  const deterministicCaptureSupported = !assetPath || pathFormat === 'glb' || pathFormat === 'gltf';
491
546
 
492
547
  const modes = runtimeRef.current ? supportedModelDiagnosticModes(runtimeRef.current.model) : [];
493
- const selectedDuration = runtimeRef.current?.model.animations[selectedClip]?.duration ?? 0;
548
+ const selectedAnimation = runtimeRef.current?.model.animations[selectedClip];
549
+ const selectedDuration = selectedAnimation?.duration ?? 0;
550
+ const rootMotionAvailable = selectedAnimation
551
+ ? rootMotionTrailPoints(selectedAnimation).length > 1
552
+ : false;
494
553
  const hierarchy = inspection?.hierarchy ?? [];
495
554
 
496
555
  return (
@@ -605,9 +664,13 @@ export function ModelViewer({
605
664
  type="button"
606
665
  role="treeitem"
607
666
  key={node.id}
667
+ aria-selected={
668
+ selectedStructure?.kind === 'node' && selectedStructure.id === node.id
669
+ }
608
670
  variant="ghost"
609
671
  size="compact"
610
672
  className="vgai-model-tree-item"
673
+ onClick={() => setSelectedStructure({ kind: 'node', id: node.id })}
611
674
  style={{
612
675
  display: 'block',
613
676
  width: '100%',
@@ -620,8 +683,19 @@ export function ModelViewer({
620
683
  </div>
621
684
  </SectionHeader>
622
685
  <SectionHeader label="Materials">
623
- {inspection?.materialNames.map((name, index) => (
624
- <div key={`${name}:${index}`}>{name}</div>
686
+ {inspection?.materialSlots.map((material) => (
687
+ <Button
688
+ type="button"
689
+ variant="ghost"
690
+ size="compact"
691
+ key={material.id}
692
+ aria-pressed={
693
+ selectedStructure?.kind === 'material' && selectedStructure.id === material.id
694
+ }
695
+ onClick={() => setSelectedStructure({ kind: 'material', id: material.id })}
696
+ >
697
+ {material.name} · {material.textureSlots.join(', ') || 'no textures'}
698
+ </Button>
625
699
  ))}
626
700
  </SectionHeader>
627
701
  </div>
@@ -638,6 +712,28 @@ export function ModelViewer({
638
712
  </div>
639
713
  ))}
640
714
  </SectionHeader>
715
+ <SectionHeader label="Textures">
716
+ {inspection?.textureBindings.length ? (
717
+ <dl>
718
+ {inspection.textureBindings.map((binding) => (
719
+ <div key={`${binding.materialId}:${binding.slot}`}>
720
+ <dt>
721
+ {binding.materialName} · {binding.slot}
722
+ </dt>
723
+ <dd>
724
+ {binding.textureName} ·{' '}
725
+ {binding.width && binding.height
726
+ ? `${binding.width}×${binding.height}`
727
+ : 'unknown size'}{' '}
728
+ · format {binding.format} · {binding.colorSpace}
729
+ </dd>
730
+ </div>
731
+ ))}
732
+ </dl>
733
+ ) : (
734
+ <div>No texture dependencies.</div>
735
+ )}
736
+ </SectionHeader>
641
737
  <SectionHeader label="Source">
642
738
  <div style={{ wordBreak: 'break-all' }}>{assetPath ?? `Entity ${entityId}`}</div>
643
739
  </SectionHeader>
@@ -702,7 +798,7 @@ export function ModelViewer({
702
798
  />
703
799
  </label>
704
800
  <span>
705
- {time.toFixed(2)} / {selectedDuration.toFixed(2)}s
801
+ {time.toFixed(2)} / {selectedDuration.toFixed(2)}s · frame {Math.round(time * 30)}
706
802
  </span>
707
803
  <label>
708
804
  <Checkbox checked={loop} onChange={(event) => setLoop(event.target.checked)} /> Loop
@@ -716,6 +812,21 @@ export function ModelViewer({
716
812
  <option value={2}>2×</option>
717
813
  </Select>
718
814
  </label>
815
+ <label>
816
+ <Checkbox
817
+ checked={showSkeleton}
818
+ onChange={(event) => setShowSkeleton(event.target.checked)}
819
+ />{' '}
820
+ Skeleton
821
+ </label>
822
+ <label title={rootMotionAvailable ? undefined : 'This clip has no root position track'}>
823
+ <Checkbox
824
+ checked={showRootMotionTrail}
825
+ disabled={!rootMotionAvailable}
826
+ onChange={(event) => setShowRootMotionTrail(event.target.checked)}
827
+ />{' '}
828
+ Root motion trail
829
+ </label>
719
830
  </div>
720
831
  ) : undefined
721
832
  }
@@ -848,6 +959,58 @@ function applyModelDiagnosticMode(runtime: ModelViewerRuntime, mode: ModelDiagno
848
959
  }
849
960
  }
850
961
 
962
+ function removeNamedHelpers(runtime: ModelViewerRuntime, prefix: string): void {
963
+ for (const helper of [...runtime.helpers]) {
964
+ if (!helper.name.startsWith(prefix)) continue;
965
+ runtime.scene.remove(helper);
966
+ runtime.helpers.splice(runtime.helpers.indexOf(helper), 1);
967
+ disposeAssetPreviewSnapshot(helper);
968
+ }
969
+ }
970
+
971
+ function addSelectionBox(runtime: ModelViewerRuntime, object: THREE.Object3D): void {
972
+ const bounds = new THREE.Box3().setFromObject(object);
973
+ if (bounds.isEmpty()) return;
974
+ const helper = new THREE.BoxHelper(object, 0x67d8ff);
975
+ helper.name = '__asset_editor_selection_highlight';
976
+ runtime.scene.add(helper);
977
+ runtime.helpers.push(helper);
978
+ }
979
+
980
+ function applyModelSelectionHighlight(
981
+ runtime: ModelViewerRuntime,
982
+ selection: { kind: 'node' | 'material'; id: string } | undefined,
983
+ ): void {
984
+ removeNamedHelpers(runtime, '__asset_editor_selection_highlight');
985
+ if (!selection) return;
986
+ for (const object of modelSelectionObjects(runtime.model, selection))
987
+ addSelectionBox(runtime, object);
988
+ }
989
+
990
+ function applyAnimationDiagnosticHelpers(
991
+ runtime: ModelViewerRuntime,
992
+ options: { selectedClip: number; showSkeleton: boolean; showRootMotionTrail: boolean },
993
+ ): void {
994
+ removeNamedHelpers(runtime, '__asset_editor_animation_helper');
995
+ if (options.showSkeleton) {
996
+ const helper = new THREE.SkeletonHelper(runtime.model);
997
+ helper.name = '__asset_editor_animation_helper_skeleton';
998
+ runtime.scene.add(helper);
999
+ runtime.helpers.push(helper);
1000
+ }
1001
+ if (!options.showRootMotionTrail) return;
1002
+ const clip = runtime.model.animations[options.selectedClip];
1003
+ const points = clip ? rootMotionTrailPoints(clip) : [];
1004
+ if (points.length < 2) return;
1005
+ const line = new THREE.Line(
1006
+ new THREE.BufferGeometry().setFromPoints(points),
1007
+ new THREE.LineBasicMaterial({ color: 0xffb454 }),
1008
+ );
1009
+ line.name = '__asset_editor_animation_helper_root_motion';
1010
+ runtime.scene.add(line);
1011
+ runtime.helpers.push(line);
1012
+ }
1013
+
851
1014
  function disposeModel(root: THREE.Object3D): void {
852
1015
  disposeAssetPreviewSnapshot(root);
853
1016
  root.traverse((object) => {
@@ -1,14 +1,17 @@
1
1
  import { faCheck, faDownload, faSpinner } from '@fortawesome/free-solid-svg-icons';
2
2
  import { lazy, Suspense, useCallback, useEffect, useState } from 'react';
3
+ import { beginAssetImportJob, updateAssetImportJob } from '../../asset-workflow/asset-import-jobs';
3
4
  import { Button, EditorIcon, SectionHeader, Select, themeVars } from '../../design-system';
4
5
  import {
6
+ AssetDeliveryError,
5
7
  type AssetFileOption,
6
- downloadOnlineAsset,
8
+ assetDeliveryIssueMessage,
7
9
  getOnlineAssetFiles,
8
10
  getOnlineAssetPreview,
9
11
  type OnlineAssetPreview,
10
12
  } from '../../editor-api';
11
13
  import type { OnlineAssetInfo } from '../../editor-store';
14
+ import { downloadOnlineAssetWithHistory } from '../asset-editor-persistence';
12
15
 
13
16
  const ModelViewer = lazy(() =>
14
17
  import('./ModelViewer').then((module) => ({ default: module.ModelViewer })),
@@ -37,6 +40,9 @@ interface Props {
37
40
  /** The Library Inspector and center Asset Editor reuse one acquisition
38
41
  * implementation while placing preview and properties in the outer shell. */
39
42
  mode?: 'full' | 'inspector' | 'preview';
43
+ /** Selection-only Inspector details show one bounded live preview. Asset
44
+ * document details leave preview ownership to the center document. */
45
+ showPreview?: boolean;
40
46
  /** W2: called with the local serving path once the download lands — the
41
47
  * owning asset DOCUMENT (`asset-documents.tsx`) swaps to the local viewer
42
48
  * in place (was `store.resolveOnlineAssetTab` in the right-rail era). */
@@ -44,7 +50,12 @@ interface Props {
44
50
  }
45
51
 
46
52
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one catalog document coordinates preview resolution, metadata, format selection, and explicit project import.
47
- export function OnlineAssetDetail({ online, mode = 'full', onResolved }: Props) {
53
+ export function OnlineAssetDetail({
54
+ online,
55
+ mode = 'full',
56
+ showPreview = false,
57
+ onResolved,
58
+ }: Props) {
48
59
  const [fileOptions, setFileOptions] = useState<AssetFileOption[]>([]);
49
60
  const [selectedFile, setSelectedFile] = useState<AssetFileOption | null>(null);
50
61
  const [loadingFiles, setLoadingFiles] = useState(true);
@@ -75,9 +86,11 @@ export function OnlineAssetDetail({ online, mode = 'full', onResolved }: Props)
75
86
  if (!cancelled) {
76
87
  setFileOptions([]);
77
88
  setFilesError(
78
- error instanceof Error
79
- ? error.message
80
- : 'Asset delivery metadata is unavailable while offline.',
89
+ error instanceof AssetDeliveryError
90
+ ? assetDeliveryIssueMessage(error.code)
91
+ : error instanceof Error
92
+ ? error.message
93
+ : 'Asset delivery metadata is unavailable while offline.',
81
94
  );
82
95
  }
83
96
  })
@@ -94,7 +107,7 @@ export function OnlineAssetDetail({ online, mode = 'full', onResolved }: Props)
94
107
  (online.type === 'model' || online.type === 'animation' || online.type === 'source');
95
108
 
96
109
  useEffect(() => {
97
- if (mode === 'inspector') {
110
+ if (mode === 'inspector' && !showPreview) {
98
111
  setLoadingPreview(false);
99
112
  return;
100
113
  }
@@ -122,29 +135,55 @@ export function OnlineAssetDetail({ online, mode = 'full', onResolved }: Props)
122
135
  return () => {
123
136
  cancelled = true;
124
137
  };
125
- }, [mode, online.id, online.source, supportsInteractivePreview]);
138
+ }, [mode, online.id, online.source, showPreview, supportsInteractivePreview]);
126
139
 
140
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one explicit acquisition transition owns job state, atomic history, document resolution, cancellation, and recovery.
127
141
  const onDownload = useCallback(async () => {
128
142
  if (!selectedFile) return;
143
+ const jobId = `single:${online.source}:${online.id}`;
144
+ const job = beginAssetImportJob(jobId, online.name);
145
+ updateAssetImportJob(jobId, {
146
+ stage: 'fetch',
147
+ loaded: 0,
148
+ total: selectedFile.sizeBytes ?? 0,
149
+ status: 'running',
150
+ });
129
151
  setDownloadStatus('downloading');
130
152
  try {
131
- const params: Parameters<typeof downloadOnlineAsset>[0] = {
153
+ const params: Parameters<typeof downloadOnlineAssetWithHistory>[0] = {
132
154
  source: online.source,
133
155
  id: online.id,
134
156
  name: online.name,
135
157
  url: selectedFile.url,
136
158
  format: selectedFile.format,
159
+ jobId,
160
+ signal: job.controller.signal,
137
161
  };
138
162
  if (selectedFile.includes) params.includes = selectedFile.includes;
139
- const result = await downloadOnlineAsset(params);
163
+ const result = await downloadOnlineAssetWithHistory(params);
140
164
  if (result.ok && result.path) {
165
+ updateAssetImportJob(jobId, {
166
+ stage: 'commit',
167
+ loaded: selectedFile.sizeBytes ?? 1,
168
+ total: selectedFile.sizeBytes ?? 1,
169
+ status: result.alreadyExists ? 'skipped' : 'imported',
170
+ message: result.path,
171
+ });
141
172
  setDownloadStatus('done');
142
173
  // Switch this document to the local asset viewer
143
174
  onResolved?.(`/${result.path}`);
144
175
  } else {
176
+ updateAssetImportJob(jobId, {
177
+ status: 'failed',
178
+ message: result.error ?? 'The import was rejected.',
179
+ });
145
180
  setDownloadStatus('error');
146
181
  }
147
- } catch {
182
+ } catch (error) {
183
+ updateAssetImportJob(jobId, {
184
+ status: job.controller.signal.aborted ? 'cancelled' : 'failed',
185
+ message: error instanceof Error ? error.message : String(error),
186
+ });
148
187
  setDownloadStatus('error');
149
188
  }
150
189
  }, [online, selectedFile, onResolved]);
@@ -164,13 +203,13 @@ export function OnlineAssetDetail({ online, mode = 'full', onResolved }: Props)
164
203
  </div>
165
204
  )}
166
205
 
167
- {mode !== 'inspector' && (
206
+ {(mode !== 'inspector' || showPreview) && (
168
207
  <>
169
208
  <div
170
209
  style={{
171
210
  width: '100%',
172
211
  height: '100%',
173
- minHeight: 240,
212
+ minHeight: mode === 'inspector' ? 160 : 240,
174
213
  background: themeVars.surface.shell,
175
214
  borderRadius: themeVars.shape.small,
176
215
  overflow: 'hidden',
@@ -319,6 +358,7 @@ export function OnlineAssetDetail({ online, mode = 'full', onResolved }: Props)
319
358
  ) : fileOptions.length > 0 ? (
320
359
  <>
321
360
  <Select
361
+ aria-label="Asset delivery"
322
362
  value={selectedFile ? fileOptions.indexOf(selectedFile) : 0}
323
363
  onChange={(e) => setSelectedFile(fileOptions[Number(e.target.value)] ?? null)}
324
364
  style={selectStyle}
@@ -1,40 +1,21 @@
1
1
  import { resolveUrl } from '@engine/loader';
2
2
  import { useEffect, useState } from 'react';
3
+ import { assetCapabilities } from '../../asset-workflow/asset-capabilities';
3
4
  import { themeVars } from '../../design-system';
4
5
  import { AudioViewer } from './AudioViewer';
5
6
  import { GenericJsonViewer } from './GenericJsonViewer';
6
7
  import { ImageViewer } from './ImageViewer';
7
8
  import { ModelViewer } from './ModelViewer';
8
9
 
9
- const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'svg', 'gif']);
10
- const AUDIO_EXTENSIONS = new Set(['mp3', 'ogg', 'wav', 'flac']);
11
- const MODEL_EXTENSIONS = new Set(['glb', 'gltf', 'fbx', 'obj', 'dae', 'stl', 'ply', 'bvh', '3ds']);
12
- const TEXT_EXTENSIONS = new Set([
13
- 'txt',
14
- 'md',
15
- 'ts',
16
- 'tsx',
17
- 'js',
18
- 'jsx',
19
- 'css',
20
- 'html',
21
- 'mtl',
22
- 'csv',
23
- 'xml',
24
- 'yaml',
25
- 'yml',
26
- ]);
27
-
28
10
  export type SourceAssetRoute = 'model' | 'image' | 'audio' | 'json' | 'text' | 'binary';
29
11
 
30
12
  export function sourceAssetRoute(assetPath: string): SourceAssetRoute {
31
- const file = assetPath.split(/[?#]/, 1)[0]!.toLowerCase();
32
- const extension = file.slice(file.lastIndexOf('.') + 1);
33
- if (MODEL_EXTENSIONS.has(extension)) return 'model';
34
- if (IMAGE_EXTENSIONS.has(extension)) return 'image';
35
- if (AUDIO_EXTENSIONS.has(extension)) return 'audio';
36
- if (extension === 'json') return 'json';
37
- if (TEXT_EXTENSIONS.has(extension)) return 'text';
13
+ const capability = assetCapabilities(assetPath);
14
+ if (capability.editor === 'model') return 'model';
15
+ if (capability.editor === 'image') return 'image';
16
+ if (capability.editor === 'audio') return 'audio';
17
+ if (capability.editor === 'data') return 'json';
18
+ if (capability.editor === 'source' && capability.previewable) return 'text';
38
19
  return 'binary';
39
20
  }
40
21
 
@@ -53,6 +53,32 @@
53
53
  font-size: 10px;
54
54
  }
55
55
 
56
+ .vgai-asset-import-jobs {
57
+ display: flex;
58
+ min-width: 0;
59
+ flex-wrap: wrap;
60
+ align-items: center;
61
+ gap: 4px;
62
+ color: var(--vgai-text-2);
63
+ font-size: 10px;
64
+ }
65
+
66
+ .vgai-asset-import-jobs > span {
67
+ display: inline-flex;
68
+ min-width: 0;
69
+ align-items: center;
70
+ gap: 4px;
71
+ padding: 2px 4px;
72
+ border: 1px solid var(--vgai-border-1);
73
+ border-radius: var(--vgai-radius-sm, 3px);
74
+ background: var(--vgai-bg-1);
75
+ }
76
+
77
+ .vgai-asset-import-jobs [data-status="failed"],
78
+ .vgai-asset-import-error {
79
+ color: var(--vgai-danger, var(--vgai-text-1));
80
+ }
81
+
56
82
  .vgai-asset-library-navigator__results {
57
83
  flex: 1;
58
84
  min-height: 0;
@@ -312,8 +338,9 @@
312
338
  .vgai-asset-badge {
313
339
  padding: 1px 5px;
314
340
  border-radius: 999px;
315
- background: var(--vgai-bg-3);
341
+ background: var(--asset-badge-background);
316
342
  color: var(--vgai-text-2);
343
+ font-size: var(--asset-font-floor);
317
344
  white-space: nowrap;
318
345
  }
319
346
 
@@ -328,7 +355,7 @@
328
355
 
329
356
  .vgai-asset-facet-button {
330
357
  width: 100%;
331
- min-height: 28px;
358
+ min-height: var(--asset-row-height);
332
359
  padding: 4px 7px;
333
360
  border: 0;
334
361
  border-radius: 4px;
@@ -340,7 +367,7 @@
340
367
 
341
368
  .vgai-asset-facet-button:hover,
342
369
  .vgai-asset-facet-button[aria-pressed="true"] {
343
- background: var(--vgai-accent-muted);
370
+ background: var(--asset-selection);
344
371
  color: var(--vgai-text-1);
345
372
  }
346
373
 
@@ -92,6 +92,7 @@ export const editorIcons = {
92
92
  unlocked: faLockOpen,
93
93
  },
94
94
  tool: {
95
+ extension: faPuzzlePiece,
95
96
  move: faUpDownLeftRight,
96
97
  rotate: faRotate,
97
98
  snap: faMagnet,