@volter/editor-game 0.5.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE +23 -0
- package/contributions/asset-budget-asset.menu.ts +26 -0
- package/contributions/asset-budget.action.ts +18 -0
- package/contributions/asset-budget.document.tsx +21 -0
- package/contributions/asset-budget.menu.ts +22 -0
- package/contributions/audio-unlock.service.ts +17 -0
- package/contributions/audio.utility.tsx +15 -0
- package/contributions/autoplay.service.ts +48 -0
- package/contributions/bridge.command.ts +172 -0
- package/contributions/build-profiles.document.tsx +21 -0
- package/contributions/build-progress.status.tsx +45 -0
- package/contributions/build.action.ts +26 -0
- package/contributions/build.command.ts +27 -0
- package/contributions/build.header.tsx +37 -0
- package/contributions/build.menu.ts +31 -0
- package/contributions/build.service.ts +31 -0
- package/contributions/connection.status.tsx +43 -0
- package/contributions/coverage.service.ts +118 -0
- package/contributions/edit-mode-audio.service.ts +23 -0
- package/contributions/edit-mode-networking.service.ts +30 -0
- package/contributions/game-document.service.ts +25 -0
- package/contributions/game-eval.command.ts +210 -0
- package/contributions/game.layout.ts +19 -0
- package/contributions/gameplay.command.ts +255 -0
- package/contributions/generation.service.ts +96 -0
- package/contributions/generations.status.tsx +53 -0
- package/contributions/ingest.service.ts +80 -0
- package/contributions/instances.command.ts +75 -0
- package/contributions/navmesh.menu.ts +39 -0
- package/contributions/navmesh.service.ts +19 -0
- package/contributions/network.utility.tsx +17 -0
- package/contributions/play.command.ts +355 -0
- package/contributions/profiler.action.ts +16 -0
- package/contributions/profiler.menu.ts +22 -0
- package/contributions/profiler.utility.tsx +17 -0
- package/contributions/react/component-board.service.ts +21 -0
- package/contributions/react/design-time-mount.service.ts +60 -0
- package/contributions/react/pasteboard.action.ts +41 -0
- package/contributions/react/react-inspector.service.ts +85 -0
- package/contributions/react/story-documents.service.ts +44 -0
- package/contributions/scene-document.service.ts +32 -0
- package/contributions/state-watch.action.ts +17 -0
- package/contributions/state-watch.menu.ts +23 -0
- package/contributions/state-watch.utility.tsx +20 -0
- package/contributions/team-playtest.service.ts +124 -0
- package/contributions/three/camera-runtime.inspector.tsx +34 -0
- package/contributions/three/component-board.service.ts +24 -0
- package/contributions/three/component-verbs.command.ts +110 -0
- package/contributions/three/component-verbs.service.ts +92 -0
- package/contributions/three/constraints.inspector.tsx +34 -0
- package/contributions/three/model-asset-sections.service.ts +26 -0
- package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
- package/contributions/three/story-documents.service.ts +31 -0
- package/contributions/three/three-authoring.service.ts +66 -0
- package/contributions/transport.header.tsx +19 -0
- package/contributions/xstate-behavior.action.ts +42 -0
- package/contributions/xstate-behavior.document.tsx +73 -0
- package/contributions/xstate-behavior.inspector.tsx +28 -0
- package/contributions/xstate-behavior.menu.ts +23 -0
- package/package.json +144 -0
- package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
- package/src/asset-budget/asset-budget-model.ts +799 -0
- package/src/asset-budget/basis-encoder.ts +165 -0
- package/src/asset-budget/gltf-io.ts +154 -0
- package/src/asset-budget/gltf-optimize.ts +384 -0
- package/src/asset-budget/image-dims.ts +78 -0
- package/src/asset-budget/optimize-apply.ts +221 -0
- package/src/audio/AudioDebuggerPanel.tsx +457 -0
- package/src/audio/audio-debugger-model.ts +87 -0
- package/src/bridge/call.ts +60 -0
- package/src/bridge/dispatch.ts +459 -0
- package/src/bridge/live-frames.ts +25 -0
- package/src/bridge/screenshot.ts +316 -0
- package/src/build/BuildProfilesPanel.tsx +476 -0
- package/src/build/build-session.ts +247 -0
- package/src/build/format-bytes.ts +14 -0
- package/src/command-results.ts +36 -0
- package/src/coverage/live-authoring-surface.ts +30 -0
- package/src/coverage/live-project-verbs.ts +162 -0
- package/src/coverage/native-system-coverage.ts +143 -0
- package/src/coverage/root-coverage.ts +79 -0
- package/src/coverage/session-coverage.ts +193 -0
- package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
- package/src/edit-mode/edit-mode-audio.ts +56 -0
- package/src/edit-mode/edit-mode-networking.ts +112 -0
- package/src/game-document/DevicePresetPicker.tsx +84 -0
- package/src/game-document/GameCaptureFrameButton.tsx +45 -0
- package/src/game-document/GameDocument.tsx +215 -0
- package/src/game-document/GamePanel.tsx +662 -0
- package/src/game-document/InstanceInspectorPicker.tsx +183 -0
- package/src/game-document/crowd-debug.ts +183 -0
- package/src/game-document/device-preview.ts +336 -0
- package/src/game-document/game-view-store.ts +107 -0
- package/src/game-document/physics-debug.ts +187 -0
- package/src/generation/GenerationActivity.tsx +421 -0
- package/src/generation/GenerationGallery.css +231 -0
- package/src/generation/generation-documents.tsx +338 -0
- package/src/generation/generation-jobs.ts +128 -0
- package/src/generation/generation-presentation.ts +257 -0
- package/src/host/adapter-reach.ts +445 -0
- package/src/host/adapter-runtime-bindings.ts +303 -0
- package/src/host/after-paint.ts +112 -0
- package/src/host/api/configurations.ts +66 -0
- package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
- package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
- package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
- package/src/host/authoring/contract-scenes-stories.ts +204 -0
- package/src/host/authoring/creation-site-related.ts +55 -0
- package/src/host/authoring/ephemeral-persistence.ts +29 -0
- package/src/host/authoring/gesture-persist.ts +84 -0
- package/src/host/authoring/ingest-data-writer.ts +232 -0
- package/src/host/authoring/ingest-source-persistence.ts +826 -0
- package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
- package/src/host/authoring/mounted-authoring.ts +41 -0
- package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
- package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
- package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
- package/src/host/authoring/pixi-isolation-assets.ts +25 -0
- package/src/host/authoring/pixi-live-write-target.ts +979 -0
- package/src/host/authoring/pixi-source-identity.ts +141 -0
- package/src/host/authoring/pixi-still-presentation.ts +71 -0
- package/src/host/authoring/pixi-structure-history.ts +237 -0
- package/src/host/authoring/pixi-transform-channels.ts +205 -0
- package/src/host/authoring/selection-remount-handoff.ts +23 -0
- package/src/host/authoring/source-persistence-backend.ts +373 -0
- package/src/host/authoring/source-refresh-revisions.ts +81 -0
- package/src/host/authoring/struct-write-pipe.ts +143 -0
- package/src/host/auto-frame-window.ts +89 -0
- package/src/host/binding-resolver.ts +393 -0
- package/src/host/browser-transpile.ts +631 -0
- package/src/host/canvas-entry-runtime.ts +95 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
- package/src/host/components/HeaderTelemetry.tsx +341 -0
- package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
- package/src/host/components/ResolutionPicker.tsx +89 -0
- package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
- package/src/host/components/frame-debugger-model.ts +579 -0
- package/src/host/components/header-telemetry-model.ts +74 -0
- package/src/host/components/scene-document.tsx +447 -0
- package/src/host/components/utility-view-state.ts +87 -0
- package/src/host/components/world-root-stage-binding.tsx +133 -0
- package/src/host/components/world-root-stage.ts +1046 -0
- package/src/host/coverage/authoring-read-probe.ts +583 -0
- package/src/host/coverage/capability-coverage.ts +1587 -0
- package/src/host/coverage/coverage-accounting.ts +261 -0
- package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
- package/src/host/coverage/project-verb-coverage.ts +148 -0
- package/src/host/coverage/system-adapter-coverage.ts +373 -0
- package/src/host/design-system-stories/StoryLayout.tsx +104 -0
- package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
- package/src/host/document-preview-three.ts +110 -0
- package/src/host/entry-adjudication.ts +89 -0
- package/src/host/game-location-guard.ts +150 -0
- package/src/host/game-module-access.ts +196 -0
- package/src/host/game-realm-page.ts +358 -0
- package/src/host/game-realm-reclaim.ts +55 -0
- package/src/host/game-realm-storage.ts +104 -0
- package/src/host/gameplay-export.ts +288 -0
- package/src/host/gameplay-recording.ts +717 -0
- package/src/host/gated-globals.ts +1511 -0
- package/src/host/history/json-history-resource.ts +254 -0
- package/src/host/ingest/registry.ts +261 -0
- package/src/host/instance-extract-actions.ts +120 -0
- package/src/host/instance-fork-actions.ts +120 -0
- package/src/host/play-control-hook.ts +26 -0
- package/src/host/playwright-shim.ts +479 -0
- package/src/host/projection/dom.ts +295 -0
- package/src/host/projection/pixi.ts +288 -0
- package/src/host/r3f-entry-runtime.ts +77 -0
- package/src/host/react-mount-runtime.ts +162 -0
- package/src/host/realm-services.ts +148 -0
- package/src/host/recording-preview.ts +106 -0
- package/src/host/roots/module-root.ts +203 -0
- package/src/host/roots/react-root.ts +181 -0
- package/src/host/same-realm-loop-gate.ts +544 -0
- package/src/host/scene-view-drawability.ts +69 -0
- package/src/host/sdk/tools.ts +31 -0
- package/src/host/served-bundle-runtime-modules.ts +331 -0
- package/src/host/server-log-bridge.ts +60 -0
- package/src/host/staged-projects.ts +24 -0
- package/src/host/stories/mounted-story-viewport-source.ts +64 -0
- package/src/host/stories/story-arg-descriptors.ts +50 -0
- package/src/host/stories/story-media-presence.ts +152 -0
- package/src/host/surface-content.ts +87 -0
- package/src/host/take-named-export.ts +23 -0
- package/src/host/three-ingest-runtime.ts +76 -0
- package/src/host/types-fastnoise-lite.d.ts +7 -0
- package/src/host/types-mikktspace.d.ts +20 -0
- package/src/host/types-troika-three-text.d.ts +7 -0
- package/src/host/use-active-performance-source.ts +53 -0
- package/src/host/viewport-pose-memory.ts +48 -0
- package/src/host/viewport-root-presentation.ts +40 -0
- package/src/ingest/active-ingest.ts +251 -0
- package/src/ingest/active-scene-navigation.ts +43 -0
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
- package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
- package/src/ingest/capture-wait-report.ts +122 -0
- package/src/ingest/deferred-ingest-play.ts +216 -0
- package/src/ingest/deferred-ingest-session.ts +23 -0
- package/src/ingest/discovery-public-ingest.ts +242 -0
- package/src/ingest/dom-stub-mark.ts +7 -0
- package/src/ingest/entry-load.ts +56 -0
- package/src/ingest/game-contract-realm.ts +34 -0
- package/src/ingest/game-pointer-lock.ts +89 -0
- package/src/ingest/held-scene-repaint.ts +73 -0
- package/src/ingest/host-surface-box.ts +174 -0
- package/src/ingest/ingest-boot-viewport.ts +54 -0
- package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
- package/src/ingest/ingest-canvas-scene.ts +50 -0
- package/src/ingest/ingest-evidence-hook.ts +131 -0
- package/src/ingest/ingest-frame-snapshot.ts +183 -0
- package/src/ingest/ingest-play-commands.ts +134 -0
- package/src/ingest/ingest-play-control.ts +299 -0
- package/src/ingest/ingest-render-debug.ts +320 -0
- package/src/ingest/ingest-siblings.ts +487 -0
- package/src/ingest/ingest-status.ts +62 -0
- package/src/ingest/live-ingest-facet.ts +46 -0
- package/src/ingest/module-mode.ts +229 -0
- package/src/ingest/mount-canvas-ingest-root.ts +899 -0
- package/src/ingest/mount-coverage.ts +296 -0
- package/src/ingest/mount-dom-ingest-root.ts +282 -0
- package/src/ingest/mount-ingest-root.ts +744 -0
- package/src/ingest/mount-three-ingest-root.ts +366 -0
- package/src/ingest/resolve-canvas.ts +47 -0
- package/src/ingest/resolve-three.ts +123 -0
- package/src/ingest/served-bundle.ts +109 -0
- package/src/ingest/served-html-boot.ts +292 -0
- package/src/ingest/surface-canvas.ts +84 -0
- package/src/ingest/surface-dom.ts +84 -0
- package/src/ingest/surface-three.ts +128 -0
- package/src/ingest/types.ts +76 -0
- package/src/ingest/unmount-ingest-root.ts +224 -0
- package/src/navmesh/navmesh-actions.ts +27 -0
- package/src/navmesh/navmesh-handler.ts +237 -0
- package/src/navmesh/navmesh-workflow-store.ts +78 -0
- package/src/network/NetworkInspectorPanel.tsx +644 -0
- package/src/network/network-inspector-model.ts +225 -0
- package/src/play/play-boot-stall.ts +118 -0
- package/src/play/play-log-events.ts +26 -0
- package/src/play/play-mode.ts +2553 -0
- package/src/play/play-recording.ts +335 -0
- package/src/play/react-play-live-authoring.ts +165 -0
- package/src/play-bar/PlayBar.tsx +488 -0
- package/src/play-bar/PlayerCountPicker.tsx +100 -0
- package/src/profiler/FrameDebuggerPanel.tsx +458 -0
- package/src/profiler/PerformancePanel.tsx +1068 -0
- package/src/profiler/ProfilerPanel.tsx +53 -0
- package/src/profiler/frame-debugger-store.ts +97 -0
- package/src/profiler/main-thread-busy.ts +90 -0
- package/src/react/design-time-react-mount.ts +782 -0
- package/src/react/dom-authoring-adapter.ts +764 -0
- package/src/react/pasteboard-materialize.ts +154 -0
- package/src/react/react-inspector-section.tsx +2496 -0
- package/src/react/react-world-authoring-adapter.ts +3795 -0
- package/src/react/story-documents/story-args-section.ts +19 -0
- package/src/react/story-documents/story-documents.tsx +1380 -0
- package/src/react/story-paint-bounds.ts +94 -0
- package/src/react/ui-board-document.tsx +101 -0
- package/src/react/ui-board-title.ts +9 -0
- package/src/react/ui-component-board.ts +72 -0
- package/src/services/audio-pose-guard.ts +81 -0
- package/src/services/game-audio-unlock.ts +48 -0
- package/src/state-watch/StateWatchPanel.tsx +535 -0
- package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
- package/src/three/authoring/constraint-inspector-section.tsx +189 -0
- package/src/three/authoring/design-time-renderer.ts +189 -0
- package/src/three/authoring/model-asset-inspector-section.css +41 -0
- package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
- package/src/three/authoring/oid-source-persistence.ts +557 -0
- package/src/three/authoring/r3f-design-session.ts +1174 -0
- package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
- package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
- package/src/three/authoring/spatial-audio-handles.ts +215 -0
- package/src/three/authoring/spatial-collider-handles.ts +229 -0
- package/src/three/authoring/spatial-joint-handles.ts +114 -0
- package/src/three/authoring/spatial-light-handles.ts +178 -0
- package/src/three/authoring/spatial-lod-handles.ts +93 -0
- package/src/three/authoring/spatial-particle-handles.ts +368 -0
- package/src/three/authoring/three-authoring-adapter.ts +1953 -0
- package/src/three/authoring/three-scene-identity.ts +19 -0
- package/src/three/authoring/three-spatial-handles.ts +161 -0
- package/src/three/authoring/typed-three-inspector.ts +528 -0
- package/src/three/component-verbs/extract-menu.ts +71 -0
- package/src/three/component-verbs/fork-menu.ts +78 -0
- package/src/three/component-verbs/internals-menu.ts +91 -0
- package/src/three/story-documents/three-story-documents.tsx +607 -0
- package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
- package/src/three/three-board/board-framing.ts +412 -0
- package/src/three/three-board/board-layout.ts +401 -0
- package/src/three/three-board/board-scene.ts +901 -0
- package/src/three/three-board/three-component-board.ts +62 -0
- package/src/xstate/XStateBehaviorSection.tsx +130 -0
- package/src/xstate/XStateMachineInspector.tsx +566 -0
- package/src/xstate/character-animation-machine.fixture.ts +74 -0
- package/src/xstate/live-behaviors.ts +106 -0
- package/src/xstate/use-live-actor-state.ts +44 -0
- package/src/xstate/xstate-graph.ts +235 -0
- package/src/xstate/xstate-layout.ts +76 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
import { registerAvailableWorkspaceDocument } from '@volter/editor-core/workspace-available-documents';
|
|
2
|
+
/**
|
|
3
|
+
* The 3D COMPONENTS board — a generated, never-persisted 3D scene laying out
|
|
4
|
+
* every qualifying `three` story's mounted `Object3D` at TRUE SCALE, so a
|
|
5
|
+
* player-sized prop and a building can be compared by eye instead of by two
|
|
6
|
+
* thumbnails cropped to the same box.
|
|
7
|
+
*
|
|
8
|
+
* It is the three-side counterpart of the project's `UI` board
|
|
9
|
+
* (`components/world-documents.tsx`): one pinned center document per project,
|
|
10
|
+
* installed by `installRootDocuments()`, showing everything the project has
|
|
11
|
+
* built and opening ONE of them on demand.
|
|
12
|
+
*
|
|
13
|
+
* It is a workspace document like any other and it reuses the EXISTING Object3D
|
|
14
|
+
* document surface (`components/StageHost.tsx`'s
|
|
15
|
+
* `Object3DDocumentViewport`) — orbit, pick, framing, the document toolbar and
|
|
16
|
+
* the presentation session all come free, and there is deliberately no second
|
|
17
|
+
* viewport class in this repo.
|
|
18
|
+
*
|
|
19
|
+
* ## Opening one story
|
|
20
|
+
*
|
|
21
|
+
* Double-clicking an exhibit opens that story's own turntable document
|
|
22
|
+
* (`THREE_STORY_DOCUMENT_OPENER`), exactly as double-clicking a story
|
|
23
|
+
* node in the hierarchy opens a React story document (`GameHierarchy.tsx` →
|
|
24
|
+
* `openStoryDocumentByStoryId`). Double-click, not single: a single click
|
|
25
|
+
* SELECTS (which is what feeds the Exhibit section below), and opening a tab
|
|
26
|
+
* on every stray click while orbiting would make the board unusable.
|
|
27
|
+
*
|
|
28
|
+
* ## Lifetime — one owner
|
|
29
|
+
*
|
|
30
|
+
* This component owns the built {@link ThreeBoardScene}. Its effect disposes the
|
|
31
|
+
* previous scene whenever the story registry changes and on unmount; the
|
|
32
|
+
* viewport's own `build().dispose` is a no-op precisely so there is exactly ONE
|
|
33
|
+
* teardown path (`ThreeBoardScene.dispose`, whose ownership is stated in
|
|
34
|
+
* `board-scene.ts`). React runs the child viewport's cleanup before this
|
|
35
|
+
* component's, so the graph is out of the render scene before it is freed.
|
|
36
|
+
*
|
|
37
|
+
* ## What is chrome and what is content
|
|
38
|
+
*
|
|
39
|
+
* District, exhibit and ghost-slot LABELS are HTML in the overlay below —
|
|
40
|
+
* projected each frame from the document session's live camera — never
|
|
41
|
+
* in-scene sprites, so the generated content graph contains exhibits and ghost
|
|
42
|
+
* slots and nothing else. The one piece of in-scene furniture (the district
|
|
43
|
+
* floor pads) is built and marked editor-owned by `board-scene.ts`; exhibits
|
|
44
|
+
* themselves stand directly on the floor.
|
|
45
|
+
*
|
|
46
|
+
* ## Ghost slots open SOURCE
|
|
47
|
+
*
|
|
48
|
+
* A ghost slot has no story document to open, so its double-click routes to
|
|
49
|
+
* the component's source through the editor's standing open-source affordance
|
|
50
|
+
* (`instance-source-actions.ts` — copy the `file:line` locator and SAY so;
|
|
51
|
+
* there is no code surface in the workspace, and that module owns the honest
|
|
52
|
+
* degradation). Same sentences, same clipboard path, never a silent no-op.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { object3DDocumentSession } from '@volter/editor-core/authoring/object3d-document-session-registry';
|
|
56
|
+
import { Object3DDocumentViewport } from '@volter/editor-core/components/Object3DDocumentViewport';
|
|
57
|
+
import { STANDARD_COMPONENT_CAMERA_DIRECTION } from '@volter/editor-core/components/standard-viewport-dressing';
|
|
58
|
+
import { threeBoardBuildingCopy } from '@volter/editor-core/components/viewport-surface-status';
|
|
59
|
+
import { openRegisteredDocument } from '@volter/editor-core/document-open-registry';
|
|
60
|
+
import { listProjectComponents } from '@volter/editor-core/editor-api';
|
|
61
|
+
import { editorConsole } from '@volter/editor-core/editor-console';
|
|
62
|
+
import { createHmrRegistrationGroup } from '@volter/editor-core/hmr-registration-group';
|
|
63
|
+
import { CONTRIBUTED_SECTION_ORDER } from '@volter/editor-core/inspection/model';
|
|
64
|
+
import {
|
|
65
|
+
type InspectorSectionProps,
|
|
66
|
+
registerInspectorSections,
|
|
67
|
+
} from '@volter/editor-core/inspector-section-registry';
|
|
68
|
+
import { type ClipboardWriter, runInstanceSourceAction } from '@volter/editor-core/instance-source-actions';
|
|
69
|
+
import { getProjectStoryRegions } from '@volter/editor-core/stories/project-story-regions';
|
|
70
|
+
import { declaredStoryMedium } from '@volter/editor-core/stories/story-declared-medium';
|
|
71
|
+
import { THREE_STORY_DOCUMENT_OPENER } from '@volter/editor-core/stories/story-document-openers';
|
|
72
|
+
import {
|
|
73
|
+
getProjectStoryModules,
|
|
74
|
+
type ProjectStoryModule,
|
|
75
|
+
subscribeProjectStoryModules,
|
|
76
|
+
whenProjectStoriesReady,
|
|
77
|
+
} from '@volter/editor-core/stories/story-registry';
|
|
78
|
+
import { showTransientHint } from '@volter/editor-core/transient-hint';
|
|
79
|
+
import {
|
|
80
|
+
beginViewportBreakdown,
|
|
81
|
+
cancelViewportBreakdown,
|
|
82
|
+
markViewportBoardReady,
|
|
83
|
+
markViewportReactActive,
|
|
84
|
+
markViewportSegment,
|
|
85
|
+
} from '@volter/editor-core/viewport-activation-timings';
|
|
86
|
+
import { THREE_COMPONENTS_DOCUMENT_ID } from '@volter/editor-core/workspace-document-ids';
|
|
87
|
+
import {
|
|
88
|
+
activeWorkspaceDocument,
|
|
89
|
+
type WorkspaceDocumentContentProps,
|
|
90
|
+
} from '@volter/editor-core/workspace-document-registry';
|
|
91
|
+
import type { WorkspaceStateStore } from '@volter/editor-core/workspace-document-restore';
|
|
92
|
+
import { faCircleInfo } from '@fortawesome/free-solid-svg-icons';
|
|
93
|
+
import { themeVars } from '@volter/editor-sdk/widgets';
|
|
94
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
95
|
+
import * as THREE from 'three';
|
|
96
|
+
import {
|
|
97
|
+
BOARD_GHOST_GROUP_KEY,
|
|
98
|
+
type BoardExhibit,
|
|
99
|
+
type BoardGhostSlot,
|
|
100
|
+
boardComponentKeyForObject,
|
|
101
|
+
boardStoryIdForObject,
|
|
102
|
+
buildThreeBoard,
|
|
103
|
+
disposeThreeBoard,
|
|
104
|
+
summarizeBoardSkips,
|
|
105
|
+
type ThreeBoardScene,
|
|
106
|
+
threeBoardCandidateCount,
|
|
107
|
+
} from './board-scene';
|
|
108
|
+
|
|
109
|
+
/** The board is a project-wide singleton: one generated view of one registry.
|
|
110
|
+
* Symmetric with `UI_COMPONENTS_DOCUMENT_ID` — a stable project-level id, so
|
|
111
|
+
* a persisted layout never strands the panel when a root or file is renamed. */
|
|
112
|
+
export { THREE_COMPONENTS_DOCUMENT_ID };
|
|
113
|
+
|
|
114
|
+
/** The tab reads `3D`, the peer of `Scene` and `UI` in the center strip. The
|
|
115
|
+
* document ID keeps its longer historical spelling because persisted layouts
|
|
116
|
+
* address panels by id. */
|
|
117
|
+
const THREE_COMPONENTS_TITLE = '3D';
|
|
118
|
+
|
|
119
|
+
/** The narrow store surface the document needs. It is the seam's own
|
|
120
|
+
* (`workspace-document-restore.ts`), because opening a story document is the
|
|
121
|
+
* board's click-through — one type, not two identical ones. */
|
|
122
|
+
export type ThreeBoardStore = WorkspaceStateStore;
|
|
123
|
+
|
|
124
|
+
// --- Live scene state, shared with the Inspector section -------------------
|
|
125
|
+
|
|
126
|
+
let _scene: ThreeBoardScene | null = null;
|
|
127
|
+
let _version = 0;
|
|
128
|
+
const _listeners = new Set<() => void>();
|
|
129
|
+
|
|
130
|
+
function notifyChanged(): void {
|
|
131
|
+
_version++;
|
|
132
|
+
for (const listener of _listeners) listener();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function subscribeThreeBoard(listener: () => void): () => void {
|
|
136
|
+
_listeners.add(listener);
|
|
137
|
+
return () => {
|
|
138
|
+
_listeners.delete(listener);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function threeBoardVersion(): number {
|
|
143
|
+
return _version;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Document ---------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
type BuildState =
|
|
149
|
+
| { readonly status: 'idle' }
|
|
150
|
+
| { readonly status: 'building' }
|
|
151
|
+
| { readonly status: 'ready'; readonly scene: ThreeBoardScene }
|
|
152
|
+
| { readonly status: 'error'; readonly message: string };
|
|
153
|
+
|
|
154
|
+
interface ProjectedLabel {
|
|
155
|
+
readonly key: string;
|
|
156
|
+
readonly text: string;
|
|
157
|
+
readonly detail: string;
|
|
158
|
+
readonly district: boolean;
|
|
159
|
+
/** Ghost-slot styling: dashed border, muted text — the "No story yet" look. */
|
|
160
|
+
readonly ghost: boolean;
|
|
161
|
+
readonly x: number;
|
|
162
|
+
readonly y: number;
|
|
163
|
+
readonly depth: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Metres, rendered the way a scale readout should read (`0.4 m`, `12 m`). */
|
|
167
|
+
function formatMetres(value: number): string {
|
|
168
|
+
if (!Number.isFinite(value)) return '—';
|
|
169
|
+
if (value >= 10) return `${Math.round(value)} m`;
|
|
170
|
+
if (value >= 1) return `${value.toFixed(1)} m`;
|
|
171
|
+
return `${value.toFixed(2)} m`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The board's click-through: resolve a picked object to the exhibit it belongs
|
|
176
|
+
* to and open that story's own document. Exported for the headless test —
|
|
177
|
+
* everything it needs is an object and the built scene, so it never touches a
|
|
178
|
+
* renderer.
|
|
179
|
+
*
|
|
180
|
+
* Returns the opened document's id, or `null` when the pick landed on chrome,
|
|
181
|
+
* on nothing, or on an exhibit the current scene no longer knows.
|
|
182
|
+
*/
|
|
183
|
+
export function openExhibitStoryDocument(
|
|
184
|
+
store: ThreeBoardStore,
|
|
185
|
+
scene: ThreeBoardScene | null,
|
|
186
|
+
object: THREE.Object3D | null,
|
|
187
|
+
): string | null {
|
|
188
|
+
const exhibit = findExhibitIn(scene, object);
|
|
189
|
+
if (!exhibit) return null;
|
|
190
|
+
return openRegisteredDocument(THREE_STORY_DOCUMENT_OPENER, store, {
|
|
191
|
+
modulePath: exhibit.modulePath,
|
|
192
|
+
storyName: exhibit.storyName,
|
|
193
|
+
title: exhibit.label,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The ghost slot's click-through: a storyless component has no story document,
|
|
199
|
+
* so its double-click routes to the component's SOURCE through the editor's
|
|
200
|
+
* standing open-source affordance (`runInstanceSourceAction` — the same
|
|
201
|
+
* sentences and clipboard path the hierarchy's "Open Component Source" uses;
|
|
202
|
+
* the locator is the component index's own `path:line`). Returns the pending
|
|
203
|
+
* sentence to show, or `null` when the pick was not a ghost slot. Exported for
|
|
204
|
+
* the headless test, which injects its own clipboard writer.
|
|
205
|
+
*/
|
|
206
|
+
export function openGhostComponentSource(
|
|
207
|
+
scene: ThreeBoardScene | null,
|
|
208
|
+
object: THREE.Object3D | null,
|
|
209
|
+
writeText?: ClipboardWriter,
|
|
210
|
+
): Promise<string> | null {
|
|
211
|
+
const ghost = findGhostIn(scene, object);
|
|
212
|
+
if (!ghost) return null;
|
|
213
|
+
const locator = {
|
|
214
|
+
definitionLocation: () => ({ file: ghost.path, line: ghost.line }),
|
|
215
|
+
};
|
|
216
|
+
return writeText
|
|
217
|
+
? runInstanceSourceAction(locator, ghost.key, 'definition', writeText)
|
|
218
|
+
: runInstanceSourceAction(locator, ghost.key, 'definition');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Keep the board subscribed only to modules that can contribute a 3D story.
|
|
222
|
+
* The registry publishes declared modules first and its DOM/Dev tail at idle;
|
|
223
|
+
* retaining equal module objects prevents that unrelated tail from rebuilding
|
|
224
|
+
* every already-mounted exhibit. A real HMR refresh creates new module result
|
|
225
|
+
* objects and therefore still rebuilds. */
|
|
226
|
+
function useThreeBoardModules(
|
|
227
|
+
modules: readonly ProjectStoryModule[],
|
|
228
|
+
): readonly ProjectStoryModule[] {
|
|
229
|
+
const regions = getProjectStoryRegions();
|
|
230
|
+
const selected = modules.filter(
|
|
231
|
+
(module_) =>
|
|
232
|
+
module_.ok &&
|
|
233
|
+
declaredStoryMedium({ modulePath: module_.modulePath, regions }).medium === 'three',
|
|
234
|
+
);
|
|
235
|
+
const retained = useRef<readonly ProjectStoryModule[]>([]);
|
|
236
|
+
const previous = retained.current;
|
|
237
|
+
if (
|
|
238
|
+
previous.length !== selected.length ||
|
|
239
|
+
previous.some((module_, index) => module_ !== selected[index])
|
|
240
|
+
) {
|
|
241
|
+
retained.current = selected;
|
|
242
|
+
}
|
|
243
|
+
return retained.current;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function ThreeBoardContent({
|
|
247
|
+
documentId,
|
|
248
|
+
active,
|
|
249
|
+
store,
|
|
250
|
+
}: WorkspaceDocumentContentProps & { readonly store: ThreeBoardStore }) {
|
|
251
|
+
const modules = useSyncExternalStore(subscribeProjectStoryModules, getProjectStoryModules);
|
|
252
|
+
const boardModules = useThreeBoardModules(modules);
|
|
253
|
+
const [state, setState] = useState<BuildState>({ status: 'idle' });
|
|
254
|
+
if (active) markViewportReactActive(documentId);
|
|
255
|
+
|
|
256
|
+
// The board is a STANDING tab: its panel is present in every project from
|
|
257
|
+
// boot, and the layout host mounts every panel's content
|
|
258
|
+
// whether or not it is the active tab. So mounting is not the trigger —
|
|
259
|
+
// FIRST ACTIVATION is. Building the board mounts every story off-screen, one
|
|
260
|
+
// at a time, paying each story's loader latency (`board-scene.ts`), and a tab
|
|
261
|
+
// nobody has looked at must cost nothing at boot.
|
|
262
|
+
//
|
|
263
|
+
// It latches: once shown, the board stays live across tab switches, so
|
|
264
|
+
// flipping away does not throw the graph away and rebuild it on return.
|
|
265
|
+
const [revealed, setRevealed] = useState(active);
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (active) setRevealed(true);
|
|
268
|
+
}, [active]);
|
|
269
|
+
|
|
270
|
+
// Rebuild on reveal and when a THREE module changes; the previous scene's ONE
|
|
271
|
+
// teardown runs here and nowhere else. A later idle publication of DOM/Dev
|
|
272
|
+
// modules must not throw away and remount an already-usable 3D board.
|
|
273
|
+
//
|
|
274
|
+
// Both halves are SEQUENCED by `board-scene.ts`'s mount turn, which is what
|
|
275
|
+
// makes "one mount at a time" true across builds and not merely within one:
|
|
276
|
+
// a registry change mid-build aborts the in-flight build (it stops mounting
|
|
277
|
+
// and frees what it has, inside its own turn) and the replacement build
|
|
278
|
+
// queues behind it, so two builds can never interleave their mounts on the
|
|
279
|
+
// process-global loader state a story's `.load()` may touch. Teardown of a
|
|
280
|
+
// READY scene queues the same way, so its stories' effect cleanups cannot
|
|
281
|
+
// land mid-way through the next build.
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (!revealed) return;
|
|
284
|
+
const controller = new AbortController();
|
|
285
|
+
let built: ThreeBoardScene | null = null;
|
|
286
|
+
setState({ status: 'building' });
|
|
287
|
+
beginViewportBreakdown(documentId, { modulesAlreadyLoaded: boardModules.length > 0 });
|
|
288
|
+
// The component index rides along so storyless `three` components get
|
|
289
|
+
// their ghost slots — the Content gallery's own discovery
|
|
290
|
+
// (`listProjectComponents`), never a second scan. An index failure
|
|
291
|
+
// degrades to "no ghosts", not to a failed board.
|
|
292
|
+
const indexStarted = Date.now();
|
|
293
|
+
void listProjectComponents()
|
|
294
|
+
.then((listing) => {
|
|
295
|
+
const wall = Date.now() - indexStarted;
|
|
296
|
+
markViewportSegment('component-index', wall, { busyMs: 0, waitMs: wall });
|
|
297
|
+
// Still "no ghosts, not a failed board" — but SAID OUT LOUD. A failed
|
|
298
|
+
// index and a project with no components produced the same silent
|
|
299
|
+
// empty board, so a missing ghost slot looked like a design decision.
|
|
300
|
+
if (!listing.ok) {
|
|
301
|
+
editorConsole.warn(
|
|
302
|
+
`Component index unavailable, so this board shows no ghost slots: ${listing.reason}`,
|
|
303
|
+
'three-board',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return buildThreeBoard(boardModules, listing.ok ? listing.entries : [], controller.signal);
|
|
307
|
+
})
|
|
308
|
+
.then(
|
|
309
|
+
(scene) => {
|
|
310
|
+
// A build can RESOLVE in the same microtask turn that unmount aborts
|
|
311
|
+
// it: cleanup then saw `built === null` and disposed nothing, so a
|
|
312
|
+
// success landing after abort must dispose its own scene or it leaks.
|
|
313
|
+
if (controller.signal.aborted) {
|
|
314
|
+
cancelViewportBreakdown(documentId);
|
|
315
|
+
void disposeThreeBoard(scene);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
built = scene;
|
|
319
|
+
_scene = scene;
|
|
320
|
+
notifyChanged();
|
|
321
|
+
markViewportBoardReady();
|
|
322
|
+
setState({ status: 'ready', scene });
|
|
323
|
+
},
|
|
324
|
+
(error: unknown) => {
|
|
325
|
+
// A superseded build already freed itself in its own turn; the only
|
|
326
|
+
// reportable failures are the ones this build is still current for.
|
|
327
|
+
if (controller.signal.aborted) {
|
|
328
|
+
cancelViewportBreakdown(documentId);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
setState({
|
|
332
|
+
status: 'error',
|
|
333
|
+
message: error instanceof Error ? error.message : String(error),
|
|
334
|
+
});
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
return () => {
|
|
338
|
+
controller.abort();
|
|
339
|
+
if (built) {
|
|
340
|
+
if (_scene === built) _scene = null;
|
|
341
|
+
void disposeThreeBoard(built);
|
|
342
|
+
notifyChanged();
|
|
343
|
+
} else {
|
|
344
|
+
cancelViewportBreakdown(documentId);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}, [boardModules, revealed, documentId]);
|
|
348
|
+
|
|
349
|
+
const scene = state.status === 'ready' ? state.scene : null;
|
|
350
|
+
const build = useCallback(() => {
|
|
351
|
+
if (!scene) throw new Error('The 3D board is still building.');
|
|
352
|
+
return {
|
|
353
|
+
root: scene.root,
|
|
354
|
+
// Deliberately empty: this component owns the scene's lifetime (see the
|
|
355
|
+
// module doc). Two disposers for one graph is how a double-free starts.
|
|
356
|
+
dispose(): void {},
|
|
357
|
+
};
|
|
358
|
+
}, [scene]);
|
|
359
|
+
|
|
360
|
+
// The viewport hands back the picked node's own `Object3D`; the story (or
|
|
361
|
+
// ghost-slot component) identity is tagged on the wrapper above it
|
|
362
|
+
// (`board-scene.ts`). Exhibits open their story document; ghost slots route
|
|
363
|
+
// to the component's source and SAY what happened.
|
|
364
|
+
const openPicked = useCallback(
|
|
365
|
+
(object: THREE.Object3D) => {
|
|
366
|
+
if (openExhibitStoryDocument(store, scene, object) !== null) return;
|
|
367
|
+
const pending = openGhostComponentSource(scene, object);
|
|
368
|
+
if (pending) void pending.then(showTransientHint);
|
|
369
|
+
},
|
|
370
|
+
[scene, store],
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<div style={{ position: 'relative', flex: 1, minHeight: 0, pointerEvents: 'auto' }}>
|
|
375
|
+
{scene ? (
|
|
376
|
+
<>
|
|
377
|
+
<Object3DDocumentViewport
|
|
378
|
+
documentId={documentId}
|
|
379
|
+
sourcePath="Generated from this project’s stories"
|
|
380
|
+
displayName={THREE_COMPONENTS_TITLE}
|
|
381
|
+
build={build}
|
|
382
|
+
active={active}
|
|
383
|
+
onOpenNode={openPicked}
|
|
384
|
+
// Open on one readable exhibit, like a component editor; a shared
|
|
385
|
+
// true-scale overview necessarily makes a ship tiny beside a 30m
|
|
386
|
+
// lighthouse. Frame Selection follows the picked exhibit/node;
|
|
387
|
+
// clearing selection and framing restores the presence-box union.
|
|
388
|
+
// Helpers remain authored and visible at true scale in either view.
|
|
389
|
+
cameraDirection={STANDARD_COMPONENT_CAMERA_DIRECTION}
|
|
390
|
+
frameBounds={scene.frameBounds}
|
|
391
|
+
openingFrameBounds={scene.openingFrameBounds}
|
|
392
|
+
// Standard dressing plus the ground grid: exhibits stand on y=0
|
|
393
|
+
// by design, so the board earns the one opt-IN the contract has.
|
|
394
|
+
dressing={{ grid: true }}
|
|
395
|
+
/>
|
|
396
|
+
<ThreeBoardLabels documentId={documentId} scene={scene} active={active} />
|
|
397
|
+
<ThreeBoardStatus scene={scene} />
|
|
398
|
+
</>
|
|
399
|
+
) : (
|
|
400
|
+
<div
|
|
401
|
+
data-testid="three-board-status"
|
|
402
|
+
style={{ padding: 16, fontSize: 12, color: themeVars.content.muted }}
|
|
403
|
+
>
|
|
404
|
+
{state.status === 'error' && state.message}
|
|
405
|
+
{state.status === 'building' &&
|
|
406
|
+
threeBoardBuildingCopy(threeBoardCandidateCount(boardModules))}
|
|
407
|
+
{state.status === 'idle' && 'Open this tab to lay out every story at true scale.'}
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Counts (exhibits, districts, storyless components) + the honest list of
|
|
415
|
+
* stories that produced no Object3D. Ghost slots and skips are DIFFERENT
|
|
416
|
+
* facts — a ghost is a component with no story, a skip is a story that
|
|
417
|
+
* mounted nothing 3D — and the footer states both, hiding neither. */
|
|
418
|
+
function ThreeBoardStatus({ scene }: { readonly scene: ThreeBoardScene }) {
|
|
419
|
+
const [open, setOpen] = useState(false);
|
|
420
|
+
const skips = summarizeBoardSkips(scene.skipped);
|
|
421
|
+
const realDistricts = scene.districts.filter(
|
|
422
|
+
(district) => district.groupKey !== BOARD_GHOST_GROUP_KEY,
|
|
423
|
+
).length;
|
|
424
|
+
return (
|
|
425
|
+
<div
|
|
426
|
+
data-testid="three-board-status"
|
|
427
|
+
style={{
|
|
428
|
+
position: 'absolute',
|
|
429
|
+
left: 8,
|
|
430
|
+
bottom: 8,
|
|
431
|
+
maxWidth: 420,
|
|
432
|
+
padding: '4px 8px',
|
|
433
|
+
borderRadius: themeVars.shape.small,
|
|
434
|
+
background: themeVars.surface.raised,
|
|
435
|
+
border: `1px solid ${themeVars.boundary.default}`,
|
|
436
|
+
fontSize: 10,
|
|
437
|
+
color: themeVars.content.muted,
|
|
438
|
+
pointerEvents: 'auto',
|
|
439
|
+
}}
|
|
440
|
+
>
|
|
441
|
+
{scene.exhibits.length} exhibit{scene.exhibits.length === 1 ? '' : 's'} · {realDistricts}{' '}
|
|
442
|
+
district{realDistricts === 1 ? '' : 's'}
|
|
443
|
+
{scene.helperVolumes.length > 0 && (
|
|
444
|
+
<>
|
|
445
|
+
{' · '}
|
|
446
|
+
{scene.helperVolumes.length} helper volume
|
|
447
|
+
{scene.helperVolumes.length === 1 ? '' : 's'} at true scale, outside the default frame
|
|
448
|
+
</>
|
|
449
|
+
)}
|
|
450
|
+
{scene.ghostSlots.length > 0 && (
|
|
451
|
+
<>
|
|
452
|
+
{' · '}
|
|
453
|
+
{scene.ghostSlots.length} component{scene.ghostSlots.length === 1 ? '' : 's'} without
|
|
454
|
+
stories
|
|
455
|
+
</>
|
|
456
|
+
)}
|
|
457
|
+
{scene.skipped.length > 0 && (
|
|
458
|
+
<>
|
|
459
|
+
{' · '}
|
|
460
|
+
{/* Inline disclosure toggle — the role-annotated span is the
|
|
461
|
+
guard-sanctioned shape for text-link chrome (a raw native
|
|
462
|
+
button trips the product-chrome scan). */}
|
|
463
|
+
<span
|
|
464
|
+
role="button"
|
|
465
|
+
tabIndex={0}
|
|
466
|
+
aria-expanded={open}
|
|
467
|
+
onClick={() => setOpen((value) => !value)}
|
|
468
|
+
onKeyDown={(event) => {
|
|
469
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
470
|
+
event.preventDefault();
|
|
471
|
+
setOpen((value) => !value);
|
|
472
|
+
}
|
|
473
|
+
}}
|
|
474
|
+
style={{
|
|
475
|
+
cursor: 'pointer',
|
|
476
|
+
color: themeVars.accent.default,
|
|
477
|
+
}}
|
|
478
|
+
>
|
|
479
|
+
{skips.stories} {skips.stories === 1 ? 'story' : 'stories'} in {skips.modules}{' '}
|
|
480
|
+
{skips.modules === 1 ? 'module' : 'modules'} not on this board
|
|
481
|
+
</span>
|
|
482
|
+
</>
|
|
483
|
+
)}
|
|
484
|
+
{open && (
|
|
485
|
+
<div style={{ marginTop: 4, maxHeight: 160, overflow: 'auto' }}>
|
|
486
|
+
{scene.skipped.map((entry) => (
|
|
487
|
+
<div key={entry.id} style={{ padding: '1px 0' }}>
|
|
488
|
+
<code style={{ color: themeVars.content.dim }}>
|
|
489
|
+
{entry.modulePath}#{entry.storyName}
|
|
490
|
+
</code>{' '}
|
|
491
|
+
— {entry.reason}
|
|
492
|
+
</div>
|
|
493
|
+
))}
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* District + exhibit labels, projected from the live document camera into HTML.
|
|
502
|
+
* Chrome, not content: the generated graph never learns these exist.
|
|
503
|
+
*/
|
|
504
|
+
function ThreeBoardLabels({
|
|
505
|
+
documentId,
|
|
506
|
+
scene,
|
|
507
|
+
active,
|
|
508
|
+
}: {
|
|
509
|
+
readonly documentId: string;
|
|
510
|
+
readonly scene: ThreeBoardScene;
|
|
511
|
+
readonly active: boolean;
|
|
512
|
+
}) {
|
|
513
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
514
|
+
const [labels, setLabels] = useState<readonly ProjectedLabel[]>([]);
|
|
515
|
+
|
|
516
|
+
useEffect(() => {
|
|
517
|
+
if (!active) return;
|
|
518
|
+
let frame = 0;
|
|
519
|
+
let cancelled = false;
|
|
520
|
+
const anchors = labelAnchors(scene);
|
|
521
|
+
const tick = () => {
|
|
522
|
+
if (cancelled) return;
|
|
523
|
+
frame = requestAnimationFrame(tick);
|
|
524
|
+
const host = hostRef.current;
|
|
525
|
+
const session = object3DDocumentSession(documentId);
|
|
526
|
+
if (!host || !session) return;
|
|
527
|
+
if (host.clientWidth === 0 || host.clientHeight === 0) return;
|
|
528
|
+
setLabels(projectLabels(anchors, session.camera(), host.clientWidth, host.clientHeight));
|
|
529
|
+
};
|
|
530
|
+
frame = requestAnimationFrame(tick);
|
|
531
|
+
return () => {
|
|
532
|
+
cancelled = true;
|
|
533
|
+
cancelAnimationFrame(frame);
|
|
534
|
+
};
|
|
535
|
+
}, [active, documentId, scene]);
|
|
536
|
+
|
|
537
|
+
return (
|
|
538
|
+
<div
|
|
539
|
+
ref={hostRef}
|
|
540
|
+
data-testid="three-board-labels"
|
|
541
|
+
// Matches the viewport host's own `inset: -12`, so projected NDC maps
|
|
542
|
+
// onto the same rectangle the canvas covers.
|
|
543
|
+
style={{ position: 'absolute', inset: -12, pointerEvents: 'none', overflow: 'hidden' }}
|
|
544
|
+
>
|
|
545
|
+
{labels.map((label) => (
|
|
546
|
+
<div
|
|
547
|
+
key={label.key}
|
|
548
|
+
style={{
|
|
549
|
+
position: 'absolute',
|
|
550
|
+
left: label.x,
|
|
551
|
+
top: label.y,
|
|
552
|
+
transform: 'translate(-50%, -100%)',
|
|
553
|
+
whiteSpace: 'nowrap',
|
|
554
|
+
padding: label.district ? '2px 7px' : '1px 5px',
|
|
555
|
+
borderRadius: themeVars.shape.small,
|
|
556
|
+
background: themeVars.surface.raised,
|
|
557
|
+
border: `1px ${label.ghost ? 'dashed' : 'solid'} ${
|
|
558
|
+
label.district && !label.ghost ? themeVars.accent.default : themeVars.boundary.default
|
|
559
|
+
}`,
|
|
560
|
+
color:
|
|
561
|
+
label.district && !label.ghost ? themeVars.content.primary : themeVars.content.muted,
|
|
562
|
+
fontSize: label.district ? 12 : 10,
|
|
563
|
+
fontWeight: label.district ? 600 : 400,
|
|
564
|
+
opacity: label.ghost ? 0.9 : 1,
|
|
565
|
+
}}
|
|
566
|
+
>
|
|
567
|
+
{label.text}
|
|
568
|
+
<span style={{ marginLeft: 6, color: themeVars.content.dim, fontWeight: 400 }}>
|
|
569
|
+
{label.detail}
|
|
570
|
+
</span>
|
|
571
|
+
</div>
|
|
572
|
+
))}
|
|
573
|
+
</div>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** One reused vector so a 200-exhibit board allocates nothing per frame. */
|
|
578
|
+
const scratch = new THREE.Vector3();
|
|
579
|
+
|
|
580
|
+
interface LabelAnchor {
|
|
581
|
+
readonly key: string;
|
|
582
|
+
readonly text: string;
|
|
583
|
+
readonly detail: string;
|
|
584
|
+
readonly district: boolean;
|
|
585
|
+
readonly ghost: boolean;
|
|
586
|
+
readonly point: readonly [number, number, number];
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** A placard's world anchor: the FRONT EDGE of the slot's own patch of floor,
|
|
590
|
+
* on the ground — where a floor placard sits, so a label reads as belonging to
|
|
591
|
+
* the thing standing behind it and never floats mid-air over one. */
|
|
592
|
+
function placardPoint(placement: {
|
|
593
|
+
readonly anchor: readonly [number, number, number];
|
|
594
|
+
readonly footprint: readonly [number, number];
|
|
595
|
+
}): readonly [number, number, number] {
|
|
596
|
+
return [placement.anchor[0], 0, placement.anchor[2] - placement.footprint[1] / 2];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** What the overlay prints and where each label is pinned in world space:
|
|
600
|
+
* a district's title on its pad's FRONT edge on the ground, an exhibit's (or
|
|
601
|
+
* ghost slot's) placard on its own slot's front edge, also on the ground. The
|
|
602
|
+
* exhibit detail is the TRUE height in metres — nothing on this board resizes
|
|
603
|
+
* or lifts an exhibit, and the placard is what says so. */
|
|
604
|
+
function labelAnchors(scene: ThreeBoardScene): LabelAnchor[] {
|
|
605
|
+
return [
|
|
606
|
+
...scene.districts.map((district) => {
|
|
607
|
+
const ghost = district.groupKey === BOARD_GHOST_GROUP_KEY;
|
|
608
|
+
const count = district.placement.items.length;
|
|
609
|
+
return {
|
|
610
|
+
key: `district:${district.groupKey}`,
|
|
611
|
+
text: district.label,
|
|
612
|
+
detail: ghost
|
|
613
|
+
? `${count} component${count === 1 ? '' : 's'}`
|
|
614
|
+
: `${count} exhibit${count === 1 ? '' : 's'}`,
|
|
615
|
+
district: true,
|
|
616
|
+
ghost,
|
|
617
|
+
point: district.placement.anchor,
|
|
618
|
+
};
|
|
619
|
+
}),
|
|
620
|
+
...scene.exhibits.map((exhibit) => ({
|
|
621
|
+
key: `exhibit:${exhibit.id}`,
|
|
622
|
+
text: exhibit.label,
|
|
623
|
+
detail: formatMetres(exhibit.placement.size[1]),
|
|
624
|
+
district: false,
|
|
625
|
+
ghost: false,
|
|
626
|
+
point: placardPoint(exhibit.placement),
|
|
627
|
+
})),
|
|
628
|
+
...scene.ghostSlots.map((slot) => ({
|
|
629
|
+
key: `ghost:${slot.key}`,
|
|
630
|
+
text: slot.name,
|
|
631
|
+
detail: 'No story yet',
|
|
632
|
+
district: false,
|
|
633
|
+
ghost: true,
|
|
634
|
+
point: placardPoint(slot.placement),
|
|
635
|
+
})),
|
|
636
|
+
];
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Project every anchor to pixels, dropping what is off the far plane and
|
|
640
|
+
* sorting far → near so nearer labels paint over further ones. */
|
|
641
|
+
function projectLabels(
|
|
642
|
+
anchors: readonly LabelAnchor[],
|
|
643
|
+
camera: THREE.Camera,
|
|
644
|
+
width: number,
|
|
645
|
+
height: number,
|
|
646
|
+
): ProjectedLabel[] {
|
|
647
|
+
camera.updateMatrixWorld();
|
|
648
|
+
const projected: ProjectedLabel[] = [];
|
|
649
|
+
for (const anchor of anchors) {
|
|
650
|
+
scratch.set(anchor.point[0], anchor.point[1], anchor.point[2]).project(camera);
|
|
651
|
+
// `project` of a point behind the camera wraps around to a plausible
|
|
652
|
+
// on-screen NDC; the far-plane test is what rejects it.
|
|
653
|
+
if (scratch.z > 1) continue;
|
|
654
|
+
projected.push({
|
|
655
|
+
key: anchor.key,
|
|
656
|
+
text: anchor.text,
|
|
657
|
+
detail: anchor.detail,
|
|
658
|
+
district: anchor.district,
|
|
659
|
+
ghost: anchor.ghost,
|
|
660
|
+
x: (scratch.x * 0.5 + 0.5) * width,
|
|
661
|
+
y: (-scratch.y * 0.5 + 0.5) * height,
|
|
662
|
+
depth: scratch.z,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return cullOverlappingLabels(projected).sort((a, b) => b.depth - a.depth);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Approximate on-screen box of one label (the overlay's own font metrics:
|
|
669
|
+
* ~0.62 em average advance for the UI face, plus padding and the gap the
|
|
670
|
+
* detail suffix adds). Estimation is fine — the culling below only needs to
|
|
671
|
+
* agree with the DOM to within a few pixels. */
|
|
672
|
+
function labelRect(label: ProjectedLabel): readonly [number, number, number, number] {
|
|
673
|
+
const fontSize = label.district ? 12 : 10;
|
|
674
|
+
const textWidth = (label.text.length + label.detail.length) * fontSize * 0.62 + 24;
|
|
675
|
+
const boxHeight = fontSize + 8;
|
|
676
|
+
return [label.x - textWidth / 2, label.y - boxHeight, label.x + textWidth / 2, label.y];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* LABEL SANITY: at a wide framing, a dense row of placards projects into an
|
|
681
|
+
* unreadable pile. Nearer (and district) labels win; a label whose box would
|
|
682
|
+
* overlap an already-kept one is dropped for this frame — it reappears the
|
|
683
|
+
* moment the camera gives it room. Nothing is abbreviated or nudged: a placard
|
|
684
|
+
* either shows whole and anchored, or not at all.
|
|
685
|
+
*/
|
|
686
|
+
function cullOverlappingLabels(projected: readonly ProjectedLabel[]): ProjectedLabel[] {
|
|
687
|
+
const byPriority = [...projected].sort(
|
|
688
|
+
(a, b) => (b.district ? 1 : 0) - (a.district ? 1 : 0) || a.depth - b.depth,
|
|
689
|
+
);
|
|
690
|
+
const kept: { label: ProjectedLabel; rect: readonly [number, number, number, number] }[] = [];
|
|
691
|
+
for (const label of byPriority) {
|
|
692
|
+
const rect = labelRect(label);
|
|
693
|
+
const collides = kept.some(
|
|
694
|
+
(entry) =>
|
|
695
|
+
rect[0] < entry.rect[2] &&
|
|
696
|
+
rect[2] > entry.rect[0] &&
|
|
697
|
+
rect[1] < entry.rect[3] &&
|
|
698
|
+
rect[3] > entry.rect[1],
|
|
699
|
+
);
|
|
700
|
+
if (collides) continue;
|
|
701
|
+
kept.push({ label, rect });
|
|
702
|
+
}
|
|
703
|
+
return kept.map((entry) => entry.label);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// --- Inspector: the picked exhibit's story identity -------------------------
|
|
707
|
+
|
|
708
|
+
function findExhibitIn(
|
|
709
|
+
scene: ThreeBoardScene | null,
|
|
710
|
+
object: THREE.Object3D | null,
|
|
711
|
+
): BoardExhibit | null {
|
|
712
|
+
if (!scene) return null;
|
|
713
|
+
const storyId = boardStoryIdForObject(object);
|
|
714
|
+
if (!storyId) return null;
|
|
715
|
+
return scene.exhibits.find((exhibit) => exhibit.id === storyId) ?? null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function findGhostIn(
|
|
719
|
+
scene: ThreeBoardScene | null,
|
|
720
|
+
object: THREE.Object3D | null,
|
|
721
|
+
): BoardGhostSlot | null {
|
|
722
|
+
if (!scene) return null;
|
|
723
|
+
const componentKey = boardComponentKeyForObject(object);
|
|
724
|
+
if (!componentKey) return null;
|
|
725
|
+
return scene.ghostSlots.find((slot) => slot.key === componentKey) ?? null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Registered section: story identity for whatever is picked on the board.
|
|
730
|
+
*
|
|
731
|
+
* The subject is resolved from the shell's `nodeId` AND the adapter's own live
|
|
732
|
+
* selection, because a pick in this document surface reaches the adapter's
|
|
733
|
+
* selection first — reading only the shell's node id showed "pick an exhibit"
|
|
734
|
+
* while the hierarchy already had the picked mesh highlighted.
|
|
735
|
+
*/
|
|
736
|
+
export function BoardExhibitSection({ adapter, nodeId }: InspectorSectionProps) {
|
|
737
|
+
useSyncExternalStore(subscribeThreeBoard, threeBoardVersion);
|
|
738
|
+
const candidateIds = [...(nodeId ? [nodeId] : []), ...(adapter.selection?.get() ?? [])];
|
|
739
|
+
const exhibit = candidateIds.reduce<BoardExhibit | null>(
|
|
740
|
+
(found, id) => found ?? findExhibitIn(_scene, adapter.hierarchy.object3D?.(id) ?? null),
|
|
741
|
+
null,
|
|
742
|
+
);
|
|
743
|
+
return (
|
|
744
|
+
<div
|
|
745
|
+
data-testid="inspector-exhibit"
|
|
746
|
+
style={{
|
|
747
|
+
pointerEvents: 'auto',
|
|
748
|
+
padding: 8,
|
|
749
|
+
borderBottom: `1px solid ${themeVars.boundary.default}`,
|
|
750
|
+
}}
|
|
751
|
+
>
|
|
752
|
+
{/* No mini-label of its own: the projection heads this section with its
|
|
753
|
+
title and icon (`components/InspectionProjection.tsx`). */}
|
|
754
|
+
{!exhibit ? (
|
|
755
|
+
<div style={{ fontSize: 11, color: themeVars.content.dim }}>
|
|
756
|
+
Pick an exhibit to read its story.
|
|
757
|
+
</div>
|
|
758
|
+
) : (
|
|
759
|
+
<div style={{ display: 'grid', gap: 3, fontSize: 11 }}>
|
|
760
|
+
<div style={{ color: themeVars.content.primary, fontWeight: 600 }}>{exhibit.label}</div>
|
|
761
|
+
<IdentityRow label="Story" value={exhibit.storyName} />
|
|
762
|
+
<IdentityRow label="Group" value={exhibit.groupPath} />
|
|
763
|
+
<IdentityRow label="Module" value={exhibit.modulePath} />
|
|
764
|
+
<IdentityRow
|
|
765
|
+
label="Size"
|
|
766
|
+
value={exhibit.placement.size.map((value) => formatMetres(value)).join(' × ')}
|
|
767
|
+
/>
|
|
768
|
+
</div>
|
|
769
|
+
)}
|
|
770
|
+
</div>
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function IdentityRow({ label, value }: { readonly label: string; readonly value: string }) {
|
|
775
|
+
return (
|
|
776
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
777
|
+
<span style={{ width: 52, flexShrink: 0, color: themeVars.content.muted }}>{label}</span>
|
|
778
|
+
<code
|
|
779
|
+
style={{ color: themeVars.content.primary, overflow: 'hidden', textOverflow: 'ellipsis' }}
|
|
780
|
+
title={value}
|
|
781
|
+
>
|
|
782
|
+
{value}
|
|
783
|
+
</code>
|
|
784
|
+
</div>
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const registrationGroup = createHmrRegistrationGroup(import.meta.hot, 'three-board-contributions');
|
|
789
|
+
|
|
790
|
+
/** Idempotent registration of the board's Inspector section. */
|
|
791
|
+
export function ensureThreeBoardContributionsRegistered(): void {
|
|
792
|
+
registrationGroup.ensure((track) => {
|
|
793
|
+
track(
|
|
794
|
+
registerInspectorSections({
|
|
795
|
+
match: () => activeWorkspaceDocument()?.descriptor.id === THREE_COMPONENTS_DOCUMENT_ID,
|
|
796
|
+
// The FACET, not the surface that renders it (ARCHITECTURE-CORE
|
|
797
|
+
// §Editor chrome, facet taxonomy): what you picked on the board is an
|
|
798
|
+
// exhibit, and this block reads its story, group, module and size.
|
|
799
|
+
id: 'exhibit',
|
|
800
|
+
title: 'Exhibit',
|
|
801
|
+
// The board's read-only "what am I looking at" block.
|
|
802
|
+
icon: faCircleInfo,
|
|
803
|
+
order: CONTRIBUTED_SECTION_ORDER,
|
|
804
|
+
Section: BoardExhibitSection,
|
|
805
|
+
}),
|
|
806
|
+
);
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// --- Installation -----------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
/** Capture the same true-scale composition without activating its document. */
|
|
813
|
+
const threeBoardPreview = {
|
|
814
|
+
revision: () => JSON.stringify(getProjectStoryModules().map((module_) => module_.modulePath)),
|
|
815
|
+
subscribe: subscribeProjectStoryModules,
|
|
816
|
+
capture: async (
|
|
817
|
+
request: import('@volter/editor-core/document-preview-source').DocumentPreviewCaptureRequest,
|
|
818
|
+
) => {
|
|
819
|
+
await whenProjectStoriesReady();
|
|
820
|
+
const regions = getProjectStoryRegions();
|
|
821
|
+
const modules = getProjectStoryModules().filter(
|
|
822
|
+
(module_) =>
|
|
823
|
+
module_.ok &&
|
|
824
|
+
declaredStoryMedium({ modulePath: module_.modulePath, regions }).medium === 'three',
|
|
825
|
+
);
|
|
826
|
+
const listing = await listProjectComponents();
|
|
827
|
+
const board = await buildThreeBoard(modules, listing.ok ? listing.entries : []);
|
|
828
|
+
try {
|
|
829
|
+
const { captureAuthoredThreeScenePreview } = await import('../../host/document-preview-three');
|
|
830
|
+
const { createStandardEnvironment } = await import('@volter/editor-threejs/viewport/environment');
|
|
831
|
+
const { applyStandardViewportDressing } = await import(
|
|
832
|
+
'@volter/editor-core/components/standard-viewport-dressing'
|
|
833
|
+
);
|
|
834
|
+
const { isEditorOwnedObject } = await import('@volter/editor-threejs/viewport/editor-layers');
|
|
835
|
+
board.root.traverse((object) => {
|
|
836
|
+
if (isEditorOwnedObject(object)) object.visible = false;
|
|
837
|
+
});
|
|
838
|
+
const scene = new THREE.Scene();
|
|
839
|
+
scene.add(board.root);
|
|
840
|
+
const bounds = new THREE.Box3(
|
|
841
|
+
new THREE.Vector3(...board.frameBounds.min),
|
|
842
|
+
new THREE.Vector3(...board.frameBounds.max),
|
|
843
|
+
);
|
|
844
|
+
const target = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3());
|
|
845
|
+
const radius = bounds.isEmpty()
|
|
846
|
+
? 1
|
|
847
|
+
: Math.max(0.01, bounds.getBoundingSphere(new THREE.Sphere()).radius);
|
|
848
|
+
const distance = (radius / Math.sin(THREE.MathUtils.degToRad(42 / 2))) * 1.15;
|
|
849
|
+
const position = target
|
|
850
|
+
.clone()
|
|
851
|
+
.addScaledVector(new THREE.Vector3(1, 0.75, 1).normalize(), distance);
|
|
852
|
+
// A board's framing authority is its complete layout, never a camera
|
|
853
|
+
// inside one exhibit (which would photograph only that prefab).
|
|
854
|
+
const framing = { ...request, camera: { position, target, fov: 42 } };
|
|
855
|
+
return captureAuthoredThreeScenePreview(board.root, scene, framing, (renderer) => {
|
|
856
|
+
const dressing = applyStandardViewportDressing(scene, {
|
|
857
|
+
environment: createStandardEnvironment(renderer),
|
|
858
|
+
background: false,
|
|
859
|
+
});
|
|
860
|
+
return () => dressing.dispose();
|
|
861
|
+
});
|
|
862
|
+
} finally {
|
|
863
|
+
await disposeThreeBoard(board);
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
/** Register the project's 3D component canvas for Content and session restore.
|
|
869
|
+
* Its renderer mounts only when the user opens its document tab. */
|
|
870
|
+
export function installThreeBoardDocument(store: ThreeBoardStore): string {
|
|
871
|
+
ensureThreeBoardContributionsRegistered();
|
|
872
|
+
return registerAvailableWorkspaceDocument(
|
|
873
|
+
{
|
|
874
|
+
id: THREE_COMPONENTS_DOCUMENT_ID,
|
|
875
|
+
title: THREE_COMPONENTS_TITLE,
|
|
876
|
+
// A generated browse-over-the-project's-content view, not an authored
|
|
877
|
+
// subject: it is regenerated from the story registry, never persisted.
|
|
878
|
+
kind: 'content-browser',
|
|
879
|
+
workspaceRole: 'workspace-reference',
|
|
880
|
+
Content: (props) => <ThreeBoardContent {...props} store={store} />,
|
|
881
|
+
readOnly: true,
|
|
882
|
+
preview: threeBoardPreview,
|
|
883
|
+
presentation: () => ({ kind: 'workspace', id: THREE_COMPONENTS_DOCUMENT_ID }),
|
|
884
|
+
onActivate: () => store.setActiveViewportTab('edit'),
|
|
885
|
+
},
|
|
886
|
+
{ category: 'canvas' },
|
|
887
|
+
);
|
|
888
|
+
}
|