@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,2743 @@
|
|
|
1
|
+
import { isGameplayExportActive } from './gameplay-export-state';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Command listener — receives commands from the editor server via SSE
|
|
5
|
+
* and dispatches them to the EditorShellStore and play-mode functions.
|
|
6
|
+
*
|
|
7
|
+
* The server broadcasts `editor-command` events sent by the SDK/CLI.
|
|
8
|
+
* This module translates them into store method calls.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
CaptureDimensions,
|
|
13
|
+
DocumentProbeStep,
|
|
14
|
+
EditorView,
|
|
15
|
+
InspectedHierarchy,
|
|
16
|
+
InspectedInspection,
|
|
17
|
+
InspectedWriteDestination,
|
|
18
|
+
HelperVisibility as SdkHelperVisibility,
|
|
19
|
+
} from '@volter/editor-sdk';
|
|
20
|
+
import { inspectorPreviewRendererCounts } from '@volter/editor-threejs/viewport/preview-renderer';
|
|
21
|
+
import { liveHostRendererCount } from '@volter/editor-threejs/viewport/renderer-ownership';
|
|
22
|
+
import type { ViewportShadingMode } from '@volter/editor-threejs/render/viewport-shading';
|
|
23
|
+
import { captureEntityComparePreview, captureModelComparePreview } from './asset-compare';
|
|
24
|
+
import { parseForwardVector } from './asset-compare-core';
|
|
25
|
+
import {
|
|
26
|
+
type AssetPreviewBackground,
|
|
27
|
+
captureGlbBytesAssetPreview,
|
|
28
|
+
captureModelAssetPreview,
|
|
29
|
+
captureObjectAssetPreview,
|
|
30
|
+
captureSceneStageAssetPreview,
|
|
31
|
+
captureShotSetAssetPreview,
|
|
32
|
+
captureShotSetGlbBytesPreview,
|
|
33
|
+
captureShotSetModelPreview,
|
|
34
|
+
captureSourceReviewShotSetAssetPreview,
|
|
35
|
+
captureSourceReviewShotSetModelPreview,
|
|
36
|
+
parseShotSetDefinition,
|
|
37
|
+
} from './asset-preview';
|
|
38
|
+
import { setSelectedAsset } from './asset-selection';
|
|
39
|
+
import { assetCapabilities, assetDocumentKind } from './asset-workflow/asset-capabilities';
|
|
40
|
+
import { systemsForInstance } from './authoring/active-systems';
|
|
41
|
+
import { getMountFailureReports } from './authoring/mount-failure-report';
|
|
42
|
+
import { object3DDocumentSession } from './authoring/object3d-document-session-registry';
|
|
43
|
+
import {
|
|
44
|
+
activeDocumentAuthoring,
|
|
45
|
+
activeHierarchyRows,
|
|
46
|
+
activeSaveDestination,
|
|
47
|
+
activeSaveState,
|
|
48
|
+
activeSelectionCreationSite,
|
|
49
|
+
activeSelectionIds,
|
|
50
|
+
activeSelectionWriteAnchorKind,
|
|
51
|
+
} from './authoring/shell-document-ops';
|
|
52
|
+
import { parseCameraChoice, parsePoseChoice } from './capture-camera-pose';
|
|
53
|
+
import { noteCommandDispatched } from './command-dispatch';
|
|
54
|
+
import { contributedCommand, contributedCommandDerivedRefresh } from './command-registry';
|
|
55
|
+
import {
|
|
56
|
+
isAssetDocumentId,
|
|
57
|
+
openAssetDocument,
|
|
58
|
+
waitForAssetDocumentInspector,
|
|
59
|
+
} from './components/asset-documents';
|
|
60
|
+
import { openSceneTableEntryWhenListed } from './components/scene-documents';
|
|
61
|
+
import { systemAdapterEpoch } from './coverage/system-seam-evidence';
|
|
62
|
+
import {
|
|
63
|
+
connectEvents,
|
|
64
|
+
reportCommandListener,
|
|
65
|
+
reportCommandReceived,
|
|
66
|
+
reportCommandResult,
|
|
67
|
+
reportEditorState,
|
|
68
|
+
reportPlayBootPhase,
|
|
69
|
+
} from './editor-api';
|
|
70
|
+
import { captureEditorChrome } from './editor-chrome-capture';
|
|
71
|
+
import type { ConsoleEntry } from './editor-console';
|
|
72
|
+
import { editorConsole } from './editor-console';
|
|
73
|
+
import { currentEditorView } from './editor-current-view';
|
|
74
|
+
import { editorIsPlaying } from './editor-session-mode';
|
|
75
|
+
import type {
|
|
76
|
+
AssetKind,
|
|
77
|
+
EditorShellStore,
|
|
78
|
+
HelperVisibility,
|
|
79
|
+
ViewportTab,
|
|
80
|
+
} from './editor-shell-store';
|
|
81
|
+
import { collectEditorStateFacets, reusableFacetKeys } from './editor-state-facets';
|
|
82
|
+
import { entityObject3D } from './entity-object';
|
|
83
|
+
import {
|
|
84
|
+
hierarchyPanelSnapshot,
|
|
85
|
+
nextHierarchyPanelSnapshot,
|
|
86
|
+
serializeHierarchyPanel,
|
|
87
|
+
} from './hierarchy-panel-view';
|
|
88
|
+
import type { HistoryCommands } from './history/history-commands';
|
|
89
|
+
import {
|
|
90
|
+
anyLiveSessionMounted,
|
|
91
|
+
anyLiveSessionPlaying,
|
|
92
|
+
dispatchLiveCommand,
|
|
93
|
+
liveRunWindow,
|
|
94
|
+
liveScenes,
|
|
95
|
+
remountLiveSelection,
|
|
96
|
+
subscribeLiveSessions,
|
|
97
|
+
} from './live-session-registry';
|
|
98
|
+
import { setPlayBootPhaseReporter } from './play-boot-phase';
|
|
99
|
+
import { projectAdapterFacet, subscribeProjectAdapter } from './project-adapter';
|
|
100
|
+
import { getProjectModuleSplitReports } from './project-module-split';
|
|
101
|
+
import { onSessionEndedChange, sessionEndedRefusal, sessionEndedState } from './session-tombstone';
|
|
102
|
+
import { focusedStageStore } from './stage-context';
|
|
103
|
+
import { scheduleDeferredFullReport } from './state-report-deferral';
|
|
104
|
+
import { interactiveViewportRendererCounts } from './three-viewport/interactive-renderer';
|
|
105
|
+
import {
|
|
106
|
+
subscribeViewportActivationTimings,
|
|
107
|
+
viewportActivationTimings,
|
|
108
|
+
} from './viewport-activation-timings';
|
|
109
|
+
|
|
110
|
+
type AssertSameKeys<P, Q> = [keyof P] extends [keyof Q]
|
|
111
|
+
? [keyof Q] extends [keyof P]
|
|
112
|
+
? true
|
|
113
|
+
: false
|
|
114
|
+
: false;
|
|
115
|
+
type RequireTrue<T extends true> = T;
|
|
116
|
+
/**
|
|
117
|
+
* Compile-time drift guard (W3a N1) — its consumer is `tsc` itself. The SDK
|
|
118
|
+
* republishes `HelperVisibility` for control-API clients, and the
|
|
119
|
+
* `{ ...store.helperVisibility }` spread in `collectState` below typechecks
|
|
120
|
+
* happily even when the SDK mirror is MISSING keys (spreads skip excess
|
|
121
|
+
* property checks) — exactly how `joints`/`lod` drifted out of the SDK
|
|
122
|
+
* pre-W3a. This alias fails `npm run typecheck` the moment either side gains
|
|
123
|
+
* a key the other lacks.
|
|
124
|
+
*/
|
|
125
|
+
export type HelperVisibilityMirrorInSync = RequireTrue<
|
|
126
|
+
AssertSameKeys<HelperVisibility, SdkHelperVisibility>
|
|
127
|
+
>;
|
|
128
|
+
|
|
129
|
+
import {
|
|
130
|
+
type CommandResult,
|
|
131
|
+
isRelayCommandType,
|
|
132
|
+
type RelayCommandDerivedRefresh,
|
|
133
|
+
type RelayCommandType,
|
|
134
|
+
relayCommandDerivedRefresh,
|
|
135
|
+
} from '@volter/editor-sdk/session/command-table';
|
|
136
|
+
import { editorMaterials, isEditorMaterialId } from '@volter/editor-sdk/widgets';
|
|
137
|
+
import {
|
|
138
|
+
copyAuthoringNodes,
|
|
139
|
+
createAuthoringNode,
|
|
140
|
+
cutAuthoringNodes,
|
|
141
|
+
duplicateAuthoringNode,
|
|
142
|
+
groupAuthoringNodes,
|
|
143
|
+
pasteAuthoringNodes,
|
|
144
|
+
removeAuthoringNode,
|
|
145
|
+
removeManyAuthoringNodes,
|
|
146
|
+
reorderAuthoringNode,
|
|
147
|
+
reparentAuthoringNode,
|
|
148
|
+
ungroupAuthoringNode,
|
|
149
|
+
unwrapAuthoringNode,
|
|
150
|
+
wrapAuthoringNode,
|
|
151
|
+
} from './authoring/consumer-actions';
|
|
152
|
+
import { resolvePanelAuthoring } from './authoring/panel-authoring';
|
|
153
|
+
import { ontologyInvariantFacet } from './coverage/session-vitals';
|
|
154
|
+
import { documentContextFor, waitForDocumentContext } from './document-context-registry';
|
|
155
|
+
import { executeCommand, openCommandPalette } from './editor-commands';
|
|
156
|
+
import { runDocumentProbe } from './editor-document-probe';
|
|
157
|
+
import {
|
|
158
|
+
captureActiveEditorDocument,
|
|
159
|
+
presentEditorView,
|
|
160
|
+
revealStaticPanel,
|
|
161
|
+
} from './editor-view-presentation';
|
|
162
|
+
import { toolGameplaySessions } from './gameplay-sessions';
|
|
163
|
+
import {
|
|
164
|
+
InspectionRemovalUnavailableError,
|
|
165
|
+
inspectActiveSubject,
|
|
166
|
+
removeActiveInspectionField,
|
|
167
|
+
runActiveInspectionAction,
|
|
168
|
+
setActiveInspectionField,
|
|
169
|
+
} from './inspection/active-subject';
|
|
170
|
+
import { measuredReadinessWarning, readinessFacet } from './readiness';
|
|
171
|
+
import { deriveReportedPlayState } from './reported-play-state';
|
|
172
|
+
import { openLiveSceneEntry } from './scene-live-open';
|
|
173
|
+
import { editorMaterialSnapshot, setEditorMaterialPreference } from './theme-preference';
|
|
174
|
+
import { GAME_DOCUMENT_ID } from './workspace-document-ids';
|
|
175
|
+
import {
|
|
176
|
+
activateWorkspaceDocument,
|
|
177
|
+
activeWorkspaceDocumentId,
|
|
178
|
+
closeWorkspaceDocument,
|
|
179
|
+
openWorkspaceDocuments,
|
|
180
|
+
} from './workspace-document-registry';
|
|
181
|
+
import { activeWorkspaceUtility } from './workspace-host-commands';
|
|
182
|
+
import {
|
|
183
|
+
activeEditorWorkspace,
|
|
184
|
+
editorWorkspaceIds,
|
|
185
|
+
isEditorWorkspaceId,
|
|
186
|
+
setEditorWorkspace,
|
|
187
|
+
whenEditorWorkspaceApplied,
|
|
188
|
+
} from './workspace-presets';
|
|
189
|
+
import { activeWorkspaceStyleId, applyWorkspaceStyle, workspaceStyles } from './workspace-style';
|
|
190
|
+
import { toggleConsoleUtility } from './workspace-utility-commands';
|
|
191
|
+
import { worldAdoptionFacet } from './world-adoption';
|
|
192
|
+
|
|
193
|
+
export interface EditorCommand {
|
|
194
|
+
type: string;
|
|
195
|
+
_requestId?: string;
|
|
196
|
+
[key: string]: unknown;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Browser-listener scheduling seam. The live relay holds presentation until
|
|
200
|
+
* its acknowledgement settles; direct callers fall back to the next task. */
|
|
201
|
+
export interface CommandHandlingOptions {
|
|
202
|
+
deferPresentation?: ((present: () => void) => void) | undefined;
|
|
203
|
+
/** The session's own undo/redo queue — see {@link connectCommandListener}'s
|
|
204
|
+
* `history` parameter for why the verbs go through it rather than through
|
|
205
|
+
* `store.projectHistory` directly. */
|
|
206
|
+
history?: HistoryCommands | undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const REPEATED_DEBUG_READS = new Set([
|
|
210
|
+
'state',
|
|
211
|
+
'stateAll',
|
|
212
|
+
'providers',
|
|
213
|
+
'commands',
|
|
214
|
+
'events',
|
|
215
|
+
'snapshot',
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The coverage proof a read-only debug call can add to the current adapter epoch.
|
|
220
|
+
*
|
|
221
|
+
* The first successful read still earns a full status derivation so coverage immediately reflects
|
|
222
|
+
* the operation. Repeating that same proof cannot change any derived facet: forcing another full
|
|
223
|
+
* 4,000-node hierarchy/coverage walk on every `waitSimTime` clock poll only stalls the game being
|
|
224
|
+
* measured. A provider name is part of a `state` proof because each declared provider is graded
|
|
225
|
+
* independently; adapter epoch keeps a remount's first read from reusing the retired mount's proof.
|
|
226
|
+
*/
|
|
227
|
+
function repeatedDebugReadProofKeys(command: EditorCommand): readonly string[] {
|
|
228
|
+
if (command['type'] !== 'bridge-call') return [];
|
|
229
|
+
const method = command['method'];
|
|
230
|
+
if (typeof method !== 'string' || !REPEATED_DEBUG_READS.has(method)) return [];
|
|
231
|
+
const instance = command['instance'] as string | undefined;
|
|
232
|
+
try {
|
|
233
|
+
const debug = systemsForInstance(instance).debug;
|
|
234
|
+
if (debug === undefined) return [];
|
|
235
|
+
const prefix = [systemAdapterEpoch(debug), instance ?? ''].join(':');
|
|
236
|
+
const key = (member: string, provider = '') => [prefix, member, provider].join(':');
|
|
237
|
+
if (method === 'snapshot') {
|
|
238
|
+
// `snapshot` dispatches these three reads in one batch; the next lightweight `state(time)`
|
|
239
|
+
// poll is therefore already the same current-epoch proof, not a second first use.
|
|
240
|
+
return [key('snapshot'), key('state', 'time'), key('stateAll'), key('events')];
|
|
241
|
+
}
|
|
242
|
+
const provider = method === 'state' ? String((command['callArgs'] as unknown[])?.[0]) : '';
|
|
243
|
+
return [key(method, provider)];
|
|
244
|
+
} catch {
|
|
245
|
+
// An unresolved instance is a real command failure. Keep the conservative full refresh so its
|
|
246
|
+
// status/error facets cannot be hidden behind the optimization for successful repeated reads.
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface CompletedCommandRefresh {
|
|
252
|
+
readonly derived: RelayCommandDerivedRefresh;
|
|
253
|
+
/** A successful, explicit game read or command may have changed the mounted
|
|
254
|
+
* scene. During Play it earns one settled full report; ordinary input,
|
|
255
|
+
* presence and repeated reads do not. */
|
|
256
|
+
readonly playFullReport: 'none' | 'explicit' | 'if-content-changed';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function completedCommandDerivedRefresh(
|
|
260
|
+
command: EditorCommand,
|
|
261
|
+
succeeded: boolean,
|
|
262
|
+
reportedProofs: Set<string>,
|
|
263
|
+
): CompletedCommandRefresh {
|
|
264
|
+
const proofKeys = repeatedDebugReadProofKeys(command);
|
|
265
|
+
const repeated =
|
|
266
|
+
succeeded && proofKeys.length > 0 && proofKeys.every((key) => reportedProofs.has(key));
|
|
267
|
+
if (succeeded) {
|
|
268
|
+
for (const key of proofKeys) reportedProofs.add(key);
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
derived: repeated
|
|
272
|
+
? 'none'
|
|
273
|
+
: succeeded && proofKeys.length > 0
|
|
274
|
+
? 'always'
|
|
275
|
+
: (contributedCommandDerivedRefresh(command['type']) ??
|
|
276
|
+
relayCommandDerivedRefresh(command['type'])),
|
|
277
|
+
playFullReport:
|
|
278
|
+
succeeded && !repeated && proofKeys.length > 0
|
|
279
|
+
? 'explicit'
|
|
280
|
+
: succeeded && command['type'] === 'bridge-call' && command['method'] === 'invoke'
|
|
281
|
+
? 'if-content-changed'
|
|
282
|
+
: 'none',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function playCommandOwesDerivedRefresh(
|
|
287
|
+
derivedRefresh: RelayCommandDerivedRefresh,
|
|
288
|
+
playFullReport: CompletedCommandRefresh['playFullReport'],
|
|
289
|
+
contentVersion: number,
|
|
290
|
+
lastFullContentVersion: number,
|
|
291
|
+
): boolean {
|
|
292
|
+
if (derivedRefresh === 'none') return false;
|
|
293
|
+
return !(
|
|
294
|
+
derivedRefresh === 'if-content-changed' &&
|
|
295
|
+
playFullReport === 'none' &&
|
|
296
|
+
contentVersion === lastFullContentVersion
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The refusal for `editor.select(id)` on an id no authoring surface owns, or
|
|
302
|
+
* `null` when the id resolves.
|
|
303
|
+
*
|
|
304
|
+
* The resolvers are the ones a selection's own CONSUMERS use, and both of them,
|
|
305
|
+
* for the reason `entity-object.ts` records: the active adapter's
|
|
306
|
+
* `hierarchy.node` (what the hierarchy panel and the inspector resolve against)
|
|
307
|
+
* and `entityObject3D` (what the gizmo and the selection brackets resolve
|
|
308
|
+
* against). Neither is a superset of the other, so checking only one would
|
|
309
|
+
* refuse ids the editor can genuinely select.
|
|
310
|
+
*
|
|
311
|
+
* It used to consult neither: `editor.select('Player')` wrote the string
|
|
312
|
+
* straight into the store's selection set, and the inspector then composed a
|
|
313
|
+
* SUBJECT for it (measured 2026-08-14 on the translated platformer: `id:
|
|
314
|
+
* "Player"`, a blank title and an `R3F source` kind label for an entity that has
|
|
315
|
+
* never existed; through a composite the same id reached `children[0]` and drew
|
|
316
|
+
* a full transform section reading all zeros). A selection is a claim about an
|
|
317
|
+
* entity, so an id nothing owns is refused by name here rather than fabricated
|
|
318
|
+
* downstream.
|
|
319
|
+
*/
|
|
320
|
+
function unresolvedSelectionRefusal(store: EditorShellStore, id: string): string | null {
|
|
321
|
+
// Resolve through the same document-scoped binding as Hierarchy and
|
|
322
|
+
// Inspector. Asset Lab documents publish their own adapter while the scene
|
|
323
|
+
// adapter remains globally active; asking the latter would reject the exact
|
|
324
|
+
// ids editor.hierarchy() just reported for the open asset/story.
|
|
325
|
+
const adapter = activeDocumentAuthoring(store);
|
|
326
|
+
if (adapter.hierarchy.node(id) !== null) return null;
|
|
327
|
+
if (entityObject3D(adapter, store.objectMap, id) !== null) return null;
|
|
328
|
+
return (
|
|
329
|
+
`select: no entity with id "${id}" — neither the active authoring adapter's ` +
|
|
330
|
+
`hierarchy nor the live object index owns it, so there is nothing to select. ` +
|
|
331
|
+
`Read the ids that exist with editor.hierarchy() (the rows the panel is ` +
|
|
332
|
+
`rendering) or editor.status().entities.`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function applyControlSelection(
|
|
337
|
+
store: EditorShellStore,
|
|
338
|
+
ids: readonly string[],
|
|
339
|
+
options: CommandHandlingOptions | undefined,
|
|
340
|
+
): void {
|
|
341
|
+
// Object3D documents own selection outside the scene store. This is also
|
|
342
|
+
// the route used by human hierarchy clicks and present-view, and is what
|
|
343
|
+
// lets semantic Asset Lab subjects (bones, physics bodies, joints) update
|
|
344
|
+
// their native highlight and Inspector state.
|
|
345
|
+
const documentSession = activeObject3DDocumentSession();
|
|
346
|
+
if (documentSession) {
|
|
347
|
+
documentSession.select(ids);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const present = store.applySelectionBeforePresentation(ids);
|
|
351
|
+
if (options?.deferPresentation) options.deferPresentation(present);
|
|
352
|
+
else setTimeout(present, 0);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Live viewport camera pose of the FOCUSED stage — undefined when nothing has
|
|
356
|
+
* bound a camera yet (see `EditorShellStore.cameraPose`'s doc comment).
|
|
357
|
+
* A camera is a per-stage fact, so the reader follows focus the way the
|
|
358
|
+
* panels do (`focusedStageStore`, ARCHITECTURE-CORE §One stage unit 4):
|
|
359
|
+
* before this, `vgai status` reported the world's camera while the person
|
|
360
|
+
* was looking through a model document's. */
|
|
361
|
+
function collectCamera(store: EditorShellStore):
|
|
362
|
+
| {
|
|
363
|
+
position: { x: number; y: number; z: number };
|
|
364
|
+
target: { x: number; y: number; z: number };
|
|
365
|
+
fov: number;
|
|
366
|
+
}
|
|
367
|
+
| undefined {
|
|
368
|
+
const pose = focusedStageStore(store).cameraPose;
|
|
369
|
+
if (!pose) return undefined;
|
|
370
|
+
return {
|
|
371
|
+
position: { x: pose.position.x, y: pose.position.y, z: pose.position.z },
|
|
372
|
+
target: { x: pose.target.x, y: pose.target.y, z: pose.target.z },
|
|
373
|
+
fov: pose.fov,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** #145 — what the human's tab is actually showing right now: page
|
|
378
|
+
* visibility + window focus. `null` outside a real browser (the relay's
|
|
379
|
+
* node-side tests). The agent reads this to know whether the user is
|
|
380
|
+
* LOOKING at the shared session before deciding how to narrate/verify;
|
|
381
|
+
* `connectCommandListener` re-reports state on visibilitychange/focus/blur
|
|
382
|
+
* so the server's `/__editor/state` snapshot stays current between
|
|
383
|
+
* commands.
|
|
384
|
+
*
|
|
385
|
+
* P21 — `reportedAt` is stamped HERE, at read time, and it is the field that
|
|
386
|
+
* makes the other two honest. A CLI banner that says "the tab is hidden" is
|
|
387
|
+
* reading a snapshot with no age on it; the owner was told that repeatedly
|
|
388
|
+
* while looking straight at the foregrounded tab. With an age attached, the
|
|
389
|
+
* reader can see the difference between a reading taken 40ms ago and one
|
|
390
|
+
* taken 40 seconds ago, and no downstream surface has to invent a story to
|
|
391
|
+
* fill the gap. */
|
|
392
|
+
function collectPresence(): {
|
|
393
|
+
visibility: DocumentVisibilityState;
|
|
394
|
+
focused: boolean;
|
|
395
|
+
reportedAt: number;
|
|
396
|
+
} | null {
|
|
397
|
+
if (typeof document === 'undefined') return null;
|
|
398
|
+
return {
|
|
399
|
+
visibility: document.visibilityState,
|
|
400
|
+
focused: document.hasFocus(),
|
|
401
|
+
reportedAt: Date.now(),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The facets `collectState` will REUSE from a previous snapshot when it is
|
|
407
|
+
* given one — the hierarchy walk and the capability GRADING families. They are
|
|
408
|
+
* the whole measured cost of a collect (see `state-report-deferral.ts`), and
|
|
409
|
+
* none of them can change without a mount or an edit, which the deferred full
|
|
410
|
+
* collect that always follows a reusing one picks up.
|
|
411
|
+
*
|
|
412
|
+
* Everything NOT named here is derived fresh on every single report, including
|
|
413
|
+
* the fields a reader needs the instant they change: play state, loop
|
|
414
|
+
* liveness, selection, active document, save state, presence, and every error
|
|
415
|
+
* and warning channel.
|
|
416
|
+
*
|
|
417
|
+
* THE HOST'S HALF ONLY. The four capability-GRADING families
|
|
418
|
+
* (`rootCoverage`, `systemCoverage`, `projectCoverage`, `authoringCoverage`)
|
|
419
|
+
* used to be listed here; they are `@vgai/game`'s now
|
|
420
|
+
* (`contributions/coverage.service.ts`) and declare their own reusable keys
|
|
421
|
+
* on `session.reportFacet`, which {@link reusableFacetKeys} reads back. Both
|
|
422
|
+
* blockers P5b measured against that move are gone: the facet registry
|
|
423
|
+
* carries reusable key names and hands the collect its `reuse` snapshot, and
|
|
424
|
+
* the vitals' union call is now a subscription to the SAME five-second sample
|
|
425
|
+
* (`coverage/session-vitals.ts`'s `onSessionSample`) rather than a second
|
|
426
|
+
* timer. What is left in the host is the question the grade was asked ABOUT —
|
|
427
|
+
* `authoring/mounted-root-subjects.ts` — which was never a grade.
|
|
428
|
+
*/
|
|
429
|
+
export const REUSABLE_DERIVED_FACETS = ['entities', 'entityCount', 'ontologyInvariants'] as const;
|
|
430
|
+
|
|
431
|
+
/** The immediate interaction report is a PATCH over the last full state. The
|
|
432
|
+
* server already owns that full snapshot; resending these unchanged,
|
|
433
|
+
* tree-scale facets on every runtime structure/store notification turns a
|
|
434
|
+
* large Play world into megabytes of duplicate control traffic per frame.
|
|
435
|
+
*
|
|
436
|
+
* A contributed facet's own reusable keys are stripped the same way — read
|
|
437
|
+
* live, because a contribution pass adds and removes facets while the session
|
|
438
|
+
* runs. */
|
|
439
|
+
function currentStatePatch(state: Record<string, unknown>): Record<string, unknown> {
|
|
440
|
+
const patch = { ...state };
|
|
441
|
+
for (const facet of REUSABLE_DERIVED_FACETS) delete patch[facet];
|
|
442
|
+
for (const facet of reusableFacetKeys()) delete patch[facet];
|
|
443
|
+
return { ...patch, _statePatch: true };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function collectState(
|
|
447
|
+
store: EditorShellStore,
|
|
448
|
+
/**
|
|
449
|
+
* A previous full snapshot whose {@link REUSABLE_DERIVED_FACETS} this collect
|
|
450
|
+
* may copy instead of re-deriving, or `null`/omitted for a full collect.
|
|
451
|
+
*
|
|
452
|
+
* This is the interaction path's escape from a 76ms-to-1.3s synchronous
|
|
453
|
+
* derivation on every store notification. It is deliberately a caller's
|
|
454
|
+
* choice rather than an internal cache: only the caller knows whether it is
|
|
455
|
+
* on a user's critical path, and only the caller can promise the deferred
|
|
456
|
+
* full collect that makes the reused halves current again.
|
|
457
|
+
*/
|
|
458
|
+
reuse: Record<string, unknown> | null = null,
|
|
459
|
+
): Record<string, unknown> {
|
|
460
|
+
// S-1 (the SimCity ledger's false-alive ingest status): `store.playState` is
|
|
461
|
+
// editor UI STATE — ingest and module mode both write 'playing' into it
|
|
462
|
+
// without ever owning the `play-mode.ts` session the whole debug seam gates
|
|
463
|
+
// on, and ingest writes it BEFORE its mount is even attempted. Reporting it
|
|
464
|
+
// verbatim is how a session printed `playState: "playing"` in the same breath
|
|
465
|
+
// as `game.state()` refusing with "not in play mode", and how a mount that
|
|
466
|
+
// threw still reported itself alive. Derive it from what is actually running.
|
|
467
|
+
const mountFailures = getMountFailureReports();
|
|
468
|
+
const activeDocumentId = activeWorkspaceDocumentId();
|
|
469
|
+
const documentPresentation = activeDocumentId
|
|
470
|
+
? object3DDocumentSession(activeDocumentId)?.presentation()
|
|
471
|
+
: undefined;
|
|
472
|
+
const selectedIds = activeSelectionIds(store);
|
|
473
|
+
const gameplaySessions = toolGameplaySessions.getSnapshot();
|
|
474
|
+
// ONE walk, read by both `entityCount` and `entities` below. It used to be
|
|
475
|
+
// called once for each, and the walk is the expensive half of this whole
|
|
476
|
+
// function (23ms of a 76ms collect at 251 nodes, measured 2026-08-19) — two
|
|
477
|
+
// identical breadth-first traversals of the same tree in the same tick.
|
|
478
|
+
const hierarchyRows =
|
|
479
|
+
(reuse?.['entities'] as ReturnType<typeof activeHierarchyRows> | undefined) ??
|
|
480
|
+
activeHierarchyRows(store);
|
|
481
|
+
return {
|
|
482
|
+
playState: deriveReportedPlayState({
|
|
483
|
+
storePlayState: store.playState,
|
|
484
|
+
liveMounted: anyLiveSessionMounted(),
|
|
485
|
+
livePlaying: anyLiveSessionPlaying(),
|
|
486
|
+
mountFailures,
|
|
487
|
+
}),
|
|
488
|
+
// Every world whose mount FAILED, with the error that killed it. `[]` on a
|
|
489
|
+
// healthy session; non-empty with `ingest: null` is what a dead game looks
|
|
490
|
+
// like from the control API, instead of a silent "playing". (`ingest` and
|
|
491
|
+
// `ingestCaptureWait` are the ingest lane's own facets, registered by it.)
|
|
492
|
+
mountFailures: mountFailures.map((r) => ({ ...r })),
|
|
493
|
+
// PD-3 — every project module that was evaluated more than once during
|
|
494
|
+
// the current mount, i.e. every module whose module-level state the roots
|
|
495
|
+
// no longer share. `[]` on a healthy mount. A split does not fail the
|
|
496
|
+
// mount (both copies run), so this is the ONLY machine-readable signal
|
|
497
|
+
// that it happened at all.
|
|
498
|
+
moduleSplits: getProjectModuleSplitReports().map((s) => ({ path: s.path, urls: [...s.urls] })),
|
|
499
|
+
// The project's own ADAPTER, resolved (project-adapter.ts): which module
|
|
500
|
+
// supplied the binding table (`vgai.adapter.ts`, or the declared native
|
|
501
|
+
// default), the regions derived for it, and its scene table. `null` means
|
|
502
|
+
// NOBODY HAS LOOKED YET — deliberately distinct from a loaded adapter with
|
|
503
|
+
// an empty table, which is a real (and gradable) answer.
|
|
504
|
+
adapter: projectAdapterFacet(),
|
|
505
|
+
// DECLARED READINESS, per root (`readiness.ts`). One row per mounted root
|
|
506
|
+
// saying WHO answers "is this ready" — the host's own completed mount, the
|
|
507
|
+
// game's `window.vgaiGame.ready`, or a host-side measured wait. `[]` means
|
|
508
|
+
// nothing has mounted, which is not "nothing is ready".
|
|
509
|
+
readiness: readinessFacet().map((entry) => ({ ...entry })),
|
|
510
|
+
// The ONE sentence for a project whose readiness is entirely measured. It
|
|
511
|
+
// is the visibility half of the declared-vs-measured rule: an undeclared
|
|
512
|
+
// game still works, it just stops being silent about it. `null` when
|
|
513
|
+
// nothing is mounted or at least one root declared.
|
|
514
|
+
readinessWarning: measuredReadinessWarning(),
|
|
515
|
+
// WHICH WORLD the capture adopted for a self-booting root and how
|
|
516
|
+
// (`declared` from the game's contract, or the trap's measured
|
|
517
|
+
// first-non-host-render), plus every DISTINCT world that rendered
|
|
518
|
+
// afterwards. The adoption itself is unchanged; this is the reader that
|
|
519
|
+
// used to not exist for a permanent, silent choice.
|
|
520
|
+
worldAdoption: worldAdoptionFacet().map((entry) => ({ ...entry })),
|
|
521
|
+
presence: collectPresence(),
|
|
522
|
+
// Target-blaster friction #3 (#146 ledger): the play-verify loop's error
|
|
523
|
+
// channel — the same current-run-fenced uncaught-error list the relay
|
|
524
|
+
// snapshot carries (see collectPlayRunPageErrors), so `vgai status` answers
|
|
525
|
+
// "did anything go wrong since play started" without a second command.
|
|
526
|
+
// [] while stopped or when nothing threw.
|
|
527
|
+
pageErrors: collectPlayRunPageErrors(),
|
|
528
|
+
// The OTHER half of the same question, and the half that was missing: an
|
|
529
|
+
// otherwise-HEALTHY run that logged errors. `pageErrors` above is the
|
|
530
|
+
// 'runtime' slice (uncaught error/unhandledrejection) and is rendered only
|
|
531
|
+
// by the play-FAILED path, so a game that threw five `console.error`s
|
|
532
|
+
// during a fine-looking play run said nothing to any CLI reader — the same
|
|
533
|
+
// "already in the JSON and invisible in practice" failure PD-13 describes.
|
|
534
|
+
// Disjoint from `pageErrors` by construction (see collectConsoleErrors), so
|
|
535
|
+
// the two counts sum.
|
|
536
|
+
consoleErrors: collectConsoleErrors(),
|
|
537
|
+
// The SESSION-lifetime half — everything the two play-fenced facets above
|
|
538
|
+
// do not claim, which before `installEditorConsoleCapture()` was
|
|
539
|
+
// everything an editor frame logged while play was stopped and reached no
|
|
540
|
+
// reader at all (see `collectSessionErrors`). Disjoint from both by
|
|
541
|
+
// construction, so all three counts sum.
|
|
542
|
+
sessionErrors: collectSessionErrors(),
|
|
543
|
+
// Warnings had NO CLI-facing facet whatsoever until this one.
|
|
544
|
+
sessionWarnings: collectSessionWarnings(),
|
|
545
|
+
// GPU ownership is measured independently from DOM canvas attachment.
|
|
546
|
+
// These live counters make context-budget regressions observable without
|
|
547
|
+
// waiting for the browser to evict the oldest viewport.
|
|
548
|
+
rendererResources: {
|
|
549
|
+
hostLive: liveHostRendererCount(),
|
|
550
|
+
interactive: interactiveViewportRendererCounts(),
|
|
551
|
+
inspectorPreview: inspectorPreviewRendererCounts(),
|
|
552
|
+
},
|
|
553
|
+
// The ontology's LIVE invariants, re-derived on read. Every row is present
|
|
554
|
+
// every time — including the ones this session cannot measure, which say so
|
|
555
|
+
// rather than vanishing (`coverage/ontology-invariants.ts`).
|
|
556
|
+
ontologyInvariants:
|
|
557
|
+
reuse?.['ontologyInvariants'] ?? ontologyInvariantFacet().map((row) => ({ ...row })),
|
|
558
|
+
// What only a running lane knows — its loop's time scale and liveness,
|
|
559
|
+
// its seed, a pending restart, and the four capability-GRADING families
|
|
560
|
+
// (`editor-state-facets.ts`). `reuse` rides through: a facet that declared
|
|
561
|
+
// reusable keys serves them from the snapshot instead of re-deriving.
|
|
562
|
+
...collectEditorStateFacets(reuse),
|
|
563
|
+
selectedEntityId: selectedIds[0] ?? null,
|
|
564
|
+
selectedEntityIds: selectedIds,
|
|
565
|
+
// The machine door onto the creation-site index: the source
|
|
566
|
+
// location that constructed the SELECTED object, or the named reason there
|
|
567
|
+
// isn't one. `null` only when nothing is selected or the active adapter
|
|
568
|
+
// indexes no creation sites.
|
|
569
|
+
selectedCreationSite: activeSelectionCreationSite(store),
|
|
570
|
+
// The WRITE-side half of the same answer: which lane an edit to the
|
|
571
|
+
// selection would take (`WriteAnchorKind`). A sweep needs it to find one
|
|
572
|
+
// representative subject per lane — the anchor above cannot distinguish a
|
|
573
|
+
// JSX prop from a construction literal, nor either from a body-placed
|
|
574
|
+
// spawn, and those have different correctness contracts.
|
|
575
|
+
selectedWriteAnchorKind: activeSelectionWriteAnchorKind(store),
|
|
576
|
+
activeViewportTab: store.activeViewportTab,
|
|
577
|
+
activeDocumentId,
|
|
578
|
+
activeUtilityId: activeWorkspaceUtility(),
|
|
579
|
+
gameplaySession: {
|
|
580
|
+
selectedSessionId: gameplaySessions.selectedSessionId,
|
|
581
|
+
latestSessionId: gameplaySessions.sessions[0]?.id ?? null,
|
|
582
|
+
selectedStatus: gameplaySessions.selectedSession?.status ?? null,
|
|
583
|
+
cursorMs: gameplaySessions.cursorMs,
|
|
584
|
+
liveEdgeMs: gameplaySessions.liveEdgeMs,
|
|
585
|
+
},
|
|
586
|
+
// The registry is the authority for which center subjects EXIST. Doctor
|
|
587
|
+
// uses this to photograph the project's actual component boards instead
|
|
588
|
+
// of spending long activation windows guessing every medium-specific id.
|
|
589
|
+
openDocumentIds: openWorkspaceDocuments().map((document) => document.descriptor.id),
|
|
590
|
+
// W2: asset viewers are center workspace documents now; this facet keeps
|
|
591
|
+
// its legacy key vocabulary for control-API consumers — the active ASSET
|
|
592
|
+
// document's key, or the '__inspector__' sentinel. The sentinel does NOT
|
|
593
|
+
// imply a visible Inspector: that surface is selection-owned.
|
|
594
|
+
activeTabKey: (() => {
|
|
595
|
+
const activeId = activeWorkspaceDocumentId();
|
|
596
|
+
return activeId && isAssetDocumentId(activeId) ? activeId : '__inspector__';
|
|
597
|
+
})(),
|
|
598
|
+
showGrid: documentPresentation?.grid ?? store.showGrid,
|
|
599
|
+
// Per-STAGE view options, reported for the stage the person is looking at
|
|
600
|
+
// — the same store the `set-helpers`/`set-stats` verbs write and the
|
|
601
|
+
// focused stage's own viewport reads (ARCHITECTURE-CORE §One stage unit 4).
|
|
602
|
+
showHelpers: focusedStageStore(store).showHelpers,
|
|
603
|
+
showStats: focusedStageStore(store).showStats,
|
|
604
|
+
shadingMode: documentPresentation?.mode ?? store.shadingMode,
|
|
605
|
+
helperVisibility: {
|
|
606
|
+
...focusedStageStore(store).helperVisibility,
|
|
607
|
+
...(documentPresentation
|
|
608
|
+
? {
|
|
609
|
+
bounds: documentPresentation.bounds,
|
|
610
|
+
skeletons: documentPresentation.skeleton,
|
|
611
|
+
}
|
|
612
|
+
: {}),
|
|
613
|
+
},
|
|
614
|
+
// THE ARMED TOOL IS THE ACTIVE DOCUMENT'S STAGE'S, the same door
|
|
615
|
+
// `showHelpers`/`showStats` above already read. Every stage owns an
|
|
616
|
+
// `EditorShellStore` (`stage-store-registry.ts`) and the shelf's four
|
|
617
|
+
// tools write the one the person is looking at; reading the SHELL's here
|
|
618
|
+
// reported `combined` for every tool on any document with a stage of its
|
|
619
|
+
// own — measured on the Model document at WALK 5 row 4b, where the shelf
|
|
620
|
+
// was lit on Select Box and this line said `combined`. The world root's
|
|
621
|
+
// stage runs on the shell store itself, so the Scene document's answer is
|
|
622
|
+
// unchanged.
|
|
623
|
+
transformMode: focusedStageStore(store).transformMode,
|
|
624
|
+
transformSpace: store.transformSpace,
|
|
625
|
+
snapEnabled: store.snapEnabled,
|
|
626
|
+
entityCount: hierarchyRows.length,
|
|
627
|
+
// Persistence truth comes from the ACTIVE adapter's PersistenceProvider —
|
|
628
|
+
// never a per-format store field.
|
|
629
|
+
savePath: activeSaveDestination(store),
|
|
630
|
+
// SDK clients need the same persistence truth the editor UI exposes.
|
|
631
|
+
saveState: activeSaveState(store),
|
|
632
|
+
// Camera facet (B3-followup): real pose when a viewport is bound, absent
|
|
633
|
+
// otherwise — never a fabricated value (see EditorShellStore.cameraPose).
|
|
634
|
+
camera: collectCamera(store),
|
|
635
|
+
// Hierarchy facet (B3-followup): the real node tree, flattened to
|
|
636
|
+
// id/name/childIds rows (same shape editor.hierarchy.inspect declares),
|
|
637
|
+
// read from the ACTIVE authoring adapter.
|
|
638
|
+
entities: hierarchyRows,
|
|
639
|
+
// Design-surface first-frame stamps. Doctor `--timings` subtracts these
|
|
640
|
+
// from its `active-tab` ack so a tab flip is a measured wait, not a
|
|
641
|
+
// blank canvas with no number.
|
|
642
|
+
viewportActivationTimings: viewportActivationTimings(),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The answer a caller gets when a command HANDLER threw instead of returning.
|
|
648
|
+
*
|
|
649
|
+
* Two things must happen and neither used to: the caller is told what killed
|
|
650
|
+
* its command (rather than timing out against a message about the tab), and
|
|
651
|
+
* the failure is logged so it reaches `editorConsole` — which is what
|
|
652
|
+
* `collectSessionErrors` reads, and therefore what `vgai status` prints. The
|
|
653
|
+
* browser's own `unhandledrejection` path did the second job only for the
|
|
654
|
+
* FIRST occurrence, because the console capture dedupes an identical message.
|
|
655
|
+
*/
|
|
656
|
+
export function commandThrewResult(cmd: EditorCommand, error: unknown): CommandResult {
|
|
657
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
658
|
+
const text = `editor command "${String(cmd['type'])}" threw: ${message}`;
|
|
659
|
+
// biome-ignore lint/suspicious/noConsole: this IS the loud leg — editorConsole is fed by the console, and it is what `vgai status` prints.
|
|
660
|
+
console.error(text, error);
|
|
661
|
+
return { ok: false, error: text };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The dimensions a capture verb was asked for, or the refusal that names the
|
|
666
|
+
* value it was handed.
|
|
667
|
+
*
|
|
668
|
+
* SQUARE IS THE DEFAULT and `size` is how you ask for one. `width`+`height`
|
|
669
|
+
* together ask for a SHAPED frame — a video-aspect look that needs no crop
|
|
670
|
+
* afterwards — and are bounded per side and in TOTAL by the same relay budget
|
|
671
|
+
* the square ceiling comes from. Mixing the two forms is refused rather than
|
|
672
|
+
* silently resolved: a caller who sent both does not know which one they meant.
|
|
673
|
+
*
|
|
674
|
+
* `cmd` is WIRE INPUT — a JSON object from another process — so `cmd['size']`
|
|
675
|
+
* had never been anything but a cast (`as number | undefined`). A caller that
|
|
676
|
+
* sent a non-number got that value multiplied by 2 deep inside
|
|
677
|
+
* `_renderViewportImage` and `createImageData(NaN, NaN)` threw
|
|
678
|
+
* `TypeError: Value is not of type 'long'` from the middle of the render path;
|
|
679
|
+
* because the dispatcher's `.then()` had no rejection leg, the throw answered
|
|
680
|
+
* nobody and the caller waited out its whole budget for
|
|
681
|
+
* "the tab is present … and did not respond" — a message about the TAB for a
|
|
682
|
+
* defect in the argument. Measured on a racing-game ingest mount, 2026-08-15.
|
|
683
|
+
*/
|
|
684
|
+
export function captureSizeFromCommand(
|
|
685
|
+
cmd: EditorCommand,
|
|
686
|
+
): { size?: CaptureDimensions } | { error: string } {
|
|
687
|
+
const raw = cmd['size'];
|
|
688
|
+
const rawWidth = cmd['width'];
|
|
689
|
+
const rawHeight = cmd['height'];
|
|
690
|
+
const shaped = rawWidth !== undefined || rawHeight !== undefined;
|
|
691
|
+
if (shaped) {
|
|
692
|
+
if (raw !== undefined && raw !== null) {
|
|
693
|
+
return {
|
|
694
|
+
error:
|
|
695
|
+
`${String(cmd['type'])}: pass "size" (a square) OR "width"+"height" (a shaped frame), ` +
|
|
696
|
+
'never both.',
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const dimension = (name: string, value: unknown): number | string => {
|
|
700
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
701
|
+
return (
|
|
702
|
+
`${String(cmd['type'])}: "${name}" must be a whole number — got ` +
|
|
703
|
+
`${typeof value} ${JSON.stringify(value) ?? String(value)}.`
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
if (value < MIN_CAPTURE_DIMENSION || value > MAX_CAPTURE_DIMENSION) {
|
|
707
|
+
return `${String(cmd['type'])}: "${name}" must be ${MIN_CAPTURE_DIMENSION}-${MAX_CAPTURE_DIMENSION} — ${CAPTURE_BUDGET_REASON}`;
|
|
708
|
+
}
|
|
709
|
+
return value;
|
|
710
|
+
};
|
|
711
|
+
const width = dimension('width', rawWidth);
|
|
712
|
+
if (typeof width === 'string') return { error: width };
|
|
713
|
+
const height = dimension('height', rawHeight);
|
|
714
|
+
if (typeof height === 'string') return { error: height };
|
|
715
|
+
if (width * height > MAX_CAPTURE_DIMENSION * MAX_CAPTURE_DIMENSION) {
|
|
716
|
+
return {
|
|
717
|
+
error:
|
|
718
|
+
`${String(cmd['type'])}: ${width}x${height} is ${width * height} pixels, past the ` +
|
|
719
|
+
`${MAX_CAPTURE_DIMENSION}x${MAX_CAPTURE_DIMENSION} total — ${CAPTURE_BUDGET_REASON}`,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
return { size: { width, height } };
|
|
723
|
+
}
|
|
724
|
+
if (raw === undefined || raw === null) return {};
|
|
725
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 1) {
|
|
726
|
+
return {
|
|
727
|
+
error:
|
|
728
|
+
`${String(cmd['type'])}: "size" must be a finite number >= 1 — got ` +
|
|
729
|
+
`${typeof raw} ${JSON.stringify(raw) ?? String(raw)}.`,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
return { size: raw };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* The editor's own capture ceiling, and WHY it is this number.
|
|
737
|
+
*
|
|
738
|
+
* 64..1024 per side is `asset-preview.ts`'s `MIN_SIZE`/`MAX_SIZE`: the pixels
|
|
739
|
+
* cross the editor relay as base64 JSON, and 1024 is where even incompressible
|
|
740
|
+
* RGBA still fits its 50 MB request limit. A shaped frame is held to the same
|
|
741
|
+
* BUDGET rather than a laxer one — its total may not exceed a 1024 square —
|
|
742
|
+
* because the budget is about bytes on the wire, not about shape.
|
|
743
|
+
*/
|
|
744
|
+
const MIN_CAPTURE_DIMENSION = 64;
|
|
745
|
+
const MAX_CAPTURE_DIMENSION = 1024;
|
|
746
|
+
const CAPTURE_BUDGET_REASON =
|
|
747
|
+
"the editor's own ceiling: the pixels cross the relay as base64 JSON, and 1024 is where even " +
|
|
748
|
+
'incompressible RGBA still fits its 50 MB request limit. For more picture, take more views, ' +
|
|
749
|
+
'not bigger ones.';
|
|
750
|
+
|
|
751
|
+
function activeObject3DDocumentSession() {
|
|
752
|
+
const documentId = activeWorkspaceDocumentId();
|
|
753
|
+
return documentId ? object3DDocumentSession(documentId) : null;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** THE ONE PLAY-RUN FENCE all four error facets below split on: `true` when a
|
|
757
|
+
* console entry logged at `timestamp` belongs to the most recent play run.
|
|
758
|
+
*
|
|
759
|
+
* Closed at BOTH ends (the live registry's newest run window), which is
|
|
760
|
+
* what makes the play facets and the session facets a genuine partition of the
|
|
761
|
+
* session's error entries — every entry belongs to exactly one side, so the
|
|
762
|
+
* counts sum with nothing double-counted and nothing dropped. With an
|
|
763
|
+
* open-ended window a single play run captured the rest of the session: every
|
|
764
|
+
* later editor error read as "during the play run" and was reported only by a
|
|
765
|
+
* banner that renders while play is live. */
|
|
766
|
+
function inPlayRun(timestamp: number): boolean {
|
|
767
|
+
const window = liveRunWindow();
|
|
768
|
+
if (!window || timestamp < window.startedAt) return false;
|
|
769
|
+
return window.endedAt === null || timestamp <= window.endedAt;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** #146 — uncaught page errors from the CURRENT play run, for `vgai status`
|
|
773
|
+
* and for `@vgai/game`'s `bridge-call` snapshot, which imports it from here
|
|
774
|
+
* (the two must report the same set; the facet moves when Play does).
|
|
775
|
+
* Reads the editor console's 'runtime'-source error entries
|
|
776
|
+
* (fed by `installEditorConsoleCapture`'s window error/unhandledrejection
|
|
777
|
+
* capture — the same events, same message format as `window.__vgai`'s own
|
|
778
|
+
* pageErrors ring buffer), fenced to the play run. Same 100-cap as the bridge
|
|
779
|
+
* (`PAGE_ERROR_CAP`), keeping the most recent. */
|
|
780
|
+
export function collectPlayRunPageErrors(): string[] {
|
|
781
|
+
return editorConsole
|
|
782
|
+
.getEntries()
|
|
783
|
+
.filter((e) => e.level === 'error' && e.source === 'runtime' && inPlayRun(e.timestamp))
|
|
784
|
+
.slice(-100)
|
|
785
|
+
.map((e) => (e.count > 1 ? `${e.message} (×${e.count})` : e.message));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** How many of the newest console-error messages the summary carries, and how
|
|
789
|
+
* far each is truncated. A banner that reprints every message on a noisy run
|
|
790
|
+
* stops being readable, which is the failure mode this exists to fix (same
|
|
791
|
+
* reasoning as `authoringWarningsWarning`'s per-file summary in the CLI); the
|
|
792
|
+
* full text is in the editor console and the run's `logs/play-*.jsonl`. */
|
|
793
|
+
const CONSOLE_ERROR_SAMPLE = 3;
|
|
794
|
+
const CONSOLE_ERROR_MESSAGE_CAP = 200;
|
|
795
|
+
|
|
796
|
+
/** Error-level editor-console entries from the CURRENT play run that
|
|
797
|
+
* `collectPlayRunPageErrors` above does NOT already report — i.e. everything except
|
|
798
|
+
* the 'runtime' source. That exclusion is what makes the two facets disjoint,
|
|
799
|
+
* so a reader can add the counts without double-counting one error.
|
|
800
|
+
*
|
|
801
|
+
* The dominant member is source 'game': `play-mode.ts`'s console patch funnels
|
|
802
|
+
* the running game's own `console.error` here, and that channel reached no CLI
|
|
803
|
+
* reader at all. `count` totals OCCURRENCES (the console store collapses
|
|
804
|
+
* identical consecutive messages into one entry with a `count`), so a loop
|
|
805
|
+
* erroring every frame reports the real number rather than 1. Fenced to
|
|
806
|
+
* the live run window exactly like `collectPlayRunPageErrors`, and `{count: 0,
|
|
807
|
+
* recent: []}` while stopped. */
|
|
808
|
+
function collectConsoleErrors(): { count: number; recent: string[] } {
|
|
809
|
+
return summarizeEntries(
|
|
810
|
+
editorConsole
|
|
811
|
+
.getEntries()
|
|
812
|
+
.filter((e) => e.level === 'error' && e.source !== 'runtime' && inPlayRun(e.timestamp)),
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** The shared `{count, recent}` shape: `count` totals OCCURRENCES (the console
|
|
817
|
+
* store collapses identical consecutive messages into one entry carrying a
|
|
818
|
+
* `count`), `recent` is the newest few, source-tagged and truncated. */
|
|
819
|
+
function summarizeEntries(entries: readonly ConsoleEntry[]): { count: number; recent: string[] } {
|
|
820
|
+
const count = entries.reduce((sum, e) => sum + e.count, 0);
|
|
821
|
+
const recent = entries.slice(-CONSOLE_ERROR_SAMPLE).map((e) => {
|
|
822
|
+
const source = e.source ? `[${e.source}] ` : '';
|
|
823
|
+
const repeats = e.count > 1 ? ` (×${e.count})` : '';
|
|
824
|
+
const message =
|
|
825
|
+
e.message.length > CONSOLE_ERROR_MESSAGE_CAP
|
|
826
|
+
? `${e.message.slice(0, CONSOLE_ERROR_MESSAGE_CAP)}…`
|
|
827
|
+
: e.message;
|
|
828
|
+
return `${source}${message}${repeats}`;
|
|
829
|
+
});
|
|
830
|
+
return { count, recent };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Error-level entries from the whole EDITOR SESSION that the two play-fenced
|
|
834
|
+
* facets above do NOT report — i.e. everything logged outside the most recent
|
|
835
|
+
* play run's window (`inPlayRun`). That exclusion is what keeps all three
|
|
836
|
+
* disjoint, exactly as `collectConsoleErrors` excludes source 'runtime'.
|
|
837
|
+
*
|
|
838
|
+
* Measured defect this closes: a human watching the editor's browser console
|
|
839
|
+
* saw real errors — Content-tab story previews throwing `useRapier must be
|
|
840
|
+
* used within <Physics>` — while `vgai status` reported `consoleErrors:
|
|
841
|
+
* {count: 0}` and `pageErrors: []`. Nothing was wrong with either facet: both
|
|
842
|
+
* are fenced to a play run, and the ONLY funnel from a raw `console.error`
|
|
843
|
+
* into the store was play-mode's patch, installed at play start and removed at
|
|
844
|
+
* play stop. Outside play, an editor-frame error existed in the browser
|
|
845
|
+
* console and nowhere else. `installEditorConsoleCapture()` (editor boot) now
|
|
846
|
+
* feeds the store for the whole session, and this is the facet that reports
|
|
847
|
+
* it — including uncaught page errors ('runtime') thrown outside a play run,
|
|
848
|
+
* which `collectPlayRunPageErrors` deliberately still does not claim.
|
|
849
|
+
*
|
|
850
|
+
* Fence: since editor page load (the store starts empty at boot), NOT since
|
|
851
|
+
* play start. */
|
|
852
|
+
function collectSessionErrors(): { count: number; recent: string[] } {
|
|
853
|
+
return summarizeEntries(
|
|
854
|
+
editorConsole.getEntries().filter((e) => e.level === 'error' && !inPlayRun(e.timestamp)),
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Warn-level entries from the whole editor session. No play-fenced facet
|
|
859
|
+
* reports warnings at ALL — `console.warn` had no CLI-facing channel of any
|
|
860
|
+
* kind — so this one is not narrowed to outside-play: narrowing it would
|
|
861
|
+
* simply re-hide every warning a play run emits. Same `{count, recent}`
|
|
862
|
+
* discipline as the error facets; the `[source]` tag on each sample is what
|
|
863
|
+
* tells a reader whether a warning came from the game, the editor, or the
|
|
864
|
+
* server. */
|
|
865
|
+
function collectSessionWarnings(): { count: number; recent: string[] } {
|
|
866
|
+
return summarizeEntries(editorConsole.getEntries().filter((e) => e.level === 'warn'));
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* `document-script` relay op — `editor.document.run(ctx => …)`. The same
|
|
871
|
+
* wire contract as `page-script` (a step's own source, reconstructed here;
|
|
872
|
+
* closures do not survive), bound not to a page shim but to the object the
|
|
873
|
+
* ACTIVE document published as its context. Refuses by name when no document
|
|
874
|
+
* is active or the active one published nothing, so an agent is never told
|
|
875
|
+
* a step ran against a document that has no session to run it on.
|
|
876
|
+
*/
|
|
877
|
+
async function handleDocumentScript(cmd: EditorCommand): Promise<CommandResult> {
|
|
878
|
+
const src = cmd['src'];
|
|
879
|
+
if (typeof src !== 'string') {
|
|
880
|
+
return { ok: false, error: 'document-script requires a string "src" (the step\'s toString())' };
|
|
881
|
+
}
|
|
882
|
+
const documentId = activeWorkspaceDocumentId();
|
|
883
|
+
if (!documentId) {
|
|
884
|
+
return {
|
|
885
|
+
ok: false,
|
|
886
|
+
error: 'document-script: no document is active',
|
|
887
|
+
data: { code: 'DOCUMENT_SCRIPT_UNAVAILABLE' },
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
await waitForDocumentContext(documentId);
|
|
891
|
+
const context = documentContextFor(documentId);
|
|
892
|
+
if (activeWorkspaceDocumentId() !== documentId) {
|
|
893
|
+
return {
|
|
894
|
+
ok: false,
|
|
895
|
+
error: 'document-script: the active document changed while its context was loading',
|
|
896
|
+
data: { code: 'DOCUMENT_SCRIPT_UNAVAILABLE', documentId },
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
if (context === undefined) {
|
|
900
|
+
return {
|
|
901
|
+
ok: false,
|
|
902
|
+
error:
|
|
903
|
+
`document-script: the active document (${documentId}) publishes no context to run ` +
|
|
904
|
+
'against — a document opts in through its `publishContext` prop (the mesh document ' +
|
|
905
|
+
'publishes its session).',
|
|
906
|
+
data: { code: 'DOCUMENT_SCRIPT_UNAVAILABLE', documentId },
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
let step: (ctx: unknown, info: { documentId: string }) => unknown;
|
|
910
|
+
try {
|
|
911
|
+
step = new Function('ctx', 'info', `return (${src})(ctx, info)`) as typeof step;
|
|
912
|
+
} catch (err) {
|
|
913
|
+
return {
|
|
914
|
+
ok: false,
|
|
915
|
+
error: `document-script: failed to reconstruct the step function from source — ${
|
|
916
|
+
err instanceof Error ? err.message : String(err)
|
|
917
|
+
}`,
|
|
918
|
+
data: { code: 'DOCUMENT_SCRIPT_ERROR' },
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
const result = await step(context, { documentId });
|
|
923
|
+
return { ok: true, data: { result: result === undefined ? null : result } };
|
|
924
|
+
} catch (err) {
|
|
925
|
+
return {
|
|
926
|
+
ok: false,
|
|
927
|
+
error: `document-script: step threw — ${err instanceof Error ? err.message : String(err)}`,
|
|
928
|
+
data: { code: 'DOCUMENT_SCRIPT_ERROR' },
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function handleAssetPreviewCommand(
|
|
934
|
+
store: EditorShellStore,
|
|
935
|
+
cmd: EditorCommand,
|
|
936
|
+
): Promise<CommandResult> {
|
|
937
|
+
const assetPath = cmd['assetPath'];
|
|
938
|
+
const entityId = cmd['entityId'];
|
|
939
|
+
// The THIRD source: a GLB that travels IN the command rather than being
|
|
940
|
+
// fetched or looked up — the module-look lane (`project.bake.preview`)
|
|
941
|
+
// builds an Object3D in Node, where there is no GPU, and hands the editor
|
|
942
|
+
// the exported bytes. Counted rather than XOR-ed because there are now more
|
|
943
|
+
// than two sources and "exactly one" has to stay exactly one.
|
|
944
|
+
const glbBase64 = cmd['glbBase64'];
|
|
945
|
+
const sourceCount =
|
|
946
|
+
Number(typeof assetPath === 'string') +
|
|
947
|
+
Number(typeof entityId === 'string') +
|
|
948
|
+
Number(typeof glbBase64 === 'string');
|
|
949
|
+
if (sourceCount !== 1) {
|
|
950
|
+
return {
|
|
951
|
+
ok: false,
|
|
952
|
+
error: 'capture-asset-preview requires exactly one of assetPath, entityId or glbBase64.',
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
const width = cmd['width'];
|
|
956
|
+
const height = cmd['height'];
|
|
957
|
+
const background = cmd['background'];
|
|
958
|
+
const shots = cmd['shots'];
|
|
959
|
+
const shotSet = cmd['shotSet'];
|
|
960
|
+
const forward = cmd['forward'];
|
|
961
|
+
const compare = cmd['compare'];
|
|
962
|
+
const stage = cmd['stage'];
|
|
963
|
+
const camera = cmd['camera'];
|
|
964
|
+
const pose = cmd['pose'];
|
|
965
|
+
if (
|
|
966
|
+
(width !== undefined && typeof width !== 'number') ||
|
|
967
|
+
(height !== undefined && typeof height !== 'number') ||
|
|
968
|
+
(background !== undefined && background !== 'neutral' && background !== 'transparent') ||
|
|
969
|
+
(shots !== undefined && shots !== 'source')
|
|
970
|
+
) {
|
|
971
|
+
return { ok: false, error: 'Invalid asset preview dimensions, background, or shots mode.' };
|
|
972
|
+
}
|
|
973
|
+
if (stage !== undefined && stage !== 'lab' && stage !== 'scene') {
|
|
974
|
+
return {
|
|
975
|
+
ok: false,
|
|
976
|
+
error: `capture-asset-preview stage must be "lab" or "scene", got ${String(stage)}.`,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
if (shotSet !== undefined && shots !== undefined) {
|
|
980
|
+
return {
|
|
981
|
+
ok: false,
|
|
982
|
+
error: 'capture-asset-preview cannot combine a shotSet definition with a shots mode.',
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
// The free capture camera and clip pose (`--azimuth/--elevation/--distance`,
|
|
986
|
+
// `--clip/--time`) belong to the plain Asset Lab legs: a shot set / source
|
|
987
|
+
// review / compare each stage their own cameras and poses, and the scene
|
|
988
|
+
// stage photographs an entity where it stands. Refused by name, never
|
|
989
|
+
// silently ignored.
|
|
990
|
+
const cameraChoice = parseCameraChoice('capture-asset-preview', camera);
|
|
991
|
+
if (typeof cameraChoice === 'string') return { ok: false, error: cameraChoice };
|
|
992
|
+
const posedClip = parsePoseChoice('capture-asset-preview', pose);
|
|
993
|
+
if (typeof posedClip === 'string') return { ok: false, error: posedClip };
|
|
994
|
+
if (
|
|
995
|
+
(cameraChoice || posedClip) &&
|
|
996
|
+
(shots !== undefined || shotSet !== undefined || compare !== undefined || stage === 'scene')
|
|
997
|
+
) {
|
|
998
|
+
return {
|
|
999
|
+
ok: false,
|
|
1000
|
+
error:
|
|
1001
|
+
'capture-asset-preview cannot combine camera/pose with a shots mode, a shotSet ' +
|
|
1002
|
+
'definition, compare, or stage "scene" — those legs stage their own cameras and poses.',
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
// A project-defined labeled shot set travels WITH the command (the CLI
|
|
1006
|
+
// resolves `--shots <set>` through the registered
|
|
1007
|
+
// `project.<set>.previewShots` tool); validate the untrusted
|
|
1008
|
+
// definition at the relay boundary so a malformed contribution fails with
|
|
1009
|
+
// a named reason instead of a deep three.js error.
|
|
1010
|
+
let shotSetDefinition: ReturnType<typeof parseShotSetDefinition> | undefined;
|
|
1011
|
+
if (shotSet !== undefined) {
|
|
1012
|
+
try {
|
|
1013
|
+
shotSetDefinition = parseShotSetDefinition(shotSet);
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const parsedForward = parseForwardVector(forward);
|
|
1019
|
+
if (shots === 'source' && parsedForward === null) {
|
|
1020
|
+
return {
|
|
1021
|
+
ok: false,
|
|
1022
|
+
error: 'capture-asset-preview --shots source requires a non-degenerate forward [x,y,z].',
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
if (compare !== undefined) {
|
|
1026
|
+
// B8.4 — the compare mode (`vgai screenshot <model.glb> --compare <ref.glb>`).
|
|
1027
|
+
// Validated here at the relay boundary so a malformed payload fails with
|
|
1028
|
+
// a named reason instead of a deep three.js error.
|
|
1029
|
+
if (shots !== undefined || shotSetDefinition !== undefined) {
|
|
1030
|
+
return { ok: false, error: 'capture-asset-preview cannot combine compare with shots.' };
|
|
1031
|
+
}
|
|
1032
|
+
if (
|
|
1033
|
+
typeof compare !== 'object' ||
|
|
1034
|
+
compare === null ||
|
|
1035
|
+
typeof (compare as Record<string, unknown>)['glbBase64'] !== 'string' ||
|
|
1036
|
+
((compare as Record<string, unknown>)['forward'] !== undefined &&
|
|
1037
|
+
parseForwardVector((compare as Record<string, unknown>)['forward']) === null)
|
|
1038
|
+
) {
|
|
1039
|
+
return {
|
|
1040
|
+
ok: false,
|
|
1041
|
+
error:
|
|
1042
|
+
'capture-asset-preview compare requires { glbBase64: string, forward?: [x, y, z] } ' +
|
|
1043
|
+
'with a non-degenerate ground-plane forward.',
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// Wire-carried GLB bytes stand NOWHERE, so they take no scene stage and no
|
|
1048
|
+
// compare (whose reference is named some other way). An explicit `shotSet`
|
|
1049
|
+
// definition is a different matter and is served: a shot set stages the
|
|
1050
|
+
// subject itself, which is what lets `vgai screenshot <module> --orbit <n>`
|
|
1051
|
+
// circle a model that only ever existed as bytes. `shots: 'source'` stays
|
|
1052
|
+
// out — it is the asset-path review set, keyed to a stored forward vector.
|
|
1053
|
+
if (typeof glbBase64 === 'string') {
|
|
1054
|
+
if (shots !== undefined || compare !== undefined) {
|
|
1055
|
+
return {
|
|
1056
|
+
ok: false,
|
|
1057
|
+
error: 'capture-asset-preview cannot combine glbBase64 with a named shots mode or compare.',
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
if (stage === 'scene') {
|
|
1061
|
+
return {
|
|
1062
|
+
ok: false,
|
|
1063
|
+
error:
|
|
1064
|
+
'capture-asset-preview stage "scene" photographs a live scene entity — glbBase64 bytes stand nowhere in the scene.',
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
// `stage: 'scene'` photographs a LIVE entity in the live scene (see
|
|
1069
|
+
// `captureSceneStageAssetPreview`), so it is meaningful only for the plain
|
|
1070
|
+
// four-view entity capture: a model loaded from `assetPath` stands nowhere,
|
|
1071
|
+
// and the shot-set/source/compare modes each stage their own subject.
|
|
1072
|
+
// Refused by name at this boundary rather than silently downgraded to the
|
|
1073
|
+
// lab stage — a caller who asked for the scene and got the studio would
|
|
1074
|
+
// never know.
|
|
1075
|
+
const liveScene = stage === 'scene' ? store.scene : null;
|
|
1076
|
+
if (stage === 'scene') {
|
|
1077
|
+
if (typeof entityId !== 'string') {
|
|
1078
|
+
return {
|
|
1079
|
+
ok: false,
|
|
1080
|
+
error:
|
|
1081
|
+
'capture-asset-preview stage "scene" photographs a live scene entity — pass entityId, not assetPath.',
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
if (shots !== undefined || shotSetDefinition !== undefined || compare !== undefined) {
|
|
1085
|
+
return {
|
|
1086
|
+
ok: false,
|
|
1087
|
+
error:
|
|
1088
|
+
'capture-asset-preview cannot combine stage "scene" with a shots mode, a shotSet definition or compare.',
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
if (!liveScene) {
|
|
1092
|
+
return {
|
|
1093
|
+
ok: false,
|
|
1094
|
+
error:
|
|
1095
|
+
'capture-asset-preview stage "scene" requires a bound viewport scene; none is bound yet.',
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// Resolve the screenshot subject through the SAME active-document adapter
|
|
1100
|
+
// that produced `status.entities`, drives Hierarchy/Inspector selection and
|
|
1101
|
+
// answers `frame-entity`. An adopted Play scene can own a stable OID in its
|
|
1102
|
+
// projection without publishing that object through the shell's edit-mode
|
|
1103
|
+
// `objectMap`; checking the map alone made the final door reject an id every
|
|
1104
|
+
// preceding door had just accepted.
|
|
1105
|
+
const entityObject =
|
|
1106
|
+
typeof entityId === 'string'
|
|
1107
|
+
? entityObject3D(activeDocumentAuthoring(store), store.objectMap, entityId)
|
|
1108
|
+
: null;
|
|
1109
|
+
if (typeof entityId === 'string' && !entityObject) {
|
|
1110
|
+
return { ok: false, error: `Entity not found: ${entityId}` };
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
const options = {
|
|
1114
|
+
...(typeof width === 'number' ? { width } : {}),
|
|
1115
|
+
...(typeof height === 'number' ? { height } : {}),
|
|
1116
|
+
...(background ? { background: background as AssetPreviewBackground } : {}),
|
|
1117
|
+
...(cameraChoice ? { camera: cameraChoice } : {}),
|
|
1118
|
+
...(posedClip ? { pose: posedClip } : {}),
|
|
1119
|
+
};
|
|
1120
|
+
if (compare !== undefined) {
|
|
1121
|
+
const compareRecord = compare as { glbBase64: string; forward?: unknown };
|
|
1122
|
+
const refForward = parseForwardVector(compareRecord.forward);
|
|
1123
|
+
const compareOptions = {
|
|
1124
|
+
...(typeof width === 'number' ? { width } : {}),
|
|
1125
|
+
...(typeof height === 'number' ? { height } : {}),
|
|
1126
|
+
...(refForward ? { refForward } : {}),
|
|
1127
|
+
};
|
|
1128
|
+
const capture =
|
|
1129
|
+
typeof assetPath === 'string'
|
|
1130
|
+
? await captureModelComparePreview(assetPath, compareRecord.glbBase64, compareOptions)
|
|
1131
|
+
: await captureEntityComparePreview(
|
|
1132
|
+
entityObject!,
|
|
1133
|
+
compareRecord.glbBase64,
|
|
1134
|
+
compareOptions,
|
|
1135
|
+
);
|
|
1136
|
+
return { ok: true, data: { ...capture } };
|
|
1137
|
+
}
|
|
1138
|
+
if (shotSetDefinition !== undefined) {
|
|
1139
|
+
const capture =
|
|
1140
|
+
typeof glbBase64 === 'string'
|
|
1141
|
+
? await captureShotSetGlbBytesPreview(glbBase64, shotSetDefinition, options)
|
|
1142
|
+
: typeof assetPath === 'string'
|
|
1143
|
+
? await captureShotSetModelPreview(assetPath, shotSetDefinition, options)
|
|
1144
|
+
: captureShotSetAssetPreview(entityObject!, shotSetDefinition, options);
|
|
1145
|
+
return { ok: true, data: { ...capture } };
|
|
1146
|
+
}
|
|
1147
|
+
if (shots === 'source') {
|
|
1148
|
+
const capture =
|
|
1149
|
+
typeof assetPath === 'string'
|
|
1150
|
+
? await captureSourceReviewShotSetModelPreview(assetPath, parsedForward!, options)
|
|
1151
|
+
: captureSourceReviewShotSetAssetPreview(entityObject!, parsedForward!, options);
|
|
1152
|
+
return { ok: true, data: { ...capture } };
|
|
1153
|
+
}
|
|
1154
|
+
if (liveScene) {
|
|
1155
|
+
const capture = captureSceneStageAssetPreview(entityObject!, liveScene, options);
|
|
1156
|
+
return { ok: true, data: { ...capture } };
|
|
1157
|
+
}
|
|
1158
|
+
if (typeof glbBase64 === 'string') {
|
|
1159
|
+
const capture = await captureGlbBytesAssetPreview(glbBase64, options);
|
|
1160
|
+
return { ok: true, data: { ...capture } };
|
|
1161
|
+
}
|
|
1162
|
+
const capture =
|
|
1163
|
+
typeof assetPath === 'string'
|
|
1164
|
+
? await captureModelAssetPreview(assetPath, options)
|
|
1165
|
+
: captureObjectAssetPreview(entityObject!, options);
|
|
1166
|
+
return { ok: true, data: { ...capture } };
|
|
1167
|
+
} catch (err) {
|
|
1168
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Exported (alongside `collectState` above) so unit tests can dispatch
|
|
1174
|
+
* commands directly against a headless `EditorShellStore`, with no SSE/server
|
|
1175
|
+
* round-trip — see `packages/editor/test/command-listener.test.ts`, notably
|
|
1176
|
+
* the false-ack regression test: an unrecognized command must return
|
|
1177
|
+
* `{ok:false}`, never fall through to a fabricated `{ok:true}`.
|
|
1178
|
+
*/
|
|
1179
|
+
export async function handleCommand(
|
|
1180
|
+
store: EditorShellStore,
|
|
1181
|
+
cmd: EditorCommand,
|
|
1182
|
+
options?: CommandHandlingOptions,
|
|
1183
|
+
): Promise<CommandResult> {
|
|
1184
|
+
// EVERY relayed command is observed (`command-dispatch.ts`): Play's idle
|
|
1185
|
+
// watchdog reads it, and a stamp filed per-case would quietly exclude
|
|
1186
|
+
// whichever case someone forgot.
|
|
1187
|
+
noteCommandDispatched(cmd['type'] as string);
|
|
1188
|
+
// A video export OWNS the paused run it is stepping frame by frame, so no
|
|
1189
|
+
// other command may touch it mid-export. `stop` is the one exception and
|
|
1190
|
+
// falls THROUGH: the export's cancel handle lives with the verb that
|
|
1191
|
+
// started it (`@vgai/game`'s `play.command.ts`), and its `stop` handler
|
|
1192
|
+
// aborts the controller before tearing the run down. The FLAG stays host
|
|
1193
|
+
// state (`gameplay-export-state.ts`) because two surfaces outside that verb
|
|
1194
|
+
// read it — this prologue and the PlayBar's transport ownership.
|
|
1195
|
+
if (isGameplayExportActive() && cmd['type'] !== 'stop') {
|
|
1196
|
+
return {
|
|
1197
|
+
ok: false,
|
|
1198
|
+
error: 'Video export owns this paused run. Stop it to cancel before another command.',
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
// A running lane answers its own play verbs first (`LiveSession.command`,
|
|
1202
|
+
// the live registry): an ingested game owns its mount, so the first-party
|
|
1203
|
+
// boot must never be handed its container.
|
|
1204
|
+
const answered = dispatchLiveCommand(cmd as { type: string });
|
|
1205
|
+
if (answered) return answered;
|
|
1206
|
+
if (cmd['type'] === 'play' || cmd['type'] === 'restart') {
|
|
1207
|
+
// NO live ingest, but a mount FAILED — refuse with the failure instead of
|
|
1208
|
+
// letting `enterPlayMode` boot a first-party composition over an ingest
|
|
1209
|
+
// manifest. It cannot: `resolveRootBinding` throws for every ingest
|
|
1210
|
+
// identity by construction, and the sentence it throws is about
|
|
1211
|
+
// `ThreeHostContext` not carrying an `EditorStore` — true, internal, and
|
|
1212
|
+
// about a mechanism the reader was never using. MEASURED on the
|
|
1213
|
+
// bubbo-bubbo canvas ingest: the real cause was a capture window spent on
|
|
1214
|
+
// a hidden tab, and `vgai play` reported the resolver's contract note,
|
|
1215
|
+
// naming neither the game nor the reason. A failed mount is the answer to
|
|
1216
|
+
// "why can't I play this", whichever lane failed.
|
|
1217
|
+
const failures = getMountFailureReports();
|
|
1218
|
+
if (failures.length > 0) {
|
|
1219
|
+
return {
|
|
1220
|
+
ok: false,
|
|
1221
|
+
error:
|
|
1222
|
+
`this project's ${failures.length === 1 ? 'root' : 'roots'} did not mount, so there ` +
|
|
1223
|
+
`is nothing to ${cmd['type'] === 'play' ? 'play' : 'restart'}: ` +
|
|
1224
|
+
failures.map((f) => `"${f.worldId}" (${f.identity}) — ${f.message}`).join('; '),
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
// The dispatch keys off the TABLE's union, not off a raw string. Two things
|
|
1229
|
+
// follow, and they are the whole point of `command-table.ts`: a `case` whose
|
|
1230
|
+
// label is not a row does not compile, and a row with no `case` fails the
|
|
1231
|
+
// exhaustiveness check in the `default` below. The runtime guard in front of
|
|
1232
|
+
// it is what makes the narrowing honest — an unknown string from an older
|
|
1233
|
+
// CLI still reaches the "unknown command type" answer.
|
|
1234
|
+
// A PACKAGE'S verb (`@volter/editor-sdk/commands`, `command-registry.ts`):
|
|
1235
|
+
// answered by its own handler, through the same relay, ack and derivation
|
|
1236
|
+
// as the host's table below. Asked first so a contributed verb never
|
|
1237
|
+
// reads as "editor page predates this CLI".
|
|
1238
|
+
const contributed = contributedCommand(cmd['type']);
|
|
1239
|
+
if (contributed) {
|
|
1240
|
+
try {
|
|
1241
|
+
const answer = await contributed.handle(cmd);
|
|
1242
|
+
return {
|
|
1243
|
+
ok: answer.ok,
|
|
1244
|
+
...(answer.error !== undefined ? { error: answer.error } : {}),
|
|
1245
|
+
...(answer.data !== undefined ? { data: answer.data } : {}),
|
|
1246
|
+
};
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
return commandThrewResult(cmd, error);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (!isRelayCommandType(cmd['type'])) {
|
|
1252
|
+
return {
|
|
1253
|
+
ok: false,
|
|
1254
|
+
error:
|
|
1255
|
+
`unknown command type "${cmd['type']}" — not one of the editor's own, and no package this ` +
|
|
1256
|
+
'project declares contributes it (or the editor page predates this CLI)',
|
|
1257
|
+
data: { code: 'UNKNOWN_COMMAND_TYPE' },
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
const commandType: RelayCommandType = cmd['type'];
|
|
1261
|
+
switch (commandType) {
|
|
1262
|
+
// Selection
|
|
1263
|
+
case 'select': {
|
|
1264
|
+
const id = (cmd['id'] as string | null) ?? null;
|
|
1265
|
+
if (id !== null) {
|
|
1266
|
+
const refusal = unresolvedSelectionRefusal(store, id);
|
|
1267
|
+
if (refusal) return { ok: false, error: refusal };
|
|
1268
|
+
}
|
|
1269
|
+
applyControlSelection(store, id ? [id] : [], options);
|
|
1270
|
+
break;
|
|
1271
|
+
}
|
|
1272
|
+
case 'select-multiple': {
|
|
1273
|
+
const ids = cmd['ids'] as string[];
|
|
1274
|
+
// All-or-nothing: a partial selection silently dropping the id the caller
|
|
1275
|
+
// cared about is the same fabrication in a quieter form.
|
|
1276
|
+
for (const id of ids) {
|
|
1277
|
+
const refusal = unresolvedSelectionRefusal(store, id);
|
|
1278
|
+
if (refusal) return { ok: false, error: refusal };
|
|
1279
|
+
}
|
|
1280
|
+
applyControlSelection(store, ids, options);
|
|
1281
|
+
break;
|
|
1282
|
+
}
|
|
1283
|
+
case 'select-all': {
|
|
1284
|
+
applyControlSelection(
|
|
1285
|
+
store,
|
|
1286
|
+
activeHierarchyRows(store).map((row) => row.id),
|
|
1287
|
+
options,
|
|
1288
|
+
);
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Viewport
|
|
1293
|
+
case 'focus-entity':
|
|
1294
|
+
if (!activeObject3DDocumentSession()?.frameIds([cmd['id'] as string])) {
|
|
1295
|
+
store.focusOnEntity(cmd['id'] as string);
|
|
1296
|
+
}
|
|
1297
|
+
break;
|
|
1298
|
+
// The STRICT entity-targeted sibling of `focus-entity`: the same edit
|
|
1299
|
+
// viewport framing, but an id nothing in the scene answers to is a named
|
|
1300
|
+
// refusal rather than the silent no-op `focus-entity` keeps (its viewport
|
|
1301
|
+
// action simply finds no object in `objectMap` and does nothing —
|
|
1302
|
+
// world-root-stage.ts). That matters for a scripted flow that frames an
|
|
1303
|
+
// entity and then photographs it: framing that quietly did nothing hands
|
|
1304
|
+
// back a confident picture of whatever the camera happened to be on.
|
|
1305
|
+
case 'frame-entity': {
|
|
1306
|
+
const id = cmd['id'];
|
|
1307
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1308
|
+
return { ok: false, error: 'frame-entity requires a string "id" (the entity to frame).' };
|
|
1309
|
+
}
|
|
1310
|
+
const documentSession = activeObject3DDocumentSession();
|
|
1311
|
+
if (documentSession) {
|
|
1312
|
+
if (!documentSession.frameIds([id])) {
|
|
1313
|
+
return {
|
|
1314
|
+
ok: false,
|
|
1315
|
+
error: `Entity not found: ${id}`,
|
|
1316
|
+
data: { code: 'ENTITY_NOT_FOUND' },
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
break;
|
|
1320
|
+
}
|
|
1321
|
+
// A Canvas Scene has no Object3D by design. Its native framing answer is
|
|
1322
|
+
// the adapter-owned screen rect consumed by CanvasSceneControls. The
|
|
1323
|
+
// provider may temporarily return null while Pixi is between layouts;
|
|
1324
|
+
// strict existence is therefore the owned hierarchy node + the rect
|
|
1325
|
+
// capability, not one timing-sensitive measurement.
|
|
1326
|
+
// Resolve against the active document just like Hierarchy/Inspector do.
|
|
1327
|
+
// The global edit-mode composite intentionally does not merge 2D rect
|
|
1328
|
+
// providers; the open Canvas Scene publishes its own native adapter.
|
|
1329
|
+
const activeAdapter = activeDocumentAuthoring(store);
|
|
1330
|
+
if (activeAdapter.hierarchy.node(id) !== null && activeAdapter.rects) {
|
|
1331
|
+
store.focusOnEntity(id);
|
|
1332
|
+
break;
|
|
1333
|
+
}
|
|
1334
|
+
// The SAME resolver the framing itself uses (`entity-object.ts`). Gating
|
|
1335
|
+
// on `store.objectMap` alone refused every entity of a running game: an
|
|
1336
|
+
// adopted play scene's nodes are the adapter's, not the shell map's.
|
|
1337
|
+
if (!entityObject3D(activeAdapter, store.objectMap, id)) {
|
|
1338
|
+
return { ok: false, error: `Entity not found: ${id}`, data: { code: 'ENTITY_NOT_FOUND' } };
|
|
1339
|
+
}
|
|
1340
|
+
store.focusOnEntity(id);
|
|
1341
|
+
break;
|
|
1342
|
+
}
|
|
1343
|
+
case 'focus-selection':
|
|
1344
|
+
if (!activeObject3DDocumentSession()?.frameSelection()) store.focusOnSelection();
|
|
1345
|
+
break;
|
|
1346
|
+
case 'view-preset': {
|
|
1347
|
+
// The cast below is the ONLY thing between the relay and a raw table
|
|
1348
|
+
// lookup, so an absent or unknown preset has to be refused BY NAME here.
|
|
1349
|
+
// Unguarded it reached `cameraPresetDirection` and threw
|
|
1350
|
+
// `directions[preset] is not iterable (cannot read property undefined)`
|
|
1351
|
+
// into the session console — a TypeError that names neither the command
|
|
1352
|
+
// nor the vocabulary (measured live, 2026-09-18, driving `editor.view`
|
|
1353
|
+
// through `vgai eval` on a game project). Same shape as `set-camera`
|
|
1354
|
+
// below: state what is required, list what is accepted.
|
|
1355
|
+
const requested = cmd['preset'];
|
|
1356
|
+
const presets = ['top', 'front', 'right', 'perspective'] as const;
|
|
1357
|
+
if (typeof requested !== 'string' || !(presets as readonly string[]).includes(requested)) {
|
|
1358
|
+
return {
|
|
1359
|
+
ok: false,
|
|
1360
|
+
error: `view-preset requires one of ${presets.join(', ')}, got ${
|
|
1361
|
+
requested === undefined ? 'nothing' : JSON.stringify(requested)
|
|
1362
|
+
}.`,
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
const preset = requested as (typeof presets)[number];
|
|
1366
|
+
const documentSession = activeObject3DDocumentSession();
|
|
1367
|
+
if (documentSession) {
|
|
1368
|
+
documentSession.setViewPreset(preset === 'perspective' ? 'isometric' : preset);
|
|
1369
|
+
} else store.setViewPreset(preset);
|
|
1370
|
+
break;
|
|
1371
|
+
}
|
|
1372
|
+
case 'set-camera': {
|
|
1373
|
+
const position = cmd['position'] as { x: number; y: number; z: number } | undefined;
|
|
1374
|
+
const target = cmd['target'] as { x: number; y: number; z: number } | undefined;
|
|
1375
|
+
const fov = cmd['fov'] as number | undefined;
|
|
1376
|
+
if (
|
|
1377
|
+
!position ||
|
|
1378
|
+
!target ||
|
|
1379
|
+
typeof position.x !== 'number' ||
|
|
1380
|
+
typeof position.y !== 'number' ||
|
|
1381
|
+
typeof position.z !== 'number' ||
|
|
1382
|
+
typeof target.x !== 'number' ||
|
|
1383
|
+
typeof target.y !== 'number' ||
|
|
1384
|
+
typeof target.z !== 'number'
|
|
1385
|
+
) {
|
|
1386
|
+
return { ok: false, error: 'set-camera requires numeric {x,y,z} position and target.' };
|
|
1387
|
+
}
|
|
1388
|
+
const documentSession = activeObject3DDocumentSession();
|
|
1389
|
+
if (documentSession) documentSession.setCameraPose(position, target, fov);
|
|
1390
|
+
else store.setCameraPose(position, target, fov);
|
|
1391
|
+
break;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// THE AGENT'S LOOKING, AS A WATCHABLE ACT. These three drive the OPEN
|
|
1395
|
+
// Object3D document's own camera — the one the human is looking through —
|
|
1396
|
+
// and orbit/turntable ack only when the animated move ends, so a scripted
|
|
1397
|
+
// "walk around the model" is something a person sees happen rather than a
|
|
1398
|
+
// jump cut between two poses. They are document verbs by construction:
|
|
1399
|
+
// there is no Scene fallback, because the Scene viewport's camera answers
|
|
1400
|
+
// to `view-preset`/`set-camera` and has no framed subject to circle.
|
|
1401
|
+
case 'document-orbit':
|
|
1402
|
+
case 'document-turntable': {
|
|
1403
|
+
const documentSession = activeObject3DDocumentSession();
|
|
1404
|
+
if (!documentSession) {
|
|
1405
|
+
return {
|
|
1406
|
+
ok: false,
|
|
1407
|
+
error:
|
|
1408
|
+
`${cmd['type']} needs an Object3D document open and active (a model, a live ` +
|
|
1409
|
+
'module, an entity model). Open one with `editor.openAsset(<path>)` first.',
|
|
1410
|
+
data: { code: 'NO_ACTIVE_OBJECT3D_DOCUMENT' },
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
const seconds = cmd['type'] === 'document-orbit' ? cmd['duration'] : cmd['seconds'];
|
|
1414
|
+
if (
|
|
1415
|
+
seconds !== undefined &&
|
|
1416
|
+
(typeof seconds !== 'number' || !(seconds >= 0 && seconds <= 60))
|
|
1417
|
+
) {
|
|
1418
|
+
return {
|
|
1419
|
+
ok: false,
|
|
1420
|
+
error: `${cmd['type']}: the move's length must be a number of seconds in 0..60.`,
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
const outcome =
|
|
1424
|
+
cmd['type'] === 'document-orbit'
|
|
1425
|
+
? await documentSession.orbit({
|
|
1426
|
+
...(typeof cmd['azimuth'] === 'number' ? { azimuth: cmd['azimuth'] } : {}),
|
|
1427
|
+
...(typeof cmd['elevation'] === 'number' ? { elevation: cmd['elevation'] } : {}),
|
|
1428
|
+
...(typeof seconds === 'number' ? { duration: seconds } : {}),
|
|
1429
|
+
})
|
|
1430
|
+
: await documentSession.turntable({
|
|
1431
|
+
...(typeof seconds === 'number' ? { seconds } : {}),
|
|
1432
|
+
...(typeof cmd['revolutions'] === 'number'
|
|
1433
|
+
? { revolutions: cmd['revolutions'] }
|
|
1434
|
+
: {}),
|
|
1435
|
+
});
|
|
1436
|
+
return { ok: true, data: { ...outcome } };
|
|
1437
|
+
}
|
|
1438
|
+
case 'document-frame': {
|
|
1439
|
+
const documentSession = activeObject3DDocumentSession();
|
|
1440
|
+
if (!documentSession) {
|
|
1441
|
+
return {
|
|
1442
|
+
ok: false,
|
|
1443
|
+
error:
|
|
1444
|
+
'document-frame needs an Object3D document open and active. For the Scene ' +
|
|
1445
|
+
'viewport use `frame-entity`/`focus-selection`.',
|
|
1446
|
+
data: { code: 'NO_ACTIVE_OBJECT3D_DOCUMENT' },
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
const fit = cmd['fit'];
|
|
1450
|
+
if (fit !== undefined && (typeof fit !== 'number' || !(fit >= 0.1 && fit <= 10))) {
|
|
1451
|
+
return { ok: false, error: 'document-frame: "fit" must be a number in 0.1..10.' };
|
|
1452
|
+
}
|
|
1453
|
+
if (!documentSession.frame(typeof fit === 'number' ? fit : 1)) {
|
|
1454
|
+
return {
|
|
1455
|
+
ok: false,
|
|
1456
|
+
error: 'Nothing to frame: the document subject has no measurable bounds.',
|
|
1457
|
+
data: { code: 'EMPTY_FRAME_BOUNDS' },
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
return { ok: true, data: { ...documentSession.cameraPose() } };
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Panels
|
|
1464
|
+
case 'viewport-tab': {
|
|
1465
|
+
// The tabs are Edit · Play. `'scene'`/`'game'` are REMOVED, and
|
|
1466
|
+
// a removed value rejects loudly naming the replacement rather than
|
|
1467
|
+
// silently doing nothing (this command is driven by the CLI/SDK, where a
|
|
1468
|
+
// no-op reads as "the editor ignored me").
|
|
1469
|
+
const tab = cmd['tab'];
|
|
1470
|
+
const removed: Record<string, ViewportTab> = { scene: 'edit', game: 'play' };
|
|
1471
|
+
if (typeof tab === 'string' && removed[tab]) {
|
|
1472
|
+
return {
|
|
1473
|
+
ok: false,
|
|
1474
|
+
error:
|
|
1475
|
+
`viewport-tab: "${tab}" was REMOVED — the viewport tabs are ` +
|
|
1476
|
+
`Edit · Play. Fix: use "${removed[tab]}".`,
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
if (tab !== 'edit' && tab !== 'play') {
|
|
1480
|
+
return { ok: false, error: `viewport-tab requires "edit" or "play", got ${String(tab)}.` };
|
|
1481
|
+
}
|
|
1482
|
+
store.setActiveViewportTab(tab);
|
|
1483
|
+
break;
|
|
1484
|
+
}
|
|
1485
|
+
// FOCUS A PANEL. The vocabulary is the static-panel REGISTRY, resolved by
|
|
1486
|
+
// `revealStaticPanel` at call time — the same resolution and the same
|
|
1487
|
+
// refusal a view's `panel` gets, and the same reveal the Window menu's own
|
|
1488
|
+
// items use. This handler recognises no panel by name.
|
|
1489
|
+
case 'show-panel': {
|
|
1490
|
+
try {
|
|
1491
|
+
const panel = cmd['panel'];
|
|
1492
|
+
if (typeof panel !== 'string' || panel.trim() === '') {
|
|
1493
|
+
return { ok: false, error: 'show-panel requires a non-empty "panel".' };
|
|
1494
|
+
}
|
|
1495
|
+
return { ok: true, data: { panel: await revealStaticPanel(panel) } };
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
// W2: the legacy right-rail tab commands keep their key vocabulary but
|
|
1501
|
+
// now drive the CENTER workspace documents (`asset-documents.tsx`).
|
|
1502
|
+
// '__inspector__' ("show the Inspector") is a no-op now: the Inspector is
|
|
1503
|
+
// selection-owned, so a command cannot force it open without a subject.
|
|
1504
|
+
case 'active-tab': {
|
|
1505
|
+
const key = cmd['key'] as string;
|
|
1506
|
+
// The ack must MEAN the document is in front. This handler used to
|
|
1507
|
+
// discard `activateWorkspaceDocument`'s boolean, so asking for a
|
|
1508
|
+
// document that does not exist acked `ok` while the workspace kept
|
|
1509
|
+
// showing whatever tab was already there — and every caller that
|
|
1510
|
+
// photographs, inspects or drives "the active document" then read a
|
|
1511
|
+
// surface it never asked for and had no way to notice (doctor's walk
|
|
1512
|
+
// photographed the UI board and filed it as the Scene). The refusal
|
|
1513
|
+
// lists what IS open, because a wrong id is nearly always a stale or
|
|
1514
|
+
// misspelled one and the registry already knows the real set.
|
|
1515
|
+
if (key !== '__inspector__' && !activateWorkspaceDocument(key)) {
|
|
1516
|
+
const open = openWorkspaceDocuments().map((d) => d.descriptor.id);
|
|
1517
|
+
return {
|
|
1518
|
+
ok: false,
|
|
1519
|
+
error:
|
|
1520
|
+
`No open workspace document with id "${key}" — nothing was activated. ` +
|
|
1521
|
+
(open.length > 0
|
|
1522
|
+
? `Open documents: ${open.join(', ')}.`
|
|
1523
|
+
: 'No documents are open in this workspace.'),
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
case 'select-asset': {
|
|
1529
|
+
// The OTHER half of the browser's selection-vs-open contract
|
|
1530
|
+
// (`asset-selection.ts`): a single click SELECTS an asset and fills the
|
|
1531
|
+
// Inspector; a double click OPENS its document. Only `open` had a verb,
|
|
1532
|
+
// so every `asset.inspector` contribution — a project's own Inspector
|
|
1533
|
+
// door — was reachable by mouse alone.
|
|
1534
|
+
const path = cmd['path'];
|
|
1535
|
+
if (typeof path !== 'string' || path.trim() === '') {
|
|
1536
|
+
return { ok: false, error: 'select-asset requires a non-empty "path".' };
|
|
1537
|
+
}
|
|
1538
|
+
// No existence check, for the same reason `open-asset-tab` has none:
|
|
1539
|
+
// the sections that match report their own failures (the Edit Mesh
|
|
1540
|
+
// door says "exports no build()"), and a second file-exists door here
|
|
1541
|
+
// would answer for a tier it cannot see (a hosted project's source has
|
|
1542
|
+
// no `public/` listing). An unknown path selects and the Inspector
|
|
1543
|
+
// shows nothing matched, which is the same answer the browser gives.
|
|
1544
|
+
const clean = path.replace(/^\/+/, '');
|
|
1545
|
+
const name = clean.split('/').pop() ?? clean;
|
|
1546
|
+
const capability = assetCapabilities(name);
|
|
1547
|
+
setSelectedAsset({
|
|
1548
|
+
path,
|
|
1549
|
+
name,
|
|
1550
|
+
kind: assetDocumentKind(capability) ?? 'unknown',
|
|
1551
|
+
capabilities: capability,
|
|
1552
|
+
origin: 'project',
|
|
1553
|
+
});
|
|
1554
|
+
break;
|
|
1555
|
+
}
|
|
1556
|
+
case 'open-asset-tab': {
|
|
1557
|
+
const kind = cmd['kind'] as AssetKind;
|
|
1558
|
+
const documentId = openAssetDocument(store, cmd['path'] as string, kind);
|
|
1559
|
+
if (!(await waitForAssetDocumentInspector(documentId, kind))) {
|
|
1560
|
+
return {
|
|
1561
|
+
ok: false,
|
|
1562
|
+
error: `Asset document did not finish mounting its Inspector: ${documentId}`,
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
case 'close-asset-tab': {
|
|
1568
|
+
// Legacy key vocabulary: the deleted store-era `closeAssetTab` only
|
|
1569
|
+
// ever acted on asset tabs and no-op'd for anything else (scene/game/
|
|
1570
|
+
// story keys included). `closeWorkspaceDocument` itself has no such
|
|
1571
|
+
// guard — it closes ANY registered id, pinned or not (`closeable:
|
|
1572
|
+
// false` is only a UI-affordance rule, not a registry invariant; see
|
|
1573
|
+
// workspace-document-registry.ts) — so this command must keep the
|
|
1574
|
+
// no-op itself. `isAssetDocumentId` is exactly the legacy asset key
|
|
1575
|
+
// vocabulary check (project asset path / `asset-editor:entity:<id>` /
|
|
1576
|
+
// `online:<source>:<id>` — see asset-documents.tsx), so gating on it
|
|
1577
|
+
// blocks the pinned `workspace:scene`/`workspace:game` ids (and any
|
|
1578
|
+
// story `story:<path>#<name>` id) while preserving the real behavior
|
|
1579
|
+
// for actual asset tabs.
|
|
1580
|
+
const key = cmd['key'] as string;
|
|
1581
|
+
if (isAssetDocumentId(key)) closeWorkspaceDocument(key);
|
|
1582
|
+
break;
|
|
1583
|
+
}
|
|
1584
|
+
case 'toggle-command-palette':
|
|
1585
|
+
// The palette is the workbench's; the frame hands over the opener that
|
|
1586
|
+
// shows it (`editor-commands.ts`).
|
|
1587
|
+
openCommandPalette();
|
|
1588
|
+
break;
|
|
1589
|
+
case 'toggle-console':
|
|
1590
|
+
toggleConsoleUtility();
|
|
1591
|
+
break;
|
|
1592
|
+
// RELOAD THIS PAGE — `@vgai/live`'s `page.reload()` and P20's prescribed
|
|
1593
|
+
// recovery. Deliberately NOT routed through `page-script`: that verb is
|
|
1594
|
+
// gated on a mounted game surface, and the one thing a reload has to fix —
|
|
1595
|
+
// a page whose module-scope loaders and page-lifetime asset caches hold
|
|
1596
|
+
// bytes that have since changed on disk — is just as real with play
|
|
1597
|
+
// stopped, and just as real in a product that has no game at all.
|
|
1598
|
+
//
|
|
1599
|
+
// It is the HOST's for that last reason. It was a `@vgai/game` command
|
|
1600
|
+
// contribution until walk 5, so `page.reload()` answered `unknown command
|
|
1601
|
+
// type "page-reload"` in the model editor, which declares no `@vgai/game`.
|
|
1602
|
+
//
|
|
1603
|
+
// Scheduled for the NEXT task rather than run inline, so this handler can
|
|
1604
|
+
// return and the caller's ack can travel before the navigation tears the
|
|
1605
|
+
// channel down: the client acks the ORDER here and waits for the new page
|
|
1606
|
+
// load on the server's own tab table.
|
|
1607
|
+
case 'page-reload':
|
|
1608
|
+
setTimeout(() => {
|
|
1609
|
+
window.location.reload();
|
|
1610
|
+
}, 0);
|
|
1611
|
+
return { ok: true, data: { scheduled: true } };
|
|
1612
|
+
// NAMED WORKSPACES (ARCHITECTURE-CORE §Editor chrome) — the session
|
|
1613
|
+
// operation `editor.workspace(id)`, the third of the ruling's three
|
|
1614
|
+
// switching doors beside `Window → Workspace` and the registered actions.
|
|
1615
|
+
// A wrong id refuses and NAMES the vocabulary: this is a fixed registry,
|
|
1616
|
+
// so the refusal can be complete.
|
|
1617
|
+
case 'set-workspace': {
|
|
1618
|
+
const id = cmd['workspace'];
|
|
1619
|
+
if (!isEditorWorkspaceId(id)) {
|
|
1620
|
+
return {
|
|
1621
|
+
ok: false,
|
|
1622
|
+
error: `set-workspace requires one of ${editorWorkspaceIds().join(', ')}, got ${String(id)}.`,
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
if (activeEditorWorkspace() === id)
|
|
1626
|
+
return { ok: true, data: { workspace: id, applied: true } };
|
|
1627
|
+
// Registered BEFORE the store flip — the dock's rebuild is what resolves
|
|
1628
|
+
// it, and that can land before this handler's next await point. Bounded,
|
|
1629
|
+
// because the store flip is real whether or not a dock is mounted to
|
|
1630
|
+
// follow it: a hung wait would otherwise be reported as "the editor did
|
|
1631
|
+
// not respond", which names the wrong thing.
|
|
1632
|
+
const rebuilt = whenEditorWorkspaceApplied().then(() => true);
|
|
1633
|
+
setEditorWorkspace(id);
|
|
1634
|
+
const applied = await Promise.race([
|
|
1635
|
+
rebuilt,
|
|
1636
|
+
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 3000)),
|
|
1637
|
+
]);
|
|
1638
|
+
// NOTHING REVEALS A DRAWER UTILITY HERE ANY MORE. `drawerUtility` was
|
|
1639
|
+
// this line's reason and it is retired: a Blender editor AREA is an
|
|
1640
|
+
// EDITOR GROUP, not a drawer view (orchestrator ruling 2026-09-19), so
|
|
1641
|
+
// the node editor and the UV editor are `workspace.document`
|
|
1642
|
+
// contributions the workspace opens into `vgai:area:<id>`
|
|
1643
|
+
// (`workspace-areas.ts`) and the drawer keeps only the utilities that
|
|
1644
|
+
// are not Blender areas.
|
|
1645
|
+
return { ok: true, data: { workspace: id, applied } };
|
|
1646
|
+
}
|
|
1647
|
+
case 'set-style': {
|
|
1648
|
+
const id = cmd['style'];
|
|
1649
|
+
const ids = workspaceStyles().map((bundle) => bundle.id);
|
|
1650
|
+
if (typeof id !== 'string' || !ids.includes(id)) {
|
|
1651
|
+
return {
|
|
1652
|
+
ok: false,
|
|
1653
|
+
error: `set-style requires one of ${ids.join(', ')}, got ${String(id)}.`,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
applyWorkspaceStyle(id);
|
|
1657
|
+
// Reported from the axes, not echoed: a bundle applies through the
|
|
1658
|
+
// settings layer that DECLARES each axis (`updatePreferenceSettings`),
|
|
1659
|
+
// so what the chrome wears is what this answers.
|
|
1660
|
+
const applied = activeWorkspaceStyleId();
|
|
1661
|
+
if (applied !== id) {
|
|
1662
|
+
return {
|
|
1663
|
+
ok: false,
|
|
1664
|
+
error: `set-style applied "${id}" but the editor is wearing ${applied === null ? 'a custom mix of axes' : `"${applied}"`}.`,
|
|
1665
|
+
data: { style: applied },
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
return { ok: true, data: { style: id } };
|
|
1669
|
+
}
|
|
1670
|
+
case 'set-appearance': {
|
|
1671
|
+
// The MATERIAL apart from the bundle that usually carries it.
|
|
1672
|
+
// Appearance is palette × material by ruling (ARCHITECTURE-CORE
|
|
1673
|
+
// §Editor chrome), so nothing through the session could otherwise ask
|
|
1674
|
+
// "is it the blur or the palette?" about a stall a style switch
|
|
1675
|
+
// produces. This is the door that measures the axes apart; it applies
|
|
1676
|
+
// through the same preference writer the menu uses, so what the chrome
|
|
1677
|
+
// wears afterwards is what it answers.
|
|
1678
|
+
const material = cmd['material'];
|
|
1679
|
+
if (material === undefined) {
|
|
1680
|
+
return { ok: false, error: 'set-appearance needs a `material` to set.' };
|
|
1681
|
+
}
|
|
1682
|
+
if (!isEditorMaterialId(material)) {
|
|
1683
|
+
const ids = editorMaterials().map((choice) => choice.id);
|
|
1684
|
+
return {
|
|
1685
|
+
ok: false,
|
|
1686
|
+
error: `set-appearance material must be one of ${ids.join(', ')}, got ${String(material)}.`,
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
setEditorMaterialPreference(material);
|
|
1690
|
+
return {
|
|
1691
|
+
ok: true,
|
|
1692
|
+
data: { material: editorMaterialSnapshot(), style: activeWorkspaceStyleId() },
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
case 'present-view': {
|
|
1696
|
+
try {
|
|
1697
|
+
const presented = await presentEditorView(store, cmd['view'] as EditorView);
|
|
1698
|
+
return { ok: true, data: { ...presented } };
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
case 'current-view':
|
|
1704
|
+
return { ok: true, data: { view: currentEditorView(store) } };
|
|
1705
|
+
case 'inspect': {
|
|
1706
|
+
// `editor.inspect` — the SERIALIZED projection of the inspection model
|
|
1707
|
+
// (`inspection/serialize.ts`), composed from the same live state, by the
|
|
1708
|
+
// same composer, as the column and the compact card
|
|
1709
|
+
// (`inspection/active-subject.ts`). The `InspectedInspection` annotation
|
|
1710
|
+
// is the drift check: the SDK's wire mirror and the editor's own
|
|
1711
|
+
// serialized shape are structurally compared by `tsc` on every build —
|
|
1712
|
+
// including its `{none:true}` arm, which is what a human seeing no
|
|
1713
|
+
// inspector at all serializes to.
|
|
1714
|
+
const subject: InspectedInspection = inspectActiveSubject(store);
|
|
1715
|
+
return { ok: true, data: { subject } };
|
|
1716
|
+
}
|
|
1717
|
+
case 'run-inspection-action': {
|
|
1718
|
+
try {
|
|
1719
|
+
const actionId = cmd['actionId'];
|
|
1720
|
+
if (typeof actionId !== 'string' || actionId.trim() === '') {
|
|
1721
|
+
throw new Error('run-inspection-action requires a non-empty actionId.');
|
|
1722
|
+
}
|
|
1723
|
+
const subject: InspectedInspection = await runActiveInspectionAction(store, actionId);
|
|
1724
|
+
return { ok: true, data: { subject } };
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
case 'run-command': {
|
|
1730
|
+
// `editor.command(id, args)` — the ONE door to a command by id (U8's
|
|
1731
|
+
// ruling 1: "so `vgai eval` reaches it through the frame's command
|
|
1732
|
+
// service"). Under the frame that IS `ICommandService`; standalone it is
|
|
1733
|
+
// the views registry, and `editor-commands.ts` owns both arms plus
|
|
1734
|
+
// the refusal that names the id shape that would have worked.
|
|
1735
|
+
try {
|
|
1736
|
+
const commandId = cmd['commandId'];
|
|
1737
|
+
if (typeof commandId !== 'string' || commandId.trim() === '') {
|
|
1738
|
+
throw new Error('run-command requires a non-empty commandId.');
|
|
1739
|
+
}
|
|
1740
|
+
const result = await executeCommand(commandId, cmd['args']);
|
|
1741
|
+
// The command's own answer, as far as it survives the wire: a view
|
|
1742
|
+
// verb answers with its state, a workbench command usually with
|
|
1743
|
+
// nothing. `undefined` is not JSON, so it is reported as null rather
|
|
1744
|
+
// than dropping the key and making "ran, said nothing" look like a
|
|
1745
|
+
// malformed reply.
|
|
1746
|
+
return { ok: true, data: { result: result === undefined ? null : result } };
|
|
1747
|
+
} catch (error) {
|
|
1748
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
case 'hierarchy': {
|
|
1752
|
+
// `editor.hierarchy` — the hierarchy panel's OWN rendered row tree,
|
|
1753
|
+
// serialized. Not a fresh walk of the adapter: the panel publishes the
|
|
1754
|
+
// rows and predicates it rendered with and this serializes those, so the
|
|
1755
|
+
// door cannot report a tree the human is not looking at
|
|
1756
|
+
// (`hierarchy-panel-view.ts` header). `editor.status().entities` answers a
|
|
1757
|
+
// deliberately DIFFERENT question — the raw adapter tree, unprojected.
|
|
1758
|
+
const snapshot = hierarchyPanelSnapshot();
|
|
1759
|
+
if (snapshot === null) {
|
|
1760
|
+
return {
|
|
1761
|
+
ok: false,
|
|
1762
|
+
error:
|
|
1763
|
+
'the hierarchy panel (GameHierarchy) is not mounted — no row tree is rendered, so there is nothing to report. Open the Hierarchy panel in the workspace dock and retry.',
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
const hierarchy: InspectedHierarchy = serializeHierarchyPanel(snapshot, {
|
|
1767
|
+
playState: store.playState,
|
|
1768
|
+
activeViewportTab: store.activeViewportTab,
|
|
1769
|
+
});
|
|
1770
|
+
return { ok: true, data: { hierarchy } };
|
|
1771
|
+
}
|
|
1772
|
+
// EXPAND/COLLAPSE ALL, and they ship as a pair for a reason. The machine
|
|
1773
|
+
// door uses the panel's own mutations. Reading the raw adapter hierarchy
|
|
1774
|
+
// here would fabricate rows the panel has not rendered and would make
|
|
1775
|
+
// collapsed-branch certification meaningless; and expanding without a way
|
|
1776
|
+
// back is a ONE-WAY door — the fold is written to this project's persisted
|
|
1777
|
+
// preference, and a chevron's own click is not drivable through the
|
|
1778
|
+
// control API (`editor.document.click` refuses editor chrome by name), so
|
|
1779
|
+
// for as long as `expand-hierarchy-all` stood alone, no reader that used
|
|
1780
|
+
// it could ever see the tree's REST STATE again. Both call the toolbar's
|
|
1781
|
+
// own actions, so the human and machine paths cannot diverge. The ack
|
|
1782
|
+
// resolves on the panel's NEXT PUBLISHED SNAPSHOT, so a `hierarchy()` in
|
|
1783
|
+
// the same breath reads the mutated tree rather than the one before it.
|
|
1784
|
+
case 'expand-hierarchy-all':
|
|
1785
|
+
case 'collapse-hierarchy-all': {
|
|
1786
|
+
const expanding = cmd['type'] === 'expand-hierarchy-all';
|
|
1787
|
+
const snapshot = hierarchyPanelSnapshot();
|
|
1788
|
+
if (snapshot === null) {
|
|
1789
|
+
return {
|
|
1790
|
+
ok: false,
|
|
1791
|
+
error: `the hierarchy panel (GameHierarchy) is not mounted — no row tree is rendered, so there is nothing to ${expanding ? 'expand' : 'collapse'}. Open the Hierarchy panel in the workspace dock and retry.`,
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
const committed = nextHierarchyPanelSnapshot();
|
|
1795
|
+
if (expanding) snapshot.expandAll();
|
|
1796
|
+
else snapshot.collapseAll();
|
|
1797
|
+
try {
|
|
1798
|
+
await committed;
|
|
1799
|
+
} catch (error) {
|
|
1800
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1801
|
+
}
|
|
1802
|
+
return { ok: true, data: {} };
|
|
1803
|
+
}
|
|
1804
|
+
// UNDO/REDO over the control API. The keyboard shortcut and the command
|
|
1805
|
+
// palette have always had this; an agent authoring through the editor did
|
|
1806
|
+
// not — and for an ingest root, whose only authoring surface IS the editor,
|
|
1807
|
+
// that left an edit with no way back. Same queue, same guards, same
|
|
1808
|
+
// per-transaction semantics as the key press.
|
|
1809
|
+
case 'undo':
|
|
1810
|
+
case 'redo': {
|
|
1811
|
+
const history = options?.history;
|
|
1812
|
+
if (!history) {
|
|
1813
|
+
return {
|
|
1814
|
+
ok: false,
|
|
1815
|
+
error: `${String(cmd['type'])}: this editor has no history session attached, so there is nothing to undo.`,
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
const moved = cmd['type'] === 'undo' ? await history.undo() : await history.redo();
|
|
1819
|
+
const snapshot = history.getSnapshot();
|
|
1820
|
+
return {
|
|
1821
|
+
ok: true,
|
|
1822
|
+
data: {
|
|
1823
|
+
moved,
|
|
1824
|
+
canUndo: snapshot.canUndo,
|
|
1825
|
+
canRedo: snapshot.canRedo,
|
|
1826
|
+
undoLabel: snapshot.undoLabel,
|
|
1827
|
+
redoLabel: snapshot.redoLabel,
|
|
1828
|
+
},
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
case 'set-inspection-field': {
|
|
1832
|
+
try {
|
|
1833
|
+
const path = cmd['path'];
|
|
1834
|
+
if (typeof path !== 'string' || path.trim() === '') {
|
|
1835
|
+
throw new Error('set-inspection-field requires a non-empty field path.');
|
|
1836
|
+
}
|
|
1837
|
+
// THE ACK NAMES ITS DESTINATION. A write with no persistence route
|
|
1838
|
+
// still ACKs `ok` — it lands on the live object — so without `write`
|
|
1839
|
+
// the caller cannot tell a persisted edit from a vanished one, and
|
|
1840
|
+
// `scripts/doctor-walk.ts` graded a healthy consent-off session as a
|
|
1841
|
+
// silent no-op on exactly that ambiguity.
|
|
1842
|
+
const written: { subject: InspectedInspection; write: InspectedWriteDestination } =
|
|
1843
|
+
await setActiveInspectionField(store, path, cmd['value']);
|
|
1844
|
+
return { ok: true, data: { subject: written.subject, write: written.write } };
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
// THE OTHER HALF OF THE WRITE DOOR, and the only one that can express
|
|
1850
|
+
// byte-ABSENCE. `set-inspection-field` writes a VALUE, so reverting a prop
|
|
1851
|
+
// an authoring gesture APPENDED leaves an explicit `[0, 0, 0]` where the
|
|
1852
|
+
// source carried nothing — the file ends one attribute heavier than it
|
|
1853
|
+
// started and no byte-level round trip can close. The human revert arrow
|
|
1854
|
+
// has reached `io.remove` since it shipped; the control API had no door at
|
|
1855
|
+
// all, which made every agent-driven edit/revert unverifiable at byte
|
|
1856
|
+
// level on lanes that were in fact healthy.
|
|
1857
|
+
//
|
|
1858
|
+
// Same io, same persistence pipe, same awaited per-edit ack as `set`.
|
|
1859
|
+
case 'remove-inspection-field': {
|
|
1860
|
+
try {
|
|
1861
|
+
const path = cmd['path'];
|
|
1862
|
+
if (typeof path !== 'string' || path.trim() === '') {
|
|
1863
|
+
throw new Error('remove-inspection-field requires a non-empty field path.');
|
|
1864
|
+
}
|
|
1865
|
+
const removed: { subject: InspectedInspection; write: InspectedWriteDestination } =
|
|
1866
|
+
await removeActiveInspectionField(store, path);
|
|
1867
|
+
return { ok: true, data: { subject: removed.subject, write: removed.write } };
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
// A LANE WITH NO REMOVAL DOOR IS NOT A FAILED REMOVAL, and the caller
|
|
1870
|
+
// has to be able to tell them apart without matching prose: an
|
|
1871
|
+
// instrument grades the first UNVERIFIABLE (an unreached seam) and the
|
|
1872
|
+
// second FAILED (a removal that ran and left the bytes changed).
|
|
1873
|
+
if (error instanceof InspectionRemovalUnavailableError) {
|
|
1874
|
+
return { ok: false, error: error.message, data: { code: 'REMOVAL_UNAVAILABLE' } };
|
|
1875
|
+
}
|
|
1876
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
// THE STRUCTURE OPS over the control API — the same operations the
|
|
1880
|
+
// hierarchy row's context menu performs, on the same
|
|
1881
|
+
// `authoring/consumer-actions.ts` helpers, so there is one implementation
|
|
1882
|
+
// and not a second that can disagree with the menu.
|
|
1883
|
+
//
|
|
1884
|
+
// A COMMAND VERB rather than inspector `quickActions` deliberately:
|
|
1885
|
+
// `inspect().quickActions` reports the verbs a human sees on the
|
|
1886
|
+
// inspector's identity row, and a dozen structure icons there would be
|
|
1887
|
+
// either a UI redesign or a list of actions nobody can see — both worse
|
|
1888
|
+
// than transcribing what the component verbs already established for
|
|
1889
|
+
// exactly this gap (`@vgai/game/contributions/component-verbs.command.ts`,
|
|
1890
|
+
// which is where `extract-component`/`fork-component` live now).
|
|
1891
|
+
//
|
|
1892
|
+
// `id`/`ids` default to the current selection, the menu's own subject. An
|
|
1893
|
+
// op the active adapter does not provide answers `ok: false` naming it,
|
|
1894
|
+
// never a silent no-op.
|
|
1895
|
+
case 'structure-op': {
|
|
1896
|
+
try {
|
|
1897
|
+
const op = String(cmd['op'] ?? '');
|
|
1898
|
+
// THE ACTIVE DOCUMENT'S ADAPTER AND ITS SELECTION, which is the pair
|
|
1899
|
+
// every other consumer of this seam already reads (`editor-hotkeys.ts`'s
|
|
1900
|
+
// `actionSelectionIds`, the hierarchy panel, `editor.status()`). This
|
|
1901
|
+
// case read `getActiveAuthoring` + the SHELL store instead, so on a
|
|
1902
|
+
// document whose adapter owns its own selection — the Blender Model
|
|
1903
|
+
// document, whose Outliner publishes Blender row ids — it refused with
|
|
1904
|
+
// "the active authoring adapter exposes no structure provider" while
|
|
1905
|
+
// the document's own adapter had one and the panel was drawing its
|
|
1906
|
+
// selection (measured 2026-09-21: `editor.structure('duplicate')` on a
|
|
1907
|
+
// selected Torus, B6's last named leftover).
|
|
1908
|
+
const adapter = resolvePanelAuthoring(store).adapter;
|
|
1909
|
+
const selected = activeSelectionIds(store);
|
|
1910
|
+
const ids = Array.isArray(cmd['ids'])
|
|
1911
|
+
? (cmd['ids'] as unknown[]).filter((v): v is string => typeof v === 'string')
|
|
1912
|
+
: typeof cmd['id'] === 'string' && cmd['id'].trim() !== ''
|
|
1913
|
+
? [cmd['id']]
|
|
1914
|
+
: selected;
|
|
1915
|
+
const first = ids[0];
|
|
1916
|
+
const needsId = (): string => {
|
|
1917
|
+
if (!first) throw new Error(`structure-op "${op}" needs an id or a selected row.`);
|
|
1918
|
+
return first;
|
|
1919
|
+
};
|
|
1920
|
+
const optional = (key: string): string | undefined =>
|
|
1921
|
+
typeof cmd[key] === 'string' && (cmd[key] as string).trim() !== ''
|
|
1922
|
+
? (cmd[key] as string)
|
|
1923
|
+
: undefined;
|
|
1924
|
+
const structure = adapter.structure;
|
|
1925
|
+
if (!structure) {
|
|
1926
|
+
return {
|
|
1927
|
+
ok: false,
|
|
1928
|
+
error: 'structure-op: the active authoring adapter exposes no structure provider.',
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
switch (op) {
|
|
1932
|
+
case 'create': {
|
|
1933
|
+
const kind = optional('kind');
|
|
1934
|
+
if (!kind) return { ok: false, error: 'structure-op "create" needs a `kind`.' };
|
|
1935
|
+
const created = createAuthoringNode(adapter, kind, optional('parentId'));
|
|
1936
|
+
return { ok: true, data: { id: created.id, write: await created.ack } };
|
|
1937
|
+
}
|
|
1938
|
+
case 'delete': {
|
|
1939
|
+
const write =
|
|
1940
|
+
ids.length > 1 && structure.removeMany
|
|
1941
|
+
? await removeManyAuthoringNodes(adapter, ids)
|
|
1942
|
+
: await removeAuthoringNode(adapter, needsId());
|
|
1943
|
+
return { ok: true, data: { write } };
|
|
1944
|
+
}
|
|
1945
|
+
case 'duplicate': {
|
|
1946
|
+
const copy = duplicateAuthoringNode(adapter, needsId());
|
|
1947
|
+
return { ok: true, data: { id: copy.id, write: await copy.ack } };
|
|
1948
|
+
}
|
|
1949
|
+
case 'reparent': {
|
|
1950
|
+
const parentId = optional('parentId') ?? null;
|
|
1951
|
+
return {
|
|
1952
|
+
ok: true,
|
|
1953
|
+
data: { write: await reparentAuthoringNode(adapter, needsId(), parentId) },
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
case 'reorder': {
|
|
1957
|
+
if (!structure.reorder) {
|
|
1958
|
+
return { ok: false, error: 'structure-op: this adapter has no `reorder`.' };
|
|
1959
|
+
}
|
|
1960
|
+
const before = optional('beforeSiblingId') ?? null;
|
|
1961
|
+
return {
|
|
1962
|
+
ok: true,
|
|
1963
|
+
data: { write: await reorderAuthoringNode(adapter, needsId(), before) },
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
case 'wrap': {
|
|
1967
|
+
if (!structure.wrap) {
|
|
1968
|
+
return { ok: false, error: 'structure-op: this adapter has no `wrap`.' };
|
|
1969
|
+
}
|
|
1970
|
+
return {
|
|
1971
|
+
ok: true,
|
|
1972
|
+
data: { write: await wrapAuthoringNode(adapter, needsId(), optional('tag')) },
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
case 'unwrap': {
|
|
1976
|
+
if (!structure.unwrap) {
|
|
1977
|
+
return { ok: false, error: 'structure-op: this adapter has no `unwrap`.' };
|
|
1978
|
+
}
|
|
1979
|
+
return { ok: true, data: { write: await unwrapAuthoringNode(adapter, needsId()) } };
|
|
1980
|
+
}
|
|
1981
|
+
case 'group': {
|
|
1982
|
+
if (!structure.group) {
|
|
1983
|
+
return { ok: false, error: 'structure-op: this adapter has no `group`.' };
|
|
1984
|
+
}
|
|
1985
|
+
const grouped = groupAuthoringNodes(adapter, ids);
|
|
1986
|
+
return { ok: true, data: { id: grouped.id, write: await grouped.ack } };
|
|
1987
|
+
}
|
|
1988
|
+
case 'ungroup': {
|
|
1989
|
+
if (!structure.ungroup) {
|
|
1990
|
+
return { ok: false, error: 'structure-op: this adapter has no `ungroup`.' };
|
|
1991
|
+
}
|
|
1992
|
+
const ungrouped = ungroupAuthoringNode(adapter, needsId());
|
|
1993
|
+
return { ok: true, data: { ids: ungrouped.ids, write: await ungrouped.ack } };
|
|
1994
|
+
}
|
|
1995
|
+
case 'copy': {
|
|
1996
|
+
if (!structure.copy) {
|
|
1997
|
+
return { ok: false, error: 'structure-op: this adapter has no `copy`.' };
|
|
1998
|
+
}
|
|
1999
|
+
return { ok: true, data: { copied: await copyAuthoringNodes(adapter, ids) } };
|
|
2000
|
+
}
|
|
2001
|
+
case 'cut': {
|
|
2002
|
+
if (!structure.cut) {
|
|
2003
|
+
return { ok: false, error: 'structure-op: this adapter has no `cut`.' };
|
|
2004
|
+
}
|
|
2005
|
+
const outcome = await cutAuthoringNodes(adapter, ids);
|
|
2006
|
+
return outcome === false
|
|
2007
|
+
? { ok: false, error: 'structure-op "cut" was refused by the adapter.' }
|
|
2008
|
+
: { ok: true, data: { write: outcome } };
|
|
2009
|
+
}
|
|
2010
|
+
case 'paste': {
|
|
2011
|
+
if (!structure.paste) {
|
|
2012
|
+
return { ok: false, error: 'structure-op: this adapter has no `paste`.' };
|
|
2013
|
+
}
|
|
2014
|
+
const parentId =
|
|
2015
|
+
optional('parentId') ??
|
|
2016
|
+
(first ? (adapter.hierarchy.node(first)?.parentId ?? null) : null);
|
|
2017
|
+
const outcome = await pasteAuthoringNodes(adapter, parentId);
|
|
2018
|
+
return outcome === false
|
|
2019
|
+
? { ok: false, error: 'structure-op "paste" was refused by the adapter.' }
|
|
2020
|
+
: { ok: true, data: { write: outcome } };
|
|
2021
|
+
}
|
|
2022
|
+
default:
|
|
2023
|
+
return {
|
|
2024
|
+
ok: false,
|
|
2025
|
+
error:
|
|
2026
|
+
`structure-op: unknown op ${JSON.stringify(op)}. Known ops: create, delete, ` +
|
|
2027
|
+
'duplicate, reparent, reorder, wrap, unwrap, group, ungroup, copy, cut, paste.',
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
case 'document-probe': {
|
|
2035
|
+
// `editor.document.*` — the scoped editor-chrome door. Every refusal
|
|
2036
|
+
// (no active document, unmounted surface, target outside the scope)
|
|
2037
|
+
// comes back as the step's own honest error text, because the scope
|
|
2038
|
+
// NAME is the useful half of the answer
|
|
2039
|
+
// (`editor-document-probe.ts`'s header).
|
|
2040
|
+
try {
|
|
2041
|
+
const result = await runDocumentProbe(cmd['step'] as DocumentProbeStep);
|
|
2042
|
+
return { ok: true, data: { ...result } };
|
|
2043
|
+
} catch (error) {
|
|
2044
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
case 'capture-active-document': {
|
|
2048
|
+
const requested = captureSizeFromCommand(cmd);
|
|
2049
|
+
if ('error' in requested) return { ok: false, error: requested.error };
|
|
2050
|
+
try {
|
|
2051
|
+
if (cmd['view']) await presentEditorView(store, cmd['view'] as EditorView);
|
|
2052
|
+
const capture = await captureActiveEditorDocument(store, requested.size);
|
|
2053
|
+
return { ok: true, data: { ...capture } };
|
|
2054
|
+
} catch (error) {
|
|
2055
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
case 'capture-editor-chrome': {
|
|
2059
|
+
// The editor PAGE itself — see `editor-chrome-capture.ts`. The page is
|
|
2060
|
+
// photographed at its own LAYOUT; `scale` chooses only how many output
|
|
2061
|
+
// pixels one CSS pixel becomes, defaulting to the display's own ratio.
|
|
2062
|
+
// A stroke weight compared against a 2x reference needs `scale: 2`.
|
|
2063
|
+
const scale = cmd['scale'];
|
|
2064
|
+
if (scale !== undefined && (typeof scale !== 'number' || !(scale > 0) || scale > 4)) {
|
|
2065
|
+
return {
|
|
2066
|
+
ok: false,
|
|
2067
|
+
error:
|
|
2068
|
+
'capture-editor-chrome: "scale" must be a number greater than 0 and no more than 4 ' +
|
|
2069
|
+
'(output pixels per CSS pixel; the frame crosses the relay as base64 PNG). ' +
|
|
2070
|
+
"It defaults to the page's own devicePixelRatio.",
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
try {
|
|
2074
|
+
const capture = await captureEditorChrome(
|
|
2075
|
+
store,
|
|
2076
|
+
scale === undefined ? undefined : { scale },
|
|
2077
|
+
);
|
|
2078
|
+
return { ok: true, data: { ...capture } };
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
case 'capture-viewport': {
|
|
2084
|
+
// Fresh, unthrottled on-demand capture (see EditorShellStore.captureViewportImage's
|
|
2085
|
+
// doc comment) — distinct from the periodic autosave thumbnail the
|
|
2086
|
+
// project-thumbnail snapshot path serves. `size` is optional; defaults
|
|
2087
|
+
// to the store's standard thumbnail size.
|
|
2088
|
+
const requested = captureSizeFromCommand(cmd);
|
|
2089
|
+
if ('error' in requested) return { ok: false, error: requested.error };
|
|
2090
|
+
const dataUrl = store.captureViewportImage(requested.size);
|
|
2091
|
+
if (!dataUrl) {
|
|
2092
|
+
// The refusal names the MECHANISM and the door that does answer.
|
|
2093
|
+
// "Viewport is not bound yet" alone was true and useless over a live
|
|
2094
|
+
// canvas or ingest session: this door photographs the editor's own
|
|
2095
|
+
// THREE viewport, which a canvas-surface world never binds — so the
|
|
2096
|
+
// reader waited for a binding that was never coming instead of
|
|
2097
|
+
// reaching for the capture that was already available.
|
|
2098
|
+
return {
|
|
2099
|
+
ok: false,
|
|
2100
|
+
error:
|
|
2101
|
+
"This door photographs the editor's own three.js Scene viewport, and nothing has bound one " +
|
|
2102
|
+
'(no renderer/scene/camera). A canvas-surface world (a first-party canvas root, or a ' +
|
|
2103
|
+
'PixiJS/Phaser/Babylon ingest) never binds it — it draws on its own canvas in the Game ' +
|
|
2104
|
+
'document. Capture that through `capture-active-document` (`editor.captureActiveDocument()`) ' +
|
|
2105
|
+
'or the running game through `bridge-screenshot` (`vgai screenshot`).',
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
const comma = dataUrl.indexOf(',');
|
|
2109
|
+
const base64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
|
|
2110
|
+
return { ok: true, data: { base64, mimeType: 'image/png' } };
|
|
2111
|
+
}
|
|
2112
|
+
case 'capture-asset-preview':
|
|
2113
|
+
return handleAssetPreviewCommand(store, cmd);
|
|
2114
|
+
/**
|
|
2115
|
+
* OPEN one piece of the adapter's scene table by id — the table's ONE
|
|
2116
|
+
* `open` verb (ARCHITECTURE-CORE §Editor). One verb for a scene, a prefab,
|
|
2117
|
+
* or a story state, because the table makes them siblings: they differ only
|
|
2118
|
+
* in instance site.
|
|
2119
|
+
*
|
|
2120
|
+
* TWO CLIENTS, one verb. With a game LIVE in this session the live half
|
|
2121
|
+
* answers first (`scene-live-open.ts`): the running game IS the surface, so
|
|
2122
|
+
* a `game-contract` scene is opened by asking the game to navigate and a
|
|
2123
|
+
* `root-mount` scene by showing the document it draws into. It answers
|
|
2124
|
+
* `null` for the reaches that are the Edit workspace's
|
|
2125
|
+
* (`components/scene-documents.tsx`), which then runs unchanged.
|
|
2126
|
+
*
|
|
2127
|
+
* Both halves answer from the SAME declared field — the live half switches
|
|
2128
|
+
* on `entry.reach.kind`, and the Edit half's `liveReach` is derived from
|
|
2129
|
+
* `entry.reach` alone (`scene-document-plan.ts`'s `liveReachOf`).
|
|
2130
|
+
* `authorable` decides the Edit DOCUMENT and nothing else — it is not asked
|
|
2131
|
+
* here, because whether a running game can be sent to one of its own screens
|
|
2132
|
+
* is the game's contract to answer, not the host's opinion of whether that
|
|
2133
|
+
* screen is authorable.
|
|
2134
|
+
*
|
|
2135
|
+
* The two halves agree about every reach the live half REACHES:
|
|
2136
|
+
* `liveReach` is `'game-contract' | 'root-mount' | 'entrypoint-selection'`,
|
|
2137
|
+
* exactly the set that ends `ok: true` there when the session can honour
|
|
2138
|
+
* it, so with nothing running both refuse as
|
|
2139
|
+
* `SCENE_NAVIGATION_NOT_RUNNING` rather than as a dead end.
|
|
2140
|
+
* A session with no remount seam still refuses `entrypoint-selection`
|
|
2141
|
+
* as `SCENE_NOT_OPENABLE_LIVE` — that is a session fact, not a reach
|
|
2142
|
+
* fact.
|
|
2143
|
+
*
|
|
2144
|
+
* Every refusal is CODED, because the classes are graded differently and
|
|
2145
|
+
* prose cannot separate them: `SCENE_NOT_FOUND` names the ids that DO
|
|
2146
|
+
* exist, `SCENE_NOT_OPENABLE` quotes the adapter's own declared reason
|
|
2147
|
+
* verbatim rather than paraphrasing a claim about someone else's game, and
|
|
2148
|
+
* `SCENE_NAVIGATION_NOT_RUNNING` says the scene is live-only rather than
|
|
2149
|
+
* unreachable.
|
|
2150
|
+
*/
|
|
2151
|
+
// The document table the host resolved (ARCHITECTURE-CORE §The project
|
|
2152
|
+
// model, "Documents, not scenes"), as the wire projection `getState`
|
|
2153
|
+
// already carries. A COMMAND rather than a state read so a standing
|
|
2154
|
+
// document that lists the table (a production's Shots bin) is driven the
|
|
2155
|
+
// same way every other surface is.
|
|
2156
|
+
case 'document-table': {
|
|
2157
|
+
const facet = projectAdapterFacet();
|
|
2158
|
+
if (!facet) {
|
|
2159
|
+
return {
|
|
2160
|
+
ok: false,
|
|
2161
|
+
error: 'The project adapter has not resolved yet — no document table to list.',
|
|
2162
|
+
data: { code: 'SCENE_TABLE_UNAVAILABLE' },
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
return {
|
|
2166
|
+
ok: true,
|
|
2167
|
+
data: {
|
|
2168
|
+
default: facet.scenes.default ?? null,
|
|
2169
|
+
entries: facet.scenes.entries,
|
|
2170
|
+
pending: facet.documentsPending === true,
|
|
2171
|
+
},
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
case 'open': {
|
|
2176
|
+
const id = cmd['id'];
|
|
2177
|
+
if (typeof id !== 'string' || id.trim() === '') {
|
|
2178
|
+
return { ok: false, error: 'open requires a non-empty scene-table entry id.' };
|
|
2179
|
+
}
|
|
2180
|
+
// Play's `open()` is live navigation. Edit's `open()` is the tab row —
|
|
2181
|
+
// an ingest mount is not Play (ARCHITECTURE-CORE: every authorable scene
|
|
2182
|
+
// is an Edit document). Routing ingest through the live half is how
|
|
2183
|
+
// opening GameScreen put the Play document up.
|
|
2184
|
+
// A deferred ingest run is a real Play SESSION even though it does not
|
|
2185
|
+
// allocate `play-mode.ts`'s first-party session object. Use the shared
|
|
2186
|
+
// mode predicate so opening an adapter scene reaches that running game's
|
|
2187
|
+
// contract instead of silently falling back to its Edit document.
|
|
2188
|
+
const liveGame = editorIsPlaying();
|
|
2189
|
+
const entry = liveGame
|
|
2190
|
+
? projectAdapterFacet()?.scenes.entries.find((candidate) => candidate.id === id)
|
|
2191
|
+
: undefined;
|
|
2192
|
+
// The running lane's own remount (Play), asked of the live
|
|
2193
|
+
// registry so this verb names no lane.
|
|
2194
|
+
const remountSelection = liveGame
|
|
2195
|
+
? async (args: { selection: string; key: string; regionId: string }) =>
|
|
2196
|
+
(await remountLiveSelection(args)) ?? {
|
|
2197
|
+
ok: false as const,
|
|
2198
|
+
error: 'No running lane can remount with a selection.',
|
|
2199
|
+
}
|
|
2200
|
+
: undefined;
|
|
2201
|
+
const live = entry
|
|
2202
|
+
? await openLiveSceneEntry(entry, {
|
|
2203
|
+
scenes: liveScenes,
|
|
2204
|
+
activateGameDocument: () => activateWorkspaceDocument(GAME_DOCUMENT_ID),
|
|
2205
|
+
gameDocumentId: GAME_DOCUMENT_ID,
|
|
2206
|
+
...(remountSelection ? { remountSelection } : {}),
|
|
2207
|
+
})
|
|
2208
|
+
: null;
|
|
2209
|
+
const opened = live ?? (await openSceneTableEntryWhenListed(store, id));
|
|
2210
|
+
return opened.ok
|
|
2211
|
+
? {
|
|
2212
|
+
ok: true,
|
|
2213
|
+
data: {
|
|
2214
|
+
documentId: opened.documentId,
|
|
2215
|
+
title: opened.title,
|
|
2216
|
+
...('scene' in opened && opened.scene ? { scene: opened.scene } : {}),
|
|
2217
|
+
...('restart' in opened && opened.restart ? { restart: true } : {}),
|
|
2218
|
+
},
|
|
2219
|
+
}
|
|
2220
|
+
: {
|
|
2221
|
+
ok: false,
|
|
2222
|
+
error: opened.error,
|
|
2223
|
+
data: {
|
|
2224
|
+
code: opened.code,
|
|
2225
|
+
...(opened.known === undefined ? {} : { known: [...opened.known] }),
|
|
2226
|
+
},
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// Display — set semantics (only toggle when value differs)
|
|
2231
|
+
case 'set-grid': {
|
|
2232
|
+
const documentSession = activeObject3DDocumentSession();
|
|
2233
|
+
if (documentSession) documentSession.setGrid(cmd['enabled'] as boolean);
|
|
2234
|
+
else if (store.showGrid !== (cmd['enabled'] as boolean)) store.toggleGrid();
|
|
2235
|
+
break;
|
|
2236
|
+
}
|
|
2237
|
+
// Helpers and the stats tile are per-STAGE view options, read by the
|
|
2238
|
+
// focused stage's own viewport and its overlay set (ARCHITECTURE-CORE
|
|
2239
|
+
// §One stage unit 4) — the same reason `set-grid` above already asks the
|
|
2240
|
+
// active document's session. A verb that wrote the shell's copy while the
|
|
2241
|
+
// person was looking at a model document would toggle nothing they can see.
|
|
2242
|
+
case 'set-helpers': {
|
|
2243
|
+
const stage = focusedStageStore(store);
|
|
2244
|
+
if (stage.showHelpers !== (cmd['enabled'] as boolean)) stage.toggleHelpers();
|
|
2245
|
+
break;
|
|
2246
|
+
}
|
|
2247
|
+
case 'set-stats': {
|
|
2248
|
+
const stage = focusedStageStore(store);
|
|
2249
|
+
if (stage.showStats !== (cmd['enabled'] as boolean)) stage.toggleStats();
|
|
2250
|
+
break;
|
|
2251
|
+
}
|
|
2252
|
+
case 'set-shading-mode':
|
|
2253
|
+
if (activeObject3DDocumentSession()) {
|
|
2254
|
+
activeObject3DDocumentSession()?.setMode(cmd['mode'] as ViewportShadingMode);
|
|
2255
|
+
} else {
|
|
2256
|
+
store.setShadingMode(cmd['mode'] as ViewportShadingMode);
|
|
2257
|
+
}
|
|
2258
|
+
break;
|
|
2259
|
+
case 'set-helper-type': {
|
|
2260
|
+
const stage = focusedStageStore(store);
|
|
2261
|
+
const helperType = cmd['helperType'] as keyof HelperVisibility;
|
|
2262
|
+
const documentSession = activeObject3DDocumentSession();
|
|
2263
|
+
if (documentSession && helperType === 'bounds') {
|
|
2264
|
+
documentSession.setBounds(cmd['enabled'] as boolean);
|
|
2265
|
+
break;
|
|
2266
|
+
}
|
|
2267
|
+
if (documentSession && helperType === 'skeletons') {
|
|
2268
|
+
documentSession.setSkeleton(cmd['enabled'] as boolean);
|
|
2269
|
+
break;
|
|
2270
|
+
}
|
|
2271
|
+
if (stage.helperVisibility[helperType] !== (cmd['enabled'] as boolean)) {
|
|
2272
|
+
stage.toggleHelperType(helperType);
|
|
2273
|
+
}
|
|
2274
|
+
break;
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
// Transform tools — set semantics
|
|
2278
|
+
case 'set-transform-mode':
|
|
2279
|
+
store.setTransformMode(cmd['mode'] as 'combined' | 'translate' | 'rotate' | 'scale');
|
|
2280
|
+
break;
|
|
2281
|
+
case 'set-transform-space':
|
|
2282
|
+
store.setTransformSpace(cmd['space'] as 'world' | 'local');
|
|
2283
|
+
break;
|
|
2284
|
+
case 'set-snap':
|
|
2285
|
+
if (store.snapEnabled !== (cmd['enabled'] as boolean)) store.toggleSnap();
|
|
2286
|
+
break;
|
|
2287
|
+
|
|
2288
|
+
// The REPL door over the ACTIVE document's published context — Edit mode,
|
|
2289
|
+
// no play gate (`document-context-registry.ts`).
|
|
2290
|
+
case 'document-script':
|
|
2291
|
+
return handleDocumentScript(cmd);
|
|
2292
|
+
|
|
2293
|
+
// Version-skew honesty (the false-ack fix):
|
|
2294
|
+
// every unrecognized command type used to fall through to `return {ok:
|
|
2295
|
+
// true}` below — a silent lie: the browser did NOTHING, but the SDK/CLI
|
|
2296
|
+
// caller was told it succeeded. Reporting a structured failure must hold
|
|
2297
|
+
// for ANY unrecognized `type`, not just the ones known about today. The
|
|
2298
|
+
// `data.code` marker is what the SDK maps to its `*_UNSUPPORTED` codes;
|
|
2299
|
+
// the prose is for humans only.
|
|
2300
|
+
default: {
|
|
2301
|
+
// Unreachable: `isRelayCommandType` above already answered for anything
|
|
2302
|
+
// outside the table. This assignment is the EXHAUSTIVENESS CHECK — it
|
|
2303
|
+
// compiles only while every row of `RELAY_COMMANDS` has a case here.
|
|
2304
|
+
const unhandled: never = commandType;
|
|
2305
|
+
return {
|
|
2306
|
+
ok: false,
|
|
2307
|
+
error: `unknown command type "${String(unhandled)}" — editor page predates this CLI`,
|
|
2308
|
+
data: { code: 'UNKNOWN_COMMAND_TYPE' },
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
return { ok: true };
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* Connect the command listener to the editor server's SSE stream.
|
|
2318
|
+
* Dispatches incoming commands to the store and play-mode functions.
|
|
2319
|
+
* Reports state after each command and on initial connect.
|
|
2320
|
+
*/
|
|
2321
|
+
export function connectCommandListener(
|
|
2322
|
+
store: EditorShellStore,
|
|
2323
|
+
/**
|
|
2324
|
+
* The session's own undo/redo queue — the SAME object the keyboard shortcut
|
|
2325
|
+
* and the command palette drive, so a relayed undo is the user's undo and not
|
|
2326
|
+
* a second path into history. Omitted by the isolated component tests that
|
|
2327
|
+
* only need the listener's transport half; the verbs then refuse by name
|
|
2328
|
+
* rather than reaching for `store.projectHistory` behind the queue's back.
|
|
2329
|
+
*/
|
|
2330
|
+
history?: HistoryCommands,
|
|
2331
|
+
): () => void {
|
|
2332
|
+
// Browser mode (Phase A2): the `vgai` CLI control channel is server-only (it
|
|
2333
|
+
// is an SSE command stream + state POSTs to `/__editor/*`, which do not exist
|
|
2334
|
+
// without a Node server). Skip it — otherwise the EventSource retry-loops
|
|
2335
|
+
// against a 404 and every `reportEditorState` POSTs into the void.
|
|
2336
|
+
const source = connectEvents();
|
|
2337
|
+
|
|
2338
|
+
// The last FULL snapshot this page sent. Every report on a user's critical
|
|
2339
|
+
// path reuses its derived facets rather than re-deriving them — see
|
|
2340
|
+
// `state-report-deferral.ts` for the measurement that bought this.
|
|
2341
|
+
let lastFullState: Record<string, unknown> | null = null;
|
|
2342
|
+
/**
|
|
2343
|
+
* The store's `contentVersion` at the moment `lastFullState` was collected.
|
|
2344
|
+
*
|
|
2345
|
+
* The facets `collectState` is allowed to REUSE are the tree-scale ones — the
|
|
2346
|
+
* hierarchy rows and the capability grading. When the store notifies with
|
|
2347
|
+
* nothing but a selection change, `contentVersion` holds still, and every
|
|
2348
|
+
* reused facet in `lastFullState` is therefore already current: scheduling a
|
|
2349
|
+
* full re-derivation to "make it current again" re-derives an answer it
|
|
2350
|
+
* already has. Measured at N=20000 on the canvas lane as a 54-70ms
|
|
2351
|
+
* `IdleRequestCallback @ command-listener.ts` block after EVERY click.
|
|
2352
|
+
*/
|
|
2353
|
+
let lastFullContentVersion = -1;
|
|
2354
|
+
// One entry per read-only debug proof in the CURRENT adapter epoch. The epoch is in the key, so
|
|
2355
|
+
// remounts naturally pay for (and publish) their own first proof while repeated clock/snapshot
|
|
2356
|
+
// polls reuse the full report that already contains the identical verdict.
|
|
2357
|
+
const reportedDebugReadProofs = new Set<string>();
|
|
2358
|
+
/**
|
|
2359
|
+
* A Play scene transition is a burst, not one store notification. The
|
|
2360
|
+
* translated FPS measured 4,523 hierarchy objects and several asynchronous
|
|
2361
|
+
* React commits between `unity.load_scene.MainScene` returning and the tree
|
|
2362
|
+
* becoming quiescent. Re-deriving on every notification caused 325-430ms
|
|
2363
|
+
* main-thread blocks and eventually starved the command relay; deriving on
|
|
2364
|
+
* the command's first notification left the cached hierarchy at IntroMenu.
|
|
2365
|
+
*
|
|
2366
|
+
* Arm exactly one refresh for the burst and move it behind a short quiet
|
|
2367
|
+
* window. Once it lands, ordinary runtime structure, input, state polling,
|
|
2368
|
+
* presence and store reports cannot arm another one. A later explicit game
|
|
2369
|
+
* command/read or Play boundary can arm the next transition honestly.
|
|
2370
|
+
*/
|
|
2371
|
+
let playFullReportArmed = false;
|
|
2372
|
+
let playSettleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2373
|
+
let playCommandContentVersion: number | null = null;
|
|
2374
|
+
let playCommandContentTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2375
|
+
// A tombstone stops POSTing snapshots too. `vgai status` reads the server's
|
|
2376
|
+
// last snapshot, so a corpse that kept reporting would keep MINTING
|
|
2377
|
+
// fresh-looking state for a session that no longer exists — the exact
|
|
2378
|
+
// impersonation the tombstone latch exists to end.
|
|
2379
|
+
const reportLatestState = () => {
|
|
2380
|
+
if (sessionEndedState() !== null) return;
|
|
2381
|
+
lastFullState = collectState(store);
|
|
2382
|
+
lastFullContentVersion = store.contentVersion;
|
|
2383
|
+
void reportEditorState(lastFullState);
|
|
2384
|
+
};
|
|
2385
|
+
let cancelDeferredFullReport: (() => void) | null = null;
|
|
2386
|
+
const cancelPlaySettledFullReport = () => {
|
|
2387
|
+
if (playSettleTimer !== null) {
|
|
2388
|
+
clearTimeout(playSettleTimer);
|
|
2389
|
+
playSettleTimer = null;
|
|
2390
|
+
}
|
|
2391
|
+
cancelDeferredFullReport?.();
|
|
2392
|
+
cancelDeferredFullReport = null;
|
|
2393
|
+
};
|
|
2394
|
+
const clearPlayCommandContentCandidate = () => {
|
|
2395
|
+
playCommandContentVersion = null;
|
|
2396
|
+
if (playCommandContentTimer !== null) clearTimeout(playCommandContentTimer);
|
|
2397
|
+
playCommandContentTimer = null;
|
|
2398
|
+
};
|
|
2399
|
+
const schedulePlaySettledFullReport = () => {
|
|
2400
|
+
clearPlayCommandContentCandidate();
|
|
2401
|
+
playFullReportArmed = true;
|
|
2402
|
+
cancelPlaySettledFullReport();
|
|
2403
|
+
playSettleTimer = setTimeout(() => {
|
|
2404
|
+
playSettleTimer = null;
|
|
2405
|
+
cancelDeferredFullReport = scheduleDeferredFullReport(() => {
|
|
2406
|
+
cancelDeferredFullReport = null;
|
|
2407
|
+
playFullReportArmed = false;
|
|
2408
|
+
reportLatestState();
|
|
2409
|
+
});
|
|
2410
|
+
}, 250);
|
|
2411
|
+
};
|
|
2412
|
+
const armPlayCommandContentCandidate = (baseline: number) => {
|
|
2413
|
+
if (store.contentVersion !== baseline) {
|
|
2414
|
+
schedulePlaySettledFullReport();
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
clearPlayCommandContentCandidate();
|
|
2418
|
+
playCommandContentVersion = baseline;
|
|
2419
|
+
playCommandContentTimer = setTimeout(clearPlayCommandContentCandidate, 1_000);
|
|
2420
|
+
};
|
|
2421
|
+
const editorIsActivelyPlaying = () => editorIsPlaying() && store.playState === 'playing';
|
|
2422
|
+
const settleUnscopedPlayCommandRefresh = (commandStartContentVersion: number | null) => {
|
|
2423
|
+
if (commandStartContentVersion !== null) {
|
|
2424
|
+
// A successful relayed command whose declared policy still owes a
|
|
2425
|
+
// derived refresh must not disappear merely because it does not carry
|
|
2426
|
+
// one of the special debug-invoke transition scopes above. Read commands
|
|
2427
|
+
// reach here only when contentVersion moved; `always` mutation/boundary
|
|
2428
|
+
// commands reach here by declaration. Ordinary store notifications have
|
|
2429
|
+
// no command baseline and remain cheap during Play.
|
|
2430
|
+
schedulePlaySettledFullReport();
|
|
2431
|
+
} else if (
|
|
2432
|
+
playCommandContentVersion !== null &&
|
|
2433
|
+
store.contentVersion !== playCommandContentVersion
|
|
2434
|
+
) {
|
|
2435
|
+
schedulePlaySettledFullReport();
|
|
2436
|
+
} else if (playFullReportArmed && store.contentVersion !== lastFullContentVersion) {
|
|
2437
|
+
// A transition already earned one refresh. Keep moving that ONE
|
|
2438
|
+
// refresh behind the mount burst; this does not arm periodic work.
|
|
2439
|
+
schedulePlaySettledFullReport();
|
|
2440
|
+
}
|
|
2441
|
+
};
|
|
2442
|
+
const handledByPlayReportGate = (
|
|
2443
|
+
playFullReport: CompletedCommandRefresh['playFullReport'],
|
|
2444
|
+
commandStartContentVersion: number | null,
|
|
2445
|
+
derivedRefresh: RelayCommandDerivedRefresh,
|
|
2446
|
+
): boolean => {
|
|
2447
|
+
if (!editorIsActivelyPlaying()) return false;
|
|
2448
|
+
if (
|
|
2449
|
+
!playCommandOwesDerivedRefresh(
|
|
2450
|
+
derivedRefresh,
|
|
2451
|
+
playFullReport,
|
|
2452
|
+
store.contentVersion,
|
|
2453
|
+
lastFullContentVersion,
|
|
2454
|
+
)
|
|
2455
|
+
)
|
|
2456
|
+
return true;
|
|
2457
|
+
if (playFullReport === 'explicit') {
|
|
2458
|
+
schedulePlaySettledFullReport();
|
|
2459
|
+
} else if (playFullReport === 'if-content-changed') {
|
|
2460
|
+
armPlayCommandContentCandidate(commandStartContentVersion ?? store.contentVersion);
|
|
2461
|
+
} else settleUnscopedPlayCommandRefresh(commandStartContentVersion);
|
|
2462
|
+
return true;
|
|
2463
|
+
};
|
|
2464
|
+
/**
|
|
2465
|
+
* Report NOW with everything cheap fresh, and make the expensive halves
|
|
2466
|
+
* current in the background.
|
|
2467
|
+
*
|
|
2468
|
+
* This is the path every interaction takes. The immediate POST is what keeps
|
|
2469
|
+
* the "UI Play/Stop is visible to `vgai status` immediately" contract and
|
|
2470
|
+
* every other same-tick freshness promise in this file — playState, loop
|
|
2471
|
+
* liveness, selection, save state, presence and the error channels are all
|
|
2472
|
+
* derived fresh here. What it does NOT do is re-walk the hierarchy and
|
|
2473
|
+
* re-grade every capability on the frame the user clicked; a single deferred
|
|
2474
|
+
* full collect does that once, however many changes arrived in the burst.
|
|
2475
|
+
*/
|
|
2476
|
+
const reportCurrentState = (
|
|
2477
|
+
/**
|
|
2478
|
+
* `'if-content-changed'` skips the deferred full re-derivation when the
|
|
2479
|
+
* store's `contentVersion` has not moved since the last full collect —
|
|
2480
|
+
* see `lastFullContentVersion`. ONLY the store-notification path may ask
|
|
2481
|
+
* for it: every other trigger here (restart-required, the ingest capture
|
|
2482
|
+
* wait, the adapter load, focus/blur, a completed command) can change a
|
|
2483
|
+
* reused facet WITHOUT any store notification at all, and the store's
|
|
2484
|
+
* version knows nothing about them.
|
|
2485
|
+
*/
|
|
2486
|
+
derivedRefresh: RelayCommandDerivedRefresh = 'always',
|
|
2487
|
+
playFullReport: CompletedCommandRefresh['playFullReport'] = 'none',
|
|
2488
|
+
commandStartContentVersion: number | null = null,
|
|
2489
|
+
) => {
|
|
2490
|
+
if (sessionEndedState() !== null) return;
|
|
2491
|
+
if (lastFullState === null) {
|
|
2492
|
+
// Nothing to reuse yet — the honest floor is to pay for a real collect
|
|
2493
|
+
// rather than report a fabricated or empty derivation.
|
|
2494
|
+
reportLatestState();
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
const state = collectState(store, lastFullState);
|
|
2498
|
+
lastFullState = state;
|
|
2499
|
+
void reportEditorState(currentStatePatch(state));
|
|
2500
|
+
if (derivedRefresh === 'none') return;
|
|
2501
|
+
if (handledByPlayReportGate(playFullReport, commandStartContentVersion, derivedRefresh)) return;
|
|
2502
|
+
// Pause/stop leave a complete, stable surface and must publish it. Any
|
|
2503
|
+
// pending Play refresh is superseded by this non-presenting full report.
|
|
2504
|
+
cancelPlaySettledFullReport();
|
|
2505
|
+
clearPlayCommandContentCandidate();
|
|
2506
|
+
playFullReportArmed = false;
|
|
2507
|
+
if (
|
|
2508
|
+
derivedRefresh === 'if-content-changed' &&
|
|
2509
|
+
store.contentVersion === lastFullContentVersion
|
|
2510
|
+
) {
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
if (cancelDeferredFullReport !== null) return;
|
|
2514
|
+
cancelDeferredFullReport = scheduleDeferredFullReport(() => {
|
|
2515
|
+
cancelDeferredFullReport = null;
|
|
2516
|
+
reportLatestState();
|
|
2517
|
+
});
|
|
2518
|
+
};
|
|
2519
|
+
let storeReportScheduled = false;
|
|
2520
|
+
/** A burst that included any trigger OTHER than a store notify takes the
|
|
2521
|
+
* deferred full refresh unconditionally — see `reportCurrentState`'s
|
|
2522
|
+
* `derivedRefresh` argument for why the store's version cannot speak for
|
|
2523
|
+
* those. Widening, never narrowing: one such trigger in a burst is enough. */
|
|
2524
|
+
let storeReportNeedsFullRefresh = false;
|
|
2525
|
+
let reportedStorePlayState = store.playState;
|
|
2526
|
+
const scheduleStateReport = (needsFullRefresh: boolean) => {
|
|
2527
|
+
// One editor action commonly emits several store notifications. Collapse
|
|
2528
|
+
// that synchronous burst into one current snapshot without delaying it a
|
|
2529
|
+
// frame — UI Play/Stop must be visible to `vgai status` immediately even
|
|
2530
|
+
// though no relayed command caused the transition.
|
|
2531
|
+
if (needsFullRefresh) storeReportNeedsFullRefresh = true;
|
|
2532
|
+
if (storeReportScheduled) return;
|
|
2533
|
+
storeReportScheduled = true;
|
|
2534
|
+
queueMicrotask(() => {
|
|
2535
|
+
storeReportScheduled = false;
|
|
2536
|
+
const forceRefresh = storeReportNeedsFullRefresh;
|
|
2537
|
+
storeReportNeedsFullRefresh = false;
|
|
2538
|
+
const playBoundary = reportedStorePlayState !== store.playState;
|
|
2539
|
+
reportedStorePlayState = store.playState;
|
|
2540
|
+
reportCurrentState(
|
|
2541
|
+
forceRefresh ? 'always' : 'if-content-changed',
|
|
2542
|
+
playBoundary && store.playState === 'playing' ? 'explicit' : 'none',
|
|
2543
|
+
);
|
|
2544
|
+
});
|
|
2545
|
+
};
|
|
2546
|
+
const reportStoreChange = () => scheduleStateReport(false);
|
|
2547
|
+
const reportExternalChange = () => scheduleStateReport(true);
|
|
2548
|
+
const unsubscribeStoreReport = store.subscribe(reportStoreChange);
|
|
2549
|
+
// R1 — restart-required transitions don't flow through the store (they
|
|
2550
|
+
// have their own listener set in play-mode.ts), but `vgai status` readers
|
|
2551
|
+
// need `restartRequired` fresh even when NO editor command caused the
|
|
2552
|
+
// change (an external agent editing an R3F entry mid-play is exactly the
|
|
2553
|
+
// silent-staleness case R1 closes). Re-POST state on every transition.
|
|
2554
|
+
const unsubscribeRestartReport = subscribeLiveSessions(reportExternalChange);
|
|
2555
|
+
// Same reason, for the ingest capture wait: it starts and ends outside any
|
|
2556
|
+
// store notification (a mount awaiting its game's first frame), and on a
|
|
2557
|
+
// hidden tab it can hold for as long as the human is away. Without this the
|
|
2558
|
+
// server's snapshot would predate the wait entirely, so `vgai status` would
|
|
2559
|
+
// answer "nothing is ingested" for a mount that is very much in flight. The
|
|
2560
|
+
// wait's OTHER transition — parked↔running as the tab hides and shows —
|
|
2561
|
+
// already re-POSTs through `reportPresence`'s `visibilitychange` listener.
|
|
2562
|
+
// Same reason again, for the project's ADAPTER: it loads asynchronously at
|
|
2563
|
+
// editor init and on project switches, outside any store notification. The
|
|
2564
|
+
// adapter facet is the proof that a project's `vgai.adapter.ts` (or the
|
|
2565
|
+
// declared native default) loaded at all, so a snapshot that predates the
|
|
2566
|
+
// load would answer "no adapter" for one that is loaded and live.
|
|
2567
|
+
const unsubscribeAdapterReport = subscribeProjectAdapter(reportExternalChange);
|
|
2568
|
+
// A viewport's first completed frame also lands outside the store. Report
|
|
2569
|
+
// it proactively so remote readers can poll the server-held state instead
|
|
2570
|
+
// of injecting no-op `active-tab` commands while a large board is rendering.
|
|
2571
|
+
const unsubscribeViewportActivationReport =
|
|
2572
|
+
subscribeViewportActivationTimings(reportExternalChange);
|
|
2573
|
+
|
|
2574
|
+
// Report initial state
|
|
2575
|
+
reportLatestState();
|
|
2576
|
+
// …and the standing fact the state snapshot cannot carry: this page is now
|
|
2577
|
+
// running a command listener. Before this, a page that beat but never got
|
|
2578
|
+
// here was indistinguishable from a healthy one until somebody sent a
|
|
2579
|
+
// command and watched it hang. See `reportCommandListener`.
|
|
2580
|
+
void reportCommandListener(true);
|
|
2581
|
+
// The other standing fact of that shape: which step of a play boot this page
|
|
2582
|
+
// is inside. Wired HERE because this is the module that owns "facts this page
|
|
2583
|
+
// reports upstream", and because a phase is only useful to a reader who can
|
|
2584
|
+
// also see the command it explains. See `play-boot-phase.ts`.
|
|
2585
|
+
setPlayBootPhaseReporter(reportPlayBootPhase);
|
|
2586
|
+
|
|
2587
|
+
// And again on every RE-open. The server drops a tab's health snapshot
|
|
2588
|
+
// when its connection goes, and a tab with no health is deliberately never
|
|
2589
|
+
// chosen as the command controller (`editor-sse.ts`'s `selectedController`:
|
|
2590
|
+
// an identified tab that has not reported yet is connected but not
|
|
2591
|
+
// command-ready). Without this, a reconnect — the control socket's backoff
|
|
2592
|
+
// after any blip — left the tab uncontrollable until the next store
|
|
2593
|
+
// change, presence event, or command happened to fire.
|
|
2594
|
+
source.addEventListener('open', () => {
|
|
2595
|
+
// A reconnect is transport state, not a project mutation. In Play, a full
|
|
2596
|
+
// reconnect collect was enough to stall a large world and provoke another
|
|
2597
|
+
// disconnect; reuse the last honest derived facets and publish the cheap
|
|
2598
|
+
// liveness/presence fields immediately.
|
|
2599
|
+
reportCurrentState('if-content-changed');
|
|
2600
|
+
// Listener readiness belongs to the PAGE, but the server deliberately
|
|
2601
|
+
// drops its cached proof when this page has no connection left. Re-prove
|
|
2602
|
+
// it on the successor connection instead of leaving status at
|
|
2603
|
+
// `not attached` until some unrelated state transition happens.
|
|
2604
|
+
void reportCommandListener(sessionEndedState() === null);
|
|
2605
|
+
});
|
|
2606
|
+
|
|
2607
|
+
// A page whose session has ENDED must leave the bijection's account of live
|
|
2608
|
+
// tabs rather than sit in it answering probes. Reporting the listener
|
|
2609
|
+
// detached is exactly the fact `server/server-utils.ts`'s
|
|
2610
|
+
// `commandListenerHealth` already prints per tab (`not attached`), so the
|
|
2611
|
+
// server's own tab table names the corpse without a second liveness notion
|
|
2612
|
+
// beside it. The `resume` branch of the lease's recovery re-attaches.
|
|
2613
|
+
const unsubscribeTombstone = onSessionEndedChange((state) => {
|
|
2614
|
+
void reportCommandListener(state === null);
|
|
2615
|
+
});
|
|
2616
|
+
|
|
2617
|
+
source.addEventListener('editor-command', (e: MessageEvent) => {
|
|
2618
|
+
try {
|
|
2619
|
+
const cmd = JSON.parse(e.data as string) as EditorCommand;
|
|
2620
|
+
const requestId = cmd._requestId;
|
|
2621
|
+
const commandStartContentVersion = store.contentVersion;
|
|
2622
|
+
// A tombstone answers, and what it answers is a REFUSAL naming its state.
|
|
2623
|
+
// Silence here would be worse than the defect: the caller would wait out
|
|
2624
|
+
// its whole budget and then be told the tab did not respond — a sentence
|
|
2625
|
+
// about a healthy tab, for a page that is dead.
|
|
2626
|
+
const tombstone = sessionEndedState();
|
|
2627
|
+
if (tombstone !== null) {
|
|
2628
|
+
const error = sessionEndedRefusal(tombstone, String(cmd['type']));
|
|
2629
|
+
if (requestId) void reportCommandResult(requestId, false, error);
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
// Receipt FIRST, before any work: it answers "this tab's command
|
|
2633
|
+
// listener is running", which is the one thing the relay cannot observe
|
|
2634
|
+
// and the thing a long-budget command (`play`'s 120s) otherwise spends
|
|
2635
|
+
// its whole budget failing to learn. Not awaited — the work must not
|
|
2636
|
+
// queue behind it. See `server/server-utils.ts`'s `RELAY_DELIVERY_ACK_MS`.
|
|
2637
|
+
if (requestId) void reportCommandReceived(requestId);
|
|
2638
|
+
let present: (() => void) | undefined;
|
|
2639
|
+
handleCommand(store, cmd, {
|
|
2640
|
+
deferPresentation: (effect) => {
|
|
2641
|
+
present = effect;
|
|
2642
|
+
},
|
|
2643
|
+
...(history ? { history } : {}),
|
|
2644
|
+
})
|
|
2645
|
+
// A handler that THROWS must still answer its caller. Without this leg
|
|
2646
|
+
// the rejection reached only the browser's `unhandledrejection`
|
|
2647
|
+
// channel: the relay's caller got nothing and timed out into
|
|
2648
|
+
// "the tab is present … and did not respond" — which blames the tab
|
|
2649
|
+
// for a defect in the command — and the console capture DEDUPES a
|
|
2650
|
+
// repeated message, so the SECOND occurrence of the same failure left
|
|
2651
|
+
// the session journal completely silent. Measured 2026-08-15 on a
|
|
2652
|
+
// racing-game ingest mount (`capture-viewport` with a non-number
|
|
2653
|
+
// `size`; see `captureSizeFromCommand`).
|
|
2654
|
+
.catch((error: unknown) => commandThrewResult(cmd, error))
|
|
2655
|
+
.then((result) => {
|
|
2656
|
+
// Report command result back to server (so SDK/CLI gets the response)
|
|
2657
|
+
if (requestId) {
|
|
2658
|
+
const ack = reportCommandResult(
|
|
2659
|
+
requestId,
|
|
2660
|
+
result.ok,
|
|
2661
|
+
result.error,
|
|
2662
|
+
result.data,
|
|
2663
|
+
present !== undefined,
|
|
2664
|
+
);
|
|
2665
|
+
if (present) {
|
|
2666
|
+
// Do not merely queue behind the result send in the same task: wait until the original
|
|
2667
|
+
// caller's response has finished. Only then may a 500-row hierarchy reveal or inspector
|
|
2668
|
+
// preview monopolize the page's main thread. Presentation still runs after the bounded
|
|
2669
|
+
// receipt wait if the server vanished.
|
|
2670
|
+
const schedule = () => window.setTimeout(present!, 0);
|
|
2671
|
+
void ack.then(schedule, schedule);
|
|
2672
|
+
}
|
|
2673
|
+
} else if (present) {
|
|
2674
|
+
window.setTimeout(present, 0);
|
|
2675
|
+
}
|
|
2676
|
+
// The selection SET is already current even when its React presentation is deferred, so
|
|
2677
|
+
// this snapshot reports the applied truth without waiting for hierarchy/Inspector work
|
|
2678
|
+
// — nor, now, for a re-walk and re-grade of the whole project (`reportCurrentState`).
|
|
2679
|
+
const refresh = completedCommandDerivedRefresh(cmd, result.ok, reportedDebugReadProofs);
|
|
2680
|
+
reportCurrentState(refresh.derived, refresh.playFullReport, commandStartContentVersion);
|
|
2681
|
+
});
|
|
2682
|
+
} catch {
|
|
2683
|
+
/* ignore malformed events */
|
|
2684
|
+
}
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
// When the server switches projects, reload to pick up the new project's files
|
|
2688
|
+
source.addEventListener('project-changed', () => {
|
|
2689
|
+
window.location.reload();
|
|
2690
|
+
});
|
|
2691
|
+
|
|
2692
|
+
// #145 — presence freshness: the state snapshot the server holds is only
|
|
2693
|
+
// re-POSTed after commands, so visibility/focus changes between commands
|
|
2694
|
+
// would go stale. Report on the three events that change presence.
|
|
2695
|
+
//
|
|
2696
|
+
// Through `reportCurrentState`, never a full collect: a focus/blur used to
|
|
2697
|
+
// re-derive the entire status surface on the event's own frame, which was
|
|
2698
|
+
// measured at 1221ms (`DOMWindow.onfocus`) and 1306ms (`DOMWindow.onblur`)
|
|
2699
|
+
// of main-thread block on a game-heavy project. Tabbing into the editor
|
|
2700
|
+
// froze it for over a second, every time, and nothing a focus changes is in
|
|
2701
|
+
// the expensive half.
|
|
2702
|
+
// Wrapped, never passed directly: this is an EVENT listener, and handing the
|
|
2703
|
+
// browser's `Event` straight into `reportCurrentState`'s argument would make
|
|
2704
|
+
// the refresh policy depend on an accident of the DOM signature.
|
|
2705
|
+
const reportPresence = (): void => reportCurrentState();
|
|
2706
|
+
document.addEventListener('visibilitychange', reportPresence);
|
|
2707
|
+
window.addEventListener('focus', reportPresence);
|
|
2708
|
+
window.addEventListener('blur', reportPresence);
|
|
2709
|
+
|
|
2710
|
+
// pageErrors freshness (target-blaster friction #3): a runtime error
|
|
2711
|
+
// between commands must reach the server snapshot too, or `vgai status`
|
|
2712
|
+
// reads stale-clean. Deferred a tick so the boot-installed error-capture
|
|
2713
|
+
// listener (`installEditorConsoleCapture`, which feeds editorConsole — the
|
|
2714
|
+
// list collectPlayRunPageErrors/collectSessionErrors read) runs FIRST regardless of
|
|
2715
|
+
// registration order.
|
|
2716
|
+
const reportAfterError = () => setTimeout(reportPresence, 0);
|
|
2717
|
+
window.addEventListener('error', reportAfterError);
|
|
2718
|
+
window.addEventListener('unhandledrejection', reportAfterError);
|
|
2719
|
+
|
|
2720
|
+
return () => {
|
|
2721
|
+
// Reported BEFORE the socket closes, so it still has a channel to travel
|
|
2722
|
+
// on. A teardown that also loses the connection is reported by the
|
|
2723
|
+
// server's own `close` handler; this is the case where the page keeps its
|
|
2724
|
+
// channel and stops listening.
|
|
2725
|
+
void reportCommandListener(false);
|
|
2726
|
+
cancelDeferredFullReport?.();
|
|
2727
|
+
cancelDeferredFullReport = null;
|
|
2728
|
+
if (playSettleTimer !== null) clearTimeout(playSettleTimer);
|
|
2729
|
+
playSettleTimer = null;
|
|
2730
|
+
clearPlayCommandContentCandidate();
|
|
2731
|
+
unsubscribeTombstone();
|
|
2732
|
+
unsubscribeStoreReport();
|
|
2733
|
+
unsubscribeRestartReport();
|
|
2734
|
+
unsubscribeAdapterReport();
|
|
2735
|
+
unsubscribeViewportActivationReport();
|
|
2736
|
+
document.removeEventListener('visibilitychange', reportPresence);
|
|
2737
|
+
window.removeEventListener('focus', reportPresence);
|
|
2738
|
+
window.removeEventListener('blur', reportPresence);
|
|
2739
|
+
window.removeEventListener('error', reportAfterError);
|
|
2740
|
+
window.removeEventListener('unhandledrejection', reportAfterError);
|
|
2741
|
+
source.close();
|
|
2742
|
+
};
|
|
2743
|
+
}
|