@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.
- package/BUNDLED_NOTICES +1527 -0
- package/LICENSE +686 -0
- package/LICENSE-APACHE +202 -0
- package/NOTICE +18 -0
- package/README.md +24 -0
- package/dist/build/chunk-JS4D4XZ3.js +175 -0
- package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
- package/dist/build/vite-plugin-product-contributions.js +100 -0
- package/dist/build/vite-plugin-product-contributions.js.map +7 -0
- package/dist/build/vite-plugin-shared-react.js +25 -0
- package/dist/build/vite-plugin-shared-react.js.map +7 -0
- package/dist/build/vite-plugin-shared-three.js +86 -0
- package/dist/build/vite-plugin-shared-three.js.map +7 -0
- package/dist/server/chunk-KMJC7AGO.js +98 -0
- package/dist/server/chunk-KMJC7AGO.js.map +7 -0
- package/dist/server/chunk-PSILUMGS.js +46 -0
- package/dist/server/chunk-PSILUMGS.js.map +7 -0
- package/dist/server/frame-proxy.js +255 -0
- package/dist/server/frame-proxy.js.map +7 -0
- package/dist/server/open-browser.js +53 -0
- package/dist/server/open-browser.js.map +7 -0
- package/dist/server/process-shutdown.js +127 -0
- package/dist/server/process-shutdown.js.map +7 -0
- package/dist/server/session-registry.js +141 -0
- package/dist/server/session-registry.js.map +7 -0
- package/dist/server/spawn-opener.js +11 -0
- package/dist/server/spawn-opener.js.map +7 -0
- package/dist/server/worktree-identity.js +11 -0
- package/dist/server/worktree-identity.js.map +7 -0
- package/dist-server/packaged.mjs +88816 -0
- package/package.json +118 -0
- package/scripts/build-plugins.mjs +14 -0
- package/scripts/build-server.mjs +26 -0
- package/scripts/build-session.mjs +9 -0
- package/server/account-credentials.ts +60 -0
- package/server/account-service.ts +1434 -0
- package/server/adapter-region-includes.ts +47 -0
- package/server/asset-catalog-v2.ts +175 -0
- package/server/asset-history-snapshots.ts +196 -0
- package/server/asset-ledger-store.ts +195 -0
- package/server/asset-library-routes.ts +1595 -0
- package/server/blender-wasm-artifact.ts +430 -0
- package/server/boot-timings.ts +106 -0
- package/server/canonical-path.ts +50 -0
- package/server/checkout-workspace-preflight.ts +349 -0
- package/server/cloud-asset-catalog.ts +158 -0
- package/server/coding-inference-launch.ts +237 -0
- package/server/collaboration-account-client.ts +202 -0
- package/server/collaboration-attribution.ts +122 -0
- package/server/collaboration-session.ts +934 -0
- package/server/comfyui-bridge.ts +248 -0
- package/server/console-ledger.ts +499 -0
- package/server/creation-site-transform.ts +304 -0
- package/server/creation-site-write.ts +648 -0
- package/server/data-file-serialize.ts +158 -0
- package/server/editor-brand-html.ts +72 -0
- package/server/editor-control-socket.ts +366 -0
- package/server/editor-server-options.ts +190 -0
- package/server/editor-server.ts +1483 -0
- package/server/editor-sse.ts +352 -0
- package/server/engine-provenance.ts +169 -0
- package/server/engine-source-restart.ts +117 -0
- package/server/frame-bridge.ts +115 -0
- package/server/frame-proxy.ts +490 -0
- package/server/frame-workbench.ts +259 -0
- package/server/frontend-handoff.ts +229 -0
- package/server/game-globals-shadow.ts +113 -0
- package/server/gameplay-session-retention.ts +149 -0
- package/server/gameplay-sessions.ts +123 -0
- package/server/generation-jobs.ts +201 -0
- package/server/generation-reconciler.ts +137 -0
- package/server/generative-execution-context.ts +59 -0
- package/server/git-workflow.ts +612 -0
- package/server/harness-chat-caller.ts +36 -0
- package/server/harness-chat-service.ts +1681 -0
- package/server/idle-shutdown.ts +170 -0
- package/server/js-profiling-policy.ts +52 -0
- package/server/launcher-settings.ts +84 -0
- package/server/local-asset-catalog.ts +410 -0
- package/server/managed-account-defaults.ts +22 -0
- package/server/mock-control-plane.ts +540 -0
- package/server/model-import-conversion.ts +345 -0
- package/server/native-credential-store.ts +162 -0
- package/server/open-browser.ts +118 -0
- package/server/packaged.ts +1192 -0
- package/server/play-stall.ts +126 -0
- package/server/process-shutdown.ts +218 -0
- package/server/product-presets.ts +55 -0
- package/server/project-build-artifact.ts +95 -0
- package/server/project-components.ts +100 -0
- package/server/project-dependency-invalidation.ts +114 -0
- package/server/project-file-scan.ts +235 -0
- package/server/project-hmr-files.ts +215 -0
- package/server/project-install-roots.ts +210 -0
- package/server/project-kinds.ts +71 -0
- package/server/project-mcp-servers.ts +82 -0
- package/server/project-module-freshness.ts +109 -0
- package/server/project-module-instance.ts +375 -0
- package/server/project-optimize-deps-entries.ts +827 -0
- package/server/project-output-writer.ts +679 -0
- package/server/project-package-origin.ts +63 -0
- package/server/project-root-surface.ts +831 -0
- package/server/project-scratch-path.ts +47 -0
- package/server/project-script-hmr.ts +619 -0
- package/server/project-serving-plugins.ts +236 -0
- package/server/project-tools.ts +653 -0
- package/server/project-validation.ts +250 -0
- package/server/project-verbs.ts +127 -0
- package/server/project-view.ts +52 -0
- package/server/project-watch.ts +1134 -0
- package/server/project-work-coordinator.ts +288 -0
- package/server/provider-credentials.ts +331 -0
- package/server/recent-projects-store.ts +98 -0
- package/server/redact-secrets.ts +71 -0
- package/server/repository-presence.ts +267 -0
- package/server/routes/account.ts +357 -0
- package/server/routes/agents.ts +60 -0
- package/server/routes/assets.ts +154 -0
- package/server/routes/build.ts +13 -0
- package/server/routes/collaboration.ts +595 -0
- package/server/routes/configurations.ts +419 -0
- package/server/routes/context.ts +264 -0
- package/server/routes/control-plane.ts +1811 -0
- package/server/routes/logs.ts +272 -0
- package/server/routes/project-identity.ts +302 -0
- package/server/routes/project-open.ts +429 -0
- package/server/routes/project-source.ts +686 -0
- package/server/routes/project-state.ts +383 -0
- package/server/routes/relay.ts +1029 -0
- package/server/routes/route-helpers.ts +29 -0
- package/server/routes/served-modules.ts +138 -0
- package/server/routes/session-tabs.ts +196 -0
- package/server/routes/settings.ts +121 -0
- package/server/routes/share-control.ts +486 -0
- package/server/routes/themes.ts +116 -0
- package/server/routes/tools.ts +230 -0
- package/server/routes/worktrees.ts +595 -0
- package/server/scoped-game-css.ts +461 -0
- package/server/server-utils.ts +1194 -0
- package/server/session-product.ts +112 -0
- package/server/session-registry.ts +230 -0
- package/server/share-claims.ts +138 -0
- package/server/share-host.ts +526 -0
- package/server/share-session-gateway.ts +1362 -0
- package/server/share-tunnel.ts +123 -0
- package/server/spawn-opener.ts +74 -0
- package/server/support/play/log-format.ts +76 -0
- package/server/support/play/session-record.ts +199 -0
- package/server/support/project/build-discipline.ts +718 -0
- package/server/support/project/inspection-node.ts +119 -0
- package/server/support/project/log-naming.ts +54 -0
- package/server/support/project/provenance.ts +290 -0
- package/server/support/project/run-name.ts +18 -0
- package/server/support/project/session-journal.ts +830 -0
- package/server/support/project/shared.ts +327 -0
- package/server/tab-bootstrap.ts +62 -0
- package/server/tab-heartbeat.ts +477 -0
- package/server/tab-lifecycle.ts +550 -0
- package/server/tab-presence.ts +1508 -0
- package/server/team-agent-mirror.ts +95 -0
- package/server/twin-auth.ts +138 -0
- package/server/vendored-lock-recorder.ts +968 -0
- package/server/worktree-identity.ts +121 -0
- package/server/worktree-management.ts +398 -0
- package/server/worktree-retention.d.mts +13 -0
- package/server/worktree-retention.mjs +205 -0
- package/src/EditorContext.tsx +169 -0
- package/src/account.ts +333 -0
- package/src/action-registry.ts +532 -0
- package/src/active-product.ts +74 -0
- package/src/active-project.ts +155 -0
- package/src/adapter-editor-config.ts +25 -0
- package/src/adapter-observation.ts +49 -0
- package/src/animation/stage-transport.ts +379 -0
- package/src/animation/three-clips-subject.ts +172 -0
- package/src/api/asset-library-wire.ts +45 -0
- package/src/api/assets.ts +365 -0
- package/src/api/base.ts +10 -0
- package/src/api/build.ts +99 -0
- package/src/api/git-wire.ts +56 -0
- package/src/api/logs.ts +92 -0
- package/src/api/project-identity.ts +74 -0
- package/src/api/project-open.ts +355 -0
- package/src/api/project-source.ts +162 -0
- package/src/api/project-state.ts +107 -0
- package/src/api/relay.ts +268 -0
- package/src/api/settings.ts +36 -0
- package/src/api/themes.ts +45 -0
- package/src/api/worktrees.ts +257 -0
- package/src/asset-compare-core.ts +171 -0
- package/src/asset-compare.ts +294 -0
- package/src/asset-editor-context.tsx +100 -0
- package/src/asset-events.ts +97 -0
- package/src/asset-inspector-actions.ts +87 -0
- package/src/asset-preview-framing.ts +357 -0
- package/src/asset-preview.ts +2802 -0
- package/src/asset-selection-viewer-registry.ts +113 -0
- package/src/asset-selection.ts +133 -0
- package/src/asset-workflow/asset-capabilities.ts +344 -0
- package/src/asset-workflow/asset-import-jobs.ts +106 -0
- package/src/asset-workflow/asset-ledger-backend.ts +126 -0
- package/src/asset-workflow/asset-ledger.ts +156 -0
- package/src/asset-workflow/asset-materialization-report.ts +140 -0
- package/src/asset-workflow/asset-pack-manifest.ts +320 -0
- package/src/asset-workflow/asset-types.ts +142 -0
- package/src/asset-workflow/asset-workflow-quality.ts +61 -0
- package/src/asset-workflow/audio-preview-player.ts +193 -0
- package/src/asset-workflow/audio-waveform.ts +22 -0
- package/src/asset-workflow/cloud-asset-client.ts +263 -0
- package/src/asset-workflow/folder-preview.ts +345 -0
- package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
- package/src/asset-workflow/image-view-scale.ts +32 -0
- package/src/asset-workflow/import-contract.ts +124 -0
- package/src/asset-workflow/ledger-write-lock.ts +244 -0
- package/src/asset-workflow/model-inspection.ts +818 -0
- package/src/asset-workflow/pixi-spritesheet.ts +197 -0
- package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/asset-workflow/project-asset-commands.ts +23 -0
- package/src/asset-workflow/project-asset-health.ts +139 -0
- package/src/asset-workflow/project-asset-operations.ts +154 -0
- package/src/asset-workflow/project-asset-roots.ts +68 -0
- package/src/asset-workflow/project-content.ts +288 -0
- package/src/asset-workflow/project-source-index.ts +550 -0
- package/src/asset-workflow/thumbnail-system.ts +256 -0
- package/src/authoring/active-adapter.ts +199 -0
- package/src/authoring/active-systems.ts +422 -0
- package/src/authoring/adapter-key.ts +18 -0
- package/src/authoring/authoring-asset-url.ts +27 -0
- package/src/authoring/bootstrap-state.ts +49 -0
- package/src/authoring/boundary-authoring-adapter.ts +184 -0
- package/src/authoring/breakpoint-state.ts +43 -0
- package/src/authoring/canvas-scene-guides.ts +76 -0
- package/src/authoring/component-instance-root.ts +171 -0
- package/src/authoring/composite-authoring-adapter.ts +2110 -0
- package/src/authoring/consumer-actions.ts +520 -0
- package/src/authoring/css-numeric-style.ts +97 -0
- package/src/authoring/design-time-layers.ts +849 -0
- package/src/authoring/design-time-mount-registry.ts +235 -0
- package/src/authoring/design-time-settle.ts +343 -0
- package/src/authoring/edit-mode-authoring.ts +619 -0
- package/src/authoring/eyedropper-session.ts +60 -0
- package/src/authoring/instance-source-menu-register.ts +13 -0
- package/src/authoring/instance-source-menu.ts +135 -0
- package/src/authoring/layered-pick.ts +183 -0
- package/src/authoring/live-gesture-lock.ts +50 -0
- package/src/authoring/live-object-transform.ts +62 -0
- package/src/authoring/mount-failure-report.ts +154 -0
- package/src/authoring/mounted-root-subjects.ts +144 -0
- package/src/authoring/no-authoring-adapter.ts +55 -0
- package/src/authoring/null-inspection-subjects.tsx +75 -0
- package/src/authoring/object3d-document-persistence.ts +106 -0
- package/src/authoring/object3d-document-session-registry.ts +120 -0
- package/src/authoring/object3d-document-session.ts +1326 -0
- package/src/authoring/object3d-gesture-controller.ts +113 -0
- package/src/authoring/panel-authoring.ts +121 -0
- package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
- package/src/authoring/prefab-instance-section-model.ts +55 -0
- package/src/authoring/project-authoring-session.ts +105 -0
- package/src/authoring/provenance.ts +99 -0
- package/src/authoring/quarks-particle-systems.ts +19 -0
- package/src/authoring/react-canvas-navigation.ts +255 -0
- package/src/authoring/react-design-canvas-style.ts +20 -0
- package/src/authoring/react-story-board.ts +937 -0
- package/src/authoring/selection-scope.ts +195 -0
- package/src/authoring/shell-document-ops.ts +169 -0
- package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
- package/src/authoring/shell-viewport-policy.ts +48 -0
- package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/authoring/stories-scope.ts +35 -0
- package/src/authoring/story-board-chrome-fit.ts +107 -0
- package/src/authoring/story-board-presentation.ts +111 -0
- package/src/authoring/three-projection-core.ts +226 -0
- package/src/authoring/viewport-pick-context.ts +40 -0
- package/src/authoring/viewport-raycast.ts +240 -0
- package/src/authoring/viewport-tool-context.ts +73 -0
- package/src/authoring/world-canvas-viewport-state.ts +35 -0
- package/src/authoring/world-hidden-viewport.ts +152 -0
- package/src/authoring/world-pan-state.ts +198 -0
- package/src/authoring/world-session-state.ts +101 -0
- package/src/authoring/write-pipe.ts +173 -0
- package/src/availability-tick.ts +66 -0
- package/src/bitmap-label.ts +120 -0
- package/src/blender-tab-metrics.ts +161 -0
- package/src/board-open-actions.ts +20 -0
- package/src/boot-routing.ts +391 -0
- package/src/build-identity.ts +15 -0
- package/src/bytes-codec.ts +62 -0
- package/src/camera-authoring.ts +175 -0
- package/src/cancellation-reason.ts +58 -0
- package/src/canvas-preview-frames.ts +482 -0
- package/src/capture-camera-pose.ts +77 -0
- package/src/chrome-registry.ts +159 -0
- package/src/chrome-slot-registry.ts +91 -0
- package/src/collaboration-client.ts +264 -0
- package/src/collaboration-presence.ts +41 -0
- package/src/command-dispatch.ts +19 -0
- package/src/command-listener.ts +2743 -0
- package/src/command-registry.ts +70 -0
- package/src/component-board-registry.ts +205 -0
- package/src/component-states-registry.ts +200 -0
- package/src/components/AgentPresentationNotice.tsx +75 -0
- package/src/components/AlignToolbar.tsx +204 -0
- package/src/components/AppRoot.tsx +394 -0
- package/src/components/ApplicationMenus.tsx +415 -0
- package/src/components/AssetBrowser.tsx +2843 -0
- package/src/components/AssetEditorShell.tsx +216 -0
- package/src/components/AssetInspectorToolSection.tsx +124 -0
- package/src/components/BoardRulers.tsx +354 -0
- package/src/components/CameraInfo.tsx +81 -0
- package/src/components/CanvasSceneViewport.tsx +556 -0
- package/src/components/CapabilityCoverageSection.tsx +185 -0
- package/src/components/CenterDocuments.tsx +22 -0
- package/src/components/ChromeSlot.tsx +20 -0
- package/src/components/CodeView.tsx +470 -0
- package/src/components/CollaborationHeaderControl.css +74 -0
- package/src/components/CompactInspectorCard.tsx +190 -0
- package/src/components/CompactInspectorShell.tsx +39 -0
- package/src/components/ConsolePanel.css +55 -0
- package/src/components/ConsolePanel.tsx +270 -0
- package/src/components/DefaultEditorLayout.tsx +106 -0
- package/src/components/DocumentHeaderStrip.tsx +96 -0
- package/src/components/DocumentShelfRail.tsx +45 -0
- package/src/components/DocumentThumbnail.tsx +345 -0
- package/src/components/EditorLeaseGuard.tsx +372 -0
- package/src/components/ErrorBanner.tsx +123 -0
- package/src/components/FolderPreviewTile.tsx +159 -0
- package/src/components/GameHierarchy.tsx +3842 -0
- package/src/components/GameplaySessionTimeline.tsx +295 -0
- package/src/components/InspectionProjection.tsx +968 -0
- package/src/components/Inspector.tsx +233 -0
- package/src/components/InspectorCanvasPreview.tsx +35 -0
- package/src/components/InspectorFieldsSection.tsx +290 -0
- package/src/components/InspectorObjectPreview.tsx +57 -0
- package/src/components/InspectorStoriesSection.tsx +92 -0
- package/src/components/InspectorToolSection.tsx +96 -0
- package/src/components/InspectorTransformSection.tsx +245 -0
- package/src/components/LightExplorerPanel.tsx +433 -0
- package/src/components/MediaProperties.tsx +145 -0
- package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
- package/src/components/Object3DDocumentToolbar.css +100 -0
- package/src/components/Object3DDocumentToolbar.tsx +454 -0
- package/src/components/Object3DDocumentViewport.tsx +58 -0
- package/src/components/ProjectHeader.css +617 -0
- package/src/components/ProjectHeader.tsx +328 -0
- package/src/components/ProjectLayout.tsx +249 -0
- package/src/components/ReactCanvasControls.tsx +284 -0
- package/src/components/RootSelectionOverlay.tsx +3162 -0
- package/src/components/RootTextEditor.tsx +79 -0
- package/src/components/SaveStatus.tsx +70 -0
- package/src/components/StageHost.tsx +1954 -0
- package/src/components/StageOverlays.tsx +21 -0
- package/src/components/StartupErrorScreen.tsx +76 -0
- package/src/components/StartupLoadingScreen.tsx +65 -0
- package/src/components/StatsOverlay.tsx +78 -0
- package/src/components/SurfaceStateOverlay.tsx +24 -0
- package/src/components/ToolContributionSurfaces.tsx +65 -0
- package/src/components/ToolHost.tsx +370 -0
- package/src/components/ToolObject3DPreview.tsx +39 -0
- package/src/components/Toolbar.tsx +595 -0
- package/src/components/TransientHint.tsx +44 -0
- package/src/components/TransportStrip.tsx +174 -0
- package/src/components/VersionControlSection.tsx +470 -0
- package/src/components/VgaiLogo.css +83 -0
- package/src/components/VgaiLogo.tsx +35 -0
- package/src/components/ViewportControlsHint.tsx +60 -0
- package/src/components/ViewportFurniture.tsx +401 -0
- package/src/components/ViewportOverlay.tsx +145 -0
- package/src/components/ViewportOverlaysMenu.tsx +153 -0
- package/src/components/ViewportShadingMenu.tsx +300 -0
- package/src/components/ViewportViewMenu.tsx +101 -0
- package/src/components/WorkspaceDocumentSurface.tsx +204 -0
- package/src/components/WorkspaceUtilitySurface.tsx +22 -0
- package/src/components/WorktreeSwitcher.css +239 -0
- package/src/components/WorktreeSwitcher.tsx +890 -0
- package/src/components/account-documents.tsx +1162 -0
- package/src/components/asset-documents.tsx +812 -0
- package/src/components/asset-editor-persistence.ts +216 -0
- package/src/components/asset-selection-section.tsx +535 -0
- package/src/components/asset-thumbnails.tsx +312 -0
- package/src/components/asset-viewers/AudioViewer.tsx +201 -0
- package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
- package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
- package/src/components/asset-viewers/ImageViewer.tsx +300 -0
- package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
- package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
- package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
- package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
- package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
- package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
- package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
- package/src/components/asset-viewers/VideoViewer.tsx +101 -0
- package/src/components/asset-viewers/shader-source.ts +144 -0
- package/src/components/asset-workflow.css +731 -0
- package/src/components/board-guides.ts +150 -0
- package/src/components/compact-inspector.css +539 -0
- package/src/components/core-utilities.tsx +90 -0
- package/src/components/editor-notifications.css +40 -0
- package/src/components/engine-workspace.css +274 -0
- package/src/components/inspector-preview-section.tsx +238 -0
- package/src/components/inspector-property-grouping.ts +64 -0
- package/src/components/inspector-revert-label.ts +20 -0
- package/src/components/inspector-selection.ts +42 -0
- package/src/components/inspector-stories-gating.ts +171 -0
- package/src/components/inspector-transform-subject.ts +11 -0
- package/src/components/inspector-transform.ts +75 -0
- package/src/components/kind-documents.tsx +500 -0
- package/src/components/palette-action-publisher.tsx +140 -0
- package/src/components/primitives/DraftColorInput.tsx +74 -0
- package/src/components/product-shell.css +1829 -0
- package/src/components/project-tool-documents.tsx +410 -0
- package/src/components/scene-documents.tsx +223 -0
- package/src/components/stage-keyboard.tsx +37 -0
- package/src/components/stage-overlay-set.tsx +107 -0
- package/src/components/stage-presence-markers.ts +478 -0
- package/src/components/standard-viewport-dressing.ts +417 -0
- package/src/components/status-contributions.tsx +403 -0
- package/src/components/tool-documents.tsx +309 -0
- package/src/components/tool-schema-form.tsx +262 -0
- package/src/components/use-after-paint.ts +41 -0
- package/src/components/use-project-image-assets.ts +86 -0
- package/src/components/viewport-header-controls.css +90 -0
- package/src/components/viewport-surface-status.tsx +55 -0
- package/src/components/workspace-history.ts +32 -0
- package/src/components/workspace-static-panel-registry.tsx +152 -0
- package/src/components/workspace-surfaces.css +621 -0
- package/src/components/world-documents.tsx +586 -0
- package/src/components/world-overlay-gestures.ts +1694 -0
- package/src/composite-screenshot.ts +1641 -0
- package/src/console-sync.ts +131 -0
- package/src/constraint-helper.ts +338 -0
- package/src/content-entry-source-registry.ts +184 -0
- package/src/coverage/authoring-seam-evidence.ts +300 -0
- package/src/coverage/canvas-reveal.ts +192 -0
- package/src/coverage/design-time-surfaces.ts +101 -0
- package/src/coverage/live-seam-evidence.ts +11 -0
- package/src/coverage/ontology-invariants.ts +466 -0
- package/src/coverage/session-vitals.ts +501 -0
- package/src/coverage/system-seam-evidence.ts +72 -0
- package/src/crash-null-boundary.ts +36 -0
- package/src/creation-site-edit.ts +1479 -0
- package/src/creation-site-registry.ts +160 -0
- package/src/delegate-harness-registry.ts +143 -0
- package/src/document-context-registry.ts +177 -0
- package/src/document-open-registry.ts +200 -0
- package/src/document-preview-source.ts +20 -0
- package/src/document-renderer-session.ts +138 -0
- package/src/editor-api.ts +46 -0
- package/src/editor-chrome-capture.ts +139 -0
- package/src/editor-commands.ts +164 -0
- package/src/editor-console.ts +563 -0
- package/src/editor-current-view.ts +82 -0
- package/src/editor-document-probe.ts +881 -0
- package/src/editor-git-client.ts +115 -0
- package/src/editor-host-door.ts +462 -0
- package/src/editor-hotkeys.ts +842 -0
- package/src/editor-lease-view.ts +39 -0
- package/src/editor-lease.ts +415 -0
- package/src/editor-mode.ts +19 -0
- package/src/editor-notifications.ts +140 -0
- package/src/editor-presence.ts +563 -0
- package/src/editor-presentation-activity.ts +50 -0
- package/src/editor-presentation-notice.ts +42 -0
- package/src/editor-runtime.tsx +145 -0
- package/src/editor-server-response.ts +86 -0
- package/src/editor-session-attribution.ts +75 -0
- package/src/editor-session-mode.ts +54 -0
- package/src/editor-shell-store.ts +1304 -0
- package/src/editor-state-facets.ts +74 -0
- package/src/editor-styles.css +33 -0
- package/src/editor-view-presentation.ts +810 -0
- package/src/editor-viewport.ts +5302 -0
- package/src/entity-lod.ts +31 -0
- package/src/entity-object.ts +91 -0
- package/src/files/file-provider.ts +62 -0
- package/src/files/project-files.ts +264 -0
- package/src/finders/index.ts +136 -0
- package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
- package/src/frame/bridge.tsx +1408 -0
- package/src/frame/product.ts +77 -0
- package/src/gameplay-dom-recording.ts +319 -0
- package/src/gameplay-export-state.ts +14 -0
- package/src/gameplay-replay.ts +417 -0
- package/src/gameplay-session-time.ts +9 -0
- package/src/gameplay-sessions.ts +204 -0
- package/src/harness-chat-types.ts +253 -0
- package/src/hierarchy-component-marks.ts +298 -0
- package/src/hierarchy-drop.ts +91 -0
- package/src/hierarchy-expansion-state.ts +80 -0
- package/src/hierarchy-header-slot.ts +52 -0
- package/src/hierarchy-internals.ts +197 -0
- package/src/hierarchy-kind-icon.ts +217 -0
- package/src/hierarchy-mark-reader.ts +73 -0
- package/src/hierarchy-menu-registry.ts +67 -0
- package/src/hierarchy-node-rows.ts +307 -0
- package/src/hierarchy-panel-view.ts +280 -0
- package/src/hierarchy-projection.ts +76 -0
- package/src/hierarchy-row-cache.ts +243 -0
- package/src/hierarchy-row-model.ts +308 -0
- package/src/hierarchy-rows.ts +11 -0
- package/src/hierarchy-walk.ts +86 -0
- package/src/history/editor-session.ts +25 -0
- package/src/history/history-commands.ts +147 -0
- package/src/history/history-delegate.ts +163 -0
- package/src/history/history-limit-notices.ts +43 -0
- package/src/history/history-service.ts +1173 -0
- package/src/history/persistence-coordinator.ts +35 -0
- package/src/history/project-file-history.ts +386 -0
- package/src/history/project-root-history-backends.ts +139 -0
- package/src/history/resource-registry.ts +209 -0
- package/src/history/snapshot-store.ts +103 -0
- package/src/history/source-history-backend.ts +546 -0
- package/src/history/types.ts +124 -0
- package/src/hmr-registration-group.ts +67 -0
- package/src/hmr-stable-react-context.ts +23 -0
- package/src/hotkeys.ts +188 -0
- package/src/inference-diagnostics.ts +69 -0
- package/src/initial-project.ts +80 -0
- package/src/inspection/active-subject.ts +578 -0
- package/src/inspection/active-surface.ts +142 -0
- package/src/inspection/compose.ts +1064 -0
- package/src/inspection/display.ts +170 -0
- package/src/inspection/document-subject.ts +109 -0
- package/src/inspection/game-subject.ts +85 -0
- package/src/inspection/model.ts +542 -0
- package/src/inspection/null-subject.ts +115 -0
- package/src/inspection/serialize.ts +357 -0
- package/src/inspection/use-active-inspection.ts +180 -0
- package/src/inspector-presentation.ts +201 -0
- package/src/inspector-section-registry.ts +221 -0
- package/src/instance-source-actions.ts +163 -0
- package/src/instanced-presentation.ts +164 -0
- package/src/js-heap.ts +71 -0
- package/src/key-actions.ts +91 -0
- package/src/keymap-presets.ts +401 -0
- package/src/layout-policy.ts +31 -0
- package/src/learn-links.ts +73 -0
- package/src/light-explorer-model.ts +134 -0
- package/src/live-canvas-frame.ts +55 -0
- package/src/live-document.ts +279 -0
- package/src/live-module-source.ts +230 -0
- package/src/live-session-registry.ts +220 -0
- package/src/live-transition.ts +633 -0
- package/src/manifest-project.ts +107 -0
- package/src/model-thumbnail.ts +523 -0
- package/src/native-selection-style.ts +202 -0
- package/src/object3d-document-write-policy.ts +137 -0
- package/src/packaged-runtime.ts +108 -0
- package/src/palettes/maya.palette.json +57 -0
- package/src/palettes/substance.palette.json +57 -0
- package/src/pasteboard-module.ts +81 -0
- package/src/performance-profiler.ts +367 -0
- package/src/performance-sources.ts +69 -0
- package/src/play-boot-phase.ts +145 -0
- package/src/presentation-surface.ts +248 -0
- package/src/project-adapter.ts +1143 -0
- package/src/project-asset-refresh.ts +26 -0
- package/src/project-declaration-refresh.ts +31 -0
- package/src/project-local-state.ts +118 -0
- package/src/project-manager.ts +243 -0
- package/src/project-module-changes.ts +163 -0
- package/src/project-module-split.ts +266 -0
- package/src/project-provenance.ts +115 -0
- package/src/project-ready.ts +42 -0
- package/src/project-session-reset.ts +67 -0
- package/src/project-shape.ts +68 -0
- package/src/project-tool-discovery.ts +66 -0
- package/src/project-tools.ts +107 -0
- package/src/project-work-types.ts +149 -0
- package/src/projection/three.ts +898 -0
- package/src/projection/types.ts +44 -0
- package/src/readiness.ts +113 -0
- package/src/reflection-probe-helper.ts +142 -0
- package/src/reported-play-state.ts +90 -0
- package/src/resolve-relative-specifier.ts +33 -0
- package/src/scene-document-plan.ts +315 -0
- package/src/scene-framing.ts +315 -0
- package/src/scene-live-open.ts +210 -0
- package/src/scene-view-fog.ts +89 -0
- package/src/scoped-game-css.ts +152 -0
- package/src/session-orphan-record.ts +193 -0
- package/src/session-tombstone.ts +126 -0
- package/src/settings/settings-provider.ts +82 -0
- package/src/settings-store.ts +345 -0
- package/src/shared-view-restore.ts +42 -0
- package/src/shell-store-door.ts +45 -0
- package/src/source-conflict.ts +122 -0
- package/src/spatial-handle-visuals.ts +332 -0
- package/src/stage-context.ts +341 -0
- package/src/stage-store-registry.ts +63 -0
- package/src/stale-chunk-recovery.ts +34 -0
- package/src/state-report-deferral.ts +73 -0
- package/src/storage/host-files-storage.ts +97 -0
- package/src/storage/http-storage.ts +174 -0
- package/src/storage/index.ts +75 -0
- package/src/storage/mem-storage.ts +149 -0
- package/src/storage/path-lock.ts +44 -0
- package/src/storage/paths.ts +26 -0
- package/src/storage/types.ts +116 -0
- package/src/stories/StoryComponentThumbnail.tsx +184 -0
- package/src/stories/StoryPreviewMount.tsx +306 -0
- package/src/stories/component-content-source.tsx +106 -0
- package/src/stories/component-states-source.ts +78 -0
- package/src/stories/compose-project-stories.ts +255 -0
- package/src/stories/pixi-story-model.ts +30 -0
- package/src/stories/prefabs-finder.ts +54 -0
- package/src/stories/prefabs-from-stories.ts +182 -0
- package/src/stories/project-story-discovery.ts +78 -0
- package/src/stories/project-story-regions.ts +24 -0
- package/src/stories/story-actions.ts +54 -0
- package/src/stories/story-capture-command.ts +124 -0
- package/src/stories/story-capture.ts +584 -0
- package/src/stories/story-declared-medium.ts +126 -0
- package/src/stories/story-discovery.ts +176 -0
- package/src/stories/story-document-openers.ts +36 -0
- package/src/stories/story-dom-runtime.ts +78 -0
- package/src/stories/story-grouping.ts +111 -0
- package/src/stories/story-lane.ts +88 -0
- package/src/stories/story-mount-turn.ts +27 -0
- package/src/stories/story-opener.ts +260 -0
- package/src/stories/story-pixi-preview.ts +408 -0
- package/src/stories/story-presentation.ts +215 -0
- package/src/stories/story-registry.ts +517 -0
- package/src/stories/story-three-preview.ts +807 -0
- package/src/stories/three-story-model.ts +96 -0
- package/src/story-three-preview-runtime.ts +56 -0
- package/src/surface-keyboard.ts +112 -0
- package/src/surface-state.ts +124 -0
- package/src/tab-bootstrap.js +628 -0
- package/src/tab-census.ts +198 -0
- package/src/tab-lifecycle-client.ts +196 -0
- package/src/theme-library.ts +844 -0
- package/src/theme-preference.ts +378 -0
- package/src/theme.css +3437 -0
- package/src/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/three-viewport/camera-fit.ts +41 -0
- package/src/three-viewport/interactive-renderer.ts +132 -0
- package/src/three-viewport/selection-brackets.ts +310 -0
- package/src/three-viewport/selection-outline.ts +183 -0
- package/src/three-viewport/skeleton-helper.ts +61 -0
- package/src/three-viewport/source-color.ts +167 -0
- package/src/three-viewport/studio-environment.ts +96 -0
- package/src/three-viewport-presentation.ts +23 -0
- package/src/tool-contribution-play.ts +74 -0
- package/src/tool-loader.ts +1798 -0
- package/src/transform-mode-request.ts +57 -0
- package/src/transient-hint.ts +78 -0
- package/src/trigger-volume-helper.ts +116 -0
- package/src/ui-source/adapter-region-includes.ts +241 -0
- package/src/ui-source/ensure-import.ts +132 -0
- package/src/ui-source/file-region-resolver.ts +302 -0
- package/src/ui-source/inspect.ts +775 -0
- package/src/ui-source/oid-transform.ts +1845 -0
- package/src/ui-source/plan-csf-story.ts +196 -0
- package/src/ui-source/plan-extract-component.ts +421 -0
- package/src/ui-source/plan-fork-component.ts +621 -0
- package/src/ui-source/plan-named-style.ts +95 -0
- package/src/ui-source/plan-source-edit.ts +353 -0
- package/src/ui-source/r3f-contract-resolver.ts +327 -0
- package/src/ui-source/r3f-diagnostic-index.ts +165 -0
- package/src/ui-source/r3f-environment-binding.ts +133 -0
- package/src/ui-source/r3f-joint-binding.ts +251 -0
- package/src/ui-source/r3f-lod-binding.ts +91 -0
- package/src/ui-source/r3f-particle-binding.ts +235 -0
- package/src/ui-source/r3f-physics-binding.ts +483 -0
- package/src/ui-source/r3f-project-contracts.ts +444 -0
- package/src/ui-source/relative-import-specifier.ts +34 -0
- package/src/ui-source/reparent-guard.ts +544 -0
- package/src/ui-source/source-edit-request.ts +97 -0
- package/src/ui-source/source-write-backend.ts +618 -0
- package/src/ui-source/syntactic-prop-specs.ts +296 -0
- package/src/ui-source/tier-source-write-backend.ts +279 -0
- package/src/ui-source/ts-ast.ts +171 -0
- package/src/ui-source/utility-class-support.ts +169 -0
- package/src/ui-source/write-component-default.ts +133 -0
- package/src/ui-source/writer.ts +1989 -0
- package/src/viewport-activation-timings.ts +840 -0
- package/src/viewport-authoring-policy.ts +186 -0
- package/src/viewport-controls-hint.ts +57 -0
- package/src/viewport-door.ts +237 -0
- package/src/viewport-shading-boundary.ts +12 -0
- package/src/vite-error-surface.ts +141 -0
- package/src/wait-until.ts +37 -0
- package/src/workspace-areas.ts +156 -0
- package/src/workspace-aux-commands.ts +11 -0
- package/src/workspace-available-documents.ts +127 -0
- package/src/workspace-core-utilities.ts +31 -0
- package/src/workspace-document-ids.ts +59 -0
- package/src/workspace-document-registry.ts +612 -0
- package/src/workspace-document-restore.ts +145 -0
- package/src/workspace-host-commands.ts +141 -0
- package/src/workspace-persistence-gate.ts +40 -0
- package/src/workspace-play-utilities.ts +44 -0
- package/src/workspace-presets.ts +425 -0
- package/src/workspace-regions.ts +294 -0
- package/src/workspace-state-persistence.ts +536 -0
- package/src/workspace-static-panels.ts +73 -0
- package/src/workspace-status-registry.ts +121 -0
- package/src/workspace-style.ts +239 -0
- package/src/workspace-utility-commands.ts +74 -0
- package/src/workspace-utility-registry.ts +263 -0
- package/src/workspace-viewport-rect.ts +97 -0
- package/src/world-adoption.ts +115 -0
- package/src/world-document-routing.ts +104 -0
- package/vite-plugin-creation-site-write.ts +184 -0
- package/vite-plugin-creation-site.ts +80 -0
- package/vite-plugin-game-static.ts +303 -0
- package/vite-plugin-module-doorways.ts +336 -0
- package/vite-plugin-product-contributions.ts +197 -0
- package/vite-plugin-project-game-static.ts +125 -0
- package/vite-plugin-project-jsx-js.ts +65 -0
- package/vite-plugin-project-root-absolute-assets.ts +359 -0
- package/vite-plugin-shared-react.ts +420 -0
- package/vite-plugin-shared-three.ts +203 -0
- 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
|
+
}
|