@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,384 @@
1
+ /**
2
+ * Real gltf-transform optimization operations for the Asset Budget window
3
+ * (W4a M3) — bytes in, bytes out, no storage/UI concerns here (headless,
4
+ * unit-tested against generated fixtures).
5
+ *
6
+ * - `compressModelMeshopt` — reorder + quantize + EXT_meshopt_compression
7
+ * via gltf-transform's `meshopt()` transform (encoder: `meshoptimizer`).
8
+ * - `compressModelDraco` — KHR_draco_mesh_compression via `draco()`
9
+ * (encoder: `draco3d`). Capability-probed; unavailable throws the probe's
10
+ * honest reason instead of pretending.
11
+ * - `compressTexturesKtx2` — KHR_texture_basisu via Binomial's own basis
12
+ * encoder (vendored wasm, `basis-encoder.ts`). Textures become KTX2;
13
+ * geometry is untouched.
14
+ * - `generateLodLevels` — the W2b "Generate levels" completion: clone a
15
+ * named node's subtree, `weldPrimitive` + `simplifyPrimitive`
16
+ * (MeshoptSimplifier) each mesh, and add `<node><suffix>` siblings the
17
+ * `mesh.lod` schema block can reference by name (entity-factory resolves
18
+ * LOD levels via getObjectByName inside the SAME glTF). Additive: the
19
+ * base geometry is untouched, so removing the lod rows restores the
20
+ * original appearance exactly.
21
+ *
22
+ * Never fabricate success (anti-shim): an op whose codec did not load throws
23
+ * that codec's own reason rather than returning the input unchanged.
24
+ */
25
+
26
+ import type { Document, Mesh, Node, Texture } from '@gltf-transform/core';
27
+ import { KHRTextureBasisu } from '@gltf-transform/extensions';
28
+ import {
29
+ draco,
30
+ getGLPrimitiveCount,
31
+ getTextureColorSpace,
32
+ listTextureSlots,
33
+ meshopt,
34
+ prune,
35
+ simplifyPrimitive,
36
+ weldPrimitive,
37
+ } from '@gltf-transform/functions';
38
+ import {
39
+ type BasisEncoderArtifacts,
40
+ type BasisEncoderModule,
41
+ getBasisEncoder,
42
+ } from './basis-encoder';
43
+ import { getGltfIO, getMeshoptSimplifier } from './gltf-io';
44
+
45
+ export interface OptimizeOutcome {
46
+ readonly bytes: Uint8Array;
47
+ readonly beforeBytes: number;
48
+ readonly afterBytes: number;
49
+ }
50
+
51
+ async function readGlb(bytes: Uint8Array): Promise<Document> {
52
+ const { io } = await getGltfIO();
53
+ return io.readBinary(new Uint8Array(bytes));
54
+ }
55
+
56
+ async function writeGlb(document: Document, beforeBytes: number): Promise<OptimizeOutcome> {
57
+ const { io } = await getGltfIO();
58
+ const out = await io.writeBinary(document);
59
+ return { bytes: out, beforeBytes, afterBytes: out.byteLength };
60
+ }
61
+
62
+ /** Meshopt-compress a GLB (EXT_meshopt_compression + quantization). */
63
+ export async function compressModelMeshopt(bytes: Uint8Array): Promise<OptimizeOutcome> {
64
+ const { capabilities } = await getGltfIO();
65
+ if (!capabilities.meshoptEncoder.available) {
66
+ throw new Error(capabilities.meshoptEncoder.reason);
67
+ }
68
+ const meshoptModule = await import('meshoptimizer');
69
+ await meshoptModule.MeshoptEncoder.ready;
70
+ const document = await readGlb(bytes);
71
+ await document.transform(meshopt({ encoder: meshoptModule.MeshoptEncoder }));
72
+ return writeGlb(document, bytes.byteLength);
73
+ }
74
+
75
+ /** Draco-compress a GLB (KHR_draco_mesh_compression). */
76
+ export async function compressModelDraco(bytes: Uint8Array): Promise<OptimizeOutcome> {
77
+ const { capabilities } = await getGltfIO();
78
+ if (!capabilities.dracoEncoder.available) {
79
+ throw new Error(capabilities.dracoEncoder.reason);
80
+ }
81
+ const document = await readGlb(bytes);
82
+ await document.transform(draco());
83
+ return writeGlb(document, bytes.byteLength);
84
+ }
85
+
86
+ // --- KHR_texture_basisu (KTX2) texture compression ---------------------------
87
+
88
+ /**
89
+ * Which Basis mode a texture is encoded in.
90
+ *
91
+ * `auto` is the only mode with an opinion, and it is the standard one: ETC1S
92
+ * for ALBEDO-CLASS (sRGB) textures — base color, emissive — because its
93
+ * ~0.3-1 bpp is where the file-size win lives, and UASTC for NON-COLOR data —
94
+ * normal, metallic-roughness, occlusion — because ETC1S's block artifacts
95
+ * corrupt data that is read as vectors and coefficients rather than looked at.
96
+ */
97
+ export type Ktx2Mode = 'auto' | 'etc1s' | 'uastc';
98
+
99
+ export interface Ktx2Options {
100
+ /** Default `auto` — see {@link Ktx2Mode}. */
101
+ readonly mode?: Ktx2Mode;
102
+ /** Encoder artifacts, when the caller already has the bytes (headless
103
+ * tests). Omitted in the editor, which fetches the vendored copies. */
104
+ readonly artifacts?: BasisEncoderArtifacts;
105
+ }
106
+
107
+ /** Per-texture record of what actually happened — the honest report the UI's
108
+ * before/after diff is built from. */
109
+ export interface Ktx2TextureOutcome {
110
+ readonly name: string;
111
+ /** The glTF slots this texture fills (`baseColorTexture`, …), or `[]`. */
112
+ readonly slots: readonly string[];
113
+ readonly mode: Exclude<Ktx2Mode, 'auto'>;
114
+ readonly beforeBytes: number;
115
+ readonly afterBytes: number;
116
+ }
117
+
118
+ export interface Ktx2CompressionOutcome extends OptimizeOutcome {
119
+ readonly textures: readonly Ktx2TextureOutcome[];
120
+ }
121
+
122
+ /** Basis's own default quality level (`basisu -q 128`). ETC1S REQUIRES one —
123
+ * left unset, `basisu_frontend::init()` fails outright. */
124
+ const ETC1S_QUALITY = 128;
125
+
126
+ /** The source formats Basis decodes itself, so nothing here needs an image
127
+ * decoder. Anything else is skipped by name rather than guessed at. */
128
+ const DECODABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg']);
129
+
130
+ function ktx2ModeFor(texture: Texture, requested: Ktx2Mode): 'etc1s' | 'uastc' {
131
+ if (requested !== 'auto') return requested;
132
+ return getTextureColorSpace(texture) === 'srgb' ? 'etc1s' : 'uastc';
133
+ }
134
+
135
+ /**
136
+ * Encode ONE texture's image bytes as KTX2.
137
+ *
138
+ * The output buffer is caller-owned and sized from the texture's real pixel
139
+ * dimensions, not from its compressed byte length: a well-compressed PNG can
140
+ * be a tiny fraction of its raster, and UASTC's fixed 8 bpp plus a full mip
141
+ * chain is not. Uncompressed RGBA (4 bpp) plus the mip tail plus a container
142
+ * allowance is a ceiling no Basis payload for the same image reaches.
143
+ */
144
+ function encodeTextureToKtx2(
145
+ basis: BasisEncoderModule,
146
+ texture: Texture,
147
+ mode: 'etc1s' | 'uastc',
148
+ ): Uint8Array {
149
+ const image = texture.getImage();
150
+ const size = texture.getSize();
151
+ const label = texture.getName() || '(unnamed)';
152
+ if (!image || !size) {
153
+ throw new Error(`Texture "${label}" (${texture.getMimeType()}) has no readable image.`);
154
+ }
155
+ const srgb = getTextureColorSpace(texture) === 'srgb';
156
+ const encoder = new basis.BasisEncoder();
157
+ try {
158
+ encoder.setCreateKTX2File(true);
159
+ // Zstd-supercompress the UASTC payload; ETC1S carries its own BasisLZ.
160
+ encoder.setKTX2UASTCSupercompression(true);
161
+ encoder.setKTX2AndBasisSRGBTransferFunc(srgb);
162
+ encoder.setDebug(false);
163
+ // The encoder narrates every slice to stdout unless told otherwise.
164
+ encoder.setStatusOutput(false);
165
+ encoder.setComputeStats(false);
166
+ encoder.setPerceptual(mode === 'etc1s');
167
+ encoder.setMipGen(true);
168
+ encoder.setMipSRGB(srgb);
169
+ encoder.setUASTC(mode === 'uastc');
170
+ encoder.setQualityLevel(ETC1S_QUALITY);
171
+ const imageType =
172
+ texture.getMimeType() === 'image/png'
173
+ ? basis.ldr_image_type.cPNGImage.value
174
+ : basis.ldr_image_type.cJPGImage.value;
175
+ if (!encoder.setSliceSourceImage(0, image, 0, 0, imageType)) {
176
+ throw new Error(`Basis could not read texture "${label}" (${texture.getMimeType()}).`);
177
+ }
178
+ const [width, height] = size;
179
+ const scratch = new Uint8Array(Math.ceil(width * height * 4 * 1.5) + 64 * 1024);
180
+ const written = encoder.encode(scratch);
181
+ if (written <= 0) {
182
+ throw new Error(`The Basis encoder produced no output for texture "${label}".`);
183
+ }
184
+ return scratch.slice(0, written);
185
+ } finally {
186
+ encoder.delete();
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Re-encode every PNG/JPEG texture in a GLB as KTX2 (`KHR_texture_basisu`).
192
+ *
193
+ * Geometry is untouched — this is the texture-side sibling of the mesh codecs
194
+ * above, and the two compose. Throws, naming the file's actual contents, when
195
+ * there is nothing to compress; a partial success (some textures in a format
196
+ * Basis cannot read) is reported per texture rather than hidden.
197
+ *
198
+ * Size is NOT guaranteed to fall: UASTC is a fixed 8 bpp, so a model whose
199
+ * textures are all normal maps can grow. That is why the outcome carries the
200
+ * real before/after — the Asset Budget shows the diff and only writes after
201
+ * the user confirms it (see `optimize-apply.ts`).
202
+ */
203
+ export async function compressTexturesKtx2(
204
+ bytes: Uint8Array,
205
+ options: Ktx2Options = {},
206
+ ): Promise<Ktx2CompressionOutcome> {
207
+ const document = await readGlb(bytes);
208
+ const root = document.getRoot();
209
+ const candidates = root
210
+ .listTextures()
211
+ .filter((texture) => DECODABLE_MIME_TYPES.has(texture.getMimeType()));
212
+ if (candidates.length === 0) {
213
+ const found = root.listTextures().map((texture) => texture.getMimeType());
214
+ throw new Error(
215
+ found.length === 0
216
+ ? 'This model has no textures to compress — KTX2 compresses images, not geometry (use meshopt or Draco).'
217
+ : `None of this model's ${found.length} texture(s) is a PNG or JPEG that Basis can read (found: ${[...new Set(found)].join(', ')}).`,
218
+ );
219
+ }
220
+
221
+ const basis = await getBasisEncoder(options.artifacts);
222
+ const requested = options.mode ?? 'auto';
223
+ const textures: Ktx2TextureOutcome[] = [];
224
+
225
+ for (const texture of candidates) {
226
+ const beforeBytes = texture.getImage()?.byteLength ?? 0;
227
+ const mode = ktx2ModeFor(texture, requested);
228
+ const ktx2 = encodeTextureToKtx2(basis, texture, mode);
229
+ textures.push({
230
+ name: texture.getName(),
231
+ slots: listTextureSlots(texture),
232
+ mode,
233
+ beforeBytes,
234
+ afterBytes: ktx2.byteLength,
235
+ });
236
+ texture.setImage(ktx2).setMimeType('image/ktx2');
237
+ }
238
+
239
+ document.createExtension(KHRTextureBasisu).setRequired(true);
240
+ const outcome = await writeGlb(document, bytes.byteLength);
241
+ return { ...outcome, textures };
242
+ }
243
+
244
+ // --- simplify() → LOD levels (completes the W2b stub) ------------------------
245
+
246
+ export interface LodLevelSpec {
247
+ /** Appended to the base node name (`Crate` → `Crate_LOD1`). */
248
+ readonly suffix: string;
249
+ /** Target ratio of vertices to KEEP (0–1). */
250
+ readonly ratio: number;
251
+ /** Simplification error limit (fraction of mesh radius). */
252
+ readonly error: number;
253
+ /** Distance written into the `mesh.lod` row for this level. */
254
+ readonly distance: number;
255
+ }
256
+
257
+ /** Two-level default ladder — coarse but useful, editable per-row afterwards
258
+ * through the existing W2b distance inputs. */
259
+ export const DEFAULT_LOD_LEVELS: readonly LodLevelSpec[] = [
260
+ { suffix: '_LOD1', ratio: 0.5, error: 0.001, distance: 25 },
261
+ { suffix: '_LOD2', ratio: 0.15, error: 0.01, distance: 50 },
262
+ ];
263
+
264
+ export interface GeneratedLodLevel {
265
+ readonly node: string;
266
+ readonly ratio: number;
267
+ readonly distance: number;
268
+ /** REAL post-simplification GL-primitive count (measured, not target). */
269
+ readonly triangles: number;
270
+ }
271
+
272
+ export interface LodGenerationOutcome extends OptimizeOutcome {
273
+ readonly baseNode: string;
274
+ readonly baseTriangles: number;
275
+ readonly levels: readonly GeneratedLodLevel[];
276
+ }
277
+
278
+ function subtreeHasSkin(node: Node): boolean {
279
+ if (node.getSkin()) return true;
280
+ return node.listChildren().some(subtreeHasSkin);
281
+ }
282
+
283
+ function subtreeMeshes(node: Node): { node: Node; mesh: Mesh }[] {
284
+ const owner = node.getMesh();
285
+ return [...(owner ? [{ node, mesh: owner }] : []), ...node.listChildren().flatMap(subtreeMeshes)];
286
+ }
287
+
288
+ function subtreeTriangles(node: Node): number {
289
+ return subtreeMeshes(node).reduce(
290
+ (sum, { mesh }) =>
291
+ sum + mesh.listPrimitives().reduce((s, prim) => s + getGLPrimitiveCount(prim), 0),
292
+ 0,
293
+ );
294
+ }
295
+
296
+ /**
297
+ * Manually copy a node subtree (gltf-transform forbids `Node.clone()` —
298
+ * parent invariants): new nodes with the same TRS, every name suffixed so
299
+ * level names stay unique and `getObjectByName` stays deterministic, and an
300
+ * OWN `mesh.clone()` per mesh-bearing node so simplification never touches
301
+ * the base geometry.
302
+ */
303
+ function copySubtree(document: Document, source: Node, suffix: string): Node {
304
+ const copy = document
305
+ .createNode(source.getName() ? `${source.getName()}${suffix}` : '')
306
+ .setTranslation(source.getTranslation())
307
+ .setRotation(source.getRotation())
308
+ .setScale(source.getScale());
309
+ const mesh = source.getMesh();
310
+ if (mesh) {
311
+ // Mesh.clone() SHARES primitive references (proven by test — simplifying
312
+ // a "cloned" prim mutated the base). Build an own mesh with cloned
313
+ // primitives; accessors are still shared until simplification replaces
314
+ // them on the copy only.
315
+ const ownMesh = document.createMesh(mesh.getName() ? `${mesh.getName()}${suffix}` : '');
316
+ for (const prim of mesh.listPrimitives()) ownMesh.addPrimitive(prim.clone());
317
+ copy.setMesh(ownMesh);
318
+ }
319
+ for (const child of source.listChildren()) copy.addChild(copySubtree(document, child, suffix));
320
+ return copy;
321
+ }
322
+
323
+ /**
324
+ * Generate simplified LOD sibling nodes for `baseNodeName` inside the GLB.
325
+ * Throws (loudly, with the reason) for: missing/unnamed node, skinned
326
+ * subtrees (atomic-subtree rule — same constraint the W2b rows enforce), and
327
+ * name collisions from an earlier generation run.
328
+ */
329
+ export async function generateLodLevels(
330
+ bytes: Uint8Array,
331
+ baseNodeName: string,
332
+ levels: readonly LodLevelSpec[] = DEFAULT_LOD_LEVELS,
333
+ ): Promise<LodGenerationOutcome> {
334
+ const simplifier = await getMeshoptSimplifier();
335
+ const document = await readGlb(bytes);
336
+ const root = document.getRoot();
337
+ const baseNode = root.listNodes().find((node) => node.getName() === baseNodeName);
338
+ if (!baseNode) throw new Error(`Node "${baseNodeName}" was not found in the model.`);
339
+ if (subtreeHasSkin(baseNode)) {
340
+ throw new Error(
341
+ `Node "${baseNodeName}" is (or contains) a skinned mesh — LOD generation cannot lift a skinned subtree (atomic-subtree rule).`,
342
+ );
343
+ }
344
+ if (subtreeMeshes(baseNode).length === 0) {
345
+ throw new Error(`Node "${baseNodeName}" has no mesh to simplify.`);
346
+ }
347
+ const taken = new Set(root.listNodes().map((node) => node.getName()));
348
+ for (const level of levels) {
349
+ if (taken.has(`${baseNodeName}${level.suffix}`)) {
350
+ throw new Error(
351
+ `Node "${baseNodeName}${level.suffix}" already exists — LOD levels appear to be generated already. Remove them first to regenerate.`,
352
+ );
353
+ }
354
+ }
355
+
356
+ const baseTriangles = subtreeTriangles(baseNode);
357
+ const parents = baseNode
358
+ .listParents()
359
+ .filter((parent) => parent.propertyType === 'Node' || parent.propertyType === 'Scene');
360
+ const generated: GeneratedLodLevel[] = [];
361
+ for (const level of levels) {
362
+ const clone = copySubtree(document, baseNode, level.suffix);
363
+ for (const { mesh } of subtreeMeshes(clone)) {
364
+ // The copy owns its meshes (copySubtree) — weld + simplify in place.
365
+ for (const prim of mesh.listPrimitives()) {
366
+ weldPrimitive(prim);
367
+ simplifyPrimitive(prim, { simplifier, ratio: level.ratio, error: level.error });
368
+ }
369
+ }
370
+ for (const parent of parents) {
371
+ (parent as Node).addChild(clone);
372
+ }
373
+ generated.push({
374
+ node: `${baseNodeName}${level.suffix}`,
375
+ ratio: level.ratio,
376
+ distance: level.distance,
377
+ triangles: subtreeTriangles(clone),
378
+ });
379
+ }
380
+ // Drop any accessors orphaned by simplification's accessor replacement.
381
+ await document.transform(prune());
382
+ const outcome = await writeGlb(document, bytes.byteLength);
383
+ return { ...outcome, baseNode: baseNodeName, baseTriangles, levels: generated };
384
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Minimal pure image-header parsers for the Asset Budget model (W4a).
3
+ *
4
+ * Only formats whose dimensions can be read from a header without a decoder
5
+ * are parsed (PNG IHDR, JPEG SOFn, GIF logical screen, lossy/lossless WebP).
6
+ * Anything else returns `null` — the budget reports "unknown" rather than a
7
+ * fabricated estimate (anti-shim).
8
+ */
9
+
10
+ export interface ImageDims {
11
+ readonly width: number;
12
+ readonly height: number;
13
+ }
14
+
15
+ function parsePng(bytes: Uint8Array, view: DataView): ImageDims | null {
16
+ // 89 50 4E 47 0D 0A 1A 0A, IHDR width/height at byte 16/20 (BE).
17
+ if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) return null;
18
+ if (bytes.length < 24) return null;
19
+ return { width: view.getUint32(16), height: view.getUint32(20) };
20
+ }
21
+
22
+ function parseJpeg(bytes: Uint8Array, view: DataView): ImageDims | null {
23
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
24
+ let offset = 2;
25
+ while (offset + 9 < bytes.length) {
26
+ if (bytes[offset] !== 0xff) return null;
27
+ const marker = bytes[offset + 1]!;
28
+ // SOFn markers C0–CF except C4 (DHT), C8 (JPG), CC (DAC).
29
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc)
30
+ return { width: view.getUint16(offset + 7), height: view.getUint16(offset + 5) };
31
+ offset += 2 + view.getUint16(offset + 2);
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function parseGif(bytes: Uint8Array, view: DataView): ImageDims | null {
37
+ if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46) return null;
38
+ return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
39
+ }
40
+
41
+ function parseWebp(bytes: Uint8Array, view: DataView): ImageDims | null {
42
+ const tag = (offset: number, text: string): boolean =>
43
+ text.split('').every((char, index) => bytes[offset + index] === char.charCodeAt(0));
44
+ if (!tag(0, 'RIFF') || !tag(8, 'WEBP') || bytes.length < 30) return null;
45
+ if (tag(12, 'VP8 ')) {
46
+ return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };
47
+ }
48
+ if (tag(12, 'VP8L')) {
49
+ const b0 = bytes[21]!;
50
+ const b1 = bytes[22]!;
51
+ const b2 = bytes[23]!;
52
+ const b3 = bytes[24]!;
53
+ return {
54
+ width: 1 + (((b1 & 0x3f) << 8) | b0),
55
+ height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)),
56
+ };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ export function parseImageDims(bytes: Uint8Array): ImageDims | null {
62
+ if (bytes.length < 16) return null;
63
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
64
+ for (const parse of [parsePng, parseJpeg, parseGif, parseWebp]) {
65
+ const dims = parse(bytes, view);
66
+ if (dims) return dims;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Estimated GPU upload size for an uncompressed RGBA texture of these
73
+ * dimensions with a full mip chain (×4/3) — the same convention
74
+ * gltf-transform's `inspect()` uses for its texture `gpuSize`.
75
+ */
76
+ export function estimateImageGpuBytes(dims: ImageDims): number {
77
+ return Math.ceil(dims.width * dims.height * 4 * (4 / 3));
78
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Storage-side APPLY flow for Asset Budget optimizations (W4a M3).
3
+ *
4
+ * One place owns "optimized bytes land in the project": write through the
5
+ * storage seam, stamp central provenance (`.vgai/provenance.json`, the SAME
6
+ * document/idiom the generative-assets pipeline stamps and
7
+ * `project-provenance.ts` projects — one operation with an `outputs` entry
8
+ * carrying path/bytes/sha256), invalidate the engine's URL-keyed asset cache
9
+ * for the rewritten file, and fire the editor's asset-mutation events so open
10
+ * viewers re-read.
11
+ *
12
+ * Undo stance (decided per repo patterns): the editor undo stack is 50-deep
13
+ * SCENE-DOCUMENT snapshots and never covers binary asset bytes, so binary
14
+ * rewrites are CONFIRMED-DESTRUCTIVE — the UI shows the real before → after
15
+ * size diff and only calls this after explicit confirmation. LOD generation
16
+ * is additive (base geometry untouched; levels are new named siblings), and
17
+ * its scene-side `mesh.lod` write goes through the store and IS undoable.
18
+ *
19
+ * A failed provenance stamp never silently vanishes: the write has already
20
+ * happened, so the failure is returned for the UI to surface.
21
+ */
22
+
23
+ import { sha256Hex } from '@volter/editor-core/bytes-codec';
24
+ import { assertEditorServerAnswered } from '@volter/editor-core/editor-server-response';
25
+ import { sourceMutationAttribution } from '@volter/editor-core/editor-session-attribution';
26
+ import { handleProjectMutationFailure } from '@volter/editor-core/source-conflict';
27
+ import type { StorageBackend } from '@volter/editor-core/storage/types';
28
+ import { invalidateCachedAsset } from '@volter/threejs-runtime/asset-loaders';
29
+
30
+ const PROVENANCE_PATH = '.vgai/provenance.json';
31
+
32
+ /** Serving path (`/models/x.glb`) or bare path → storage path under public/. */
33
+ export function assetStoragePath(path: string): string {
34
+ const clean = path.split(/[?#]/, 1)[0]!.replace(/^\/+/, '');
35
+ return clean.startsWith('public/') ? clean : `public/${clean}`;
36
+ }
37
+
38
+ /** Storage path → runtime serving path (`public/models/x.glb` → `/models/x.glb`). */
39
+ export function assetServingPath(storagePath: string): string {
40
+ return storagePath.startsWith('public/')
41
+ ? `/${storagePath.slice('public/'.length)}`
42
+ : `/${storagePath}`;
43
+ }
44
+
45
+ /**
46
+ * Resolve a serving/scene `src` (or bare path) to the path THIS backend
47
+ * actually stores it under, by probing both spellings: the live backend is
48
+ * serving-rooted (`HttpStorage` is hard-rooted at `public/`), while a
49
+ * project-rooted one (a `MemStorage` fixture) prefixes `public/`. Throws
50
+ * naming both candidates when neither exists — never guesses.
51
+ */
52
+ export async function resolveAssetStoragePath(
53
+ backend: StorageBackend,
54
+ path: string,
55
+ ): Promise<string> {
56
+ const bare = path.split(/[?#]/, 1)[0]!.replace(/^\.\//, '').replace(/^\/+/, '');
57
+ const candidates = bare.startsWith('public/')
58
+ ? [bare, bare.slice('public/'.length)]
59
+ : [bare, `public/${bare}`];
60
+ for (const candidate of candidates) {
61
+ if (await backend.exists(candidate)) return candidate;
62
+ }
63
+ throw new Error(`Asset not found in project storage (tried: ${candidates.join(', ')})`);
64
+ }
65
+
66
+ /**
67
+ * The central provenance ledger's project-rooted path spelling (the pipeline
68
+ * idiom `project-provenance.ts` also forces): a serving-rooted storage path
69
+ * (`models/x.glb`) is recorded as its on-disk `public/models/x.glb` truth.
70
+ */
71
+ function ledgerAssetPath(storagePath: string): string {
72
+ return storagePath.startsWith('public/') ? storagePath : `public/${storagePath}`;
73
+ }
74
+
75
+ /**
76
+ * Ledger IO honoring the backend's reach: server-mode `HttpStorage` is
77
+ * hard-rooted at `public/`, so writing `.vgai/provenance.json` through it
78
+ * would land at `public/.vgai/provenance.json` — a divergent second ledger
79
+ * (the exact T3.3 "two-`.vgai`-dirs" bug class). The dev server's
80
+ * `.vgai/`-scoped `/__editor/vgai-file` GET/POST route pair is the recorded
81
+ * fix for that class; every other
82
+ * backend reaches the project root directly.
83
+ */
84
+ async function readLedgerText(backend: StorageBackend): Promise<string | null> {
85
+ if (backend.id === 'http') {
86
+ // NOT read through `editor-server-response.ts`: the GET half of this route
87
+ // answers `text/plain` (routes/project-source.ts) because it serves raw
88
+ // file bytes, and that reader's whole rule is "the editor server answers
89
+ // JSON" — it would reject this route's successes.
90
+ const res = await fetch(
91
+ `/__editor/vgai-file?${new URLSearchParams({ path: PROVENANCE_PATH })}`,
92
+ );
93
+ if (res.status === 404) return null;
94
+ if (!res.ok) throw new Error(`read ${PROVENANCE_PATH}: HTTP ${res.status}`);
95
+ return res.text();
96
+ }
97
+ if (!(await backend.exists(PROVENANCE_PATH))) return null;
98
+ return backend.read(PROVENANCE_PATH);
99
+ }
100
+
101
+ async function writeLedgerText(backend: StorageBackend, content: string): Promise<void> {
102
+ if (backend.id === 'http') {
103
+ const write = async () => {
104
+ const res = await fetch('/__editor/vgai-file', {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ path: PROVENANCE_PATH, content, ...sourceMutationAttribution() }),
108
+ });
109
+ // The POST half DOES answer JSON, so this one is read: `res.ok` alone
110
+ // accepted a page fallback and the provenance ledger write vanished.
111
+ assertEditorServerAnswered(res, `Write ${PROVENANCE_PATH} failed`);
112
+ if (!res.ok) {
113
+ await handleProjectMutationFailure(res, {
114
+ label: `Write ${PROVENANCE_PATH}`,
115
+ attempted: { [PROVENANCE_PATH]: content },
116
+ reapply: write,
117
+ });
118
+ }
119
+ };
120
+ await write();
121
+ return;
122
+ }
123
+ await backend.write(PROVENANCE_PATH, content);
124
+ }
125
+
126
+ export interface OptimizeStampInput {
127
+ /** Provenance operation name, e.g. `asset-budget.meshopt`. */
128
+ readonly operationName: string;
129
+ readonly storagePath: string;
130
+ readonly beforeBytes: number;
131
+ readonly beforeSha256: string;
132
+ /** Operation parameters recorded as provenance `input` (with the source). */
133
+ readonly params?: Record<string, unknown>;
134
+ }
135
+
136
+ /**
137
+ * Append one operation to the central provenance document. Throws when the
138
+ * existing document is unreadable (never clobber a ledger we cannot parse).
139
+ */
140
+ export async function stampOptimizeProvenance(
141
+ backend: StorageBackend,
142
+ input: OptimizeStampInput,
143
+ outputBytes: Uint8Array,
144
+ ): Promise<void> {
145
+ let document: { version: 1; operations: Record<string, unknown> } = {
146
+ version: 1,
147
+ operations: {},
148
+ };
149
+ const existing = await readLedgerText(backend);
150
+ if (existing !== null) {
151
+ const parsed: unknown = JSON.parse(existing);
152
+ if (
153
+ !parsed ||
154
+ typeof parsed !== 'object' ||
155
+ typeof (parsed as { operations?: unknown }).operations !== 'object'
156
+ ) {
157
+ throw new Error(`${PROVENANCE_PATH} exists but is not a provenance document`);
158
+ }
159
+ document = parsed as typeof document;
160
+ }
161
+ const operationId = `asset-budget-${crypto.randomUUID()}`;
162
+ document.operations[operationId] = {
163
+ createdAt: new Date().toISOString(),
164
+ operation: { name: input.operationName, source: 'editor:asset-budget' },
165
+ input: {
166
+ path: ledgerAssetPath(input.storagePath),
167
+ bytes: input.beforeBytes,
168
+ sha256: input.beforeSha256,
169
+ ...(input.params ? { params: input.params } : {}),
170
+ },
171
+ outputs: [
172
+ {
173
+ path: ledgerAssetPath(input.storagePath),
174
+ bytes: outputBytes.byteLength,
175
+ sha256: await sha256Hex(outputBytes),
176
+ mediaType: 'model/gltf-binary',
177
+ role: 'asset',
178
+ },
179
+ ],
180
+ };
181
+ await writeLedgerText(backend, `${JSON.stringify(document, null, 2)}\n`);
182
+ }
183
+
184
+ export interface ApplyOptimizedResult {
185
+ /** Set when the FILE write succeeded but the provenance stamp failed. */
186
+ readonly stampError?: string;
187
+ }
188
+
189
+ /**
190
+ * Land optimized bytes: write → stamp → cache-invalidate → notify. Callers
191
+ * confirmed the diff already (module header). Returns any stamp failure for
192
+ * the UI to surface honestly.
193
+ */
194
+ export async function applyOptimizedBytes(
195
+ backend: StorageBackend,
196
+ stamp: OptimizeStampInput,
197
+ outputBytes: Uint8Array,
198
+ ): Promise<ApplyOptimizedResult> {
199
+ await backend.write(stamp.storagePath, outputBytes);
200
+ let stampError: string | undefined;
201
+ try {
202
+ await stampOptimizeProvenance(backend, stamp, outputBytes);
203
+ } catch (error) {
204
+ stampError = error instanceof Error ? error.message : String(error);
205
+ }
206
+ // The engine gltf cache is keyed by the URL the scene used — both the
207
+ // serving spelling and any bare spelling must drop.
208
+ const serving = assetServingPath(stamp.storagePath);
209
+ invalidateCachedAsset(serving);
210
+ invalidateCachedAsset(serving.replace(/^\/+/, ''));
211
+ invalidateCachedAsset(stamp.storagePath);
212
+ if (typeof window !== 'undefined') {
213
+ window.dispatchEvent(
214
+ new CustomEvent('editor:asset-mutation', {
215
+ detail: { type: 'change', paths: [stamp.storagePath, serving] },
216
+ }),
217
+ );
218
+ window.dispatchEvent(new CustomEvent('editor:assets-changed'));
219
+ }
220
+ return stampError ? { stampError } : {};
221
+ }