@volter/editor-core 0.5.57

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 (719) hide show
  1. package/BUNDLED_NOTICES +1527 -0
  2. package/LICENSE +686 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/NOTICE +18 -0
  5. package/README.md +24 -0
  6. package/dist/build/chunk-JS4D4XZ3.js +175 -0
  7. package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
  8. package/dist/build/vite-plugin-product-contributions.js +100 -0
  9. package/dist/build/vite-plugin-product-contributions.js.map +7 -0
  10. package/dist/build/vite-plugin-shared-react.js +25 -0
  11. package/dist/build/vite-plugin-shared-react.js.map +7 -0
  12. package/dist/build/vite-plugin-shared-three.js +86 -0
  13. package/dist/build/vite-plugin-shared-three.js.map +7 -0
  14. package/dist/server/chunk-KMJC7AGO.js +98 -0
  15. package/dist/server/chunk-KMJC7AGO.js.map +7 -0
  16. package/dist/server/chunk-PSILUMGS.js +46 -0
  17. package/dist/server/chunk-PSILUMGS.js.map +7 -0
  18. package/dist/server/frame-proxy.js +255 -0
  19. package/dist/server/frame-proxy.js.map +7 -0
  20. package/dist/server/open-browser.js +53 -0
  21. package/dist/server/open-browser.js.map +7 -0
  22. package/dist/server/process-shutdown.js +127 -0
  23. package/dist/server/process-shutdown.js.map +7 -0
  24. package/dist/server/session-registry.js +141 -0
  25. package/dist/server/session-registry.js.map +7 -0
  26. package/dist/server/spawn-opener.js +11 -0
  27. package/dist/server/spawn-opener.js.map +7 -0
  28. package/dist/server/worktree-identity.js +11 -0
  29. package/dist/server/worktree-identity.js.map +7 -0
  30. package/dist-server/packaged.mjs +88816 -0
  31. package/package.json +118 -0
  32. package/scripts/build-plugins.mjs +14 -0
  33. package/scripts/build-server.mjs +26 -0
  34. package/scripts/build-session.mjs +9 -0
  35. package/server/account-credentials.ts +60 -0
  36. package/server/account-service.ts +1434 -0
  37. package/server/adapter-region-includes.ts +47 -0
  38. package/server/asset-catalog-v2.ts +175 -0
  39. package/server/asset-history-snapshots.ts +196 -0
  40. package/server/asset-ledger-store.ts +195 -0
  41. package/server/asset-library-routes.ts +1595 -0
  42. package/server/blender-wasm-artifact.ts +430 -0
  43. package/server/boot-timings.ts +106 -0
  44. package/server/canonical-path.ts +50 -0
  45. package/server/checkout-workspace-preflight.ts +349 -0
  46. package/server/cloud-asset-catalog.ts +158 -0
  47. package/server/coding-inference-launch.ts +237 -0
  48. package/server/collaboration-account-client.ts +202 -0
  49. package/server/collaboration-attribution.ts +122 -0
  50. package/server/collaboration-session.ts +934 -0
  51. package/server/comfyui-bridge.ts +248 -0
  52. package/server/console-ledger.ts +499 -0
  53. package/server/creation-site-transform.ts +304 -0
  54. package/server/creation-site-write.ts +648 -0
  55. package/server/data-file-serialize.ts +158 -0
  56. package/server/editor-brand-html.ts +72 -0
  57. package/server/editor-control-socket.ts +366 -0
  58. package/server/editor-server-options.ts +190 -0
  59. package/server/editor-server.ts +1483 -0
  60. package/server/editor-sse.ts +352 -0
  61. package/server/engine-provenance.ts +169 -0
  62. package/server/engine-source-restart.ts +117 -0
  63. package/server/frame-bridge.ts +115 -0
  64. package/server/frame-proxy.ts +490 -0
  65. package/server/frame-workbench.ts +259 -0
  66. package/server/frontend-handoff.ts +229 -0
  67. package/server/game-globals-shadow.ts +113 -0
  68. package/server/gameplay-session-retention.ts +149 -0
  69. package/server/gameplay-sessions.ts +123 -0
  70. package/server/generation-jobs.ts +201 -0
  71. package/server/generation-reconciler.ts +137 -0
  72. package/server/generative-execution-context.ts +59 -0
  73. package/server/git-workflow.ts +612 -0
  74. package/server/harness-chat-caller.ts +36 -0
  75. package/server/harness-chat-service.ts +1681 -0
  76. package/server/idle-shutdown.ts +170 -0
  77. package/server/js-profiling-policy.ts +52 -0
  78. package/server/launcher-settings.ts +84 -0
  79. package/server/local-asset-catalog.ts +410 -0
  80. package/server/managed-account-defaults.ts +22 -0
  81. package/server/mock-control-plane.ts +540 -0
  82. package/server/model-import-conversion.ts +345 -0
  83. package/server/native-credential-store.ts +162 -0
  84. package/server/open-browser.ts +118 -0
  85. package/server/packaged.ts +1192 -0
  86. package/server/play-stall.ts +126 -0
  87. package/server/process-shutdown.ts +218 -0
  88. package/server/product-presets.ts +55 -0
  89. package/server/project-build-artifact.ts +95 -0
  90. package/server/project-components.ts +100 -0
  91. package/server/project-dependency-invalidation.ts +114 -0
  92. package/server/project-file-scan.ts +235 -0
  93. package/server/project-hmr-files.ts +215 -0
  94. package/server/project-install-roots.ts +210 -0
  95. package/server/project-kinds.ts +71 -0
  96. package/server/project-mcp-servers.ts +82 -0
  97. package/server/project-module-freshness.ts +109 -0
  98. package/server/project-module-instance.ts +375 -0
  99. package/server/project-optimize-deps-entries.ts +827 -0
  100. package/server/project-output-writer.ts +679 -0
  101. package/server/project-package-origin.ts +63 -0
  102. package/server/project-root-surface.ts +831 -0
  103. package/server/project-scratch-path.ts +47 -0
  104. package/server/project-script-hmr.ts +619 -0
  105. package/server/project-serving-plugins.ts +236 -0
  106. package/server/project-tools.ts +653 -0
  107. package/server/project-validation.ts +250 -0
  108. package/server/project-verbs.ts +127 -0
  109. package/server/project-view.ts +52 -0
  110. package/server/project-watch.ts +1134 -0
  111. package/server/project-work-coordinator.ts +288 -0
  112. package/server/provider-credentials.ts +331 -0
  113. package/server/recent-projects-store.ts +98 -0
  114. package/server/redact-secrets.ts +71 -0
  115. package/server/repository-presence.ts +267 -0
  116. package/server/routes/account.ts +357 -0
  117. package/server/routes/agents.ts +60 -0
  118. package/server/routes/assets.ts +154 -0
  119. package/server/routes/build.ts +13 -0
  120. package/server/routes/collaboration.ts +595 -0
  121. package/server/routes/configurations.ts +419 -0
  122. package/server/routes/context.ts +264 -0
  123. package/server/routes/control-plane.ts +1811 -0
  124. package/server/routes/logs.ts +272 -0
  125. package/server/routes/project-identity.ts +302 -0
  126. package/server/routes/project-open.ts +429 -0
  127. package/server/routes/project-source.ts +686 -0
  128. package/server/routes/project-state.ts +383 -0
  129. package/server/routes/relay.ts +1029 -0
  130. package/server/routes/route-helpers.ts +29 -0
  131. package/server/routes/served-modules.ts +138 -0
  132. package/server/routes/session-tabs.ts +196 -0
  133. package/server/routes/settings.ts +121 -0
  134. package/server/routes/share-control.ts +486 -0
  135. package/server/routes/themes.ts +116 -0
  136. package/server/routes/tools.ts +230 -0
  137. package/server/routes/worktrees.ts +595 -0
  138. package/server/scoped-game-css.ts +461 -0
  139. package/server/server-utils.ts +1194 -0
  140. package/server/session-product.ts +112 -0
  141. package/server/session-registry.ts +230 -0
  142. package/server/share-claims.ts +138 -0
  143. package/server/share-host.ts +526 -0
  144. package/server/share-session-gateway.ts +1362 -0
  145. package/server/share-tunnel.ts +123 -0
  146. package/server/spawn-opener.ts +74 -0
  147. package/server/support/play/log-format.ts +76 -0
  148. package/server/support/play/session-record.ts +199 -0
  149. package/server/support/project/build-discipline.ts +718 -0
  150. package/server/support/project/inspection-node.ts +119 -0
  151. package/server/support/project/log-naming.ts +54 -0
  152. package/server/support/project/provenance.ts +290 -0
  153. package/server/support/project/run-name.ts +18 -0
  154. package/server/support/project/session-journal.ts +830 -0
  155. package/server/support/project/shared.ts +327 -0
  156. package/server/tab-bootstrap.ts +62 -0
  157. package/server/tab-heartbeat.ts +477 -0
  158. package/server/tab-lifecycle.ts +550 -0
  159. package/server/tab-presence.ts +1508 -0
  160. package/server/team-agent-mirror.ts +95 -0
  161. package/server/twin-auth.ts +138 -0
  162. package/server/vendored-lock-recorder.ts +968 -0
  163. package/server/worktree-identity.ts +121 -0
  164. package/server/worktree-management.ts +398 -0
  165. package/server/worktree-retention.d.mts +13 -0
  166. package/server/worktree-retention.mjs +205 -0
  167. package/src/EditorContext.tsx +169 -0
  168. package/src/account.ts +333 -0
  169. package/src/action-registry.ts +532 -0
  170. package/src/active-product.ts +74 -0
  171. package/src/active-project.ts +155 -0
  172. package/src/adapter-editor-config.ts +25 -0
  173. package/src/adapter-observation.ts +49 -0
  174. package/src/animation/stage-transport.ts +379 -0
  175. package/src/animation/three-clips-subject.ts +172 -0
  176. package/src/api/asset-library-wire.ts +45 -0
  177. package/src/api/assets.ts +365 -0
  178. package/src/api/base.ts +10 -0
  179. package/src/api/build.ts +99 -0
  180. package/src/api/git-wire.ts +56 -0
  181. package/src/api/logs.ts +92 -0
  182. package/src/api/project-identity.ts +74 -0
  183. package/src/api/project-open.ts +355 -0
  184. package/src/api/project-source.ts +162 -0
  185. package/src/api/project-state.ts +107 -0
  186. package/src/api/relay.ts +268 -0
  187. package/src/api/settings.ts +36 -0
  188. package/src/api/themes.ts +45 -0
  189. package/src/api/worktrees.ts +257 -0
  190. package/src/asset-compare-core.ts +171 -0
  191. package/src/asset-compare.ts +294 -0
  192. package/src/asset-editor-context.tsx +100 -0
  193. package/src/asset-events.ts +97 -0
  194. package/src/asset-inspector-actions.ts +87 -0
  195. package/src/asset-preview-framing.ts +357 -0
  196. package/src/asset-preview.ts +2802 -0
  197. package/src/asset-selection-viewer-registry.ts +113 -0
  198. package/src/asset-selection.ts +133 -0
  199. package/src/asset-workflow/asset-capabilities.ts +344 -0
  200. package/src/asset-workflow/asset-import-jobs.ts +106 -0
  201. package/src/asset-workflow/asset-ledger-backend.ts +126 -0
  202. package/src/asset-workflow/asset-ledger.ts +156 -0
  203. package/src/asset-workflow/asset-materialization-report.ts +140 -0
  204. package/src/asset-workflow/asset-pack-manifest.ts +320 -0
  205. package/src/asset-workflow/asset-types.ts +142 -0
  206. package/src/asset-workflow/asset-workflow-quality.ts +61 -0
  207. package/src/asset-workflow/audio-preview-player.ts +193 -0
  208. package/src/asset-workflow/audio-waveform.ts +22 -0
  209. package/src/asset-workflow/cloud-asset-client.ts +263 -0
  210. package/src/asset-workflow/folder-preview.ts +345 -0
  211. package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
  212. package/src/asset-workflow/image-view-scale.ts +32 -0
  213. package/src/asset-workflow/import-contract.ts +124 -0
  214. package/src/asset-workflow/ledger-write-lock.ts +244 -0
  215. package/src/asset-workflow/model-inspection.ts +818 -0
  216. package/src/asset-workflow/pixi-spritesheet.ts +197 -0
  217. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  218. package/src/asset-workflow/project-asset-commands.ts +23 -0
  219. package/src/asset-workflow/project-asset-health.ts +139 -0
  220. package/src/asset-workflow/project-asset-operations.ts +154 -0
  221. package/src/asset-workflow/project-asset-roots.ts +68 -0
  222. package/src/asset-workflow/project-content.ts +288 -0
  223. package/src/asset-workflow/project-source-index.ts +550 -0
  224. package/src/asset-workflow/thumbnail-system.ts +256 -0
  225. package/src/authoring/active-adapter.ts +199 -0
  226. package/src/authoring/active-systems.ts +422 -0
  227. package/src/authoring/adapter-key.ts +18 -0
  228. package/src/authoring/authoring-asset-url.ts +27 -0
  229. package/src/authoring/bootstrap-state.ts +49 -0
  230. package/src/authoring/boundary-authoring-adapter.ts +184 -0
  231. package/src/authoring/breakpoint-state.ts +43 -0
  232. package/src/authoring/canvas-scene-guides.ts +76 -0
  233. package/src/authoring/component-instance-root.ts +171 -0
  234. package/src/authoring/composite-authoring-adapter.ts +2110 -0
  235. package/src/authoring/consumer-actions.ts +520 -0
  236. package/src/authoring/css-numeric-style.ts +97 -0
  237. package/src/authoring/design-time-layers.ts +849 -0
  238. package/src/authoring/design-time-mount-registry.ts +235 -0
  239. package/src/authoring/design-time-settle.ts +343 -0
  240. package/src/authoring/edit-mode-authoring.ts +619 -0
  241. package/src/authoring/eyedropper-session.ts +60 -0
  242. package/src/authoring/instance-source-menu-register.ts +13 -0
  243. package/src/authoring/instance-source-menu.ts +135 -0
  244. package/src/authoring/layered-pick.ts +183 -0
  245. package/src/authoring/live-gesture-lock.ts +50 -0
  246. package/src/authoring/live-object-transform.ts +62 -0
  247. package/src/authoring/mount-failure-report.ts +154 -0
  248. package/src/authoring/mounted-root-subjects.ts +144 -0
  249. package/src/authoring/no-authoring-adapter.ts +55 -0
  250. package/src/authoring/null-inspection-subjects.tsx +75 -0
  251. package/src/authoring/object3d-document-persistence.ts +106 -0
  252. package/src/authoring/object3d-document-session-registry.ts +120 -0
  253. package/src/authoring/object3d-document-session.ts +1326 -0
  254. package/src/authoring/object3d-gesture-controller.ts +113 -0
  255. package/src/authoring/panel-authoring.ts +121 -0
  256. package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
  257. package/src/authoring/prefab-instance-section-model.ts +55 -0
  258. package/src/authoring/project-authoring-session.ts +105 -0
  259. package/src/authoring/provenance.ts +99 -0
  260. package/src/authoring/quarks-particle-systems.ts +19 -0
  261. package/src/authoring/react-canvas-navigation.ts +255 -0
  262. package/src/authoring/react-design-canvas-style.ts +20 -0
  263. package/src/authoring/react-story-board.ts +937 -0
  264. package/src/authoring/selection-scope.ts +195 -0
  265. package/src/authoring/shell-document-ops.ts +169 -0
  266. package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
  267. package/src/authoring/shell-viewport-policy.ts +48 -0
  268. package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
  269. package/src/authoring/stories-scope.ts +35 -0
  270. package/src/authoring/story-board-chrome-fit.ts +107 -0
  271. package/src/authoring/story-board-presentation.ts +111 -0
  272. package/src/authoring/three-projection-core.ts +226 -0
  273. package/src/authoring/viewport-pick-context.ts +40 -0
  274. package/src/authoring/viewport-raycast.ts +240 -0
  275. package/src/authoring/viewport-tool-context.ts +73 -0
  276. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  277. package/src/authoring/world-hidden-viewport.ts +152 -0
  278. package/src/authoring/world-pan-state.ts +198 -0
  279. package/src/authoring/world-session-state.ts +101 -0
  280. package/src/authoring/write-pipe.ts +173 -0
  281. package/src/availability-tick.ts +66 -0
  282. package/src/bitmap-label.ts +120 -0
  283. package/src/blender-tab-metrics.ts +161 -0
  284. package/src/board-open-actions.ts +20 -0
  285. package/src/boot-routing.ts +391 -0
  286. package/src/build-identity.ts +15 -0
  287. package/src/bytes-codec.ts +62 -0
  288. package/src/camera-authoring.ts +175 -0
  289. package/src/cancellation-reason.ts +58 -0
  290. package/src/canvas-preview-frames.ts +482 -0
  291. package/src/capture-camera-pose.ts +77 -0
  292. package/src/chrome-registry.ts +159 -0
  293. package/src/chrome-slot-registry.ts +91 -0
  294. package/src/collaboration-client.ts +264 -0
  295. package/src/collaboration-presence.ts +41 -0
  296. package/src/command-dispatch.ts +19 -0
  297. package/src/command-listener.ts +2743 -0
  298. package/src/command-registry.ts +70 -0
  299. package/src/component-board-registry.ts +205 -0
  300. package/src/component-states-registry.ts +200 -0
  301. package/src/components/AgentPresentationNotice.tsx +75 -0
  302. package/src/components/AlignToolbar.tsx +204 -0
  303. package/src/components/AppRoot.tsx +394 -0
  304. package/src/components/ApplicationMenus.tsx +415 -0
  305. package/src/components/AssetBrowser.tsx +2843 -0
  306. package/src/components/AssetEditorShell.tsx +216 -0
  307. package/src/components/AssetInspectorToolSection.tsx +124 -0
  308. package/src/components/BoardRulers.tsx +354 -0
  309. package/src/components/CameraInfo.tsx +81 -0
  310. package/src/components/CanvasSceneViewport.tsx +556 -0
  311. package/src/components/CapabilityCoverageSection.tsx +185 -0
  312. package/src/components/CenterDocuments.tsx +22 -0
  313. package/src/components/ChromeSlot.tsx +20 -0
  314. package/src/components/CodeView.tsx +470 -0
  315. package/src/components/CollaborationHeaderControl.css +74 -0
  316. package/src/components/CompactInspectorCard.tsx +190 -0
  317. package/src/components/CompactInspectorShell.tsx +39 -0
  318. package/src/components/ConsolePanel.css +55 -0
  319. package/src/components/ConsolePanel.tsx +270 -0
  320. package/src/components/DefaultEditorLayout.tsx +106 -0
  321. package/src/components/DocumentHeaderStrip.tsx +96 -0
  322. package/src/components/DocumentShelfRail.tsx +45 -0
  323. package/src/components/DocumentThumbnail.tsx +345 -0
  324. package/src/components/EditorLeaseGuard.tsx +372 -0
  325. package/src/components/ErrorBanner.tsx +123 -0
  326. package/src/components/FolderPreviewTile.tsx +159 -0
  327. package/src/components/GameHierarchy.tsx +3842 -0
  328. package/src/components/GameplaySessionTimeline.tsx +295 -0
  329. package/src/components/InspectionProjection.tsx +968 -0
  330. package/src/components/Inspector.tsx +233 -0
  331. package/src/components/InspectorCanvasPreview.tsx +35 -0
  332. package/src/components/InspectorFieldsSection.tsx +290 -0
  333. package/src/components/InspectorObjectPreview.tsx +57 -0
  334. package/src/components/InspectorStoriesSection.tsx +92 -0
  335. package/src/components/InspectorToolSection.tsx +96 -0
  336. package/src/components/InspectorTransformSection.tsx +245 -0
  337. package/src/components/LightExplorerPanel.tsx +433 -0
  338. package/src/components/MediaProperties.tsx +145 -0
  339. package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
  340. package/src/components/Object3DDocumentToolbar.css +100 -0
  341. package/src/components/Object3DDocumentToolbar.tsx +454 -0
  342. package/src/components/Object3DDocumentViewport.tsx +58 -0
  343. package/src/components/ProjectHeader.css +617 -0
  344. package/src/components/ProjectHeader.tsx +328 -0
  345. package/src/components/ProjectLayout.tsx +249 -0
  346. package/src/components/ReactCanvasControls.tsx +284 -0
  347. package/src/components/RootSelectionOverlay.tsx +3162 -0
  348. package/src/components/RootTextEditor.tsx +79 -0
  349. package/src/components/SaveStatus.tsx +70 -0
  350. package/src/components/StageHost.tsx +1954 -0
  351. package/src/components/StageOverlays.tsx +21 -0
  352. package/src/components/StartupErrorScreen.tsx +76 -0
  353. package/src/components/StartupLoadingScreen.tsx +65 -0
  354. package/src/components/StatsOverlay.tsx +78 -0
  355. package/src/components/SurfaceStateOverlay.tsx +24 -0
  356. package/src/components/ToolContributionSurfaces.tsx +65 -0
  357. package/src/components/ToolHost.tsx +370 -0
  358. package/src/components/ToolObject3DPreview.tsx +39 -0
  359. package/src/components/Toolbar.tsx +595 -0
  360. package/src/components/TransientHint.tsx +44 -0
  361. package/src/components/TransportStrip.tsx +174 -0
  362. package/src/components/VersionControlSection.tsx +470 -0
  363. package/src/components/VgaiLogo.css +83 -0
  364. package/src/components/VgaiLogo.tsx +35 -0
  365. package/src/components/ViewportControlsHint.tsx +60 -0
  366. package/src/components/ViewportFurniture.tsx +401 -0
  367. package/src/components/ViewportOverlay.tsx +145 -0
  368. package/src/components/ViewportOverlaysMenu.tsx +153 -0
  369. package/src/components/ViewportShadingMenu.tsx +300 -0
  370. package/src/components/ViewportViewMenu.tsx +101 -0
  371. package/src/components/WorkspaceDocumentSurface.tsx +204 -0
  372. package/src/components/WorkspaceUtilitySurface.tsx +22 -0
  373. package/src/components/WorktreeSwitcher.css +239 -0
  374. package/src/components/WorktreeSwitcher.tsx +890 -0
  375. package/src/components/account-documents.tsx +1162 -0
  376. package/src/components/asset-documents.tsx +812 -0
  377. package/src/components/asset-editor-persistence.ts +216 -0
  378. package/src/components/asset-selection-section.tsx +535 -0
  379. package/src/components/asset-thumbnails.tsx +312 -0
  380. package/src/components/asset-viewers/AudioViewer.tsx +201 -0
  381. package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
  382. package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  383. package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  384. package/src/components/asset-viewers/ImageViewer.tsx +300 -0
  385. package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
  386. package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
  387. package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
  388. package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  389. package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
  390. package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
  391. package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
  392. package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
  393. package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  394. package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
  395. package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  396. package/src/components/asset-viewers/VideoViewer.tsx +101 -0
  397. package/src/components/asset-viewers/shader-source.ts +144 -0
  398. package/src/components/asset-workflow.css +731 -0
  399. package/src/components/board-guides.ts +150 -0
  400. package/src/components/compact-inspector.css +539 -0
  401. package/src/components/core-utilities.tsx +90 -0
  402. package/src/components/editor-notifications.css +40 -0
  403. package/src/components/engine-workspace.css +274 -0
  404. package/src/components/inspector-preview-section.tsx +238 -0
  405. package/src/components/inspector-property-grouping.ts +64 -0
  406. package/src/components/inspector-revert-label.ts +20 -0
  407. package/src/components/inspector-selection.ts +42 -0
  408. package/src/components/inspector-stories-gating.ts +171 -0
  409. package/src/components/inspector-transform-subject.ts +11 -0
  410. package/src/components/inspector-transform.ts +75 -0
  411. package/src/components/kind-documents.tsx +500 -0
  412. package/src/components/palette-action-publisher.tsx +140 -0
  413. package/src/components/primitives/DraftColorInput.tsx +74 -0
  414. package/src/components/product-shell.css +1829 -0
  415. package/src/components/project-tool-documents.tsx +410 -0
  416. package/src/components/scene-documents.tsx +223 -0
  417. package/src/components/stage-keyboard.tsx +37 -0
  418. package/src/components/stage-overlay-set.tsx +107 -0
  419. package/src/components/stage-presence-markers.ts +478 -0
  420. package/src/components/standard-viewport-dressing.ts +417 -0
  421. package/src/components/status-contributions.tsx +403 -0
  422. package/src/components/tool-documents.tsx +309 -0
  423. package/src/components/tool-schema-form.tsx +262 -0
  424. package/src/components/use-after-paint.ts +41 -0
  425. package/src/components/use-project-image-assets.ts +86 -0
  426. package/src/components/viewport-header-controls.css +90 -0
  427. package/src/components/viewport-surface-status.tsx +55 -0
  428. package/src/components/workspace-history.ts +32 -0
  429. package/src/components/workspace-static-panel-registry.tsx +152 -0
  430. package/src/components/workspace-surfaces.css +621 -0
  431. package/src/components/world-documents.tsx +586 -0
  432. package/src/components/world-overlay-gestures.ts +1694 -0
  433. package/src/composite-screenshot.ts +1641 -0
  434. package/src/console-sync.ts +131 -0
  435. package/src/constraint-helper.ts +338 -0
  436. package/src/content-entry-source-registry.ts +184 -0
  437. package/src/coverage/authoring-seam-evidence.ts +300 -0
  438. package/src/coverage/canvas-reveal.ts +192 -0
  439. package/src/coverage/design-time-surfaces.ts +101 -0
  440. package/src/coverage/live-seam-evidence.ts +11 -0
  441. package/src/coverage/ontology-invariants.ts +466 -0
  442. package/src/coverage/session-vitals.ts +501 -0
  443. package/src/coverage/system-seam-evidence.ts +72 -0
  444. package/src/crash-null-boundary.ts +36 -0
  445. package/src/creation-site-edit.ts +1479 -0
  446. package/src/creation-site-registry.ts +160 -0
  447. package/src/delegate-harness-registry.ts +143 -0
  448. package/src/document-context-registry.ts +177 -0
  449. package/src/document-open-registry.ts +200 -0
  450. package/src/document-preview-source.ts +20 -0
  451. package/src/document-renderer-session.ts +138 -0
  452. package/src/editor-api.ts +46 -0
  453. package/src/editor-chrome-capture.ts +139 -0
  454. package/src/editor-commands.ts +164 -0
  455. package/src/editor-console.ts +563 -0
  456. package/src/editor-current-view.ts +82 -0
  457. package/src/editor-document-probe.ts +881 -0
  458. package/src/editor-git-client.ts +115 -0
  459. package/src/editor-host-door.ts +462 -0
  460. package/src/editor-hotkeys.ts +842 -0
  461. package/src/editor-lease-view.ts +39 -0
  462. package/src/editor-lease.ts +415 -0
  463. package/src/editor-mode.ts +19 -0
  464. package/src/editor-notifications.ts +140 -0
  465. package/src/editor-presence.ts +563 -0
  466. package/src/editor-presentation-activity.ts +50 -0
  467. package/src/editor-presentation-notice.ts +42 -0
  468. package/src/editor-runtime.tsx +145 -0
  469. package/src/editor-server-response.ts +86 -0
  470. package/src/editor-session-attribution.ts +75 -0
  471. package/src/editor-session-mode.ts +54 -0
  472. package/src/editor-shell-store.ts +1304 -0
  473. package/src/editor-state-facets.ts +74 -0
  474. package/src/editor-styles.css +33 -0
  475. package/src/editor-view-presentation.ts +810 -0
  476. package/src/editor-viewport.ts +5302 -0
  477. package/src/entity-lod.ts +31 -0
  478. package/src/entity-object.ts +91 -0
  479. package/src/files/file-provider.ts +62 -0
  480. package/src/files/project-files.ts +264 -0
  481. package/src/finders/index.ts +136 -0
  482. package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
  483. package/src/frame/bridge.tsx +1408 -0
  484. package/src/frame/product.ts +77 -0
  485. package/src/gameplay-dom-recording.ts +319 -0
  486. package/src/gameplay-export-state.ts +14 -0
  487. package/src/gameplay-replay.ts +417 -0
  488. package/src/gameplay-session-time.ts +9 -0
  489. package/src/gameplay-sessions.ts +204 -0
  490. package/src/harness-chat-types.ts +253 -0
  491. package/src/hierarchy-component-marks.ts +298 -0
  492. package/src/hierarchy-drop.ts +91 -0
  493. package/src/hierarchy-expansion-state.ts +80 -0
  494. package/src/hierarchy-header-slot.ts +52 -0
  495. package/src/hierarchy-internals.ts +197 -0
  496. package/src/hierarchy-kind-icon.ts +217 -0
  497. package/src/hierarchy-mark-reader.ts +73 -0
  498. package/src/hierarchy-menu-registry.ts +67 -0
  499. package/src/hierarchy-node-rows.ts +307 -0
  500. package/src/hierarchy-panel-view.ts +280 -0
  501. package/src/hierarchy-projection.ts +76 -0
  502. package/src/hierarchy-row-cache.ts +243 -0
  503. package/src/hierarchy-row-model.ts +308 -0
  504. package/src/hierarchy-rows.ts +11 -0
  505. package/src/hierarchy-walk.ts +86 -0
  506. package/src/history/editor-session.ts +25 -0
  507. package/src/history/history-commands.ts +147 -0
  508. package/src/history/history-delegate.ts +163 -0
  509. package/src/history/history-limit-notices.ts +43 -0
  510. package/src/history/history-service.ts +1173 -0
  511. package/src/history/persistence-coordinator.ts +35 -0
  512. package/src/history/project-file-history.ts +386 -0
  513. package/src/history/project-root-history-backends.ts +139 -0
  514. package/src/history/resource-registry.ts +209 -0
  515. package/src/history/snapshot-store.ts +103 -0
  516. package/src/history/source-history-backend.ts +546 -0
  517. package/src/history/types.ts +124 -0
  518. package/src/hmr-registration-group.ts +67 -0
  519. package/src/hmr-stable-react-context.ts +23 -0
  520. package/src/hotkeys.ts +188 -0
  521. package/src/inference-diagnostics.ts +69 -0
  522. package/src/initial-project.ts +80 -0
  523. package/src/inspection/active-subject.ts +578 -0
  524. package/src/inspection/active-surface.ts +142 -0
  525. package/src/inspection/compose.ts +1064 -0
  526. package/src/inspection/display.ts +170 -0
  527. package/src/inspection/document-subject.ts +109 -0
  528. package/src/inspection/game-subject.ts +85 -0
  529. package/src/inspection/model.ts +542 -0
  530. package/src/inspection/null-subject.ts +115 -0
  531. package/src/inspection/serialize.ts +357 -0
  532. package/src/inspection/use-active-inspection.ts +180 -0
  533. package/src/inspector-presentation.ts +201 -0
  534. package/src/inspector-section-registry.ts +221 -0
  535. package/src/instance-source-actions.ts +163 -0
  536. package/src/instanced-presentation.ts +164 -0
  537. package/src/js-heap.ts +71 -0
  538. package/src/key-actions.ts +91 -0
  539. package/src/keymap-presets.ts +401 -0
  540. package/src/layout-policy.ts +31 -0
  541. package/src/learn-links.ts +73 -0
  542. package/src/light-explorer-model.ts +134 -0
  543. package/src/live-canvas-frame.ts +55 -0
  544. package/src/live-document.ts +279 -0
  545. package/src/live-module-source.ts +230 -0
  546. package/src/live-session-registry.ts +220 -0
  547. package/src/live-transition.ts +633 -0
  548. package/src/manifest-project.ts +107 -0
  549. package/src/model-thumbnail.ts +523 -0
  550. package/src/native-selection-style.ts +202 -0
  551. package/src/object3d-document-write-policy.ts +137 -0
  552. package/src/packaged-runtime.ts +108 -0
  553. package/src/palettes/maya.palette.json +57 -0
  554. package/src/palettes/substance.palette.json +57 -0
  555. package/src/pasteboard-module.ts +81 -0
  556. package/src/performance-profiler.ts +367 -0
  557. package/src/performance-sources.ts +69 -0
  558. package/src/play-boot-phase.ts +145 -0
  559. package/src/presentation-surface.ts +248 -0
  560. package/src/project-adapter.ts +1143 -0
  561. package/src/project-asset-refresh.ts +26 -0
  562. package/src/project-declaration-refresh.ts +31 -0
  563. package/src/project-local-state.ts +118 -0
  564. package/src/project-manager.ts +243 -0
  565. package/src/project-module-changes.ts +163 -0
  566. package/src/project-module-split.ts +266 -0
  567. package/src/project-provenance.ts +115 -0
  568. package/src/project-ready.ts +42 -0
  569. package/src/project-session-reset.ts +67 -0
  570. package/src/project-shape.ts +68 -0
  571. package/src/project-tool-discovery.ts +66 -0
  572. package/src/project-tools.ts +107 -0
  573. package/src/project-work-types.ts +149 -0
  574. package/src/projection/three.ts +898 -0
  575. package/src/projection/types.ts +44 -0
  576. package/src/readiness.ts +113 -0
  577. package/src/reflection-probe-helper.ts +142 -0
  578. package/src/reported-play-state.ts +90 -0
  579. package/src/resolve-relative-specifier.ts +33 -0
  580. package/src/scene-document-plan.ts +315 -0
  581. package/src/scene-framing.ts +315 -0
  582. package/src/scene-live-open.ts +210 -0
  583. package/src/scene-view-fog.ts +89 -0
  584. package/src/scoped-game-css.ts +152 -0
  585. package/src/session-orphan-record.ts +193 -0
  586. package/src/session-tombstone.ts +126 -0
  587. package/src/settings/settings-provider.ts +82 -0
  588. package/src/settings-store.ts +345 -0
  589. package/src/shared-view-restore.ts +42 -0
  590. package/src/shell-store-door.ts +45 -0
  591. package/src/source-conflict.ts +122 -0
  592. package/src/spatial-handle-visuals.ts +332 -0
  593. package/src/stage-context.ts +341 -0
  594. package/src/stage-store-registry.ts +63 -0
  595. package/src/stale-chunk-recovery.ts +34 -0
  596. package/src/state-report-deferral.ts +73 -0
  597. package/src/storage/host-files-storage.ts +97 -0
  598. package/src/storage/http-storage.ts +174 -0
  599. package/src/storage/index.ts +75 -0
  600. package/src/storage/mem-storage.ts +149 -0
  601. package/src/storage/path-lock.ts +44 -0
  602. package/src/storage/paths.ts +26 -0
  603. package/src/storage/types.ts +116 -0
  604. package/src/stories/StoryComponentThumbnail.tsx +184 -0
  605. package/src/stories/StoryPreviewMount.tsx +306 -0
  606. package/src/stories/component-content-source.tsx +106 -0
  607. package/src/stories/component-states-source.ts +78 -0
  608. package/src/stories/compose-project-stories.ts +255 -0
  609. package/src/stories/pixi-story-model.ts +30 -0
  610. package/src/stories/prefabs-finder.ts +54 -0
  611. package/src/stories/prefabs-from-stories.ts +182 -0
  612. package/src/stories/project-story-discovery.ts +78 -0
  613. package/src/stories/project-story-regions.ts +24 -0
  614. package/src/stories/story-actions.ts +54 -0
  615. package/src/stories/story-capture-command.ts +124 -0
  616. package/src/stories/story-capture.ts +584 -0
  617. package/src/stories/story-declared-medium.ts +126 -0
  618. package/src/stories/story-discovery.ts +176 -0
  619. package/src/stories/story-document-openers.ts +36 -0
  620. package/src/stories/story-dom-runtime.ts +78 -0
  621. package/src/stories/story-grouping.ts +111 -0
  622. package/src/stories/story-lane.ts +88 -0
  623. package/src/stories/story-mount-turn.ts +27 -0
  624. package/src/stories/story-opener.ts +260 -0
  625. package/src/stories/story-pixi-preview.ts +408 -0
  626. package/src/stories/story-presentation.ts +215 -0
  627. package/src/stories/story-registry.ts +517 -0
  628. package/src/stories/story-three-preview.ts +807 -0
  629. package/src/stories/three-story-model.ts +96 -0
  630. package/src/story-three-preview-runtime.ts +56 -0
  631. package/src/surface-keyboard.ts +112 -0
  632. package/src/surface-state.ts +124 -0
  633. package/src/tab-bootstrap.js +628 -0
  634. package/src/tab-census.ts +198 -0
  635. package/src/tab-lifecycle-client.ts +196 -0
  636. package/src/theme-library.ts +844 -0
  637. package/src/theme-preference.ts +378 -0
  638. package/src/theme.css +3437 -0
  639. package/src/three-viewport/bone-selection-highlight.ts +119 -0
  640. package/src/three-viewport/camera-fit.ts +41 -0
  641. package/src/three-viewport/interactive-renderer.ts +132 -0
  642. package/src/three-viewport/selection-brackets.ts +310 -0
  643. package/src/three-viewport/selection-outline.ts +183 -0
  644. package/src/three-viewport/skeleton-helper.ts +61 -0
  645. package/src/three-viewport/source-color.ts +167 -0
  646. package/src/three-viewport/studio-environment.ts +96 -0
  647. package/src/three-viewport-presentation.ts +23 -0
  648. package/src/tool-contribution-play.ts +74 -0
  649. package/src/tool-loader.ts +1798 -0
  650. package/src/transform-mode-request.ts +57 -0
  651. package/src/transient-hint.ts +78 -0
  652. package/src/trigger-volume-helper.ts +116 -0
  653. package/src/ui-source/adapter-region-includes.ts +241 -0
  654. package/src/ui-source/ensure-import.ts +132 -0
  655. package/src/ui-source/file-region-resolver.ts +302 -0
  656. package/src/ui-source/inspect.ts +775 -0
  657. package/src/ui-source/oid-transform.ts +1845 -0
  658. package/src/ui-source/plan-csf-story.ts +196 -0
  659. package/src/ui-source/plan-extract-component.ts +421 -0
  660. package/src/ui-source/plan-fork-component.ts +621 -0
  661. package/src/ui-source/plan-named-style.ts +95 -0
  662. package/src/ui-source/plan-source-edit.ts +353 -0
  663. package/src/ui-source/r3f-contract-resolver.ts +327 -0
  664. package/src/ui-source/r3f-diagnostic-index.ts +165 -0
  665. package/src/ui-source/r3f-environment-binding.ts +133 -0
  666. package/src/ui-source/r3f-joint-binding.ts +251 -0
  667. package/src/ui-source/r3f-lod-binding.ts +91 -0
  668. package/src/ui-source/r3f-particle-binding.ts +235 -0
  669. package/src/ui-source/r3f-physics-binding.ts +483 -0
  670. package/src/ui-source/r3f-project-contracts.ts +444 -0
  671. package/src/ui-source/relative-import-specifier.ts +34 -0
  672. package/src/ui-source/reparent-guard.ts +544 -0
  673. package/src/ui-source/source-edit-request.ts +97 -0
  674. package/src/ui-source/source-write-backend.ts +618 -0
  675. package/src/ui-source/syntactic-prop-specs.ts +296 -0
  676. package/src/ui-source/tier-source-write-backend.ts +279 -0
  677. package/src/ui-source/ts-ast.ts +171 -0
  678. package/src/ui-source/utility-class-support.ts +169 -0
  679. package/src/ui-source/write-component-default.ts +133 -0
  680. package/src/ui-source/writer.ts +1989 -0
  681. package/src/viewport-activation-timings.ts +840 -0
  682. package/src/viewport-authoring-policy.ts +186 -0
  683. package/src/viewport-controls-hint.ts +57 -0
  684. package/src/viewport-door.ts +237 -0
  685. package/src/viewport-shading-boundary.ts +12 -0
  686. package/src/vite-error-surface.ts +141 -0
  687. package/src/wait-until.ts +37 -0
  688. package/src/workspace-areas.ts +156 -0
  689. package/src/workspace-aux-commands.ts +11 -0
  690. package/src/workspace-available-documents.ts +127 -0
  691. package/src/workspace-core-utilities.ts +31 -0
  692. package/src/workspace-document-ids.ts +59 -0
  693. package/src/workspace-document-registry.ts +612 -0
  694. package/src/workspace-document-restore.ts +145 -0
  695. package/src/workspace-host-commands.ts +141 -0
  696. package/src/workspace-persistence-gate.ts +40 -0
  697. package/src/workspace-play-utilities.ts +44 -0
  698. package/src/workspace-presets.ts +425 -0
  699. package/src/workspace-regions.ts +294 -0
  700. package/src/workspace-state-persistence.ts +536 -0
  701. package/src/workspace-static-panels.ts +73 -0
  702. package/src/workspace-status-registry.ts +121 -0
  703. package/src/workspace-style.ts +239 -0
  704. package/src/workspace-utility-commands.ts +74 -0
  705. package/src/workspace-utility-registry.ts +263 -0
  706. package/src/workspace-viewport-rect.ts +97 -0
  707. package/src/world-adoption.ts +115 -0
  708. package/src/world-document-routing.ts +104 -0
  709. package/vite-plugin-creation-site-write.ts +184 -0
  710. package/vite-plugin-creation-site.ts +80 -0
  711. package/vite-plugin-game-static.ts +303 -0
  712. package/vite-plugin-module-doorways.ts +336 -0
  713. package/vite-plugin-product-contributions.ts +197 -0
  714. package/vite-plugin-project-game-static.ts +125 -0
  715. package/vite-plugin-project-jsx-js.ts +65 -0
  716. package/vite-plugin-project-root-absolute-assets.ts +359 -0
  717. package/vite-plugin-shared-react.ts +420 -0
  718. package/vite-plugin-shared-three.ts +203 -0
  719. package/vite-plugin-ui-oid.ts +2106 -0
@@ -0,0 +1,2802 @@
1
+ import type { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
2
+ import type {
3
+ AssetPreviewCameraChoice,
4
+ AssetPreviewPose,
5
+ AssetPreviewShotSetDefinition,
6
+ AssetPreviewShotWarning,
7
+ LabeledShotSetCapture,
8
+ ShotSetPoseMorph,
9
+ ShotSetPoseRotation,
10
+ ShotSetPoseStep,
11
+ ShotSetPoseTranslation,
12
+ ShotSetShot,
13
+ } from '@volter/editor-sdk';
14
+ import { EDITOR_LAYER, isEditorOwnedObject } from '@volter/editor-threejs/viewport/editor-layers';
15
+ import { markHostRenderer } from '@volter/editor-threejs/viewport/renderer-ownership';
16
+ import { isBuiltInternal } from '@volter/editor-threejs/adapter/hierarchy-marks';
17
+ import { loadSplat } from '@volter/editor-threejs/asset-loaders';
18
+ import { hasUserData } from '@volter/editor-threejs/ecs/user-data';
19
+ import { gltfLoader } from '@volter/editor-threejs/loader';
20
+ import * as THREE from 'three';
21
+ import { forwardYawRadians, parseForwardVector } from './asset-compare-core';
22
+ import {
23
+ ASSET_PREVIEW_PADDING,
24
+ boneZoomCenter,
25
+ createOrthographicShotCamera,
26
+ createProjectedSpan,
27
+ expandProjectedSpan,
28
+ fitBoneZoomFrame,
29
+ fitOrthographicFrame,
30
+ fitProjectedSpanFrame,
31
+ type OrthographicFrame,
32
+ type OrthographicViewBasis,
33
+ orthographicShotFrameWindow,
34
+ type ProjectedSpan,
35
+ projectedSpanCenter,
36
+ resetProjectedSpan,
37
+ type ShotFrameWindow,
38
+ spanOverlapsShotFrame,
39
+ turntableViewBasis,
40
+ unionOrthographicFrames,
41
+ } from './asset-preview-framing';
42
+ import { drawBitmapLabel, measureBitmapLabel } from './bitmap-label';
43
+ import {
44
+ applyStudioEnvironment,
45
+ disposeStudioEnvironment,
46
+ STUDIO_AMBIENT_WITH_ENVIRONMENT,
47
+ } from './three-viewport/studio-environment';
48
+
49
+ export const ASSET_PREVIEW_VIEWS = ['front', 'right', 'top', 'perspective'] as const;
50
+ export type AssetPreviewView = (typeof ASSET_PREVIEW_VIEWS)[number];
51
+ export type AssetPreviewBackground = 'neutral' | 'transparent';
52
+
53
+ /**
54
+ * Where an ENTITY capture is staged.
55
+ *
56
+ * `'lab'` — the neutral Asset Lab stage {@link captureObjectAssetPreview}
57
+ * renders: an isolated, yaw-normalized snapshot under fixed studio lighting,
58
+ * identical whatever the entity's surroundings are. It is the default
59
+ * everywhere.
60
+ *
61
+ * `'scene'` — {@link captureSceneStageAssetPreview}: the entity photographed
62
+ * WHERE IT STANDS, in the live scene, under the scene's own lighting, with
63
+ * the editor's own furniture excluded. Entity captures only — a model loaded
64
+ * from an asset path does not stand anywhere.
65
+ */
66
+ export type AssetPreviewStage = 'lab' | 'scene';
67
+
68
+ export interface AssetPreviewOptions {
69
+ width?: number;
70
+ height?: number;
71
+ background?: AssetPreviewBackground;
72
+ /** ONE view from a chosen angle instead of the fixed four — see the SDK's
73
+ * {@link AssetPreviewCameraChoice} for the angle conventions. */
74
+ camera?: AssetPreviewCameraChoice;
75
+ /** Sample a named clip at a time on the capture's disposable snapshot
76
+ * before framing — see the SDK's {@link AssetPreviewPose}. */
77
+ pose?: AssetPreviewPose;
78
+ }
79
+
80
+ export interface AssetPreviewImage {
81
+ /** One of the four fixed views, or `below` — the underside tile the lab
82
+ * stage adds to its sheet (a cap's gills, a barrel's floor). */
83
+ view: AssetPreviewView | 'below';
84
+ base64: string;
85
+ mimeType: 'image/png';
86
+ }
87
+
88
+ /**
89
+ * How the captured subject was oriented relative to its AUTHORED coordinates.
90
+ *
91
+ * `faceFrontSubject` yaw-normalizes a subject so the fixed front camera
92
+ * photographs its declared front. For a rig persisting `forward = [0,0,-1]`
93
+ * that is a PI yaw — correct photography whose projection maps authored +X to
94
+ * screen-LEFT in the front view. Applied silently, that read as "the front
95
+ * view mirrors world X" and cost a cold-round agent its geometry reasoning
96
+ * (round #4, 2026-08-30), so the yaw is now REPORTED here and stamped onto
97
+ * the sheet's own pixels ({@link renderFourViewCapture}'s axis markers) —
98
+ * never inferred, never silent.
99
+ */
100
+ export interface AssetPreviewOrientation {
101
+ /** The subject's declared forward, `[0,0,1]` when it declares none. */
102
+ forward: [number, number, number];
103
+ /** Yaw applied to face the front camera; 0 means authored axes = world axes. */
104
+ yawDegrees: number;
105
+ }
106
+
107
+ export interface AssetPreviewCapture {
108
+ width: number;
109
+ height: number;
110
+ orientation: AssetPreviewOrientation;
111
+ views: AssetPreviewImage[];
112
+ contactSheet: {
113
+ width: number;
114
+ height: number;
115
+ base64: string;
116
+ mimeType: 'image/png';
117
+ };
118
+ }
119
+
120
+ export interface AssetPreviewFraming {
121
+ center: THREE.Vector3;
122
+ size: THREE.Vector3;
123
+ radius: number;
124
+ }
125
+
126
+ /**
127
+ * The shot-set contract is declared ONCE, in `@volter/editor-sdk`
128
+ * (`packages/editor-sdk/src/types.ts`), because it crosses the editor relay:
129
+ * a capability tool authors a set, the CLI carries it over, and THIS module is
130
+ * the generic capture engine that renders it. Importing rather than
131
+ * re-declaring is what makes `parseShotSetDefinition`'s return annotation
132
+ * below a real drift check against the wire type — see the SDK block's own
133
+ * comment for the contract and for what a pose step's `radians` is measured
134
+ * from.
135
+ */
136
+ export type {
137
+ AssetPreviewShotSetDefinition,
138
+ AssetPreviewShotWarning,
139
+ LabeledShotSetCapture,
140
+ ShotSetPoseMorph,
141
+ ShotSetPoseRotation,
142
+ ShotSetPoseStep,
143
+ ShotSetPoseTranslation,
144
+ ShotSetShot,
145
+ } from '@volter/editor-sdk';
146
+
147
+ /** Definitions cross the editor relay as untyped JSON from project-registered
148
+ * tools; capped so one capture stays within the relay's request budget. */
149
+ const MAX_SHOT_SET_SHOTS = 24;
150
+
151
+ function isPoseRotation(value: unknown): value is ShotSetPoseRotation {
152
+ const record = value as Partial<ShotSetPoseRotation> | null;
153
+ return (
154
+ typeof record === 'object' &&
155
+ record !== null &&
156
+ typeof record.bone === 'string' &&
157
+ record.bone.length > 0 &&
158
+ (record.axis === 'x' || record.axis === 'y' || record.axis === 'z') &&
159
+ typeof record.radians === 'number' &&
160
+ Number.isFinite(record.radians)
161
+ );
162
+ }
163
+
164
+ function isPoseMorph(value: unknown): value is ShotSetPoseMorph {
165
+ const record = value as Partial<ShotSetPoseMorph> | null;
166
+ return (
167
+ typeof record === 'object' &&
168
+ record !== null &&
169
+ typeof record.morph === 'string' &&
170
+ record.morph.length > 0 &&
171
+ typeof record.influence === 'number' &&
172
+ Number.isFinite(record.influence)
173
+ );
174
+ }
175
+
176
+ function isPoseTranslation(value: unknown): value is ShotSetPoseTranslation {
177
+ const record = value as Partial<ShotSetPoseTranslation> | null;
178
+ return (
179
+ typeof record === 'object' &&
180
+ record !== null &&
181
+ typeof record.bone === 'string' &&
182
+ record.bone.length > 0 &&
183
+ (record.axis === 'x' || record.axis === 'y' || record.axis === 'z') &&
184
+ typeof record.meters === 'number' &&
185
+ Number.isFinite(record.meters)
186
+ );
187
+ }
188
+
189
+ function isPoseStep(value: unknown): value is ShotSetPoseStep {
190
+ return isPoseRotation(value) || isPoseMorph(value) || isPoseTranslation(value);
191
+ }
192
+
193
+ function isShotSetShot(value: unknown, poseNames: ReadonlySet<string>): value is ShotSetShot {
194
+ const record = value as Partial<ShotSetShot & { yaw: number; bones: unknown[] }> | null;
195
+ if (typeof record !== 'object' || record === null) return false;
196
+ if (typeof record.label !== 'string' || !/^[a-z0-9][a-z0-9-]*$/i.test(record.label)) {
197
+ return false;
198
+ }
199
+ if (
200
+ record.pose !== undefined &&
201
+ (typeof record.pose !== 'string' || !poseNames.has(record.pose))
202
+ ) {
203
+ return false;
204
+ }
205
+ if (record.view === 'turntable') {
206
+ return typeof record.yaw === 'number' && Number.isFinite(record.yaw);
207
+ }
208
+ if (record.view === 'bone-zoom') {
209
+ const zoom = record as Partial<Extract<ShotSetShot, { view: 'bone-zoom' }>>;
210
+ if (zoom.yaw !== undefined && (typeof zoom.yaw !== 'number' || !Number.isFinite(zoom.yaw))) {
211
+ return false;
212
+ }
213
+ return (
214
+ Array.isArray(zoom.bones) &&
215
+ zoom.bones.length > 0 &&
216
+ zoom.bones.every((bone) => typeof bone === 'string' && bone.length > 0) &&
217
+ typeof zoom.spanFraction === 'number' &&
218
+ Number.isFinite(zoom.spanFraction) &&
219
+ zoom.spanFraction > 0 &&
220
+ zoom.spanFraction <= 2
221
+ );
222
+ }
223
+ return false;
224
+ }
225
+
226
+ /**
227
+ * Validate an untrusted shot-set definition at the relay boundary. Throws a
228
+ * teaching error naming the defect — a malformed project contribution must
229
+ * fail with a reason, not a deep three.js stack.
230
+ */
231
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one bounded, flat validator keeps every field's teaching error at the relay boundary.
232
+ export function parseShotSetDefinition(value: unknown): AssetPreviewShotSetDefinition {
233
+ const fail = (reason: string): never => {
234
+ throw new Error(`Asset preview shot-set definition is invalid: ${reason}`);
235
+ };
236
+ const record = value as Partial<AssetPreviewShotSetDefinition> | null;
237
+ if (typeof record !== 'object' || record === null) fail('expected an object.');
238
+ const definition = record as Partial<AssetPreviewShotSetDefinition>;
239
+ if (typeof definition.name !== 'string' || !/^[a-z0-9][a-z0-9-]*$/i.test(definition.name)) {
240
+ fail('`name` must be a short alphanumeric/dash identifier.');
241
+ }
242
+ if (
243
+ definition.requiredBones !== undefined &&
244
+ (!Array.isArray(definition.requiredBones) ||
245
+ definition.requiredBones.some((bone) => typeof bone !== 'string' || bone.length === 0))
246
+ ) {
247
+ fail('`requiredBones` must be an array of non-empty bone names.');
248
+ }
249
+ if (
250
+ definition.rigRequirementHint !== undefined &&
251
+ typeof definition.rigRequirementHint !== 'string'
252
+ ) {
253
+ fail('`rigRequirementHint` must be a string.');
254
+ }
255
+ const poses = definition.poses ?? {};
256
+ if (typeof poses !== 'object' || poses === null || Array.isArray(poses)) {
257
+ fail('`poses` must be an object of named pose-step lists.');
258
+ }
259
+ for (const [poseName, steps] of Object.entries(poses)) {
260
+ if (!Array.isArray(steps) || !steps.every(isPoseStep)) {
261
+ fail(
262
+ `pose ${JSON.stringify(poseName)} must be an array of {bone, axis, radians} ` +
263
+ 'and/or {morph, influence}.',
264
+ );
265
+ }
266
+ }
267
+ const poseNames = new Set(Object.keys(poses));
268
+ if (
269
+ !Array.isArray(definition.shots) ||
270
+ definition.shots.length === 0 ||
271
+ definition.shots.length > MAX_SHOT_SET_SHOTS
272
+ ) {
273
+ fail(`\`shots\` must list 1-${MAX_SHOT_SET_SHOTS} shots.`);
274
+ }
275
+ const shots = definition.shots as unknown[];
276
+ const labels = new Set<string>();
277
+ for (const shot of shots) {
278
+ if (!isShotSetShot(shot, poseNames)) {
279
+ fail(
280
+ 'every shot needs a label plus either {view:"turntable", yaw} or ' +
281
+ '{view:"bone-zoom", bones, spanFraction, yaw?}, with every `yaw` a finite ' +
282
+ 'number of radians and `pose` naming a declared pose.',
283
+ );
284
+ }
285
+ const { label } = shot as ShotSetShot;
286
+ if (labels.has(label)) fail(`duplicate shot label ${JSON.stringify(label)}.`);
287
+ labels.add(label);
288
+ }
289
+ return definition as AssetPreviewShotSetDefinition;
290
+ }
291
+
292
+ export const SOURCE_REVIEW_SHOT_LABELS = [
293
+ 'front',
294
+ 'back',
295
+ 'left',
296
+ 'right',
297
+ 'quarter-left',
298
+ 'quarter-right',
299
+ ] as const;
300
+ export type SourceReviewShotLabel = (typeof SOURCE_REVIEW_SHOT_LABELS)[number];
301
+
302
+ export interface SourceReviewShotSetCapture {
303
+ width: number;
304
+ height: number;
305
+ shots: Array<{ label: SourceReviewShotLabel; base64: string; mimeType: 'image/png' }>;
306
+ contactSheet: {
307
+ width: number;
308
+ height: number;
309
+ base64: string;
310
+ mimeType: 'image/png';
311
+ };
312
+ }
313
+
314
+ type MorphableObject = THREE.Object3D & {
315
+ morphTargetDictionary?: Record<string, number>;
316
+ morphTargetInfluences?: number[];
317
+ };
318
+
319
+ const DEFAULT_SIZE = 512;
320
+ const MIN_SIZE = 64;
321
+ // Four views plus the 2x2 sheet cross the editor relay as base64 JSON. At
322
+ // 1024px even incompressible RGBA data remains below its 50 MB request limit.
323
+ const MAX_SIZE = 1024;
324
+ const NEUTRAL_COLOR = 0x20242a;
325
+ const MODEL_FETCH_TIMEOUT_MS = 25_000;
326
+ const MAX_MODEL_BYTES = 64 * 1024 * 1024;
327
+
328
+ export function checkedDimension(value: number | undefined): number {
329
+ const resolved = value ?? DEFAULT_SIZE;
330
+ if (!Number.isInteger(resolved) || resolved < MIN_SIZE || resolved > MAX_SIZE) {
331
+ throw new Error(`Asset preview dimensions must be integers from ${MIN_SIZE} to ${MAX_SIZE}.`);
332
+ }
333
+ return resolved;
334
+ }
335
+
336
+ export function pngBase64(canvas: HTMLCanvasElement): string {
337
+ const dataUrl = canvas.toDataURL('image/png');
338
+ const comma = dataUrl.indexOf(',');
339
+ return comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
340
+ }
341
+
342
+ /**
343
+ * The node a preview must snapshot for `object`: itself, unless a SkinnedMesh
344
+ * under it binds to bones OUTSIDE it — then the nearest ancestor that owns
345
+ * both the mesh and every bone, the skeleton root the refusal in
346
+ * `resolveExternalBones` names. A skinned mesh sits BESIDE its Armature in
347
+ * three's own glTF layout (`Scene > [SkinnedMesh, Armature > bones]`), so a
348
+ * click on a character lands on the mesh, and the mesh alone cannot be drawn
349
+ * without the bones that pose it: every Humanoid exhibit's Inspector preview
350
+ * was the red refusal listing all 68 bones (runhuman pass 143; traced live —
351
+ * selecting the rig root renders a clean bind pose). A bone whose chain never
352
+ * meets `object`'s stays external, so the honest refusal still fires there.
353
+ */
354
+ export function assetPreviewSubject(object: THREE.Object3D): THREE.Object3D {
355
+ const inside = new Set<THREE.Object3D>();
356
+ object.traverse((node) => inside.add(node));
357
+ let subject: THREE.Object3D = object;
358
+ object.traverse((node) => {
359
+ const skinned = node as THREE.SkinnedMesh;
360
+ if (!(skinned as { isSkinnedMesh?: boolean }).isSkinnedMesh || !skinned.skeleton) return;
361
+ for (const bone of skinned.skeleton.bones) {
362
+ if (!bone || inside.has(bone)) continue;
363
+ // Climb from the bone until an ancestor of the current subject is met.
364
+ const subjectChain = new Set<THREE.Object3D>();
365
+ for (let a: THREE.Object3D | null = subject; a; a = a.parent) subjectChain.add(a);
366
+ let common: THREE.Object3D | null = null;
367
+ for (let a: THREE.Object3D | null = bone; a; a = a.parent) {
368
+ if (subjectChain.has(a)) {
369
+ common = a;
370
+ break;
371
+ }
372
+ }
373
+ if (common && common !== subject) {
374
+ subject = common;
375
+ subject.traverse((n) => inside.add(n));
376
+ }
377
+ }
378
+ });
379
+ return subject;
380
+ }
381
+
382
+ /**
383
+ * Build a render-only snapshot of an authored subtree. The source remains the
384
+ * live Object3D truth: it is never reparented, hidden, normalized, or edited.
385
+ * Skinned, morphed, and instanced render semantics are preserved while the
386
+ * snapshot shares immutable geometry/material resources.
387
+ */
388
+ export function createAssetPreviewSnapshot(source: THREE.Object3D): THREE.Object3D {
389
+ // A skinned mesh can point at bones that are not in the selected subtree —
390
+ // in practice because the model was duplicated with `Object3D.clone()`
391
+ // instead of `SkeletonUtils.clone()`, which copies the bone nodes but leaves
392
+ // every clone's `skeleton` referencing the ORIGINAL bones. Where the subtree
393
+ // owns a same-named bone for each of them (it does for exactly that mount),
394
+ // the snapshot binds to its own copies — the pose those copies carry is the
395
+ // one the mount intended. Anything left unresolved is refused BY NAME below,
396
+ // because binding to a bone that is not there would be an invented pose.
397
+ const sourceNodes = new Set<THREE.Object3D>();
398
+ source.traverse((object) => {
399
+ sourceNodes.add(object);
400
+ });
401
+ const boneSubstitutes = resolveExternalBones(source, sourceNodes);
402
+ const snapshot = cloneRenderableHierarchy(source, boneSubstitutes);
403
+
404
+ // Preserve the selected root's authored world-space presentation even when
405
+ // it lives below transformed parents. Descendant local transforms are kept.
406
+ // Keep the exact composed matrix. Decomposing a rotated, non-uniformly
407
+ // scaled ancestor chain into TRS would discard shear and change geometry.
408
+ const exactWorldMatrix = objectWorldMatrix(source);
409
+ snapshot.matrix.copy(exactWorldMatrix);
410
+ snapshot.matrix.decompose(snapshot.position, snapshot.quaternion, snapshot.scale);
411
+ snapshot.matrixAutoUpdate = false;
412
+ snapshot.matrixWorld.copy(exactWorldMatrix);
413
+ snapshot.matrixWorldAutoUpdate = source.matrixWorldAutoUpdate;
414
+ // Isolation intentionally reveals a selected root that is hidden in the
415
+ // scene; descendant visibility remains authored and the source is untouched.
416
+ snapshot.visible = true;
417
+
418
+ // Editor-only helpers are presentation siblings/children, never asset data.
419
+ const helpers: THREE.Object3D[] = [];
420
+ snapshot.traverse((object) => {
421
+ if (object !== snapshot && object.userData['editorHelper']) helpers.push(object);
422
+ });
423
+ for (const helper of helpers) helper.parent?.remove(helper);
424
+
425
+ return snapshot;
426
+ }
427
+
428
+ function objectWorldMatrix(object: THREE.Object3D): THREE.Matrix4 {
429
+ const chain: THREE.Object3D[] = [];
430
+ let current: THREE.Object3D | null = object;
431
+ while (current) {
432
+ chain.push(current);
433
+ current = current.parent;
434
+ }
435
+ const manualBoundary = chain.findIndex((node) => !node.matrixWorldAutoUpdate);
436
+ const world =
437
+ manualBoundary >= 0 ? chain[manualBoundary]!.matrixWorld.clone() : new THREE.Matrix4();
438
+ const local = new THREE.Matrix4();
439
+ const firstLocal = manualBoundary >= 0 ? manualBoundary - 1 : chain.length - 1;
440
+ for (let index = firstLocal; index >= 0; index--) {
441
+ const node = chain[index]!;
442
+ if (node.matrixAutoUpdate) local.compose(node.position, node.quaternion, node.scale);
443
+ else local.copy(node.matrix);
444
+ world.multiply(local);
445
+ }
446
+ return world;
447
+ }
448
+
449
+ /**
450
+ * For every bone a skinned mesh in `source` references from OUTSIDE `source`,
451
+ * the same-named bone inside it — or a thrown refusal naming what could not be
452
+ * resolved. An empty map means the subtree's skeletons are already complete.
453
+ *
454
+ * The unresolvable case is real (a bone with no name, or one whose owner truly
455
+ * lives above the selection), and it stays an error: the alternative is a
456
+ * picture of a mesh bound to bones the caller never selected. What changed is
457
+ * that the error now says WHICH mesh and WHICH bones, because "Select the
458
+ * complete skeleton root" with nothing named is a dead end — there is no way
459
+ * to act on it from the terminal that produced it.
460
+ */
461
+ function resolveExternalBones(
462
+ source: THREE.Object3D,
463
+ sourceNodes: ReadonlySet<THREE.Object3D>,
464
+ ): Map<THREE.Bone, THREE.Bone> {
465
+ const substitutes = new Map<THREE.Bone, THREE.Bone>();
466
+ const unresolved = new Map<string, { mesh: string; bone: string; sameNamed: number }>();
467
+ let bonesByName: Map<string, THREE.Bone[]> | null = null;
468
+ const nameIndex = (): Map<string, THREE.Bone[]> => {
469
+ if (bonesByName) return bonesByName;
470
+ const index = new Map<string, THREE.Bone[]>();
471
+ for (const node of sourceNodes) {
472
+ const bone = node as THREE.Bone;
473
+ if (!bone.isBone || bone.name === '') continue;
474
+ const bucket = index.get(bone.name);
475
+ if (bucket) bucket.push(bone);
476
+ else index.set(bone.name, [bone]);
477
+ }
478
+ bonesByName = index;
479
+ return index;
480
+ };
481
+
482
+ source.traverse((object) => {
483
+ const skinned = object as THREE.SkinnedMesh;
484
+ if (!skinned.isSkinnedMesh) return;
485
+ const meshName = skinned.name || skinned.uuid;
486
+ for (const bone of skinned.skeleton.bones) {
487
+ if (bone && sourceNodes.has(bone)) continue;
488
+ // Substitution is a per-BONE fact, so an answer found for one mesh holds
489
+ // for every other mesh referencing the same bone object — and they do:
490
+ // GLTFLoader gives every primitive of one skin a SkinnedMesh bound to the
491
+ // SAME Skeleton, which is the ordinary body+outfit character. Reading
492
+ // this as "already handled, nothing to do here" and falling through to
493
+ // the unresolved ledger refused exactly those models, with a sentence
494
+ // ("owns no bone of the same name") that the first mesh had just
495
+ // disproved.
496
+ if (bone && substitutes.has(bone)) continue;
497
+ const candidates = bone ? (nameIndex().get(bone.name) ?? []) : [];
498
+ if (candidates.length === 1) {
499
+ substitutes.set(bone as THREE.Bone, candidates[0] as THREE.Bone);
500
+ continue;
501
+ }
502
+ const boneName = bone?.name || '(unnamed bone)';
503
+ unresolved.set(`${meshName} -> ${boneName}`, {
504
+ mesh: meshName,
505
+ bone: boneName,
506
+ sameNamed: candidates.length,
507
+ });
508
+ }
509
+ });
510
+
511
+ if (unresolved.size > 0) {
512
+ // Two refusals, and they are NOT the same fact: nothing of that name to
513
+ // bind to, versus several and no way to tell which was meant. Reporting
514
+ // one as the other is how an un-actionable error survives a fix that was
515
+ // supposed to remove it.
516
+ const detail = [...unresolved.values()]
517
+ .map(({ mesh, bone, sameNamed }) =>
518
+ sameNamed > 1
519
+ ? `${mesh} -> ${bone} (${sameNamed} bones inside the selection share that name — ` +
520
+ 'which one is meant is ambiguous)'
521
+ : `${mesh} -> ${bone} (no bone of that name inside the selection)`,
522
+ )
523
+ .join('; ');
524
+ throw new Error(
525
+ 'Asset preview selection references bones outside its hierarchy that it cannot bind to: ' +
526
+ `${detail}. Select the complete skeleton root — the node that owns both the skinned ` +
527
+ 'mesh and its bones.',
528
+ );
529
+ }
530
+ return substitutes;
531
+ }
532
+
533
+ /**
534
+ * Clone a complete authored hierarchy without copying editor-only helpers or
535
+ * arbitrary userData. Object3D.clone JSON-serializes userData, which is unsafe
536
+ * for live AnimationMixer references, while SkeletonUtils.clone first invokes
537
+ * that same recursive clone. This direct render clone keeps Three.js object
538
+ * semantics and remaps complete internal skeletons after building the tree.
539
+ *
540
+ * `boneSubstitutes` redirects a bone reference that points outside the source
541
+ * subtree to the same-named bone inside it before the remap (see
542
+ * {@link resolveExternalBones}); an empty map is the ordinary case.
543
+ */
544
+ function cloneRenderableHierarchy(
545
+ source: THREE.Object3D,
546
+ boneSubstitutes: ReadonlyMap<THREE.Bone, THREE.Bone> = new Map(),
547
+ ): THREE.Object3D {
548
+ const cloneBySource = new Map<THREE.Object3D, THREE.Object3D>();
549
+ const skinnedPairs: Array<[THREE.SkinnedMesh, THREE.SkinnedMesh]> = [];
550
+
551
+ const cloneNode = (object: THREE.Object3D): THREE.Object3D => {
552
+ const clone = createRenderClone(object);
553
+ cloneBySource.set(object, clone);
554
+ copyObjectPresentation(object, clone);
555
+ if ((object as THREE.SkinnedMesh).isSkinnedMesh) {
556
+ skinnedPairs.push([object as THREE.SkinnedMesh, clone as THREE.SkinnedMesh]);
557
+ }
558
+ const sourceLod = object as THREE.LOD;
559
+ if (sourceLod.isLOD) {
560
+ const cloneLod = clone as THREE.LOD;
561
+ // A deterministic still preview freezes auto-updating LODs to one level.
562
+ // Manually controlled LODs retain their exact authored visibility, which
563
+ // may intentionally contain zero or several visible levels.
564
+ const visibleLevels = sourceLod.levels.filter((level) => level.object.visible);
565
+ const activeSourceLevel = visibleLevels.length === 1 ? visibleLevels[0] : sourceLod.levels[0];
566
+ cloneLod.autoUpdate = false;
567
+ const levelObjects = new Set(sourceLod.levels.map((level) => level.object));
568
+ for (const level of sourceLod.levels) {
569
+ if (isEditorPresentation(level.object)) continue;
570
+ const levelClone = cloneNode(level.object);
571
+ levelClone.visible = sourceLod.autoUpdate
572
+ ? level === activeSourceLevel
573
+ : level.object.visible;
574
+ cloneLod.addLevel(levelClone, level.distance, level.hysteresis);
575
+ }
576
+ for (const child of object.children) {
577
+ if (!levelObjects.has(child) && !isEditorPresentation(child)) clone.add(cloneNode(child));
578
+ }
579
+ } else {
580
+ for (const child of object.children) {
581
+ if (!isEditorPresentation(child)) clone.add(cloneNode(child));
582
+ }
583
+ }
584
+ return clone;
585
+ };
586
+
587
+ const root = cloneNode(source);
588
+ for (const [original, clone] of skinnedPairs) {
589
+ const skeleton = original.skeleton.clone();
590
+ skeleton.bones = original.skeleton.bones.map((bone) => {
591
+ const mapped = cloneBySource.get(boneSubstitutes.get(bone) ?? bone);
592
+ if (!(mapped as THREE.Bone | undefined)?.isBone) {
593
+ throw new Error(`Asset preview could not map skeleton bone: ${bone.name || bone.uuid}`);
594
+ }
595
+ return mapped as THREE.Bone;
596
+ });
597
+ clone.skeleton = skeleton;
598
+ clone.bindMode = original.bindMode;
599
+ clone.bindMatrix.copy(original.bindMatrix);
600
+ clone.bindMatrixInverse.copy(original.bindMatrixInverse);
601
+ }
602
+ return root;
603
+ }
604
+
605
+ function createRenderClone(object: THREE.Object3D): THREE.Object3D {
606
+ const skinned = object as THREE.SkinnedMesh;
607
+ if (skinned.isSkinnedMesh) return new THREE.SkinnedMesh(skinned.geometry, skinned.material);
608
+ const instanced = object as THREE.InstancedMesh;
609
+ if (instanced.isInstancedMesh) {
610
+ const clone = new THREE.InstancedMesh(instanced.geometry, instanced.material, instanced.count);
611
+ clone.instanceMatrix.copy(instanced.instanceMatrix);
612
+ clone.instanceColor = instanced.instanceColor
613
+ ? (instanced.instanceColor.clone() as THREE.InstancedBufferAttribute)
614
+ : null;
615
+ clone.morphTexture = instanced.morphTexture;
616
+ return clone;
617
+ }
618
+ const mesh = object as THREE.Mesh;
619
+ if (mesh.isMesh) return new THREE.Mesh(mesh.geometry, mesh.material);
620
+ const line = object as THREE.Line;
621
+ if ((object as THREE.LineSegments).isLineSegments) {
622
+ return new THREE.LineSegments(line.geometry, line.material);
623
+ }
624
+ if ((object as THREE.LineLoop).isLineLoop)
625
+ return new THREE.LineLoop(line.geometry, line.material);
626
+ if (line.isLine) return new THREE.Line(line.geometry, line.material);
627
+ const points = object as THREE.Points;
628
+ if (points.isPoints) return new THREE.Points(points.geometry, points.material);
629
+ const sprite = object as THREE.Sprite;
630
+ if (sprite.isSprite) return new THREE.Sprite(sprite.material);
631
+ if ((object as THREE.LOD).isLOD) return new THREE.LOD();
632
+ if ((object as THREE.Bone).isBone) return new THREE.Bone();
633
+ if ((object as THREE.Group).isGroup) return new THREE.Group();
634
+ return new THREE.Object3D();
635
+ }
636
+
637
+ function copyObjectPresentation(source: THREE.Object3D, clone: THREE.Object3D): void {
638
+ clone.name = source.name;
639
+ clone.up.copy(source.up);
640
+ clone.position.copy(source.position);
641
+ clone.quaternion.copy(source.quaternion);
642
+ clone.scale.copy(source.scale);
643
+ clone.matrix.copy(source.matrix);
644
+ clone.matrixWorld.copy(source.matrixWorld);
645
+ clone.matrixAutoUpdate = source.matrixAutoUpdate;
646
+ clone.matrixWorldAutoUpdate = source.matrixWorldAutoUpdate;
647
+ clone.visible = source.visible;
648
+ clone.castShadow = source.castShadow;
649
+ clone.receiveShadow = source.receiveShadow;
650
+ // Separate preview renderers must not populate boundingSphere on geometry
651
+ // shared read-only with the live source. The deterministic cameras already
652
+ // frame the snapshot, so clone-only culling is unnecessary.
653
+ clone.frustumCulled = false;
654
+ clone.renderOrder = source.renderOrder;
655
+ // Asset Editor is a presentation scene with layer-0 cameras. Normalize only
656
+ // the clone so authored custom-layer content is visible without changing the
657
+ // live entity's layer assignment.
658
+ clone.layers.set(0);
659
+ clone.animations = [...source.animations];
660
+
661
+ const sourceMorph = source as MorphableObject;
662
+ const cloneMorph = clone as MorphableObject;
663
+ if (sourceMorph.morphTargetInfluences) {
664
+ if (sourceMorph.morphTargetDictionary) {
665
+ cloneMorph.morphTargetDictionary = { ...sourceMorph.morphTargetDictionary };
666
+ }
667
+ cloneMorph.morphTargetInfluences = [...sourceMorph.morphTargetInfluences];
668
+ }
669
+ const sourceSprite = source as THREE.Sprite;
670
+ if (sourceSprite.isSprite) (clone as THREE.Sprite).center.copy(sourceSprite.center);
671
+ }
672
+
673
+ function isEditorPresentation(object: THREE.Object3D): boolean {
674
+ const editorLayerMask = 1 << EDITOR_LAYER;
675
+ return Boolean(
676
+ object.userData['editorHelper'] ||
677
+ object.userData['editorIcon'] ||
678
+ (object.layers.mask & editorLayerMask) !== 0,
679
+ );
680
+ }
681
+
682
+ export function disposeAssetPreviewSnapshot(root: THREE.Object3D): void {
683
+ const skeletons = new Set<THREE.Skeleton>();
684
+ root.traverse((object) => {
685
+ const skinned = object as THREE.SkinnedMesh;
686
+ if (skinned.isSkinnedMesh) skeletons.add(skinned.skeleton);
687
+ const instanced = object as THREE.InstancedMesh;
688
+ if (instanced.isInstancedMesh) {
689
+ // Matrix/color buffers belong to the clone; its morph texture is shared.
690
+ // Preserve that texture for the source owner's eventual disposal too.
691
+ const morphTexture = instanced.morphTexture;
692
+ instanced.morphTexture = null;
693
+ instanced.dispose();
694
+ instanced.morphTexture = morphTexture;
695
+ }
696
+ });
697
+ for (const skeleton of skeletons) skeleton.dispose();
698
+ }
699
+
700
+ export interface NativeObjectPreviewSource {
701
+ readonly root: THREE.Object3D;
702
+ readonly animations?: readonly THREE.AnimationClip[];
703
+ dispose(): void;
704
+ }
705
+
706
+ export interface OwnedObjectPreviewSnapshot {
707
+ readonly root: THREE.Object3D;
708
+ dispose(): void;
709
+ }
710
+
711
+ /**
712
+ * Acquires a project-owned native source and a graph-isolated viewer snapshot
713
+ * as one ordered lifetime. Render resources remain shared read-only.
714
+ * Snapshot-owned skeleton textures are released before the source releases
715
+ * its geometry, materials, textures, and rigs.
716
+ */
717
+ export function createOwnedObjectPreviewSnapshot(
718
+ factory: () => NativeObjectPreviewSource,
719
+ ): OwnedObjectPreviewSnapshot {
720
+ const source = factory();
721
+ let snapshot: THREE.Object3D;
722
+ try {
723
+ snapshot = createAssetPreviewSnapshot(source.root);
724
+ snapshot.animations = [...(source.animations ?? source.root.animations)];
725
+ } catch (error) {
726
+ try {
727
+ source.dispose();
728
+ } catch (disposeError) {
729
+ throw new AggregateError(
730
+ [error, disposeError],
731
+ 'Object3D preview snapshot creation and source cleanup both failed.',
732
+ );
733
+ }
734
+ throw error;
735
+ }
736
+ let disposed = false;
737
+ return {
738
+ root: snapshot,
739
+ dispose() {
740
+ if (disposed) return;
741
+ disposed = true;
742
+ const errors: unknown[] = [];
743
+ try {
744
+ disposeAssetPreviewSnapshot(snapshot);
745
+ } catch (error) {
746
+ errors.push(error);
747
+ }
748
+ try {
749
+ source.dispose();
750
+ } catch (error) {
751
+ errors.push(error);
752
+ }
753
+ if (errors.length === 1) throw errors[0];
754
+ if (errors.length > 1) {
755
+ throw new AggregateError(errors, 'Object3D preview snapshot cleanup failed.');
756
+ }
757
+ },
758
+ };
759
+ }
760
+
761
+ export function measureAssetPreview(
762
+ object: THREE.Object3D,
763
+ view: AssetPreviewView = 'front',
764
+ ): AssetPreviewFraming {
765
+ resolveWorldMatricesLikeRenderer(object);
766
+ const box = readOnlyRenderableBounds(object, view);
767
+ if (box.isEmpty()) throw new Error('Asset preview source has no renderable bounds.');
768
+ const center = box.getCenter(new THREE.Vector3());
769
+ const size = box.getSize(new THREE.Vector3());
770
+ return {
771
+ center,
772
+ size,
773
+ radius: Math.max(size.length() / 2, 0.001),
774
+ };
775
+ }
776
+
777
+ /**
778
+ * Bring a subtree's world matrices to exactly the state the RENDERER will
779
+ * compute for it, then measure from that.
780
+ *
781
+ * This is not interchangeable with `updateWorldMatrix`, and the difference
782
+ * is measurable rather than stylistic: `SkinnedMesh` OVERRIDES
783
+ * `updateMatrixWorld` to refresh `bindMatrixInverse` from its current world
784
+ * matrix (attached bind mode), and `getVertexPosition` — how this module
785
+ * reads skinned geometry — runs the vertex through
786
+ * `bindMatrixInverse * boneWorld * boneInverse * bindMatrix`. Measure after
787
+ * `updateWorldMatrix` alone and `bindMatrixInverse` is still the value from
788
+ * before the subject was re-parented (for example under
789
+ * {@link faceFrontSubject}'s yaw pivot), so the mesh's own transform gets
790
+ * applied TWICE to every skinned vertex while the render applies it once.
791
+ *
792
+ * A yaw pivot of PI (our generated rigs persist `forward = [0,0,-1]`)
793
+ * therefore mirrored the measured bounds in Z: the size stayed right and the
794
+ * CENTRE flipped sign, which is invisible when the camera looks down Z
795
+ * (front/back) and pushes the subject bodily out of frame when it looks
796
+ * down X (left/right). That is the whole of the "long body runs off the
797
+ * frame edge at yaw +/-PI/2" defect.
798
+ *
799
+ * `updateMatrixWorld(force)` is used deliberately in preference to
800
+ * `updateWorldMatrix`: it honours `matrixWorldAutoUpdate` exactly as the
801
+ * renderer's own `scene.updateMatrixWorld()` does, so a snapshot that pins
802
+ * an exact composed matrix is measured the way it is drawn.
803
+ */
804
+ function resolveWorldMatricesLikeRenderer(object: THREE.Object3D): void {
805
+ object.parent?.updateWorldMatrix(true, false);
806
+ object.updateMatrixWorld(true);
807
+ }
808
+
809
+ /** Read exact renderable vertices without populating shared geometry bounds. */
810
+ function readOnlyRenderableBounds(root: THREE.Object3D, view: AssetPreviewView): THREE.Box3 {
811
+ const box = new THREE.Box3();
812
+ forEachRenderableVertex(root, assetViewBasis(view), view === 'perspective', (point) =>
813
+ box.expandByPoint(point),
814
+ );
815
+ return box;
816
+ }
817
+
818
+ /**
819
+ * The subject's ORIENTED extent along each supplied camera basis, from one
820
+ * walk of the same rendered vertices {@link readOnlyRenderableBounds} reads.
821
+ * This is what frames a turntable shot: an axis-aligned box is only a proxy
822
+ * for what a yawed camera sees, and for a long subject held in a bent pose
823
+ * it is a poor one.
824
+ *
825
+ * Sprites are billboards with no single true basis; their corners are
826
+ * expanded against the FIRST basis, which is an approximation this engine
827
+ * can afford (a rigged creature preview has no sprites) and never an
828
+ * under-estimate for the other bases by more than the sprite's own size.
829
+ */
830
+ function measureProjectedSpans(
831
+ root: THREE.Object3D,
832
+ bases: readonly OrthographicViewBasis[],
833
+ ): ProjectedSpan[] {
834
+ const spans = bases.map(() => createProjectedSpan());
835
+ if (bases.length === 0) return spans;
836
+ resolveWorldMatricesLikeRenderer(root);
837
+ forEachRenderableVertex(root, bases[0]!, false, (point) => {
838
+ for (let index = 0; index < bases.length; index++) {
839
+ expandProjectedSpan(spans[index]!, point, bases[index]!);
840
+ }
841
+ });
842
+ return spans;
843
+ }
844
+
845
+ /**
846
+ * How many of the staged subject's rendered PRIMITIVES could appear in each
847
+ * shot's frame, from ONE walk of the same vertices every other measurement
848
+ * here reads (the one-walk-many-bases shape {@link measureProjectedSpans}
849
+ * uses).
850
+ *
851
+ * This is the empty-frame guard's measurement. It is deliberately geometric
852
+ * rather than a pixel read of the rendered image: the capture already knows
853
+ * exactly which triangles it will draw and exactly which frustum each shot
854
+ * renders, so this is arithmetic — no readback, no threshold on a background
855
+ * colour, and it behaves identically for a transparent background and for a
856
+ * subject the same colour as the clear colour.
857
+ *
858
+ * The unit is a primitive rather than a vertex on purpose — see
859
+ * {@link spanOverlapsShotFrame}: a tight crop can sit entirely INSIDE one
860
+ * large face, containing no vertex while rendering solid geometry, and a
861
+ * guard that called that "empty" would be exactly the warning nobody
862
+ * believes.
863
+ */
864
+ function measureShotFrameCoverage(
865
+ subject: THREE.Object3D,
866
+ frames: readonly { basis: OrthographicViewBasis; window: ShotFrameWindow }[],
867
+ ): { framed: number[]; total: number } {
868
+ const framed = frames.map(() => 0);
869
+ let total = 0;
870
+ if (frames.length === 0) return { framed, total };
871
+ resolveWorldMatricesLikeRenderer(subject);
872
+ const spans = frames.map(() => createProjectedSpan());
873
+ let open = -1;
874
+ const closePrimitive = (): void => {
875
+ if (open < 0) return;
876
+ total++;
877
+ for (let index = 0; index < frames.length; index++) {
878
+ if (spanOverlapsShotFrame(spans[index]!, frames[index]!.window)) framed[index]!++;
879
+ resetProjectedSpan(spans[index]!);
880
+ }
881
+ };
882
+ forEachRenderableVertex(subject, frames[0]!.basis, false, (point, primitive) => {
883
+ if (primitive !== open) {
884
+ closePrimitive();
885
+ open = primitive;
886
+ }
887
+ for (let index = 0; index < frames.length; index++) {
888
+ expandProjectedSpan(spans[index]!, point, frames[index]!.basis);
889
+ }
890
+ });
891
+ closePrimitive();
892
+ return { framed, total };
893
+ }
894
+
895
+ /**
896
+ * Walk every vertex this subtree actually renders — skinned and morphed
897
+ * through `getVertexPosition`, per-instance for `InstancedMesh`, and the
898
+ * billboard quad's world-space corners for a `Sprite` — without populating
899
+ * bounding volumes on geometry shared read-only with the live source.
900
+ *
901
+ * `emit` also receives the index of the PRIMITIVE the vertex belongs to
902
+ * (triangle, line segment, sprite quad, splat box), which is constant across
903
+ * that primitive's vertices and strictly increasing across the walk. Callers
904
+ * that only need points ignore it; the empty-frame guard needs primitives,
905
+ * because a frame that contains no VERTEX may still be filled by one large
906
+ * face (see {@link spanOverlapsShotFrame}).
907
+ */
908
+ function forEachRenderableVertex(
909
+ root: THREE.Object3D,
910
+ basis: { right: THREE.Vector3; up: THREE.Vector3 },
911
+ perspective: boolean,
912
+ emit: (point: THREE.Vector3, primitive: number) => void,
913
+ ): void {
914
+ const splatBounds = new THREE.Box3();
915
+ const splatCorner = new THREE.Vector3();
916
+ const vertex = new THREE.Vector3();
917
+ const instanceMatrix = new THREE.Matrix4();
918
+ let primitive = 0;
919
+ const visit = (object: THREE.Object3D): void => {
920
+ if (!object.visible) return;
921
+ // A live scene subject (`captureSceneStageAssetPreview`) carries its game's
922
+ // implementation as real children — a world-space particle renderer at
923
+ // identity, a pooled batch — and framing on those puts the subject in a
924
+ // corner of its own portrait. Subtree-scoped, same rule as the selection
925
+ // cage (`content-bounds.ts`); a loaded model file carries no marks, so the
926
+ // asset lanes are unaffected. The ROOT is always measured: a caller who
927
+ // named it meant it.
928
+ if (object !== root && isBuiltInternal(object)) return;
929
+ if (hasUserData(object, 'gaussianSplat')) {
930
+ splatBounds.copy((object as SplatMesh).getBoundingBox()).applyMatrix4(object.matrixWorld);
931
+ const splatPrimitive = primitive++;
932
+ for (const x of [splatBounds.min.x, splatBounds.max.x]) {
933
+ for (const y of [splatBounds.min.y, splatBounds.max.y]) {
934
+ for (const z of [splatBounds.min.z, splatBounds.max.z]) {
935
+ emit(splatCorner.set(x, y, z), splatPrimitive);
936
+ }
937
+ }
938
+ }
939
+ }
940
+ const lod = object as THREE.LOD;
941
+ if (lod.isLOD && lod.levels.length > 0) {
942
+ const levelObjects = new Set(lod.levels.map((level) => level.object));
943
+ for (const level of lod.levels) visit(level.object);
944
+ for (const child of object.children) {
945
+ if (!levelObjects.has(child)) visit(child);
946
+ }
947
+ return;
948
+ }
949
+ const sprite = object as THREE.Sprite;
950
+ if (sprite.isSprite) {
951
+ if (sprite.material.visible) {
952
+ if (perspective && !sprite.material.sizeAttenuation) {
953
+ throw new Error(
954
+ 'Asset preview cannot deterministically frame a perspective Sprite with sizeAttenuation disabled.',
955
+ );
956
+ }
957
+ const anchor = new THREE.Vector3().setFromMatrixPosition(sprite.matrixWorld);
958
+ const scale = new THREE.Vector3().setFromMatrixScale(sprite.matrixWorld);
959
+ const cosine = Math.cos(sprite.material.rotation);
960
+ const sine = Math.sin(sprite.material.rotation);
961
+ const spritePrimitive = primitive++;
962
+ for (const x of [-sprite.center.x, 1 - sprite.center.x]) {
963
+ for (const y of [-sprite.center.y, 1 - sprite.center.y]) {
964
+ const scaledX = x * Math.abs(scale.x);
965
+ const scaledY = y * Math.abs(scale.y);
966
+ const rotatedX = cosine * scaledX - sine * scaledY;
967
+ const rotatedY = sine * scaledX + cosine * scaledY;
968
+ emit(
969
+ anchor
970
+ .clone()
971
+ .addScaledVector(basis.right, rotatedX)
972
+ .addScaledVector(basis.up, rotatedY),
973
+ spritePrimitive,
974
+ );
975
+ }
976
+ }
977
+ }
978
+ }
979
+ // Sprite's internal unit quad is shader-billboarded above; treating that
980
+ // private geometry as an ordinary world-space mesh would double-count it.
981
+ const geometry = sprite.isSprite
982
+ ? undefined
983
+ : (object as THREE.Mesh | THREE.Line | THREE.Points).geometry;
984
+ if (geometry) {
985
+ const position = geometry.getAttribute('position');
986
+ if (position) {
987
+ const renderedIndices = collectRenderedVertexIndices(object, geometry, position.count);
988
+ const stride = renderPrimitiveStride(object, renderedIndices.length);
989
+ const primitiveCount = Math.ceil(renderedIndices.length / stride);
990
+ const instanced = object as THREE.InstancedMesh;
991
+ if (instanced.isInstancedMesh) {
992
+ const morphProbe = new THREE.Mesh(instanced.geometry, instanced.material);
993
+ for (let instance = 0; instance < instanced.count; instance++) {
994
+ instanced.getMatrixAt(instance, instanceMatrix);
995
+ if (instanced.morphTexture) instanced.getMorphAt(instance, morphProbe);
996
+ renderedIndices.forEach((index, order) => {
997
+ morphProbe.getVertexPosition(index, vertex);
998
+ vertex.applyMatrix4(instanceMatrix).applyMatrix4(instanced.matrixWorld);
999
+ emit(vertex, primitive + Math.floor(order / stride));
1000
+ });
1001
+ primitive += primitiveCount;
1002
+ }
1003
+ } else {
1004
+ const mesh = object as THREE.Mesh;
1005
+ renderedIndices.forEach((index, order) => {
1006
+ if (mesh.isMesh) mesh.getVertexPosition(index, vertex);
1007
+ else getNonMeshVertexPosition(object as MorphableObject, geometry, index, vertex);
1008
+ vertex.applyMatrix4(object.matrixWorld);
1009
+ emit(vertex, primitive + Math.floor(order / stride));
1010
+ });
1011
+ primitive += primitiveCount;
1012
+ }
1013
+ }
1014
+ }
1015
+ for (const child of object.children) visit(child);
1016
+ };
1017
+ visit(root);
1018
+ }
1019
+
1020
+ function collectRenderedVertexIndices(
1021
+ object: THREE.Object3D,
1022
+ geometry: THREE.BufferGeometry,
1023
+ positionCount: number,
1024
+ ): number[] {
1025
+ const material = (object as THREE.Mesh | THREE.Line | THREE.Points).material;
1026
+ if (!material) return [];
1027
+ const elementCount = geometry.index?.count ?? positionCount;
1028
+ const drawStart = Math.max(0, geometry.drawRange.start);
1029
+ const drawEnd = Math.min(
1030
+ elementCount,
1031
+ Number.isFinite(geometry.drawRange.count) ? drawStart + geometry.drawRange.count : elementCount,
1032
+ );
1033
+ const ranges: Array<[number, number]> = [];
1034
+ if (Array.isArray(material)) {
1035
+ for (const group of geometry.groups) {
1036
+ const materialIndex = group.materialIndex ?? 0;
1037
+ if (!material[materialIndex]?.visible) continue;
1038
+ const start = Math.max(drawStart, group.start);
1039
+ const end = Math.min(drawEnd, group.start + group.count);
1040
+ if (end > start) ranges.push([start, end]);
1041
+ }
1042
+ } else if (material.visible && drawEnd > drawStart) {
1043
+ ranges.push([drawStart, drawEnd]);
1044
+ }
1045
+ const indices: number[] = [];
1046
+ for (const [start, end] of ranges) {
1047
+ const count = assembledElementCount(object, end - start);
1048
+ for (let element = start; element < start + count; element++) {
1049
+ indices.push(geometry.index ? geometry.index.getX(element) : element);
1050
+ }
1051
+ }
1052
+ return indices;
1053
+ }
1054
+
1055
+ /** How many consecutive emitted vertices form ONE drawn primitive, matching
1056
+ * the assembly {@link assembledElementCount} already trims the index list
1057
+ * to. A line STRIP is one connected run rather than N segments — grouping it
1058
+ * coarsely can only make the empty-frame guard quieter, never louder, which
1059
+ * is the safe direction for every approximation here. */
1060
+ function renderPrimitiveStride(object: THREE.Object3D, emittedCount: number): number {
1061
+ if ((object as THREE.Mesh).isMesh) return 3;
1062
+ if ((object as THREE.LineSegments).isLineSegments) return 2;
1063
+ if ((object as THREE.Line).isLine) return Math.max(emittedCount, 1);
1064
+ return 1;
1065
+ }
1066
+
1067
+ function assembledElementCount(object: THREE.Object3D, count: number): number {
1068
+ if ((object as THREE.Mesh).isMesh) return count - (count % 3);
1069
+ if ((object as THREE.LineSegments).isLineSegments) return count - (count % 2);
1070
+ if ((object as THREE.Line).isLine) return count >= 2 ? count : 0;
1071
+ return count;
1072
+ }
1073
+
1074
+ function getNonMeshVertexPosition(
1075
+ object: MorphableObject,
1076
+ geometry: THREE.BufferGeometry,
1077
+ index: number,
1078
+ target: THREE.Vector3,
1079
+ ): THREE.Vector3 {
1080
+ const position = geometry.getAttribute('position');
1081
+ target.fromBufferAttribute(position, index);
1082
+ const morphPositions = geometry.morphAttributes['position'];
1083
+ const influences = object.morphTargetInfluences;
1084
+ if (!morphPositions || !influences) return target;
1085
+ const base = target.clone();
1086
+ const delta = new THREE.Vector3();
1087
+ const sample = new THREE.Vector3();
1088
+ for (let morphIndex = 0; morphIndex < morphPositions.length; morphIndex++) {
1089
+ const influence = influences[morphIndex] ?? 0;
1090
+ if (influence === 0) continue;
1091
+ sample.fromBufferAttribute(morphPositions[morphIndex]!, index);
1092
+ if (!geometry.morphTargetsRelative) sample.sub(base);
1093
+ delta.addScaledVector(sample, influence);
1094
+ }
1095
+ return target.add(delta);
1096
+ }
1097
+
1098
+ function orthographicCamera(
1099
+ view: Exclude<AssetPreviewView, 'perspective'>,
1100
+ framing: AssetPreviewFraming,
1101
+ aspect: number,
1102
+ ): THREE.OrthographicCamera {
1103
+ const { center, size, radius } = framing;
1104
+ let horizontal = size.x;
1105
+ let vertical = size.y;
1106
+ const basis = assetViewBasis(view);
1107
+ const { direction, up } = basis;
1108
+
1109
+ if (view === 'right') {
1110
+ horizontal = size.z;
1111
+ } else {
1112
+ if (view === 'top') {
1113
+ horizontal = size.x;
1114
+ vertical = size.z;
1115
+ }
1116
+ }
1117
+
1118
+ const frame = fitOrthographicFrame(horizontal / 2, vertical / 2, aspect);
1119
+ return createOrthographicShotCamera(
1120
+ center,
1121
+ { direction, right: basis.right, up },
1122
+ frame,
1123
+ radius * 3 + 1,
1124
+ radius * 10 + 10,
1125
+ );
1126
+ }
1127
+
1128
+ function perspectiveCamera(
1129
+ framing: AssetPreviewFraming,
1130
+ aspect: number,
1131
+ requestedDirection?: THREE.Vector3,
1132
+ requestedDistance?: number,
1133
+ ): THREE.PerspectiveCamera {
1134
+ const camera = new THREE.PerspectiveCamera(35, aspect, 0.01, framing.radius * 20 + 20);
1135
+ const halfVerticalFov = THREE.MathUtils.degToRad(camera.fov / 2);
1136
+ const halfHorizontalFov = Math.atan(Math.tan(halfVerticalFov) * aspect);
1137
+ const defaultBasis = assetViewBasis('perspective');
1138
+ const viewDirection = requestedDirection?.clone().normalize() ?? defaultBasis.direction;
1139
+ const provisionalUp =
1140
+ Math.abs(viewDirection.y) > 0.999 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0);
1141
+ const right = requestedDirection
1142
+ ? provisionalUp.clone().cross(viewDirection).normalize()
1143
+ : defaultBasis.right;
1144
+ const up = requestedDirection ? viewDirection.clone().cross(right).normalize() : defaultBasis.up;
1145
+ const half = framing.size.clone().multiplyScalar(0.5);
1146
+ let distance = 0;
1147
+ for (const x of [-1, 1]) {
1148
+ for (const y of [-1, 1]) {
1149
+ for (const z of [-1, 1]) {
1150
+ const offset = new THREE.Vector3(x * half.x, y * half.y, z * half.z);
1151
+ const towardCamera = offset.dot(viewDirection);
1152
+ distance = Math.max(
1153
+ distance,
1154
+ towardCamera +
1155
+ (Math.abs(offset.dot(right)) * ASSET_PREVIEW_PADDING) / Math.tan(halfHorizontalFov),
1156
+ towardCamera +
1157
+ (Math.abs(offset.dot(up)) * ASSET_PREVIEW_PADDING) / Math.tan(halfVerticalFov),
1158
+ );
1159
+ }
1160
+ }
1161
+ }
1162
+ camera.position
1163
+ .copy(framing.center)
1164
+ .addScaledVector(viewDirection, Math.max(requestedDistance ?? distance, 0.01));
1165
+ camera.lookAt(framing.center);
1166
+ camera.updateProjectionMatrix();
1167
+ camera.updateMatrixWorld(true);
1168
+ return camera;
1169
+ }
1170
+
1171
+ function assetViewBasis(view: AssetPreviewView): {
1172
+ direction: THREE.Vector3;
1173
+ right: THREE.Vector3;
1174
+ up: THREE.Vector3;
1175
+ } {
1176
+ const direction =
1177
+ view === 'front'
1178
+ ? new THREE.Vector3(0, 0, 1)
1179
+ : view === 'right'
1180
+ ? new THREE.Vector3(1, 0, 0)
1181
+ : view === 'top'
1182
+ ? new THREE.Vector3(0, 1, 0)
1183
+ : new THREE.Vector3(1, 0.72, 1).normalize();
1184
+ const provisionalUp = view === 'top' ? new THREE.Vector3(0, 0, -1) : new THREE.Vector3(0, 1, 0);
1185
+ const right = provisionalUp.clone().cross(direction).normalize();
1186
+ const up = direction.clone().cross(right).normalize();
1187
+ return { direction, right, up };
1188
+ }
1189
+
1190
+ export function createAssetPreviewCamera(
1191
+ view: AssetPreviewView,
1192
+ framing: AssetPreviewFraming,
1193
+ aspect: number,
1194
+ ): THREE.Camera {
1195
+ return view === 'perspective'
1196
+ ? perspectiveCamera(framing, aspect)
1197
+ : orthographicCamera(view, framing, aspect);
1198
+ }
1199
+
1200
+ /** Aspect-aware perspective framing for interactive preview surfaces. Unlike
1201
+ * the fixed capture views, these may request a project-defined camera angle. */
1202
+ export function createPerspectiveAssetPreviewCamera(
1203
+ framing: AssetPreviewFraming,
1204
+ aspect: number,
1205
+ direction?: THREE.Vector3,
1206
+ ): THREE.PerspectiveCamera {
1207
+ return perspectiveCamera(framing, aspect, direction);
1208
+ }
1209
+
1210
+ /**
1211
+ * The turntable cameras for one staged subject, framed from what each yaw
1212
+ * ACTUALLY sees.
1213
+ *
1214
+ * Every yaw of a subject is measured separately (an oriented projection of
1215
+ * the rendered vertices onto that yaw's screen axes), each shot is centred
1216
+ * on its own projection, and the SCALE is the union across the set's yaws —
1217
+ * see {@link unionOrthographicFrames} for why the union rather than a
1218
+ * per-shot exact fit. The depth budget still comes from the subject's own
1219
+ * bounding radius, which no yaw can exceed.
1220
+ *
1221
+ * The predecessor derived every yaw from one world-axis-aligned box, which
1222
+ * both over-framed a bent, elongated subject and — because it centred every
1223
+ * yaw on that box's centre — placed the subject wrong the moment the
1224
+ * measured box and the drawn subject disagreed.
1225
+ */
1226
+ function turntableCameras(
1227
+ subject: THREE.Object3D,
1228
+ framing: AssetPreviewFraming,
1229
+ yaws: readonly number[],
1230
+ aspect: number,
1231
+ ): Map<number, THREE.OrthographicCamera> {
1232
+ const bases = yaws.map(turntableViewBasis);
1233
+ const spans = measureProjectedSpans(subject, bases);
1234
+ const frame = unionOrthographicFrames(
1235
+ spans.map((span): OrthographicFrame => fitProjectedSpanFrame(span, aspect)),
1236
+ );
1237
+ const cameras = new Map<number, THREE.OrthographicCamera>();
1238
+ yaws.forEach((yaw, index) => {
1239
+ cameras.set(
1240
+ yaw,
1241
+ createOrthographicShotCamera(
1242
+ projectedSpanCenter(spans[index]!, bases[index]!),
1243
+ bases[index]!,
1244
+ frame,
1245
+ framing.radius * 3 + 1,
1246
+ framing.radius * 10 + 10,
1247
+ ),
1248
+ );
1249
+ });
1250
+ return cameras;
1251
+ }
1252
+
1253
+ /** Six source-review views aligned to the asset's declared semantic forward
1254
+ * rather than mislabeled world axes. This is bounding-box framing only: it
1255
+ * does not require or modify a skeleton. */
1256
+ export function captureSourceReviewShotSetAssetPreview(
1257
+ source: THREE.Object3D,
1258
+ forward: readonly [number, number, number],
1259
+ options: AssetPreviewOptions = {},
1260
+ ): SourceReviewShotSetCapture {
1261
+ const width = checkedDimension(options.width);
1262
+ const height = checkedDimension(options.height);
1263
+ const background = options.background ?? 'neutral';
1264
+ const snapshot = createAssetPreviewSnapshot(source);
1265
+ let renderer: THREE.WebGLRenderer | null = null;
1266
+
1267
+ try {
1268
+ const framing = measureAssetPreview(snapshot, 'perspective');
1269
+ const scene = createPreviewScene(snapshot, framing);
1270
+ const activeRenderer = markHostRenderer(
1271
+ new THREE.WebGLRenderer({
1272
+ antialias: true,
1273
+ alpha: true,
1274
+ preserveDrawingBuffer: true,
1275
+ }),
1276
+ );
1277
+ renderer = activeRenderer;
1278
+ activeRenderer.setPixelRatio(1);
1279
+ activeRenderer.outputColorSpace = THREE.SRGBColorSpace;
1280
+ activeRenderer.toneMapping = THREE.ACESFilmicToneMapping;
1281
+ activeRenderer.toneMappingExposure = 1;
1282
+ activeRenderer.setClearColor(NEUTRAL_COLOR, background === 'transparent' ? 0 : 1);
1283
+ activeRenderer.setSize(width, height, false);
1284
+ applyStudioEnvironment(scene, activeRenderer);
1285
+
1286
+ const baseYaw = Math.atan2(forward[0], forward[2]);
1287
+ const aspect = width / height;
1288
+ const perspectiveDirection = (yaw: number) =>
1289
+ new THREE.Vector3(Math.sin(yaw), 0.72, Math.cos(yaw)).normalize();
1290
+ const orthographicYaws = [
1291
+ baseYaw,
1292
+ baseYaw + Math.PI,
1293
+ baseYaw - Math.PI / 2,
1294
+ baseYaw + Math.PI / 2,
1295
+ ];
1296
+ const turntable = turntableCameras(snapshot, framing, orthographicYaws, aspect);
1297
+ const shotCameras: Array<{ label: SourceReviewShotLabel; camera: THREE.Camera }> = [
1298
+ { label: 'front', camera: turntable.get(orthographicYaws[0]!)! },
1299
+ { label: 'back', camera: turntable.get(orthographicYaws[1]!)! },
1300
+ { label: 'left', camera: turntable.get(orthographicYaws[2]!)! },
1301
+ { label: 'right', camera: turntable.get(orthographicYaws[3]!)! },
1302
+ {
1303
+ label: 'quarter-left',
1304
+ camera: createPerspectiveAssetPreviewCamera(
1305
+ framing,
1306
+ aspect,
1307
+ perspectiveDirection(baseYaw - Math.PI / 4),
1308
+ ),
1309
+ },
1310
+ {
1311
+ label: 'quarter-right',
1312
+ camera: createPerspectiveAssetPreviewCamera(
1313
+ framing,
1314
+ aspect,
1315
+ perspectiveDirection(baseYaw + Math.PI / 4),
1316
+ ),
1317
+ },
1318
+ ];
1319
+
1320
+ const shots: SourceReviewShotSetCapture['shots'] = [];
1321
+ const columns = 2;
1322
+ const rows = 3;
1323
+ const sheetCanvas = document.createElement('canvas');
1324
+ sheetCanvas.width = width * columns;
1325
+ sheetCanvas.height = height * rows;
1326
+ const sheetContext = sheetCanvas.getContext('2d');
1327
+ if (!sheetContext) throw new Error('Unable to create the source-review contact sheet.');
1328
+
1329
+ shotCameras.forEach(({ label, camera }, index) => {
1330
+ activeRenderer.render(scene, camera);
1331
+ shots.push({ label, base64: pngBase64(activeRenderer.domElement), mimeType: 'image/png' });
1332
+ const x = (index % columns) * width;
1333
+ const y = Math.floor(index / columns) * height;
1334
+ sheetContext.drawImage(activeRenderer.domElement, x, y, width, height);
1335
+ const displayLabel =
1336
+ label === 'quarter-left'
1337
+ ? '3/4-L'
1338
+ : label === 'quarter-right'
1339
+ ? '3/4-R'
1340
+ : label.toUpperCase();
1341
+ drawBitmapLabel(sheetContext, displayLabel, x, y, width, height);
1342
+ });
1343
+
1344
+ return {
1345
+ width,
1346
+ height,
1347
+ shots,
1348
+ contactSheet: {
1349
+ width: width * columns,
1350
+ height: height * rows,
1351
+ base64: pngBase64(sheetCanvas),
1352
+ mimeType: 'image/png',
1353
+ },
1354
+ };
1355
+ } finally {
1356
+ if (renderer) disposeStudioEnvironment(renderer);
1357
+ renderer?.dispose();
1358
+ renderer?.forceContextLoss();
1359
+ disposeAssetPreviewSnapshot(snapshot);
1360
+ }
1361
+ }
1362
+
1363
+ /**
1364
+ * A tight orthographic close-up that frames every given world-space anchor
1365
+ * point (plus a margin proportional to the WHOLE MODEL's own bounding
1366
+ * radius — never a fixed reference-human height, so a goblin's crop stays
1367
+ * goblin-scaled). A single anchor (head, one shoulder) yields a fixed-size
1368
+ * crop around that point; two anchors that are naturally far apart in a
1369
+ * T-pose rig (both hands, both feet) still get a camera wide/tall enough to
1370
+ * hold both — the historical fixed-height-zoom defect the line-up harness's
1371
+ * own `zoom=head` fix (B5.1) already had to correct once, generalized here
1372
+ * to "the crop must actually contain what it claims to frame" for every
1373
+ * region, not just proportional scaling.
1374
+ *
1375
+ * The crop is taken from the shot's own `yaw` (the turntable convention;
1376
+ * omitted means 0, the front camera this used to be hardcoded to). An angle
1377
+ * is not decoration on a long subject: a junction whose axis runs down the
1378
+ * body's length — a tail root, a hip chain, a sail seen edge-on — has the
1379
+ * body itself standing between the front camera and the anchor, so the crop
1380
+ * frames the right point and photographs the wrong thing.
1381
+ */
1382
+ function boneZoomCamera(
1383
+ anchors: readonly THREE.Vector3[],
1384
+ basis: OrthographicViewBasis,
1385
+ overallRadius: number,
1386
+ spanFraction: number,
1387
+ aspect: number,
1388
+ ): THREE.OrthographicCamera {
1389
+ const center = boneZoomCenter(anchors);
1390
+ const frame = fitBoneZoomFrame(anchors, center, basis, overallRadius, spanFraction, aspect);
1391
+ const distance = overallRadius * 3 + 1;
1392
+ return createOrthographicShotCamera(
1393
+ center,
1394
+ basis,
1395
+ frame,
1396
+ distance,
1397
+ distance + overallRadius * 4 + 1,
1398
+ );
1399
+ }
1400
+
1401
+ /** Locate every bone a shot-set definition requires on the loaded GLB's OWN
1402
+ * skeleton (never a body-engine assumption). Throws loudly, naming exactly
1403
+ * which joints are missing, when the model has no rig at all or is missing
1404
+ * a required joint — no silent fallback to a generic bounding-box zoom for
1405
+ * a set that promised skeleton-anchored framing. */
1406
+ function findRequiredSkeletonBones(
1407
+ root: THREE.Object3D,
1408
+ definition: AssetPreviewShotSetDefinition,
1409
+ ): Map<string, THREE.Bone> {
1410
+ const bonesByName = new Map<string, THREE.Bone>();
1411
+ root.traverse((object) => {
1412
+ const bone = object as THREE.Bone;
1413
+ if (bone.isBone && object.name) bonesByName.set(object.name, bone);
1414
+ });
1415
+ const required = definition.requiredBones ?? [];
1416
+ const missing = required.filter((name) => !bonesByName.has(name));
1417
+ const hint = definition.rigRequirementHint ? ` (${definition.rigRequirementHint})` : '';
1418
+ if (required.length > 0 && bonesByName.size === 0) {
1419
+ throw new Error(
1420
+ `Asset preview '--shots ${definition.name}' requires a rigged GLB${hint}, but this ` +
1421
+ `model has no skeleton at all. Required joints: ${required.join(', ')}.`,
1422
+ );
1423
+ }
1424
+ if (missing.length > 0) {
1425
+ throw new Error(
1426
+ `Asset preview '--shots ${definition.name}' requires joints this GLB's skeleton does ` +
1427
+ `not have${hint}. Missing joint(s): ${missing.join(', ')}.`,
1428
+ );
1429
+ }
1430
+ return bonesByName;
1431
+ }
1432
+
1433
+ /**
1434
+ * Apply one of the definition's named poses to a snapshot in place — bone
1435
+ * rotations on the skeleton, morph influences on whatever meshes declare
1436
+ * them.
1437
+ *
1438
+ * BOTH halves degrade rather than throw, for the same reason: a pose may
1439
+ * touch a joint or a morph beyond the definition's `requiredBones` (a rig can
1440
+ * legitimately lack a separate upper-leg joint, or carry no face morphs at
1441
+ * all) without failing the zoom-anchor requirement. A missing step costs that
1442
+ * shot some realism; it is not a broken definition.
1443
+ */
1444
+ function applyShotSetPose(
1445
+ root: THREE.Object3D,
1446
+ bonesByName: Map<string, THREE.Bone>,
1447
+ steps: readonly ShotSetPoseStep[],
1448
+ ): void {
1449
+ const morphs = steps.filter(isPoseMorph);
1450
+ for (const step of steps) {
1451
+ if (isPoseRotation(step)) {
1452
+ const bone = bonesByName.get(step.bone);
1453
+ if (!bone) continue;
1454
+ if (step.axis === 'x') bone.rotateX(step.radians);
1455
+ else if (step.axis === 'y') bone.rotateY(step.radians);
1456
+ else bone.rotateZ(step.radians);
1457
+ } else if (isPoseTranslation(step)) {
1458
+ // The translation channel: same delta semantics as the rotation, along
1459
+ // the bone's own local axis — how a pose states a crouch, a jump apex,
1460
+ // or a gait's hip dip, which no set of rotations can.
1461
+ const bone = bonesByName.get(step.bone);
1462
+ if (!bone) continue;
1463
+ if (step.axis === 'x') bone.translateX(step.meters);
1464
+ else if (step.axis === 'y') bone.translateY(step.meters);
1465
+ else bone.translateZ(step.meters);
1466
+ }
1467
+ }
1468
+ if (morphs.length === 0) return;
1469
+ root.traverse((object) => {
1470
+ const mesh = object as THREE.Mesh;
1471
+ const dictionary = mesh.morphTargetDictionary;
1472
+ const influences = mesh.morphTargetInfluences;
1473
+ if (!dictionary || !influences) return;
1474
+ for (const { morph, influence } of morphs) {
1475
+ const index = dictionary[morph];
1476
+ if (index === undefined) continue;
1477
+ influences[index] = influence;
1478
+ }
1479
+ });
1480
+ }
1481
+
1482
+ /** First valid `userData.forward` in the tree (our baked rigs persist it on
1483
+ * the rig root, which sits below the GLTF scene root), else glTF's +Z.
1484
+ * Shared with the compare mode (`asset-compare.ts` re-exports it). */
1485
+ export function readModelForward(root: THREE.Object3D): [number, number, number] {
1486
+ let found: [number, number, number] | null = null;
1487
+ root.traverse((object) => {
1488
+ if (found) return;
1489
+ found = parseForwardVector(object.userData['forward']);
1490
+ });
1491
+ return found ?? [0, 0, 1];
1492
+ }
1493
+
1494
+ /**
1495
+ * Face-the-camera normalization (the compare path's forward detection,
1496
+ * applied to the labeled shot sets): wrap a snapshot in a pivot yawed so the
1497
+ * model's own forward faces +Z — the direction every 'front' camera looks
1498
+ * from. Models without forward extras keep the glTF +Z convention (yaw 0, an
1499
+ * exact no-op), so arbitrary GLBs render exactly as before; our generated
1500
+ * humanoids persist `userData.forward = [0,0,-1]` and previously had their
1501
+ * BACK photographed by every front-labeled shot.
1502
+ *
1503
+ * `forward` must be read from the SOURCE object (the compare path does the
1504
+ * same): render snapshots deliberately copy presentation state only, so the
1505
+ * forward extras never survive onto the snapshot itself.
1506
+ */
1507
+ function faceFrontSubject(
1508
+ snapshot: THREE.Object3D,
1509
+ forward: readonly [number, number, number],
1510
+ ): THREE.Group {
1511
+ const subject = new THREE.Group();
1512
+ subject.rotation.y = forwardYawRadians(forward);
1513
+ subject.add(snapshot);
1514
+ // Not `updateWorldMatrix`: a SkinnedMesh only refreshes its
1515
+ // `bindMatrixInverse` from `updateMatrixWorld`, and measuring before that
1516
+ // refresh applies the pivot twice to every skinned vertex — see
1517
+ // {@link resolveWorldMatricesLikeRenderer}.
1518
+ subject.updateMatrixWorld(true);
1519
+ return subject;
1520
+ }
1521
+
1522
+ /** One rendered staging of the subject — the rest snapshot, or a named
1523
+ * pose's disposable snapshot — with everything a shot needs from it. */
1524
+ interface StagedShotSubject {
1525
+ subject: THREE.Object3D;
1526
+ scene: THREE.Scene;
1527
+ framing: AssetPreviewFraming;
1528
+ }
1529
+
1530
+ /**
1531
+ * The Asset Lab's studio stage: the subject, a hemisphere ambient, a key and a
1532
+ * fill. The IBL that completes it is applied SEPARATELY
1533
+ * ({@link applyStudioEnvironment}), because a PMREM bake needs a live
1534
+ * renderer and every caller here builds its scene before its renderer.
1535
+ *
1536
+ * The hemisphere is scaled down by {@link STUDIO_AMBIENT_WITH_ENVIRONMENT}:
1537
+ * with an environment map in the scene it is the second, cruder ambient term,
1538
+ * and at its historical 1.8 the two together flattened the subject. Key and
1539
+ * fill are untouched — they still own the form.
1540
+ */
1541
+ function createPreviewScene(snapshot: THREE.Object3D, framing: AssetPreviewFraming): THREE.Scene {
1542
+ const scene = new THREE.Scene();
1543
+ scene.add(snapshot);
1544
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x354052, 1.8 * STUDIO_AMBIENT_WITH_ENVIRONMENT));
1545
+ const key = new THREE.DirectionalLight(0xffffff, 2.2);
1546
+ key.position
1547
+ .copy(framing.center)
1548
+ .addScaledVector(new THREE.Vector3(1, 1.5, 1), framing.radius * 3);
1549
+ scene.add(key);
1550
+ const fill = new THREE.DirectionalLight(0xbfd7ff, 0.8);
1551
+ fill.position
1552
+ .copy(framing.center)
1553
+ .addScaledVector(new THREE.Vector3(-1, 0.5, -0.7), framing.radius * 2);
1554
+ scene.add(fill);
1555
+ // An UNDER fill, weak and warm, so a shot from below (`--elevation -60`:
1556
+ // gills under a cap, a barrel's closed bottom) reads as a surface instead
1557
+ // of a black disc. Measured on the blind modeling bench (round 10): an
1558
+ // agent spent three looks and a probe deciding whether an unlit underside
1559
+ // was closed. Tops are unchanged — this light sees only downward faces.
1560
+ const under = new THREE.DirectionalLight(0xfff1dc, 0.45);
1561
+ under.position
1562
+ .copy(framing.center)
1563
+ .addScaledVector(new THREE.Vector3(0.3, -1, 0.4), framing.radius * 2);
1564
+ scene.add(under);
1565
+ return scene;
1566
+ }
1567
+
1568
+ // Captures are synchronous, so one offscreen context can serve them in turn.
1569
+ // Keep its studio bake between captures, like the model-thumbnail renderer.
1570
+ let objectCaptureRenderer: THREE.WebGLRenderer | null = null;
1571
+
1572
+ function disposeObjectCaptureRenderer(): void {
1573
+ const renderer = objectCaptureRenderer;
1574
+ objectCaptureRenderer = null;
1575
+ if (!renderer) return;
1576
+ disposeStudioEnvironment(renderer);
1577
+ renderer.dispose();
1578
+ renderer.forceContextLoss();
1579
+ }
1580
+
1581
+ function getObjectCaptureRenderer(): THREE.WebGLRenderer {
1582
+ if (objectCaptureRenderer?.getContext().isContextLost()) disposeObjectCaptureRenderer();
1583
+ if (!objectCaptureRenderer) {
1584
+ const renderer = markHostRenderer(
1585
+ new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }),
1586
+ );
1587
+ objectCaptureRenderer = renderer;
1588
+ renderer.domElement.addEventListener('webglcontextlost', () => {
1589
+ if (objectCaptureRenderer === renderer) disposeObjectCaptureRenderer();
1590
+ });
1591
+ }
1592
+ return objectCaptureRenderer;
1593
+ }
1594
+
1595
+ if (import.meta.hot) import.meta.hot.dispose(disposeObjectCaptureRenderer);
1596
+
1597
+ export function captureObjectAssetPreview(
1598
+ source: THREE.Object3D,
1599
+ options: AssetPreviewOptions = {},
1600
+ ): AssetPreviewCapture {
1601
+ let containsSplat = false;
1602
+ source.traverse((object) => {
1603
+ if (hasUserData(object, 'gaussianSplat')) containsSplat = true;
1604
+ });
1605
+ if (containsSplat) {
1606
+ throw new Error(
1607
+ 'Gaussian-splat capture requires the async project-asset capture path so Spark can sort each view.',
1608
+ );
1609
+ }
1610
+ const width = checkedDimension(options.width);
1611
+ const height = checkedDimension(options.height);
1612
+ const background = options.background ?? 'neutral';
1613
+ const snapshot = createAssetPreviewSnapshot(source);
1614
+
1615
+ try {
1616
+ // Yaw-normalize the subject so the fixed 'front' camera photographs the
1617
+ // model's actual front (see {@link faceFrontSubject}) — the framing
1618
+ // measurements run on the normalized subject so every view stays tight.
1619
+ // The yaw is reported and stamped onto the sheet, never applied silently
1620
+ // (see {@link AssetPreviewOrientation}).
1621
+ const forward = readModelForward(source);
1622
+ // Pose BEFORE facing/framing, on the disposable snapshot only: the
1623
+ // measured bounds and every camera must fit the posed body, and the
1624
+ // source is never mutated.
1625
+ if (options.pose) applyClipPose(snapshot, options.pose);
1626
+ const subject = faceFrontSubject(snapshot, forward);
1627
+ const orientation = assetPreviewOrientation(forward);
1628
+ const framing = measureAssetPreview(subject, 'perspective');
1629
+ const scene = createPreviewScene(subject, framing);
1630
+ const activeRenderer = getObjectCaptureRenderer();
1631
+ activeRenderer.setPixelRatio(1);
1632
+ activeRenderer.outputColorSpace = THREE.SRGBColorSpace;
1633
+ activeRenderer.toneMapping = THREE.ACESFilmicToneMapping;
1634
+ activeRenderer.toneMappingExposure = 1;
1635
+ activeRenderer.setClearColor(NEUTRAL_COLOR, background === 'transparent' ? 0 : 1);
1636
+ applyStudioEnvironment(scene, activeRenderer);
1637
+ if (options.camera) {
1638
+ return renderFreeCameraCapture(
1639
+ activeRenderer,
1640
+ scene,
1641
+ perspectiveCamera(
1642
+ framing,
1643
+ width / height,
1644
+ freeCameraDirection(options.camera),
1645
+ options.camera.distance,
1646
+ ),
1647
+ width,
1648
+ height,
1649
+ options.camera,
1650
+ orientation,
1651
+ );
1652
+ }
1653
+ const cameras = new Map<AssetPreviewView, THREE.Camera>();
1654
+ for (const view of ASSET_PREVIEW_VIEWS) {
1655
+ cameras.set(
1656
+ view,
1657
+ createAssetPreviewCamera(view, measureAssetPreview(subject, view), width / height),
1658
+ );
1659
+ }
1660
+ // The underside, from 30° round and 60° below — the frame the look used
1661
+ // to take as a separate second capture and write as `below.png`.
1662
+ const below = perspectiveCamera(
1663
+ framing,
1664
+ width / height,
1665
+ freeCameraDirection({ azimuthDegrees: 30, elevationDegrees: -60 }),
1666
+ );
1667
+ return renderFourViewCapture(activeRenderer, scene, cameras, width, height, orientation, below);
1668
+ } finally {
1669
+ disposeAssetPreviewSnapshot(snapshot);
1670
+ }
1671
+ }
1672
+
1673
+ /**
1674
+ * The standalone per-view PNG gets the SAME authored-axis marker the contact
1675
+ * sheet cell gets — an agent measuring from `front.png` alone must meet the
1676
+ * same stamped truth (see {@link AssetPreviewOrientation}). Views without a
1677
+ * marker (perspective) pass the renderer's canvas through untouched.
1678
+ */
1679
+ function stampAuthoredAxisMarker(
1680
+ rendered: HTMLCanvasElement,
1681
+ view: AssetPreviewView,
1682
+ orientation: AssetPreviewOrientation,
1683
+ ): HTMLCanvasElement {
1684
+ const marker = authoredAxisMarker(view, orientation.yawDegrees);
1685
+ if (!marker) return rendered;
1686
+ const canvas = document.createElement('canvas');
1687
+ canvas.width = rendered.width;
1688
+ canvas.height = rendered.height;
1689
+ const context = canvas.getContext('2d');
1690
+ if (!context) return rendered;
1691
+ context.drawImage(rendered, 0, 0);
1692
+ const box = measureBitmapLabel(marker.text, rendered.width, rendered.height);
1693
+ const markerX = marker.edge === 'left' ? 0 : rendered.width - box.width;
1694
+ drawBitmapLabel(
1695
+ context,
1696
+ marker.text,
1697
+ markerX,
1698
+ rendered.height - box.height,
1699
+ rendered.width,
1700
+ rendered.height,
1701
+ );
1702
+ return canvas;
1703
+ }
1704
+
1705
+ /** The reported orientation for a subject with this declared forward. */
1706
+ function assetPreviewOrientation(
1707
+ forward: readonly [number, number, number],
1708
+ ): AssetPreviewOrientation {
1709
+ const yaw = forwardYawRadians(forward);
1710
+ return {
1711
+ forward: [forward[0], forward[1], forward[2]],
1712
+ yawDegrees: Math.round(THREE.MathUtils.radToDeg(yaw)),
1713
+ };
1714
+ }
1715
+
1716
+ /**
1717
+ * Sample a named clip at a time onto a capture snapshot. The mixer binds by
1718
+ * node name against the snapshot's own clones and is deliberately never
1719
+ * stopped — `AnimationAction.stop` restores the pre-pose state, and the
1720
+ * snapshot is disposed after the capture anyway.
1721
+ */
1722
+ function applyClipPose(snapshot: THREE.Object3D, pose: AssetPreviewPose): void {
1723
+ if (!Number.isFinite(pose.timeSeconds) || pose.timeSeconds < 0) {
1724
+ throw new Error(
1725
+ `Asset preview pose time must be a finite number of seconds >= 0, got ${pose.timeSeconds}.`,
1726
+ );
1727
+ }
1728
+ const clips = snapshot.animations ?? [];
1729
+ const clip = clips.find((candidate) => candidate.name === pose.clip);
1730
+ if (!clip) {
1731
+ const available = clips.map((candidate) => `'${candidate.name}'`).join(', ');
1732
+ throw new Error(
1733
+ `Asset preview pose clip '${pose.clip}' not found on the subject. ` +
1734
+ (available
1735
+ ? `Its clips are: ${available}.`
1736
+ : 'The subject carries no animation clips at all — clips live on the model root ' +
1737
+ '(GLB animations, or a builder module\u2019s `root.animations`).'),
1738
+ );
1739
+ }
1740
+ const mixer = new THREE.AnimationMixer(snapshot);
1741
+ mixer.clipAction(clip).play();
1742
+ mixer.update(pose.timeSeconds);
1743
+ snapshot.updateMatrixWorld(true);
1744
+ }
1745
+
1746
+ /**
1747
+ * The free capture camera's direction in NORMALIZED space (the subject
1748
+ * already faces +Z after {@link faceFrontSubject}, so authored-front azimuth
1749
+ * 0 is world +Z here): the same heading convention as the turntable's yaw,
1750
+ * with elevation raising the camera above level.
1751
+ */
1752
+ function freeCameraDirection(camera: AssetPreviewCameraChoice): THREE.Vector3 {
1753
+ const azimuth = THREE.MathUtils.degToRad(camera.azimuthDegrees);
1754
+ const elevation = THREE.MathUtils.degToRad(camera.elevationDegrees);
1755
+ return new THREE.Vector3(
1756
+ Math.sin(azimuth) * Math.cos(elevation),
1757
+ Math.sin(elevation),
1758
+ Math.cos(azimuth) * Math.cos(elevation),
1759
+ ).normalize();
1760
+ }
1761
+
1762
+ /**
1763
+ * The single-view sibling of {@link renderFourViewCapture} for a chosen
1764
+ * camera: one perspective view, labeled with the angles that took it so the
1765
+ * evidence names its own viewpoint.
1766
+ */
1767
+ function renderFreeCameraCapture(
1768
+ renderer: THREE.WebGLRenderer,
1769
+ scene: THREE.Scene,
1770
+ camera: THREE.Camera,
1771
+ width: number,
1772
+ height: number,
1773
+ choice: AssetPreviewCameraChoice,
1774
+ orientation: AssetPreviewOrientation,
1775
+ ): AssetPreviewCapture {
1776
+ renderer.setSize(width, height, false);
1777
+ renderer.render(scene, camera);
1778
+ const view: AssetPreviewImage = {
1779
+ view: 'perspective',
1780
+ base64: pngBase64(renderer.domElement),
1781
+ mimeType: 'image/png',
1782
+ };
1783
+ const sheetCanvas = document.createElement('canvas');
1784
+ sheetCanvas.width = width;
1785
+ sheetCanvas.height = height;
1786
+ const sheetContext = sheetCanvas.getContext('2d');
1787
+ if (!sheetContext) throw new Error('Unable to create the asset preview contact sheet.');
1788
+ sheetContext.drawImage(renderer.domElement, 0, 0, width, height);
1789
+ const label =
1790
+ `AZ ${Math.round(choice.azimuthDegrees)} EL ${Math.round(choice.elevationDegrees)}` +
1791
+ (choice.distance === undefined ? '' : ` D ${Math.round(choice.distance)}`);
1792
+ drawBitmapLabel(sheetContext, label, 0, 0, width, height);
1793
+ return {
1794
+ width,
1795
+ height,
1796
+ orientation,
1797
+ views: [view],
1798
+ contactSheet: {
1799
+ width,
1800
+ height,
1801
+ base64: pngBase64(sheetCanvas),
1802
+ mimeType: 'image/png' as const,
1803
+ },
1804
+ };
1805
+ }
1806
+
1807
+ /**
1808
+ * The axis marker for one orthographic view: which AUTHORED axis points
1809
+ * screen-right after the subject's face-front yaw, spelled as `+X>` (that
1810
+ * axis points right) or `<+X` (it points left). `null` for the perspective
1811
+ * view, and for the rare non-cardinal yaw — where the marker instead names
1812
+ * the yaw itself so nothing is silently askew.
1813
+ */
1814
+ function authoredAxisMarker(
1815
+ view: AssetPreviewView,
1816
+ yawDegrees: number,
1817
+ ): { text: string; edge: 'left' | 'right' } | null {
1818
+ if (view === 'perspective') return null;
1819
+ const yaw = THREE.MathUtils.degToRad(yawDegrees);
1820
+ const screenRight = assetViewBasis(view).right.applyAxisAngle(new THREE.Vector3(0, 1, 0), -yaw);
1821
+ const axes: Array<[string, THREE.Vector3]> = [
1822
+ ['X', new THREE.Vector3(1, 0, 0)],
1823
+ ['Y', new THREE.Vector3(0, 1, 0)],
1824
+ ['Z', new THREE.Vector3(0, 0, 1)],
1825
+ ];
1826
+ for (const [name, axis] of axes) {
1827
+ const dot = screenRight.dot(axis);
1828
+ if (dot > 0.99) return { text: `+${name}>`, edge: 'right' };
1829
+ if (dot < -0.99) return { text: `<+${name}`, edge: 'left' };
1830
+ }
1831
+ return { text: `YAW ${yawDegrees}`, edge: 'left' };
1832
+ }
1833
+
1834
+ /**
1835
+ * Render the four fixed views and assemble the labeled 2x2 contact sheet.
1836
+ *
1837
+ * Extracted verbatim from {@link captureObjectAssetPreview}'s own loop (its
1838
+ * only caller until the scene stage below): the two stages differ in WHAT
1839
+ * they photograph — an isolated snapshot on a studio stage versus the entity
1840
+ * standing in the live scene — never in how the sheet is assembled, so a
1841
+ * caller comparing a lab and a scene capture is comparing subjects rather
1842
+ * than two independently-drifting sheet builders.
1843
+ */
1844
+ function renderFourViewCapture(
1845
+ renderer: THREE.WebGLRenderer,
1846
+ scene: THREE.Scene,
1847
+ cameras: Map<AssetPreviewView, THREE.Camera>,
1848
+ width: number,
1849
+ height: number,
1850
+ orientation: AssetPreviewOrientation,
1851
+ /** A fifth camera, the UNDERSIDE, drawn as the sheet's fifth tile (3 × 2).
1852
+ * Every blind modeling session read the sheet and then `below.png` as a
1853
+ * second image, ten seconds of generation per look; one sheet that carries
1854
+ * the underside is one read (2026-09-06). */
1855
+ below?: THREE.Camera,
1856
+ ): AssetPreviewCapture {
1857
+ const views: AssetPreviewImage[] = [];
1858
+ const tiles: (AssetPreviewView | 'below')[] = below
1859
+ ? [...ASSET_PREVIEW_VIEWS, 'below']
1860
+ : [...ASSET_PREVIEW_VIEWS];
1861
+ const columns = below ? 3 : 2;
1862
+ const sheetWidth = width * columns;
1863
+ const sheetHeight = height * 2;
1864
+ const sheetCanvas = document.createElement('canvas');
1865
+ sheetCanvas.width = sheetWidth;
1866
+ sheetCanvas.height = sheetHeight;
1867
+ const sheetContext = sheetCanvas.getContext('2d');
1868
+ if (!sheetContext) throw new Error('Unable to create the asset preview contact sheet.');
1869
+ if (renderer.getClearAlpha() > 0) {
1870
+ // A 3 × 2 sheet has one empty cell; paint it the stage colour so it
1871
+ // reads as margin, not as a white sixth frame.
1872
+ sheetContext.fillStyle = `#${new THREE.Color(NEUTRAL_COLOR).getHexString()}`;
1873
+ sheetContext.fillRect(0, 0, sheetWidth, sheetHeight);
1874
+ }
1875
+ renderer.setSize(width, height, false);
1876
+ tiles.forEach((view, index) => {
1877
+ const camera = view === 'below' ? below : cameras.get(view);
1878
+ if (!camera) return;
1879
+ renderer.render(scene, camera);
1880
+ views.push({
1881
+ view,
1882
+ base64:
1883
+ view === 'below'
1884
+ ? pngBase64(renderer.domElement)
1885
+ : pngBase64(stampAuthoredAxisMarker(renderer.domElement, view, orientation)),
1886
+ mimeType: 'image/png',
1887
+ });
1888
+ const column = index % columns;
1889
+ const rowFromTop = Math.floor(index / columns);
1890
+ const x = column * width;
1891
+ const y = rowFromTop * height;
1892
+ sheetContext.drawImage(renderer.domElement, x, y, width, height);
1893
+ const label = view === 'perspective' ? '3/4' : view.toUpperCase();
1894
+ drawBitmapLabel(sheetContext, label, x, y, width, height);
1895
+ if (view === 'below') return;
1896
+ // Stamp each orthographic cell with its authored-axis marker (`+X>` /
1897
+ // `<+X`): the sheet is evidence, and after a face-front yaw the authored
1898
+ // axes no longer match the screen's — a fact that must live in the same
1899
+ // pixels an agent measures from (see {@link AssetPreviewOrientation}).
1900
+ const marker = authoredAxisMarker(view, orientation.yawDegrees);
1901
+ if (marker) {
1902
+ const box = measureBitmapLabel(marker.text, width, height);
1903
+ const markerX = marker.edge === 'left' ? x : x + width - box.width;
1904
+ drawBitmapLabel(sheetContext, marker.text, markerX, y + height - box.height, width, height);
1905
+ }
1906
+ });
1907
+ const contactSheet = {
1908
+ width: sheetWidth,
1909
+ height: sheetHeight,
1910
+ base64: pngBase64(sheetCanvas),
1911
+ mimeType: 'image/png' as const,
1912
+ };
1913
+ return { width, height, orientation, views, contactSheet };
1914
+ }
1915
+
1916
+ /**
1917
+ * The `stage: 'scene'` half of entity capture: the entity photographed WHERE
1918
+ * IT STANDS in the live editor scene, under that scene's own lighting — the
1919
+ * counterpart to {@link captureObjectAssetPreview}'s neutral Asset Lab stage
1920
+ * (`'lab'`, which remains the default on every surface).
1921
+ *
1922
+ * The subject is deliberately NOT cloned, reparented or yaw-normalized the
1923
+ * way the lab stage's snapshot is: an entity in its authored context is the
1924
+ * whole product here, so the four fixed cameras are framed on its world
1925
+ * bounds with the same fit math the lab stage uses ({@link
1926
+ * measureAssetPreview} + {@link createAssetPreviewCamera}).
1927
+ *
1928
+ * Editor furniture is excluded by BOTH marking conventions, because neither
1929
+ * one covers everything (`editor-layers.ts`'s `isEditorOwnedObject` states
1930
+ * why): the cameras drop `EDITOR_LAYER`, and every editor-owned object in the
1931
+ * scene is hidden for the duration.
1932
+ *
1933
+ * That hiding is the ONLY live state this function touches, and it is the one
1934
+ * thing here that is not obviously self-restoring: it is undone in `finally`,
1935
+ * so a capture that throws mid-render still hands the human back their grid
1936
+ * and gizmos. The renderer is this function's own (created and destroyed
1937
+ * here, exactly as every other capture in this module does) — the editor's
1938
+ * viewport renderer and camera are never borrowed, so there is nothing of
1939
+ * theirs to put back.
1940
+ *
1941
+ * The scene's own `background`/`environment` render as authored;
1942
+ * `background: 'transparent'` clears only the alpha beneath them.
1943
+ */
1944
+ export function captureSceneStageAssetPreview(
1945
+ source: THREE.Object3D,
1946
+ scene: THREE.Scene,
1947
+ options: AssetPreviewOptions = {},
1948
+ ): AssetPreviewCapture {
1949
+ let containsSplat = false;
1950
+ source.traverse((object) => {
1951
+ if (hasUserData(object, 'gaussianSplat')) containsSplat = true;
1952
+ });
1953
+ if (containsSplat) {
1954
+ throw new Error(
1955
+ 'Gaussian-splat capture requires the async project-asset capture path so Spark can sort each view.',
1956
+ );
1957
+ }
1958
+ const width = checkedDimension(options.width);
1959
+ const height = checkedDimension(options.height);
1960
+ const background = options.background ?? 'neutral';
1961
+
1962
+ const hidden: THREE.Object3D[] = [];
1963
+ let renderer: THREE.WebGLRenderer | null = null;
1964
+ try {
1965
+ // Hidden BEFORE the framing measurement: an editor helper parented under
1966
+ // the subject must not enlarge the frame it is excluded from.
1967
+ scene.traverse((object) => {
1968
+ if (object.visible && isEditorOwnedObject(object)) {
1969
+ object.visible = false;
1970
+ hidden.push(object);
1971
+ }
1972
+ });
1973
+
1974
+ const cameras = new Map<AssetPreviewView, THREE.Camera>();
1975
+ for (const view of ASSET_PREVIEW_VIEWS) {
1976
+ const camera = createAssetPreviewCamera(
1977
+ view,
1978
+ measureAssetPreview(source, view),
1979
+ width / height,
1980
+ );
1981
+ // Authored content may live on any custom layer; only the editor's own
1982
+ // is dropped.
1983
+ camera.layers.enableAll();
1984
+ camera.layers.disable(EDITOR_LAYER);
1985
+ cameras.set(view, camera);
1986
+ }
1987
+
1988
+ const activeRenderer = markHostRenderer(
1989
+ new THREE.WebGLRenderer({
1990
+ antialias: true,
1991
+ alpha: true,
1992
+ preserveDrawingBuffer: true,
1993
+ }),
1994
+ );
1995
+ renderer = activeRenderer;
1996
+ activeRenderer.setPixelRatio(1);
1997
+ activeRenderer.outputColorSpace = THREE.SRGBColorSpace;
1998
+ activeRenderer.toneMapping = THREE.ACESFilmicToneMapping;
1999
+ activeRenderer.toneMappingExposure = 1;
2000
+ activeRenderer.setClearColor(NEUTRAL_COLOR, background === 'transparent' ? 0 : 1);
2001
+ // The scene stage never yaw-normalizes (the entity's authored context IS
2002
+ // the subject), so authored axes are world axes: yaw 0, markers included.
2003
+ return renderFourViewCapture(
2004
+ activeRenderer,
2005
+ scene,
2006
+ cameras,
2007
+ width,
2008
+ height,
2009
+ assetPreviewOrientation([0, 0, 1]),
2010
+ );
2011
+ } finally {
2012
+ for (const object of hidden) object.visible = true;
2013
+ renderer?.dispose();
2014
+ renderer?.forceContextLoss();
2015
+ }
2016
+ }
2017
+
2018
+ /** One shot, staged and framed but not yet rendered. */
2019
+ export interface PlannedShot {
2020
+ label: string;
2021
+ camera: THREE.Camera;
2022
+ scene: THREE.Scene;
2023
+ /** Present when this shot's frame contains no renderable geometry. The
2024
+ * capture still renders it — see {@link AssetPreviewShotWarning}. */
2025
+ warning?: AssetPreviewShotWarning;
2026
+ }
2027
+
2028
+ /** Everything {@link captureShotSetAssetPreview} needs before it touches a
2029
+ * GPU, plus the snapshots that staging allocated. */
2030
+ export interface ShotSetCapturePlan {
2031
+ shots: PlannedShot[];
2032
+ warnings: AssetPreviewShotWarning[];
2033
+ dispose(): void;
2034
+ }
2035
+
2036
+ /** The empty-frame warning's text, which is the whole product of the guard:
2037
+ * every surface that reports one (the capture result, the CLI, the contact
2038
+ * sheet's mark) points back at this string, so it names the shot, its
2039
+ * anchors, its pose AND the likeliest cause. */
2040
+ function emptyFrameWarning(
2041
+ shot: ShotSetShot,
2042
+ definitionName: string,
2043
+ totalPrimitives: number,
2044
+ ): AssetPreviewShotWarning {
2045
+ const posed = shot.pose === undefined ? '' : ` under pose '${shot.pose}'`;
2046
+ const aimed =
2047
+ shot.view === 'bone-zoom'
2048
+ ? `bone-zoom on ${shot.bones.join(', ')} at yaw ${(shot.yaw ?? 0).toFixed(3)}`
2049
+ : `turntable at yaw ${shot.yaw.toFixed(3)}`;
2050
+ const cause =
2051
+ shot.view === 'bone-zoom'
2052
+ ? 'Zoom anchors are read from the REST skeleton for every shot, so a pose that MOVES ' +
2053
+ 'the anchor leaves the crop behind where the joint used to be: re-aim the shot ' +
2054
+ '(another joint or yaw), widen its spanFraction, or drop it.'
2055
+ : 'Check the shot yaw and the pose it renders under.';
2056
+ return {
2057
+ label: shot.label,
2058
+ reason: 'empty-frame',
2059
+ ...(shot.view === 'bone-zoom' ? { bones: [...shot.bones] } : {}),
2060
+ ...(shot.pose === undefined ? {} : { pose: shot.pose }),
2061
+ message:
2062
+ `Asset preview '--shots ${definitionName}' shot ${JSON.stringify(shot.label)} (${aimed}` +
2063
+ `${posed}) framed NONE of the subject's ${totalPrimitives} rendered faces: the image is ` +
2064
+ `background only and proves nothing about the asset. ${cause}`,
2065
+ };
2066
+ }
2067
+
2068
+ /**
2069
+ * Stage and frame a labeled shot set against the MODEL'S OWN skeleton and
2070
+ * bounds (no assumed proportions), WITHOUT rendering: turntable angles and
2071
+ * tight bone-anchored zooms, per the project-supplied definition. Shots that
2072
+ * name a pose are staged on a disposable POSED snapshot (per named pose) so
2073
+ * the rest-pose shots and the loaded source model are never affected. Zoom
2074
+ * `spanFraction`s size each crop relative to the whole model's own bounding
2075
+ * radius — a short "goblin" rig gets a goblin-scaled crop, never a fixed
2076
+ * reference-human height.
2077
+ *
2078
+ * Split out of {@link captureShotSetAssetPreview} so that everything the
2079
+ * capture DECIDES — which snapshot, which camera, and whether a shot frames
2080
+ * any geometry at all — is reachable without a WebGL context, for the same
2081
+ * reason `asset-preview-framing.ts` exists. The caller owns `dispose()`.
2082
+ */
2083
+ export function planShotSetCapture(
2084
+ source: THREE.Object3D,
2085
+ definition: AssetPreviewShotSetDefinition,
2086
+ aspect: number,
2087
+ ): ShotSetCapturePlan {
2088
+ const snapshots: THREE.Object3D[] = [];
2089
+ const dispose = (): void => {
2090
+ for (const staged of snapshots) disposeAssetPreviewSnapshot(staged);
2091
+ snapshots.length = 0;
2092
+ };
2093
+ try {
2094
+ const snapshot = createAssetPreviewSnapshot(source);
2095
+ snapshots.push(snapshot);
2096
+ const bonesByName = findRequiredSkeletonBones(snapshot, definition);
2097
+ // Yaw-normalize the subject to face the front camera (the compare
2098
+ // path's forward detection — see {@link faceFrontSubject}): turntable
2099
+ // labels AND the +Z-fixed zoom crops are only true to the model's
2100
+ // facing once its forward actually points at them. Bone anchors are
2101
+ // read AFTER the wrap, so the zoom framing follows the rotation.
2102
+ const forward = readModelForward(source);
2103
+ const subject = faceFrontSubject(snapshot, forward);
2104
+ const framing = measureAssetPreview(subject, 'perspective');
2105
+ const scene = createPreviewScene(subject, framing);
2106
+
2107
+ // Each named pose used by a shot renders its own disposable snapshot
2108
+ // (same isolation pattern as the primary snapshot) so applying the
2109
+ // definition's joint rotations has zero side effects on the rest-pose
2110
+ // shots and the loaded source model is never mutated.
2111
+ const restStage: StagedShotSubject = { subject, scene, framing };
2112
+ const poseStages = new Map<string, StagedShotSubject>();
2113
+ for (const shot of definition.shots) {
2114
+ if (shot.pose === undefined || poseStages.has(shot.pose)) continue;
2115
+ const steps = definition.poses?.[shot.pose] ?? [];
2116
+ const poseSnapshot = createAssetPreviewSnapshot(source);
2117
+ snapshots.push(poseSnapshot);
2118
+ const poseBones = findRequiredSkeletonBones(poseSnapshot, definition);
2119
+ applyShotSetPose(poseSnapshot, poseBones, steps);
2120
+ poseSnapshot.updateMatrixWorld(true);
2121
+ const poseSubject = faceFrontSubject(poseSnapshot, forward);
2122
+ const poseFraming = measureAssetPreview(poseSubject, 'perspective');
2123
+ poseStages.set(shot.pose, {
2124
+ subject: poseSubject,
2125
+ scene: createPreviewScene(poseSubject, poseFraming),
2126
+ framing: poseFraming,
2127
+ });
2128
+ }
2129
+ const stageFor = (pose: string | undefined): StagedShotSubject =>
2130
+ pose === undefined ? restStage : poseStages.get(pose)!;
2131
+
2132
+ const anchor = (name: string): THREE.Vector3 =>
2133
+ new THREE.Vector3().setFromMatrixPosition(bonesByName.get(name)!.matrixWorld);
2134
+
2135
+ // Every turntable shot of one staged subject is framed together: each
2136
+ // yaw from its OWN projected bounds, at a scale shared across that
2137
+ // subject's yaws, so a long body in a bent pose is neither clipped at
2138
+ // yaw +/-PI/2 nor silently rescaled between frames.
2139
+ const turntableByStage = new Map<string | undefined, Map<number, THREE.OrthographicCamera>>();
2140
+ for (const pose of new Set(
2141
+ definition.shots
2142
+ .filter((shot) => shot.view === 'turntable')
2143
+ .map((shot) => shot.pose ?? undefined),
2144
+ )) {
2145
+ const staged = stageFor(pose);
2146
+ const yaws = [
2147
+ ...new Set(
2148
+ definition.shots
2149
+ .filter((shot) => shot.view === 'turntable' && (shot.pose ?? undefined) === pose)
2150
+ .map((shot) => (shot as Extract<ShotSetShot, { view: 'turntable' }>).yaw),
2151
+ ),
2152
+ ];
2153
+ turntableByStage.set(pose, turntableCameras(staged.subject, staged.framing, yaws, aspect));
2154
+ }
2155
+
2156
+ const shotCameras = definition.shots.map(
2157
+ (
2158
+ shot,
2159
+ ): {
2160
+ label: string;
2161
+ camera: THREE.OrthographicCamera;
2162
+ scene: THREE.Scene;
2163
+ stage: StagedShotSubject;
2164
+ basis: OrthographicViewBasis;
2165
+ } => {
2166
+ const staged = stageFor(shot.pose);
2167
+ if (shot.view === 'turntable') {
2168
+ return {
2169
+ label: shot.label,
2170
+ camera: turntableByStage.get(shot.pose ?? undefined)!.get(shot.yaw)!,
2171
+ scene: staged.scene,
2172
+ stage: staged,
2173
+ basis: turntableViewBasis(shot.yaw),
2174
+ };
2175
+ }
2176
+ // Bone-zoom anchors are read from the REST snapshot's bones, for
2177
+ // every shot including posed ones — and for an EXPRESSION STRIP
2178
+ // (several poses framed on the same head) that is the point, not a
2179
+ // limitation: one anchor set means one camera, so the frames differ
2180
+ // only by the face. A pose that MOVED its own anchor would want the
2181
+ // pose snapshot's bone map instead; no definition does that today,
2182
+ // and a strip never should.
2183
+ //
2184
+ // A zoom on multiple bones (both hands, both feet) sizes the crop to
2185
+ // actually contain every anchor, not just apply a fixed fraction of
2186
+ // the model's radius — see {@link boneZoomCamera}'s doc comment.
2187
+ const missingAnchor = shot.bones.find((bone) => !bonesByName.has(bone));
2188
+ if (missingAnchor !== undefined) {
2189
+ throw new Error(
2190
+ `Asset preview '--shots ${definition.name}' shot ` +
2191
+ `${JSON.stringify(shot.label)} zooms on a joint this GLB's skeleton does not ` +
2192
+ `have: ${missingAnchor}. List it in the definition's requiredBones or fix the rig.`,
2193
+ );
2194
+ }
2195
+ const basis = turntableViewBasis(shot.yaw ?? 0);
2196
+ return {
2197
+ label: shot.label,
2198
+ camera: boneZoomCamera(
2199
+ shot.bones.map(anchor),
2200
+ basis,
2201
+ framing.radius,
2202
+ shot.spanFraction,
2203
+ aspect,
2204
+ ),
2205
+ scene: staged.scene,
2206
+ stage: staged,
2207
+ basis,
2208
+ };
2209
+ },
2210
+ );
2211
+
2212
+ // The empty-frame guard. One vertex walk per STAGED SUBJECT covers every
2213
+ // shot taken of it (the same one-walk-many-bases shape
2214
+ // `measureProjectedSpans` uses), so the guard costs a constant number of
2215
+ // traversals rather than one per shot.
2216
+ const warnings: AssetPreviewShotWarning[] = [];
2217
+ const warningByIndex = new Map<number, AssetPreviewShotWarning>();
2218
+ const indicesByStage = new Map<StagedShotSubject, number[]>();
2219
+ shotCameras.forEach(({ stage }, index) => {
2220
+ const existing = indicesByStage.get(stage);
2221
+ if (existing) existing.push(index);
2222
+ else indicesByStage.set(stage, [index]);
2223
+ });
2224
+ for (const [stage, indices] of indicesByStage) {
2225
+ const { framed, total } = measureShotFrameCoverage(
2226
+ stage.subject,
2227
+ indices.map((index) => {
2228
+ const planned = shotCameras[index]!;
2229
+ return {
2230
+ basis: planned.basis,
2231
+ window: orthographicShotFrameWindow(planned.camera, planned.basis),
2232
+ };
2233
+ }),
2234
+ );
2235
+ indices.forEach((shotIndex, slot) => {
2236
+ if (framed[slot] !== 0) return;
2237
+ const warning = emptyFrameWarning(definition.shots[shotIndex]!, definition.name, total);
2238
+ warningByIndex.set(shotIndex, warning);
2239
+ warnings.push(warning);
2240
+ });
2241
+ }
2242
+
2243
+ return {
2244
+ shots: shotCameras.map(({ label, camera, scene: shotScene }, index) => {
2245
+ const warning = warningByIndex.get(index);
2246
+ return {
2247
+ label,
2248
+ camera,
2249
+ scene: shotScene,
2250
+ ...(warning ? { warning } : {}),
2251
+ };
2252
+ }),
2253
+ warnings,
2254
+ dispose,
2255
+ };
2256
+ } catch (error) {
2257
+ dispose();
2258
+ throw error;
2259
+ }
2260
+ }
2261
+
2262
+ /**
2263
+ * Render a labeled shot set — {@link planShotSetCapture}'s staging and
2264
+ * framing, rasterized, labeled and packed into a contact sheet.
2265
+ *
2266
+ * A shot the plan warned about is rendered like any other (an empty frame
2267
+ * mid-iteration must not kill a 20-shot render) and MARKED: its contact-sheet
2268
+ * label is drawn in warning red and suffixed. The contact sheet is the
2269
+ * artifact a reviewer actually looks at, and it outlives the terminal that
2270
+ * printed the warning — an unmarked background tile on it reads as coverage,
2271
+ * which is the whole harm this guard exists to stop.
2272
+ */
2273
+ export function captureShotSetAssetPreview(
2274
+ source: THREE.Object3D,
2275
+ definition: AssetPreviewShotSetDefinition,
2276
+ options: AssetPreviewOptions = {},
2277
+ ): LabeledShotSetCapture {
2278
+ const width = checkedDimension(options.width);
2279
+ const height = checkedDimension(options.height);
2280
+ const background = options.background ?? 'neutral';
2281
+ const plan = planShotSetCapture(source, definition, width / height);
2282
+ let renderer: THREE.WebGLRenderer | null = null;
2283
+
2284
+ try {
2285
+ const activeRenderer = markHostRenderer(
2286
+ new THREE.WebGLRenderer({
2287
+ antialias: true,
2288
+ alpha: true,
2289
+ preserveDrawingBuffer: true,
2290
+ }),
2291
+ );
2292
+ renderer = activeRenderer;
2293
+ activeRenderer.setPixelRatio(1);
2294
+ activeRenderer.outputColorSpace = THREE.SRGBColorSpace;
2295
+ activeRenderer.toneMapping = THREE.ACESFilmicToneMapping;
2296
+ activeRenderer.toneMappingExposure = 1;
2297
+ activeRenderer.setClearColor(NEUTRAL_COLOR, background === 'transparent' ? 0 : 1);
2298
+ activeRenderer.setSize(width, height, false);
2299
+ // A pose gets its own staged scene, so every scene in the plan is lit —
2300
+ // once, off the renderer's single cached bake.
2301
+ for (const planned of plan.shots) applyStudioEnvironment(planned.scene, activeRenderer);
2302
+
2303
+ const shots: LabeledShotSetCapture['shots'] = [];
2304
+ const columns = 2;
2305
+ const rows = Math.ceil(plan.shots.length / columns);
2306
+ const sheetWidth = width * columns;
2307
+ const sheetHeight = height * rows;
2308
+ const sheetCanvas = document.createElement('canvas');
2309
+ sheetCanvas.width = sheetWidth;
2310
+ sheetCanvas.height = sheetHeight;
2311
+ const sheetContext = sheetCanvas.getContext('2d');
2312
+ if (!sheetContext) throw new Error('Unable to create the asset preview contact sheet.');
2313
+
2314
+ plan.shots.forEach(({ label, camera, scene: shotScene, warning }, index) => {
2315
+ activeRenderer.render(shotScene, camera);
2316
+ shots.push({ label, base64: pngBase64(activeRenderer.domElement), mimeType: 'image/png' });
2317
+ const column = index % columns;
2318
+ const rowFromTop = Math.floor(index / columns);
2319
+ const x = column * width;
2320
+ const y = rowFromTop * height;
2321
+ sheetContext.drawImage(activeRenderer.domElement, x, y, width, height);
2322
+ drawBitmapLabel(
2323
+ sheetContext,
2324
+ warning ? `${label.toUpperCase()} - EMPTY` : label.toUpperCase(),
2325
+ x,
2326
+ y,
2327
+ width,
2328
+ height,
2329
+ warning ? 'warning' : 'normal',
2330
+ );
2331
+ });
2332
+
2333
+ const contactSheet = {
2334
+ width: sheetWidth,
2335
+ height: sheetHeight,
2336
+ base64: pngBase64(sheetCanvas),
2337
+ mimeType: 'image/png' as const,
2338
+ };
2339
+ return { width, height, shots, warnings: plan.warnings, contactSheet };
2340
+ } finally {
2341
+ if (renderer) disposeStudioEnvironment(renderer);
2342
+ renderer?.dispose();
2343
+ renderer?.forceContextLoss();
2344
+ plan.dispose();
2345
+ }
2346
+ }
2347
+
2348
+ function projectModelUrl(rawAssetPath: string): URL {
2349
+ // Accept the common mistake of passing the on-disk path (which still has
2350
+ // the `public/` folder that the dev server strips) and normalize it to
2351
+ // the served web path before validating, so `public/foo.glb` and
2352
+ // `/public/foo.glb` both resolve like `/foo.glb`.
2353
+ const assetPath = rawAssetPath.replace(/^\/?public(\/|$)/, '/');
2354
+ if (
2355
+ !assetPath.startsWith('/') ||
2356
+ assetPath.startsWith('//') ||
2357
+ assetPath.includes('\\') ||
2358
+ assetPath.includes('?') ||
2359
+ assetPath.includes('#')
2360
+ ) {
2361
+ throw new Error(
2362
+ 'Model asset paths must be the served web path (project-root absolute, with public/ ' +
2363
+ 'stripped): e.g. /models/generated/hero.glb',
2364
+ );
2365
+ }
2366
+ let decodedPath: string;
2367
+ try {
2368
+ decodedPath = decodeURIComponent(assetPath);
2369
+ } catch {
2370
+ throw new Error('Asset Editor model path contains invalid URL encoding.');
2371
+ }
2372
+ if (decodedPath.split('/').some((segment) => segment === '.' || segment === '..')) {
2373
+ throw new Error('Asset Editor model paths cannot traverse outside the project root.');
2374
+ }
2375
+ const projectOrigin = globalThis.location?.origin ?? 'http://localhost';
2376
+ const url = new URL(assetPath, projectOrigin);
2377
+ if (url.origin !== projectOrigin || !url.pathname.toLowerCase().match(/\.(?:glb|gltf|spz)$/)) {
2378
+ throw new Error(
2379
+ 'Asset Editor loads bounded, same-origin project .glb, .gltf, or .spz files only.',
2380
+ );
2381
+ }
2382
+ return url;
2383
+ }
2384
+
2385
+ async function boundedModelBytes(
2386
+ url: URL,
2387
+ signal: AbortSignal,
2388
+ maxBytes = MAX_MODEL_BYTES,
2389
+ ): Promise<ArrayBuffer> {
2390
+ const response = await fetch(url, { signal });
2391
+ if (!response.ok) {
2392
+ throw new Error(`Unable to load project model (${response.status} ${response.statusText}).`);
2393
+ }
2394
+ const declaredLength = Number(response.headers.get('content-length'));
2395
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
2396
+ throw new Error(
2397
+ `Asset Editor project models must be ${MAX_MODEL_BYTES / 1024 / 1024} MiB or less.`,
2398
+ );
2399
+ }
2400
+ if (!response.body) {
2401
+ const bytes = await response.arrayBuffer();
2402
+ if (bytes.byteLength > maxBytes) {
2403
+ throw new Error(
2404
+ `Asset Editor project models must be ${MAX_MODEL_BYTES / 1024 / 1024} MiB or less.`,
2405
+ );
2406
+ }
2407
+ return bytes;
2408
+ }
2409
+
2410
+ const reader = response.body.getReader();
2411
+ const chunks: Uint8Array[] = [];
2412
+ let total = 0;
2413
+ try {
2414
+ while (true) {
2415
+ const { done, value } = await reader.read();
2416
+ if (done) break;
2417
+ total += value.byteLength;
2418
+ if (total > maxBytes) {
2419
+ await reader.cancel();
2420
+ throw new Error(
2421
+ `Asset Editor project models must be ${MAX_MODEL_BYTES / 1024 / 1024} MiB or less.`,
2422
+ );
2423
+ }
2424
+ chunks.push(value);
2425
+ }
2426
+ } finally {
2427
+ reader.releaseLock();
2428
+ }
2429
+ const combined = new Uint8Array(total);
2430
+ let offset = 0;
2431
+ for (const chunk of chunks) {
2432
+ combined.set(chunk, offset);
2433
+ offset += chunk.byteLength;
2434
+ }
2435
+ return combined.buffer;
2436
+ }
2437
+
2438
+ function parseGltfData(
2439
+ data: ArrayBuffer | string,
2440
+ sourceUrl: URL,
2441
+ signal: AbortSignal,
2442
+ ): Promise<THREE.Object3D> {
2443
+ return new Promise((resolve, reject) => {
2444
+ let settled = false;
2445
+ const finish = (callback: () => void) => {
2446
+ if (settled) return false;
2447
+ settled = true;
2448
+ signal.removeEventListener('abort', abort);
2449
+ callback();
2450
+ return true;
2451
+ };
2452
+ const abort = () => finish(() => reject(signal.reason));
2453
+ signal.addEventListener('abort', abort, { once: true });
2454
+ if (signal.aborted) {
2455
+ abort();
2456
+ return;
2457
+ }
2458
+ gltfLoader.parse(
2459
+ data,
2460
+ new URL('.', sourceUrl).href,
2461
+ (gltf) => {
2462
+ // GLTFLoader keeps clips on the GLTF result rather than its scene.
2463
+ // Asset Editor's model contract is Object3D-shaped, so preserve those
2464
+ // clips instead of silently degrading animated assets into static ones.
2465
+ gltf.scene.animations = [...gltf.animations];
2466
+ if (!finish(() => resolve(gltf.scene))) {
2467
+ disposeProjectAssetModel(gltf.scene);
2468
+ }
2469
+ },
2470
+ (error) => finish(() => reject(error)),
2471
+ );
2472
+ });
2473
+ }
2474
+
2475
+ type ExternalGltfResource = { uri?: unknown; mimeType?: unknown };
2476
+
2477
+ function projectDependencyUrl(uri: string, sourceUrl: URL): URL {
2478
+ if (uri.includes('\\') || uri.includes('?') || uri.includes('#')) {
2479
+ throw new Error('Asset Editor .gltf dependencies must be plain same-origin project paths.');
2480
+ }
2481
+ const url = new URL(uri, sourceUrl);
2482
+ if (url.origin !== sourceUrl.origin) {
2483
+ throw new Error('Asset Editor .gltf dependencies must stay on the project origin.');
2484
+ }
2485
+ return url;
2486
+ }
2487
+
2488
+ function dependencyMimeType(resource: ExternalGltfResource, url: URL): string {
2489
+ if (typeof resource.mimeType === 'string') return resource.mimeType;
2490
+ const extension = url.pathname.split('.').pop()?.toLowerCase();
2491
+ if (extension === 'png') return 'image/png';
2492
+ if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg';
2493
+ if (extension === 'webp') return 'image/webp';
2494
+ return 'application/octet-stream';
2495
+ }
2496
+
2497
+ async function inlineExternalGltfResources(
2498
+ bytes: ArrayBuffer,
2499
+ sourceUrl: URL,
2500
+ signal: AbortSignal,
2501
+ ): Promise<string> {
2502
+ let document: Record<string, unknown>;
2503
+ try {
2504
+ document = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;
2505
+ } catch {
2506
+ throw new Error('Asset Editor could not parse the project .gltf JSON document.');
2507
+ }
2508
+ const resources = [
2509
+ ...(Array.isArray(document['buffers']) ? (document['buffers'] as ExternalGltfResource[]) : []),
2510
+ ...(Array.isArray(document['images']) ? (document['images'] as ExternalGltfResource[]) : []),
2511
+ ];
2512
+ let totalBytes = bytes.byteLength;
2513
+ for (const resource of resources) {
2514
+ if (typeof resource.uri !== 'string' || resource.uri.startsWith('data:')) continue;
2515
+ const dependencyUrl = projectDependencyUrl(resource.uri, sourceUrl);
2516
+ const dependency = await boundedModelBytes(dependencyUrl, signal, MAX_MODEL_BYTES - totalBytes);
2517
+ totalBytes += dependency.byteLength;
2518
+ const dependencyBytes = new Uint8Array(dependency);
2519
+ let binary = '';
2520
+ for (let offset = 0; offset < dependencyBytes.length; offset += 32_768) {
2521
+ binary += String.fromCharCode(...dependencyBytes.subarray(offset, offset + 32_768));
2522
+ }
2523
+ resource.uri = `data:${dependencyMimeType(resource, dependencyUrl)};base64,${btoa(binary)}`;
2524
+ }
2525
+ return JSON.stringify(document);
2526
+ }
2527
+
2528
+ /**
2529
+ * Parse a base64 GLB that travelled IN the relay command, rather than one
2530
+ * fetched from the project origin.
2531
+ *
2532
+ * EXTRACTED VERBATIM from `asset-compare.ts`'s private `parseRefGlb` (the
2533
+ * compare mode has shipped reference GLBs over the wire this way since B8.4);
2534
+ * `label` is the one addition, so each caller keeps its own error wording
2535
+ * while there is a single decoder. The bound is the SAME 64 MiB the fetched
2536
+ * path enforces (`MAX_MODEL_BYTES`) — bytes that arrive over the relay are no
2537
+ * cheaper to rasterize than bytes fetched over HTTP.
2538
+ */
2539
+ export function parseGlbBytesModel(base64: string, label: string): Promise<THREE.Object3D> {
2540
+ let bytes: Uint8Array<ArrayBuffer>;
2541
+ try {
2542
+ const binary = atob(base64);
2543
+ bytes = new Uint8Array(binary.length);
2544
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
2545
+ } catch {
2546
+ throw new Error(`${label} is not valid base64.`);
2547
+ }
2548
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_MODEL_BYTES) {
2549
+ throw new Error(`${label} must be non-empty and at most ${MAX_MODEL_BYTES / 1024 / 1024} MiB.`);
2550
+ }
2551
+ return new Promise((resolve, reject) => {
2552
+ gltfLoader.parse(
2553
+ bytes.buffer,
2554
+ '',
2555
+ (gltf) => {
2556
+ // Same contract as parseGltfData: GLTFLoader keeps clips on the GLTF
2557
+ // result, and the module lane's pose/clip params need them on the root.
2558
+ gltf.scene.animations = [...gltf.animations];
2559
+ resolve(gltf.scene);
2560
+ },
2561
+ (error) => reject(error instanceof Error ? error : new Error(String(error))),
2562
+ );
2563
+ });
2564
+ }
2565
+
2566
+ /**
2567
+ * The Asset Lab's four-view capture of a model that exists only as BYTES — no
2568
+ * project path, no live entity. This is the rasterization half of the
2569
+ * "module path" look (`project.bake.preview`): a Node host builds a project
2570
+ * module's `Object3D`, exports it to an in-memory GLB, and hands those bytes
2571
+ * here, because Node has no GPU and the editor session does.
2572
+ *
2573
+ * Deliberately the `'lab'` stage and nothing else — bytes stand nowhere, so
2574
+ * there is no scene to stage them in.
2575
+ */
2576
+ export async function captureGlbBytesAssetPreview(
2577
+ glbBase64: string,
2578
+ options: AssetPreviewOptions = {},
2579
+ ): Promise<AssetPreviewCapture> {
2580
+ const model = await parseGlbBytesModel(glbBase64, 'Asset preview GLB bytes');
2581
+ try {
2582
+ return captureObjectAssetPreview(model, options);
2583
+ } finally {
2584
+ disposeProjectAssetModel(model);
2585
+ }
2586
+ }
2587
+
2588
+ /**
2589
+ * The same bytes, photographed as a LABELED SHOT SET instead of the four
2590
+ * views — what `vgai screenshot <module> --orbit <n>` renders. A shot set
2591
+ * stages the subject itself, exactly the way the asset-path lane's
2592
+ * {@link captureShotSetModelPreview} does, so bytes are no less valid a
2593
+ * subject here than a project GLB is.
2594
+ */
2595
+ export async function captureShotSetGlbBytesPreview(
2596
+ glbBase64: string,
2597
+ definition: AssetPreviewShotSetDefinition,
2598
+ options: AssetPreviewOptions = {},
2599
+ ): Promise<LabeledShotSetCapture> {
2600
+ const model = await parseGlbBytesModel(glbBase64, 'Asset preview GLB bytes');
2601
+ try {
2602
+ return captureShotSetAssetPreview(model, definition, options);
2603
+ } finally {
2604
+ disposeProjectAssetModel(model);
2605
+ }
2606
+ }
2607
+
2608
+ /** A loaded project model plus, for a self-contained GLB, the exact container
2609
+ * bytes that were parsed. GLB is the one form a writer can amend in place, so
2610
+ * it is the only one that carries them; every other form answers `null`. */
2611
+ export interface ProjectAssetModelSource {
2612
+ readonly root: THREE.Object3D;
2613
+ readonly sourceBytes: Uint8Array | null;
2614
+ }
2615
+
2616
+ export async function loadProjectAssetModelSource(
2617
+ assetPath: string,
2618
+ externalSignal?: AbortSignal,
2619
+ ): Promise<ProjectAssetModelSource> {
2620
+ const url = projectModelUrl(assetPath);
2621
+ const controller = new AbortController();
2622
+ const abortFromCaller = () => controller.abort(externalSignal?.reason);
2623
+ if (externalSignal?.aborted) abortFromCaller();
2624
+ else externalSignal?.addEventListener('abort', abortFromCaller, { once: true });
2625
+ const timeout = globalThis.setTimeout(
2626
+ () =>
2627
+ controller.abort(new Error('Asset Editor project model load timed out after 25 seconds.')),
2628
+ MODEL_FETCH_TIMEOUT_MS,
2629
+ );
2630
+ try {
2631
+ const bytes = await boundedModelBytes(url, controller.signal);
2632
+ controller.signal.throwIfAborted();
2633
+ if (url.pathname.toLowerCase().endsWith('.spz')) {
2634
+ return { root: await loadSplat(assetPath, new Uint8Array(bytes)), sourceBytes: null };
2635
+ }
2636
+ if (url.pathname.toLowerCase().endsWith('.glb')) {
2637
+ // Copy BEFORE parsing. Compressed-primitive extensions hand their views
2638
+ // to workers, and a transferred buffer would leave the surgical GLB
2639
+ // animation writer holding a detached view of its own source.
2640
+ const sourceBytes = new Uint8Array(bytes.slice(0));
2641
+ return { root: await parseGltfData(bytes, url, controller.signal), sourceBytes };
2642
+ }
2643
+ const json = await inlineExternalGltfResources(bytes, url, controller.signal);
2644
+ return { root: await parseGltfData(json, url, controller.signal), sourceBytes: null };
2645
+ } finally {
2646
+ globalThis.clearTimeout(timeout);
2647
+ externalSignal?.removeEventListener('abort', abortFromCaller);
2648
+ }
2649
+ }
2650
+
2651
+ export async function loadProjectAssetModel(
2652
+ assetPath: string,
2653
+ externalSignal?: AbortSignal,
2654
+ ): Promise<THREE.Object3D> {
2655
+ return (await loadProjectAssetModelSource(assetPath, externalSignal)).root;
2656
+ }
2657
+
2658
+ /** Release a model returned by {@link loadProjectAssetModel}. */
2659
+ export function disposeProjectAssetModel(root: THREE.Object3D): void {
2660
+ if (hasUserData(root, 'gaussianSplat')) (root as SplatMesh).dispose();
2661
+ disposeAssetPreviewSnapshot(root);
2662
+ root.traverse((object) => {
2663
+ const instanced = object as THREE.InstancedMesh;
2664
+ if (instanced.isInstancedMesh) instanced.morphTexture?.dispose();
2665
+ const mesh = object as THREE.Mesh;
2666
+ mesh.geometry?.dispose();
2667
+ if (!mesh.material) return;
2668
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
2669
+ for (const material of materials) {
2670
+ for (const value of Object.values(material)) {
2671
+ if (value instanceof THREE.Texture) value.dispose();
2672
+ }
2673
+ material.dispose();
2674
+ }
2675
+ });
2676
+ }
2677
+
2678
+ export async function captureModelAssetPreview(
2679
+ assetPath: string,
2680
+ options: AssetPreviewOptions = {},
2681
+ ): Promise<AssetPreviewCapture> {
2682
+ const model = await loadProjectAssetModel(assetPath);
2683
+ try {
2684
+ if (hasUserData(model, 'gaussianSplat'))
2685
+ return await captureSplatAssetPreview(model as SplatMesh, options);
2686
+ return captureObjectAssetPreview(model, options);
2687
+ } finally {
2688
+ disposeProjectAssetModel(model);
2689
+ }
2690
+ }
2691
+
2692
+ export async function captureSourceReviewShotSetModelPreview(
2693
+ assetPath: string,
2694
+ forward: readonly [number, number, number],
2695
+ options: AssetPreviewOptions = {},
2696
+ ): Promise<SourceReviewShotSetCapture> {
2697
+ const model = await loadProjectAssetModel(assetPath);
2698
+ try {
2699
+ return captureSourceReviewShotSetAssetPreview(model, forward, options);
2700
+ } finally {
2701
+ disposeProjectAssetModel(model);
2702
+ }
2703
+ }
2704
+
2705
+ async function captureSplatAssetPreview(
2706
+ model: SplatMesh,
2707
+ options: AssetPreviewOptions,
2708
+ ): Promise<AssetPreviewCapture> {
2709
+ const width = checkedDimension(options.width);
2710
+ const height = checkedDimension(options.height);
2711
+ const background = options.background ?? 'neutral';
2712
+ const framing = measureAssetPreview(model, 'perspective');
2713
+ const scene = createPreviewScene(model, framing);
2714
+ const renderer = markHostRenderer(
2715
+ new THREE.WebGLRenderer({
2716
+ antialias: false,
2717
+ alpha: true,
2718
+ preserveDrawingBuffer: true,
2719
+ }),
2720
+ );
2721
+ let spark: SparkRenderer | null = null;
2722
+
2723
+ try {
2724
+ const sparkModule = await import('@sparkjsdev/spark');
2725
+ spark = new sparkModule.SparkRenderer({ renderer, autoUpdate: false, enableLod: false });
2726
+ scene.add(spark);
2727
+ renderer.setPixelRatio(1);
2728
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
2729
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
2730
+ renderer.toneMappingExposure = 1;
2731
+ renderer.setClearColor(NEUTRAL_COLOR, background === 'transparent' ? 0 : 1);
2732
+ renderer.setSize(width, height, false);
2733
+ applyStudioEnvironment(scene, renderer);
2734
+
2735
+ const views: AssetPreviewImage[] = [];
2736
+ const sheetCanvas = document.createElement('canvas');
2737
+ sheetCanvas.width = width * 2;
2738
+ sheetCanvas.height = height * 2;
2739
+ const sheetContext = sheetCanvas.getContext('2d');
2740
+ if (!sheetContext) throw new Error('Unable to create the asset preview contact sheet.');
2741
+
2742
+ for (const [index, view] of ASSET_PREVIEW_VIEWS.entries()) {
2743
+ const camera = createAssetPreviewCamera(
2744
+ view,
2745
+ measureAssetPreview(model, view),
2746
+ width / height,
2747
+ );
2748
+ await spark.update({ scene, camera });
2749
+ renderer.render(scene, camera);
2750
+ views.push({ view, base64: pngBase64(renderer.domElement), mimeType: 'image/png' });
2751
+ const x = (index % 2) * width;
2752
+ const y = Math.floor(index / 2) * height;
2753
+ sheetContext.drawImage(renderer.domElement, x, y, width, height);
2754
+ drawBitmapLabel(
2755
+ sheetContext,
2756
+ view === 'perspective' ? '3/4' : view.toUpperCase(),
2757
+ x,
2758
+ y,
2759
+ width,
2760
+ height,
2761
+ );
2762
+ }
2763
+
2764
+ return {
2765
+ width,
2766
+ height,
2767
+ // Splat capture never yaw-normalizes its subject: world axes hold.
2768
+ orientation: assetPreviewOrientation([0, 0, 1]),
2769
+ views,
2770
+ contactSheet: {
2771
+ width: width * 2,
2772
+ height: height * 2,
2773
+ base64: pngBase64(sheetCanvas),
2774
+ mimeType: 'image/png',
2775
+ },
2776
+ };
2777
+ } finally {
2778
+ spark?.removeFromParent();
2779
+ model.removeFromParent();
2780
+ spark?.dispose();
2781
+ disposeStudioEnvironment(renderer);
2782
+ renderer.dispose();
2783
+ renderer.forceContextLoss();
2784
+ }
2785
+ }
2786
+
2787
+ /** The `--shots <set>` asset-path entry point — loads the project GLB the
2788
+ * same bounded/validated way {@link captureModelAssetPreview} does, then
2789
+ * renders the definition's labeled shot set against ITS OWN skeleton
2790
+ * (see {@link captureShotSetAssetPreview}). */
2791
+ export async function captureShotSetModelPreview(
2792
+ assetPath: string,
2793
+ definition: AssetPreviewShotSetDefinition,
2794
+ options: AssetPreviewOptions = {},
2795
+ ): Promise<LabeledShotSetCapture> {
2796
+ const model = await loadProjectAssetModel(assetPath);
2797
+ try {
2798
+ return captureShotSetAssetPreview(model, definition, options);
2799
+ } finally {
2800
+ disposeProjectAssetModel(model);
2801
+ }
2802
+ }