@volter/editor-game 0.5.65

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 (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,1172 @@
1
+ /**
2
+ * THE ASSET BUDGET PANEL — what the `asset-budget.document` contribution
3
+ * draws. A project-scoped tool window IS a workspace document in this shell
4
+ * (the frame owns all layout; utilities are for live-observation instruments).
5
+ *
6
+ * Renders the headless `asset-budget-model.ts` report: sortable per-asset
7
+ * table (size, VRAM estimate, tris, reference count, provenance badge),
8
+ * totals + per-category subtotals, unused/duplicate sections, and a per-asset
9
+ * detail pane whose reference list IS the critique-P5 "what uses this" query.
10
+ * All data comes from one real analysis pass over the storage seam — nothing
11
+ * here fabricates a number the model didn't measure (VRAM lower bounds are
12
+ * rendered with an explicit `≥`, inspection failures with their reason).
13
+ */
14
+
15
+ import { getStorageBackend } from '@volter/editor-core/storage/index';
16
+ import { editorHost } from '@volter/editor-sdk/host';
17
+ import {
18
+ accent,
19
+ Button,
20
+ bg,
21
+ border,
22
+ danger,
23
+ fontMono,
24
+ fontSizeVar,
25
+ lineHeightVar,
26
+ radius,
27
+ Select,
28
+ spaceVar,
29
+ text,
30
+ warn,
31
+ } from '@volter/editor-sdk/widgets';
32
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
33
+ import {
34
+ type AssetBudgetCategory,
35
+ type AssetBudgetEntry,
36
+ type AssetBudgetReport,
37
+ analyzeAssetBudget,
38
+ collectProjectBudgetSource,
39
+ } from './asset-budget-model';
40
+ import { type GltfIOCapabilities, getGltfIO } from './gltf-io';
41
+ import {
42
+ compressModelDraco,
43
+ compressModelMeshopt,
44
+ compressTexturesKtx2,
45
+ generateLodLevels,
46
+ type LodGenerationOutcome,
47
+ type OptimizeOutcome,
48
+ } from './gltf-optimize';
49
+ import { applyOptimizedBytes } from './optimize-apply';
50
+
51
+ /** The document's contribution id (`asset-budget.document.tsx`), as
52
+ * `workspace.openContributedDocument` names it. */
53
+ export const ASSET_BUDGET_DOCUMENT_ID = 'asset-budget.document';
54
+
55
+ // Pending focus request (context-menu "Open in Asset Budget" hands a path
56
+ // over before/while the document mounts) — house useSyncExternalStore shape.
57
+ let _focusPath: string | null = null;
58
+ let _version = 0;
59
+ const _listeners = new Set<() => void>();
60
+
61
+ function notifyChanged(): void {
62
+ _version++;
63
+ for (const fn of _listeners) fn();
64
+ }
65
+
66
+ function subscribeFocus(fn: () => void): () => void {
67
+ _listeners.add(fn);
68
+ return () => {
69
+ _listeners.delete(fn);
70
+ };
71
+ }
72
+
73
+ function focusVersion(): number {
74
+ return _version;
75
+ }
76
+
77
+ /** Consume (read + clear) the pending focus path. */
78
+ function takeFocusPath(): string | null {
79
+ const path = _focusPath;
80
+ if (path !== null) {
81
+ _focusPath = null;
82
+ }
83
+ return path;
84
+ }
85
+
86
+ /** Open (or activate) the Asset Budget window, optionally focusing an asset
87
+ * (storage path or `/serving` path — both spellings resolve). The focus path
88
+ * is set BEFORE the open so a document that mounts fresh reads it on its
89
+ * first render, and one already open picks it up from the subscription. */
90
+ export function openAssetBudgetDocument(focusPath?: string): boolean {
91
+ if (focusPath !== undefined) {
92
+ _focusPath = focusPath;
93
+ notifyChanged();
94
+ }
95
+ return editorHost().workspace.openContributedDocument(ASSET_BUDGET_DOCUMENT_ID);
96
+ }
97
+
98
+ // --- Formatting --------------------------------------------------------------
99
+
100
+ export function formatBytes(bytes: number): string {
101
+ if (bytes < 1024) return `${bytes} B`;
102
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
103
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
104
+ }
105
+
106
+ function formatCount(value: number): string {
107
+ return value >= 10000 ? `${(value / 1000).toFixed(1)}k` : String(value);
108
+ }
109
+
110
+ /** VRAM column value: model estimate (with `≥` lower-bound marker), image
111
+ * estimate, or an honest em dash for unmeasurable categories. */
112
+ function vramLabel(entry: AssetBudgetEntry): string {
113
+ if (entry.model) {
114
+ const prefix = entry.model.unknownTextureCount > 0 ? '≥ ' : '';
115
+ return `${prefix}${formatBytes(entry.model.vramBytes)}`;
116
+ }
117
+ if (entry.image) return formatBytes(entry.image.gpuBytes);
118
+ return '—';
119
+ }
120
+
121
+ function vramValue(entry: AssetBudgetEntry): number {
122
+ return entry.model?.vramBytes ?? entry.image?.gpuBytes ?? 0;
123
+ }
124
+
125
+ // --- Sorting -----------------------------------------------------------------
126
+
127
+ type SortKey = 'path' | 'category' | 'bytes' | 'vram' | 'tris' | 'refs';
128
+
129
+ function sortValue(entry: AssetBudgetEntry, key: SortKey): number | string {
130
+ switch (key) {
131
+ case 'path':
132
+ return entry.path;
133
+ case 'category':
134
+ return entry.category;
135
+ case 'bytes':
136
+ return entry.bytes;
137
+ case 'vram':
138
+ return vramValue(entry);
139
+ case 'tris':
140
+ return entry.model?.glPrimitives ?? -1;
141
+ case 'refs':
142
+ return entry.references.length;
143
+ }
144
+ }
145
+
146
+ function sortEntries(
147
+ entries: readonly AssetBudgetEntry[],
148
+ key: SortKey,
149
+ direction: 1 | -1,
150
+ ): AssetBudgetEntry[] {
151
+ return [...entries].sort((left, right) => {
152
+ const a = sortValue(left, key);
153
+ const b = sortValue(right, key);
154
+ const order = typeof a === 'string' ? a.localeCompare(b as string) : a - (b as number);
155
+ return order !== 0 ? order * direction : left.path.localeCompare(right.path);
156
+ });
157
+ }
158
+
159
+ // --- The document content ----------------------------------------------------
160
+
161
+ const CATEGORY_LABEL: Record<AssetBudgetCategory, string> = {
162
+ model: 'Models',
163
+ image: 'Images',
164
+ audio: 'Audio',
165
+ data: 'Data',
166
+ other: 'Other',
167
+ };
168
+
169
+ interface BudgetState {
170
+ readonly report: AssetBudgetReport | null;
171
+ readonly loading: boolean;
172
+ readonly error: string | null;
173
+ }
174
+
175
+ function useBudgetReport(): BudgetState & { refresh: () => void; stale: boolean } {
176
+ const [state, setState] = useState<BudgetState>({ report: null, loading: true, error: null });
177
+ const [stale, setStale] = useState(false);
178
+ const generation = useRef(0);
179
+
180
+ const refresh = useCallback(() => {
181
+ const run = ++generation.current;
182
+ setState((prior) => ({ ...prior, loading: true, error: null }));
183
+ setStale(false);
184
+ void (async () => {
185
+ try {
186
+ const source = await collectProjectBudgetSource(getStorageBackend());
187
+ const report = await analyzeAssetBudget(source);
188
+ if (generation.current === run) setState({ report, loading: false, error: null });
189
+ } catch (error) {
190
+ if (generation.current === run) {
191
+ setState({
192
+ report: null,
193
+ loading: false,
194
+ error: error instanceof Error ? error.message : String(error),
195
+ });
196
+ }
197
+ }
198
+ })();
199
+ }, []);
200
+
201
+ useEffect(() => {
202
+ refresh();
203
+ // Project files changing mid-view: mark stale (explicit refresh, no
204
+ // surprise full rescans while the user reads the table).
205
+ return getStorageBackend().watch(() => setStale(true));
206
+ }, [refresh]);
207
+
208
+ return { ...state, refresh, stale };
209
+ }
210
+
211
+ const cellStyle: React.CSSProperties = {
212
+ padding: `${spaceVar[2]} ${spaceVar[4]}`,
213
+ whiteSpace: 'nowrap',
214
+ overflow: 'hidden',
215
+ textOverflow: 'ellipsis',
216
+ };
217
+
218
+ const numCellStyle: React.CSSProperties = {
219
+ ...cellStyle,
220
+ textAlign: 'right',
221
+ fontFamily: fontMono,
222
+ };
223
+
224
+ function HeaderCell({
225
+ label,
226
+ sortKey,
227
+ active,
228
+ direction,
229
+ onSort,
230
+ align,
231
+ }: {
232
+ label: string;
233
+ sortKey: SortKey;
234
+ active: boolean;
235
+ direction: 1 | -1;
236
+ onSort: (key: SortKey) => void;
237
+ align?: 'right';
238
+ }) {
239
+ return (
240
+ <th
241
+ aria-sort={active ? (direction === 1 ? 'ascending' : 'descending') : 'none'}
242
+ style={{ padding: 0, textAlign: align ?? 'left' }}
243
+ >
244
+ <Button
245
+ type="button"
246
+ variant="ghost"
247
+ size="compact"
248
+ data-testid={`asset-budget-sort-${sortKey}`}
249
+ onClick={() => onSort(sortKey)}
250
+ style={{
251
+ display: 'block',
252
+ width: '100%',
253
+ justifyContent: align === 'right' ? 'flex-end' : 'flex-start',
254
+ }}
255
+ >
256
+ <span
257
+ style={{
258
+ display: 'block',
259
+ fontSize: fontSizeVar.base,
260
+ fontWeight: 600,
261
+ color: active ? text[1] : text[3],
262
+ textAlign: align ?? 'left',
263
+ }}
264
+ >
265
+ {label}
266
+ {active ? (direction === 1 ? ' ↑' : ' ↓') : ''}
267
+ </span>
268
+ </Button>
269
+ </th>
270
+ );
271
+ }
272
+
273
+ function ProvenanceBadge({ entry }: { entry: AssetBudgetEntry }) {
274
+ if (!entry.provenance) return null;
275
+ return (
276
+ <span
277
+ data-testid="asset-budget-provenance-badge"
278
+ title={`${entry.provenance.operationName} · ${entry.provenance.createdAt}`}
279
+ style={{
280
+ fontSize: fontSizeVar.xs,
281
+ fontWeight: 700,
282
+ letterSpacing: 0.5,
283
+ color: accent,
284
+ border: `1px solid ${accent}`,
285
+ borderRadius: radius.sm,
286
+ padding: `0 ${spaceVar[2]}`,
287
+ marginLeft: spaceVar[3],
288
+ }}
289
+ >
290
+ GEN
291
+ </span>
292
+ );
293
+ }
294
+
295
+ function BudgetRow({
296
+ entry,
297
+ selected,
298
+ onSelect,
299
+ }: {
300
+ entry: AssetBudgetEntry;
301
+ selected: boolean;
302
+ onSelect: (path: string) => void;
303
+ }) {
304
+ const name = entry.path.split('/').pop() ?? entry.path;
305
+ const dir = entry.path.slice(0, entry.path.length - name.length);
306
+ return (
307
+ <tr
308
+ data-testid="asset-budget-row"
309
+ data-path={entry.path}
310
+ data-selected={selected ? 'true' : undefined}
311
+ onClick={() => onSelect(entry.path)}
312
+ style={{
313
+ cursor: 'pointer',
314
+ background: selected ? bg[3] : 'transparent',
315
+ borderTop: `1px solid ${border[1]}`,
316
+ }}
317
+ >
318
+ <td style={{ ...cellStyle, maxWidth: 0, width: '40%' }} title={entry.path}>
319
+ <span style={{ color: text[3], fontSize: fontSizeVar.sm }}>{dir}</span>
320
+ <span style={{ color: text[1] }}>{name}</span>
321
+ <ProvenanceBadge entry={entry} />
322
+ {entry.inspectError ? (
323
+ <span
324
+ title={entry.inspectError}
325
+ data-testid="asset-budget-inspect-error"
326
+ style={{ color: danger, marginLeft: spaceVar[3], fontWeight: 700 }}
327
+ >
328
+ !
329
+ </span>
330
+ ) : null}
331
+ </td>
332
+ <td style={{ ...cellStyle, color: text[3] }}>{entry.category}</td>
333
+ <td style={numCellStyle} data-testid="asset-budget-cell-bytes">
334
+ {formatBytes(entry.bytes)}
335
+ </td>
336
+ <td style={numCellStyle}>{vramLabel(entry)}</td>
337
+ <td style={numCellStyle}>{entry.model ? formatCount(entry.model.glPrimitives) : '—'}</td>
338
+ <td style={numCellStyle} data-testid="asset-budget-cell-refs">
339
+ {entry.references.length}
340
+ </td>
341
+ </tr>
342
+ );
343
+ }
344
+
345
+ function SummaryChips({ report }: { report: AssetBudgetReport }) {
346
+ return (
347
+ <div
348
+ style={{
349
+ display: 'flex',
350
+ flexWrap: 'wrap',
351
+ gap: spaceVar[3],
352
+ padding: `${spaceVar[4]} ${spaceVar[5]}`,
353
+ }}
354
+ >
355
+ <span
356
+ data-testid="asset-budget-totals"
357
+ style={{
358
+ fontSize: fontSizeVar.base,
359
+ color: text[1],
360
+ background: bg[3],
361
+ borderRadius: radius.sm,
362
+ padding: `${spaceVar[1]} ${spaceVar[4]}`,
363
+ }}
364
+ >
365
+ {report.entries.length} assets · {formatBytes(report.totalBytes)} · VRAM est.{' '}
366
+ {formatBytes(report.totalVramBytes)}
367
+ </span>
368
+ {report.categories.map((category) => (
369
+ <span
370
+ key={category.category}
371
+ data-testid={`asset-budget-category-${category.category}`}
372
+ style={{
373
+ fontSize: fontSizeVar.base,
374
+ color: text[2],
375
+ border: `1px solid ${border[1]}`,
376
+ borderRadius: radius.sm,
377
+ padding: `${spaceVar[1]} ${spaceVar[4]}`,
378
+ }}
379
+ >
380
+ {CATEGORY_LABEL[category.category]} {category.count} · {formatBytes(category.bytes)}
381
+ </span>
382
+ ))}
383
+ </div>
384
+ );
385
+ }
386
+
387
+ function ReportSection({
388
+ title,
389
+ testId,
390
+ children,
391
+ }: {
392
+ title: string;
393
+ testId: string;
394
+ children: React.ReactNode;
395
+ }) {
396
+ return (
397
+ <div data-testid={testId} style={{ padding: `${spaceVar[3]} ${spaceVar[5]}` }}>
398
+ <div
399
+ style={{
400
+ fontSize: fontSizeVar.base,
401
+ fontWeight: 600,
402
+ color: text[2],
403
+ marginBottom: spaceVar[2],
404
+ }}
405
+ >
406
+ {title}
407
+ </div>
408
+ {children}
409
+ </div>
410
+ );
411
+ }
412
+
413
+ function UnusedSection({
414
+ report,
415
+ onSelect,
416
+ }: {
417
+ report: AssetBudgetReport;
418
+ onSelect: (path: string) => void;
419
+ }) {
420
+ if (report.unused.length === 0) return null;
421
+ return (
422
+ <ReportSection title={`Unused assets (${report.unused.length})`} testId="asset-budget-unused">
423
+ <div
424
+ data-testid="asset-budget-unused-caveat"
425
+ style={{
426
+ fontSize: fontSizeVar.sm,
427
+ color: warn,
428
+ marginBottom: spaceVar[2],
429
+ lineHeight: lineHeightVar.normal,
430
+ }}
431
+ >
432
+ “Unused” = not referenced by the manifest, project code, or another asset.
433
+ {report.codeReferencesScanned
434
+ ? ''
435
+ : ' Code references (src/**) are not scanned in server mode, so an asset used only from code appears here — “unused” does not mean “safe to delete”.'}
436
+ </div>
437
+ {report.unused.map((path) => (
438
+ <Button
439
+ key={path}
440
+ type="button"
441
+ variant="ghost"
442
+ size="compact"
443
+ data-testid="asset-budget-unused-item"
444
+ data-path={path}
445
+ onClick={() => onSelect(path)}
446
+ style={{ display: 'block' }}
447
+ >
448
+ <span style={{ fontSize: fontSizeVar.base, fontFamily: fontMono, color: warn }}>
449
+ {path}
450
+ </span>
451
+ </Button>
452
+ ))}
453
+ </ReportSection>
454
+ );
455
+ }
456
+
457
+ function DuplicatesSection({ report }: { report: AssetBudgetReport }) {
458
+ if (report.duplicates.length === 0) return null;
459
+ return (
460
+ <ReportSection
461
+ title={`Duplicate content (${report.duplicates.length} group${report.duplicates.length > 1 ? 's' : ''})`}
462
+ testId="asset-budget-duplicates"
463
+ >
464
+ {report.duplicates.map((group) => (
465
+ <div
466
+ key={group.sha256}
467
+ data-testid="asset-budget-duplicate-group"
468
+ style={{ marginBottom: spaceVar[2] }}
469
+ >
470
+ <span style={{ fontSize: fontSizeVar.base, color: warn }}>
471
+ {formatBytes(group.wastedBytes)} wasted · {group.paths.length} copies
472
+ </span>
473
+ {group.paths.map((path) => (
474
+ <div
475
+ key={path}
476
+ style={{ fontSize: fontSizeVar.base, fontFamily: fontMono, color: text[3] }}
477
+ >
478
+ {path}
479
+ </div>
480
+ ))}
481
+ </div>
482
+ ))}
483
+ </ReportSection>
484
+ );
485
+ }
486
+
487
+ function DetailPane({
488
+ entry,
489
+ onApplied,
490
+ codeReferencesScanned,
491
+ }: {
492
+ entry: AssetBudgetEntry | null;
493
+ onApplied: () => void;
494
+ codeReferencesScanned: boolean;
495
+ }) {
496
+ if (!entry) {
497
+ return (
498
+ <div style={{ padding: spaceVar[6], fontSize: fontSizeVar.md, color: text[3] }}>
499
+ Select an asset to see its details and references.
500
+ </div>
501
+ );
502
+ }
503
+ return (
504
+ <div
505
+ data-testid="asset-budget-detail"
506
+ style={{ padding: spaceVar[6], overflow: 'auto', minHeight: 0 }}
507
+ >
508
+ <div
509
+ style={{
510
+ fontSize: fontSizeVar.md,
511
+ fontWeight: 600,
512
+ color: text[1],
513
+ wordBreak: 'break-all',
514
+ }}
515
+ >
516
+ {entry.path}
517
+ </div>
518
+ <div style={{ fontSize: fontSizeVar.base, color: text[3], marginTop: spaceVar[1] }}>
519
+ {entry.category} · {formatBytes(entry.bytes)}
520
+ {entry.sha256 ? ` · sha256 ${entry.sha256.slice(0, 12)}…` : ''}
521
+ </div>
522
+ {entry.provenance ? (
523
+ <div style={{ fontSize: fontSizeVar.base, color: accent, marginTop: spaceVar[3] }}>
524
+ Generated by {entry.provenance.operationName}
525
+ <span style={{ color: text[3] }}> · {entry.provenance.createdAt}</span>
526
+ </div>
527
+ ) : null}
528
+ {entry.inspectError ? (
529
+ <div style={{ fontSize: fontSizeVar.base, color: danger, marginTop: spaceVar[3] }}>
530
+ Inspection failed: {entry.inspectError}
531
+ </div>
532
+ ) : null}
533
+ {entry.model ? <ModelStats entry={entry} /> : null}
534
+ <OptimizeActions entry={entry} onApplied={onApplied} />
535
+ <ReferencesList entry={entry} codeReferencesScanned={codeReferencesScanned} />
536
+ </div>
537
+ );
538
+ }
539
+
540
+ function ModelStats({ entry }: { entry: AssetBudgetEntry }) {
541
+ const model = entry.model;
542
+ if (!model) return null;
543
+ const rows: [string, string][] = [
544
+ ['Triangles (GL primitives)', formatCount(model.glPrimitives)],
545
+ ['Vertices', formatCount(model.vertices)],
546
+ ['Meshes', String(model.meshCount)],
547
+ ['Materials', String(model.materialCount)],
548
+ ['Animations', String(model.animationCount)],
549
+ ['Geometry upload', formatBytes(model.geometryBytes)],
550
+ [
551
+ 'Texture VRAM est.',
552
+ `${model.unknownTextureCount > 0 ? '≥ ' : ''}${formatBytes(model.textureGpuBytes)}`,
553
+ ],
554
+ ];
555
+ return (
556
+ <div style={{ marginTop: spaceVar[4] }}>
557
+ {rows.map(([label, value]) => (
558
+ <div
559
+ key={label}
560
+ style={{ display: 'flex', justifyContent: 'space-between', fontSize: fontSizeVar.base }}
561
+ >
562
+ <span style={{ color: text[3] }}>{label}</span>
563
+ <span style={{ color: text[1], fontFamily: fontMono }}>{value}</span>
564
+ </div>
565
+ ))}
566
+ {model.textures.length > 0 ? (
567
+ <div style={{ marginTop: spaceVar[3] }}>
568
+ <div style={{ fontSize: fontSizeVar.base, fontWeight: 600, color: text[2] }}>
569
+ Textures
570
+ </div>
571
+ {model.textures.map((texture) => (
572
+ <div
573
+ key={`${texture.name}:${texture.slots.join(',')}`}
574
+ style={{ fontSize: fontSizeVar.base, color: text[3] }}
575
+ >
576
+ {texture.name || '(unnamed)'} · {texture.mimeType} · {texture.resolution} ·{' '}
577
+ {texture.gpuBytes === null ? 'VRAM unknown' : formatBytes(texture.gpuBytes)}
578
+ </div>
579
+ ))}
580
+ </div>
581
+ ) : null}
582
+ {model.unknownTextureCount > 0 ? (
583
+ <div style={{ fontSize: fontSizeVar.sm, color: warn, marginTop: spaceVar[2] }}>
584
+ {model.unknownTextureCount} texture(s) have no reliable VRAM estimate — totals are lower
585
+ bounds.
586
+ </div>
587
+ ) : null}
588
+ </div>
589
+ );
590
+ }
591
+
592
+ // --- Optimize actions (M3) ---------------------------------------------------
593
+
594
+ function useGltfCapabilities(): GltfIOCapabilities | null {
595
+ const [capabilities, setCapabilities] = useState<GltfIOCapabilities | null>(null);
596
+ useEffect(() => {
597
+ let live = true;
598
+ void getGltfIO().then((bundle) => {
599
+ if (live) setCapabilities(bundle.capabilities);
600
+ });
601
+ return () => {
602
+ live = false;
603
+ };
604
+ }, []);
605
+ return capabilities;
606
+ }
607
+
608
+ interface PendingOptimize {
609
+ readonly label: string;
610
+ readonly operationName: string;
611
+ readonly params?: Record<string, unknown>;
612
+ readonly outcome: OptimizeOutcome;
613
+ readonly lod?: LodGenerationOutcome;
614
+ }
615
+
616
+ function diffLabel(outcome: OptimizeOutcome): string {
617
+ const delta = outcome.afterBytes - outcome.beforeBytes;
618
+ const pct = outcome.beforeBytes > 0 ? Math.round((delta / outcome.beforeBytes) * 100) : 0;
619
+ return `${formatBytes(outcome.beforeBytes)} → ${formatBytes(outcome.afterBytes)} (${pct > 0 ? '+' : ''}${pct}%)`;
620
+ }
621
+
622
+ /** One codec button: availability-probed title/disabled state in one place. */
623
+ function CodecButton({
624
+ testId,
625
+ label,
626
+ availability,
627
+ busy,
628
+ activeTitle,
629
+ onClick,
630
+ }: {
631
+ testId: string;
632
+ label: string;
633
+ availability: { available: boolean; reason?: string } | undefined;
634
+ busy: boolean;
635
+ activeTitle: string;
636
+ onClick?: () => void;
637
+ }) {
638
+ const title = availability?.available
639
+ ? activeTitle
640
+ : (availability?.reason ?? 'Probing codec availability…');
641
+ return (
642
+ <Button
643
+ type="button"
644
+ size="compact"
645
+ data-testid={testId}
646
+ disabled={busy || !availability?.available || !onClick}
647
+ title={title}
648
+ onClick={onClick}
649
+ >
650
+ {label}
651
+ </Button>
652
+ );
653
+ }
654
+
655
+ function OptimizeConfirm({
656
+ pending,
657
+ busy,
658
+ onApply,
659
+ onCancel,
660
+ }: {
661
+ pending: PendingOptimize;
662
+ busy: boolean;
663
+ onApply: () => void;
664
+ onCancel: () => void;
665
+ }) {
666
+ return (
667
+ <div
668
+ data-testid="asset-budget-op-confirm"
669
+ style={{
670
+ marginTop: spaceVar[3],
671
+ padding: spaceVar[4],
672
+ border: `1px solid ${border[1]}`,
673
+ borderRadius: radius.sm,
674
+ }}
675
+ >
676
+ <div
677
+ data-testid="asset-budget-op-diff"
678
+ style={{ fontSize: fontSizeVar.base, color: text[1] }}
679
+ >
680
+ {pending.label}: {diffLabel(pending.outcome)}
681
+ </div>
682
+ {pending.lod ? (
683
+ <div style={{ fontSize: fontSizeVar.base, color: text[3], marginTop: spaceVar[1] }}>
684
+ {pending.lod.levels
685
+ .map((level) => `${level.node} ${formatCount(level.triangles)} tris`)
686
+ .join(' · ')}{' '}
687
+ (base {formatCount(pending.lod.baseTriangles)})
688
+ </div>
689
+ ) : (
690
+ <div style={{ fontSize: fontSizeVar.base, color: warn, marginTop: spaceVar[1] }}>
691
+ Rewrites the file on disk — not undoable from the editor.
692
+ </div>
693
+ )}
694
+ <div style={{ display: 'flex', gap: spaceVar[2], marginTop: spaceVar[3] }}>
695
+ <Button
696
+ type="button"
697
+ size="compact"
698
+ variant="primary"
699
+ data-testid="asset-budget-op-apply"
700
+ disabled={busy}
701
+ onClick={onApply}
702
+ >
703
+ Apply
704
+ </Button>
705
+ <Button
706
+ type="button"
707
+ size="compact"
708
+ data-testid="asset-budget-op-cancel"
709
+ disabled={busy}
710
+ onClick={onCancel}
711
+ >
712
+ Cancel
713
+ </Button>
714
+ </div>
715
+ </div>
716
+ );
717
+ }
718
+
719
+ function OptimizeNotices({
720
+ busy,
721
+ error,
722
+ stampError,
723
+ }: {
724
+ busy: string | null;
725
+ error: string | null;
726
+ stampError: string | null;
727
+ }) {
728
+ return (
729
+ <>
730
+ {busy ? (
731
+ <div
732
+ data-testid="asset-budget-op-busy"
733
+ style={{ fontSize: fontSizeVar.base, color: text[3], marginTop: spaceVar[3] }}
734
+ >
735
+ {busy}…
736
+ </div>
737
+ ) : null}
738
+ {error ? (
739
+ <div
740
+ data-testid="asset-budget-op-error"
741
+ style={{ fontSize: fontSizeVar.base, color: danger, marginTop: spaceVar[3] }}
742
+ >
743
+ {error}
744
+ </div>
745
+ ) : null}
746
+ {stampError ? (
747
+ <div
748
+ data-testid="asset-budget-stamp-error"
749
+ style={{ fontSize: fontSizeVar.base, color: warn, marginTop: spaceVar[3] }}
750
+ >
751
+ {stampError}
752
+ </div>
753
+ ) : null}
754
+ </>
755
+ );
756
+ }
757
+
758
+ /**
759
+ * Per-asset optimize buttons — real gltf-transform ops on the real bytes.
760
+ * Compression rewrites the file, which the editor undo stack (scene-document
761
+ * snapshots) cannot restore, so every apply goes through an inline
762
+ * CONFIRM-WITH-DIFF (before → after, computed from the actual result, never
763
+ * predicted). Unavailable codecs render disabled with the probe's reason
764
+ * (KTX2 always, in this environment). GLB only: multi-file .gltf write-back
765
+ * is out of scope and says so.
766
+ */
767
+ function OptimizeActions({ entry, onApplied }: { entry: AssetBudgetEntry; onApplied: () => void }) {
768
+ const capabilities = useGltfCapabilities();
769
+ const [busy, setBusy] = useState<string | null>(null);
770
+ const [error, setError] = useState<string | null>(null);
771
+ const [stampError, setStampError] = useState<string | null>(null);
772
+ const [pending, setPending] = useState<PendingOptimize | null>(null);
773
+ const [lodNode, setLodNode] = useState('');
774
+ if (entry.category !== 'model') return null;
775
+ if (!/\.glb$/i.test(entry.path)) {
776
+ return (
777
+ <div style={{ marginTop: spaceVar[5], fontSize: fontSizeVar.base, color: text[3] }}>
778
+ Optimization supports single-file .glb assets (this is a multi-file .gltf).
779
+ </div>
780
+ );
781
+ }
782
+ if (entry.inspectError) return null;
783
+
784
+ const run = (
785
+ label: string,
786
+ operationName: string,
787
+ operation: (bytes: Uint8Array) => Promise<OptimizeOutcome>,
788
+ params?: Record<string, unknown>,
789
+ ): void => {
790
+ setBusy(label);
791
+ setError(null);
792
+ setStampError(null);
793
+ setPending(null);
794
+ void getStorageBackend()
795
+ .readBytes(entry.path)
796
+ .then(operation)
797
+ .then((outcome) => {
798
+ setPending({
799
+ label,
800
+ operationName,
801
+ ...(params ? { params } : {}),
802
+ outcome,
803
+ ...('levels' in outcome ? { lod: outcome as LodGenerationOutcome } : {}),
804
+ });
805
+ })
806
+ .catch((runError: unknown) => {
807
+ setError(runError instanceof Error ? runError.message : String(runError));
808
+ })
809
+ .finally(() => setBusy(null));
810
+ };
811
+
812
+ const apply = (): void => {
813
+ if (!pending) return;
814
+ setBusy(pending.label);
815
+ void applyOptimizedBytes(
816
+ getStorageBackend(),
817
+ {
818
+ operationName: pending.operationName,
819
+ storagePath: entry.path,
820
+ beforeBytes: pending.outcome.beforeBytes,
821
+ beforeSha256: entry.sha256,
822
+ ...(pending.params ? { params: pending.params } : {}),
823
+ },
824
+ pending.outcome.bytes,
825
+ )
826
+ .then((result) => {
827
+ if (result.stampError) {
828
+ setStampError(`Optimized, but provenance stamp failed: ${result.stampError}`);
829
+ }
830
+ setPending(null);
831
+ onApplied();
832
+ })
833
+ .catch((applyError: unknown) => {
834
+ setError(applyError instanceof Error ? applyError.message : String(applyError));
835
+ })
836
+ .finally(() => setBusy(null));
837
+ };
838
+
839
+ const lodCandidates = entry.model?.meshNames ?? [];
840
+ const chosenLodNode = lodNode || lodCandidates[0] || '';
841
+
842
+ return (
843
+ <div style={{ marginTop: spaceVar[6] }}>
844
+ <div
845
+ style={{
846
+ fontSize: fontSizeVar.base,
847
+ fontWeight: 600,
848
+ color: text[2],
849
+ marginBottom: spaceVar[2],
850
+ }}
851
+ >
852
+ Optimize
853
+ </div>
854
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: spaceVar[2] }}>
855
+ <CodecButton
856
+ testId="asset-budget-op-meshopt"
857
+ label="Compress · meshopt"
858
+ availability={capabilities?.meshoptEncoder}
859
+ busy={busy !== null}
860
+ activeTitle="Reorder + quantize + EXT_meshopt_compression"
861
+ onClick={() =>
862
+ run('Meshopt', 'asset-budget.meshopt', compressModelMeshopt, { codec: 'meshopt' })
863
+ }
864
+ />
865
+ <CodecButton
866
+ testId="asset-budget-op-draco"
867
+ label="Compress · Draco"
868
+ availability={capabilities?.dracoEncoder}
869
+ busy={busy !== null}
870
+ activeTitle="KHR_draco_mesh_compression"
871
+ onClick={() => run('Draco', 'asset-budget.draco', compressModelDraco, { codec: 'draco' })}
872
+ />
873
+ <CodecButton
874
+ testId="asset-budget-op-ktx2"
875
+ label="Compress textures · KTX2"
876
+ availability={capabilities?.ktx2Encoder}
877
+ busy={busy !== null}
878
+ activeTitle="KHR_texture_basisu — ETC1S for color maps, UASTC for normal/ORM data"
879
+ onClick={() => run('KTX2', 'asset-budget.ktx2', compressTexturesKtx2, { codec: 'ktx2' })}
880
+ />
881
+ </div>
882
+ {lodCandidates.length > 0 ? (
883
+ <div style={{ display: 'flex', gap: spaceVar[2], marginTop: spaceVar[3] }}>
884
+ <Select
885
+ className="vgai-select"
886
+ aria-label="LOD base node"
887
+ data-testid="asset-budget-op-lod-node"
888
+ value={chosenLodNode}
889
+ onChange={(event) => setLodNode(event.target.value)}
890
+ style={{ flex: 1, minWidth: 0 }}
891
+ >
892
+ {lodCandidates.map((name) => (
893
+ <option key={name} value={name}>
894
+ {name}
895
+ </option>
896
+ ))}
897
+ </Select>
898
+ <CodecButton
899
+ testId="asset-budget-op-lod"
900
+ label="Generate LOD nodes"
901
+ availability={capabilities?.meshoptSimplifier}
902
+ busy={busy !== null || !chosenLodNode}
903
+ activeTitle="simplify() the chosen node into _LOD1/_LOD2 sibling nodes (additive — base geometry untouched). Add mesh.lod rows from the Mesh inspector."
904
+ onClick={() =>
905
+ run(
906
+ 'Generate LODs',
907
+ 'asset-budget.lod-levels',
908
+ (bytes) => generateLodLevels(bytes, chosenLodNode),
909
+ { baseNode: chosenLodNode },
910
+ )
911
+ }
912
+ />
913
+ </div>
914
+ ) : null}
915
+ {pending ? (
916
+ <OptimizeConfirm
917
+ pending={pending}
918
+ busy={busy !== null}
919
+ onApply={apply}
920
+ onCancel={() => setPending(null)}
921
+ />
922
+ ) : null}
923
+ <OptimizeNotices busy={busy} error={error} stampError={stampError} />
924
+ </div>
925
+ );
926
+ }
927
+
928
+ /** The critique-P5 "what uses this" query, rendered. */
929
+ function ReferencesList({
930
+ entry,
931
+ codeReferencesScanned,
932
+ }: {
933
+ entry: AssetBudgetEntry;
934
+ codeReferencesScanned: boolean;
935
+ }) {
936
+ return (
937
+ <div style={{ marginTop: spaceVar[5] }}>
938
+ <div style={{ fontSize: fontSizeVar.base, fontWeight: 600, color: text[2] }}>
939
+ What uses this ({entry.references.length})
940
+ </div>
941
+ {entry.engineVendored ? (
942
+ <div
943
+ data-testid="asset-budget-engine-runtime"
944
+ style={{ fontSize: fontSizeVar.base, color: text[3] }}
945
+ >
946
+ Engine runtime (Three.js examples/jsm) — loaded by the engine at runtime, not by project
947
+ content. Required infrastructure; not safe to delete.
948
+ </div>
949
+ ) : entry.references.length === 0 ? (
950
+ <>
951
+ <div
952
+ data-testid="asset-budget-no-references"
953
+ style={{ fontSize: fontSizeVar.base, color: warn }}
954
+ >
955
+ Nothing in this project’s manifest, code, or other assets references this asset.
956
+ </div>
957
+ {codeReferencesScanned ? null : (
958
+ <div
959
+ data-testid="asset-budget-code-not-scanned"
960
+ style={{
961
+ fontSize: fontSizeVar.sm,
962
+ color: warn,
963
+ marginTop: spaceVar[1],
964
+ lineHeight: lineHeightVar.normal,
965
+ }}
966
+ >
967
+ Code references (src/**) are not scanned in server mode — an asset used only from code
968
+ will show here. Not necessarily safe to delete.
969
+ </div>
970
+ )}
971
+ </>
972
+ ) : (
973
+ entry.references.map((reference) => (
974
+ <div
975
+ key={`${reference.ownerPath}:${reference.jsonPath ?? reference.kind}`}
976
+ data-testid="asset-budget-reference"
977
+ style={{ fontSize: fontSizeVar.base, padding: `${spaceVar[1]} 0` }}
978
+ >
979
+ <span style={{ color: text[1], fontFamily: fontMono }}>{reference.ownerPath}</span>
980
+ <span style={{ color: text[3] }}>
981
+ {' '}
982
+ · {reference.kind}
983
+ {reference.jsonPath ? ` · ${reference.jsonPath}` : ''}
984
+ </span>
985
+ </div>
986
+ ))
987
+ )}
988
+ </div>
989
+ );
990
+ }
991
+
992
+ export function AssetBudgetPanel() {
993
+ useSyncExternalStore(subscribeFocus, focusVersion);
994
+ const { report, loading, error, refresh, stale } = useBudgetReport();
995
+ const [sortKey, setSortKey] = useState<SortKey>('bytes');
996
+ const [direction, setDirection] = useState<1 | -1>(-1);
997
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
998
+
999
+ // Adopt a pending focus request (context-menu jump) once data is present.
1000
+ useEffect(() => {
1001
+ if (!report) return;
1002
+ const focus = takeFocusPath();
1003
+ if (focus === null) return;
1004
+ const normalized = focus.replace(/^\/+/, '');
1005
+ // Match all live spellings: entry paths are serving-rooted in server mode
1006
+ // (`models/x.glb`) and public/-prefixed in project-rooted snapshots, while
1007
+ // callers may hand over either form.
1008
+ const match = report.entries.find(
1009
+ (entry) =>
1010
+ entry.path === normalized ||
1011
+ entry.path === `public/${normalized}` ||
1012
+ `public/${entry.path}` === normalized,
1013
+ );
1014
+ if (match) setSelectedPath(match.path);
1015
+ }, [report]);
1016
+
1017
+ const onSort = useCallback(
1018
+ (key: SortKey) => {
1019
+ if (key === sortKey) setDirection((prior) => (prior === 1 ? -1 : 1));
1020
+ else {
1021
+ setSortKey(key);
1022
+ setDirection(key === 'path' || key === 'category' ? 1 : -1);
1023
+ }
1024
+ },
1025
+ [sortKey],
1026
+ );
1027
+
1028
+ const entries = report ? sortEntries(report.entries, sortKey, direction) : [];
1029
+ const selected = report?.entries.find((entry) => entry.path === selectedPath) ?? null;
1030
+
1031
+ return (
1032
+ <div
1033
+ data-testid="asset-budget-document"
1034
+ style={{
1035
+ display: 'flex',
1036
+ flexDirection: 'column',
1037
+ height: '100%',
1038
+ minHeight: 0,
1039
+ background: bg[1],
1040
+ color: text[1],
1041
+ pointerEvents: 'auto',
1042
+ }}
1043
+ >
1044
+ <div
1045
+ style={{
1046
+ display: 'flex',
1047
+ alignItems: 'center',
1048
+ gap: spaceVar[4],
1049
+ padding: `${spaceVar[3]} ${spaceVar[5]}`,
1050
+ borderBottom: `1px solid ${border[1]}`,
1051
+ }}
1052
+ >
1053
+ <span style={{ fontSize: fontSizeVar.md, fontWeight: 600 }}>Asset Budget</span>
1054
+ <Button type="button" size="compact" data-testid="asset-budget-refresh" onClick={refresh}>
1055
+ Refresh
1056
+ </Button>
1057
+ {stale ? (
1058
+ <span
1059
+ data-testid="asset-budget-stale"
1060
+ style={{ fontSize: fontSizeVar.base, color: warn }}
1061
+ >
1062
+ Project files changed — refresh for current numbers.
1063
+ </span>
1064
+ ) : null}
1065
+ {loading ? (
1066
+ <span
1067
+ data-testid="asset-budget-loading"
1068
+ style={{ fontSize: fontSizeVar.base, color: text[3] }}
1069
+ >
1070
+ Analyzing…
1071
+ </span>
1072
+ ) : null}
1073
+ </div>
1074
+ {error ? (
1075
+ <div
1076
+ data-testid="asset-budget-error"
1077
+ style={{ padding: spaceVar[6], color: danger, fontSize: fontSizeVar.md }}
1078
+ >
1079
+ Analysis failed: {error}
1080
+ </div>
1081
+ ) : null}
1082
+ {report ? (
1083
+ <div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
1084
+ <div
1085
+ style={{
1086
+ flex: 2,
1087
+ minWidth: 0,
1088
+ overflow: 'auto',
1089
+ borderRight: `1px solid ${border[1]}`,
1090
+ }}
1091
+ >
1092
+ <SummaryChips report={report} />
1093
+ <table
1094
+ data-testid="asset-budget-table"
1095
+ style={{ width: '100%', borderCollapse: 'collapse', fontSize: fontSizeVar.md }}
1096
+ >
1097
+ <thead>
1098
+ <tr>
1099
+ <HeaderCell
1100
+ label="Asset"
1101
+ sortKey="path"
1102
+ active={sortKey === 'path'}
1103
+ direction={direction}
1104
+ onSort={onSort}
1105
+ />
1106
+ <HeaderCell
1107
+ label="Category"
1108
+ sortKey="category"
1109
+ active={sortKey === 'category'}
1110
+ direction={direction}
1111
+ onSort={onSort}
1112
+ />
1113
+ <HeaderCell
1114
+ label="Size"
1115
+ sortKey="bytes"
1116
+ active={sortKey === 'bytes'}
1117
+ direction={direction}
1118
+ onSort={onSort}
1119
+ align="right"
1120
+ />
1121
+ <HeaderCell
1122
+ label="VRAM est."
1123
+ sortKey="vram"
1124
+ active={sortKey === 'vram'}
1125
+ direction={direction}
1126
+ onSort={onSort}
1127
+ align="right"
1128
+ />
1129
+ <HeaderCell
1130
+ label="Tris"
1131
+ sortKey="tris"
1132
+ active={sortKey === 'tris'}
1133
+ direction={direction}
1134
+ onSort={onSort}
1135
+ align="right"
1136
+ />
1137
+ <HeaderCell
1138
+ label="Refs"
1139
+ sortKey="refs"
1140
+ active={sortKey === 'refs'}
1141
+ direction={direction}
1142
+ onSort={onSort}
1143
+ align="right"
1144
+ />
1145
+ </tr>
1146
+ </thead>
1147
+ <tbody>
1148
+ {entries.map((entry) => (
1149
+ <BudgetRow
1150
+ key={entry.path}
1151
+ entry={entry}
1152
+ selected={entry.path === selectedPath}
1153
+ onSelect={setSelectedPath}
1154
+ />
1155
+ ))}
1156
+ </tbody>
1157
+ </table>
1158
+ <UnusedSection report={report} onSelect={setSelectedPath} />
1159
+ <DuplicatesSection report={report} />
1160
+ </div>
1161
+ <div style={{ flex: 1, minWidth: 220, overflow: 'auto' }}>
1162
+ <DetailPane
1163
+ entry={selected}
1164
+ onApplied={refresh}
1165
+ codeReferencesScanned={report.codeReferencesScanned}
1166
+ />
1167
+ </div>
1168
+ </div>
1169
+ ) : null}
1170
+ </div>
1171
+ );
1172
+ }