@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,1953 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreeAuthoringAdapter — THE {@link AuthoringAdapter} for the three.js
|
|
3
|
+
* SUBSTRATE: a `THREE.Object3D` tree, whatever built that tree.
|
|
4
|
+
*
|
|
5
|
+
* The name carries no adjective because the substrate needs none (owner
|
|
6
|
+
* ruling 2026-08-22, ARCHITECTURE-CORE §Roots): every Object3D graph the
|
|
7
|
+
* editor ever edits is in-memory objects — three has no document format here.
|
|
8
|
+
* A world AUTHORED as JSX is the R3F substrate (`R3fSourceAuthoringAdapter`,
|
|
9
|
+
* where the element is the entity and source is truth); the moment it RUNS,
|
|
10
|
+
* its render artifact is an Object3D graph and presents as THIS substrate —
|
|
11
|
+
* which is why play adoption and ingested R3F games both land here, with
|
|
12
|
+
* Edit↔Play id continuity as the bridge between the two substrates.
|
|
13
|
+
*
|
|
14
|
+
* Model: the running Object3D graph IS the document. This adapter walks it,
|
|
15
|
+
* mints {@link EditorNode}s directly, and reflects real three.js fields. It
|
|
16
|
+
* never reads or fabricates a descriptor of its own, and it never decides where
|
|
17
|
+
* an edit goes.
|
|
18
|
+
*
|
|
19
|
+
* ONE ADAPTER, TWO PARAMETERS (docs/ARCHITECTURE-CORE.md §Editor: authoring
|
|
20
|
+
* adapters are keyed on the SUBSTRATE they drive, never on where the graph came
|
|
21
|
+
* from). Everything below is a fact about a three surface — hierarchy,
|
|
22
|
+
* selection, picking, transforms, reflected inspector fields, the per-property
|
|
23
|
+
* editability sentence, the session's ephemeral edit record. What varies is
|
|
24
|
+
* exactly two collaborators:
|
|
25
|
+
*
|
|
26
|
+
* - an IDENTITY SCHEME (`../projection/three.ts`) — the walk that decides
|
|
27
|
+
* which objects are nodes and what their ids are: OID/creation-site stamps
|
|
28
|
+
* for a world whose source the serve-time transform reached, structural
|
|
29
|
+
* paths held in a pure projection for one it did not. The adapter consumes
|
|
30
|
+
* either without changing its hierarchy or editing behavior.
|
|
31
|
+
* - a PERSISTENCE BACKEND (`source-persistence-backend.ts`) — where a closed
|
|
32
|
+
* gesture's value goes: a creation-site literal writer, an honest
|
|
33
|
+
* live-only-with-reason, or NOTHING (the host injects
|
|
34
|
+
* `createEphemeralPersistence`, and play edits stay ephemeral by
|
|
35
|
+
* architecture).
|
|
36
|
+
*
|
|
37
|
+
* Identity stays in the provider projection. Source-backed OID worlds may
|
|
38
|
+
* already carry their authored stamps; structural ingest worlds remain
|
|
39
|
+
* untouched, with reverse object lookup owned by this adapter.
|
|
40
|
+
*
|
|
41
|
+
* Projection: this adapter walks nothing itself. The shared three projector
|
|
42
|
+
* (`../projection/three.ts`) owns the walk, the identity minting, the index and
|
|
43
|
+
* picking; everything below is a DERIVED VIEW over that one projection — which
|
|
44
|
+
* is also where the layer-31 furniture skip lives (grid, editor lights,
|
|
45
|
+
* BatchedRenderer, gizmo helper, pivot dummy, snap indicators are parked in
|
|
46
|
+
* whatever scene is mounted, and projecting them offers the user the editor's
|
|
47
|
+
* own objects as if they were the world's).
|
|
48
|
+
*
|
|
49
|
+
* Structural authoring (create/delete/reparent) is deliberately ABSENT: a
|
|
50
|
+
* running world owns and rebuilds its own graph from its own source, so a
|
|
51
|
+
* create/delete/reparent is not meaningfully re-expressible against it.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { getActiveNetworking, getActivePhysics } from '@volter/editor-core/authoring/active-systems';
|
|
55
|
+
import {
|
|
56
|
+
authoringOidOf,
|
|
57
|
+
isComponentInstanceRoot,
|
|
58
|
+
ownOidOf,
|
|
59
|
+
} from '@volter/editor-core/authoring/component-instance-root';
|
|
60
|
+
import { creationSiteRelated } from '../../host/authoring/creation-site-related';
|
|
61
|
+
import { createEphemeralPersistence } from '../../host/authoring/ephemeral-persistence';
|
|
62
|
+
import { multiChannelRefusal, persistChannelWrite } from '../../host/authoring/gesture-persist';
|
|
63
|
+
import { dataRecordAnchor, dataRecordIndexOf } from '../../host/authoring/ingest-data-writer';
|
|
64
|
+
import type { IngestSourcePersistence } from '../../host/authoring/ingest-source-persistence';
|
|
65
|
+
import {
|
|
66
|
+
createCreationSitePersistence,
|
|
67
|
+
type SourcePersistenceBackend,
|
|
68
|
+
type SourceWriteSubject,
|
|
69
|
+
} from '../../host/authoring/source-persistence-backend';
|
|
70
|
+
import { readLocalTransform } from '@volter/editor-core/authoring/three-projection-core';
|
|
71
|
+
import {
|
|
72
|
+
LIVE_ONLY_ACK,
|
|
73
|
+
LIVE_ONLY_DESTINATION,
|
|
74
|
+
type PipedWrite,
|
|
75
|
+
resolvesLiveOnly,
|
|
76
|
+
runWritePipe,
|
|
77
|
+
type WriteAck,
|
|
78
|
+
type WriteResolution,
|
|
79
|
+
} from '@volter/editor-core/authoring/write-pipe';
|
|
80
|
+
import { componentStatesProvider } from '@volter/editor-core/component-states-registry';
|
|
81
|
+
import {
|
|
82
|
+
type ChannelValue,
|
|
83
|
+
type CreationSiteLiteralReport,
|
|
84
|
+
channelFor,
|
|
85
|
+
} from '@volter/editor-core/creation-site-edit';
|
|
86
|
+
import {
|
|
87
|
+
creationSiteAnchor,
|
|
88
|
+
instancesAtSite,
|
|
89
|
+
NO_OBJECT_REASON,
|
|
90
|
+
} from '@volter/editor-core/creation-site-registry';
|
|
91
|
+
import { editorConsole } from '@volter/editor-core/editor-console';
|
|
92
|
+
import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
|
|
93
|
+
import { type JournalSubject, JsonHistoryResource } from '../../host/history/json-history-resource';
|
|
94
|
+
import {
|
|
95
|
+
nativeKindOf,
|
|
96
|
+
oidIdentity,
|
|
97
|
+
structuralIdentity,
|
|
98
|
+
type ThreeIdentity,
|
|
99
|
+
type ThreeNode,
|
|
100
|
+
type ThreeProjectionDelta,
|
|
101
|
+
ThreeProjector,
|
|
102
|
+
type ThreeWalkStats,
|
|
103
|
+
} from '@volter/editor-core/projection/three';
|
|
104
|
+
import type { SourceWriteBackend } from '@volter/editor-core/ui-source/source-write-backend';
|
|
105
|
+
import type {
|
|
106
|
+
AuthoringAdapter,
|
|
107
|
+
AuthoringCapabilities,
|
|
108
|
+
AuthoringProvenance,
|
|
109
|
+
ComponentInstanceApplyResult,
|
|
110
|
+
ComponentInstanceDescription,
|
|
111
|
+
ComponentInstanceOverride,
|
|
112
|
+
ComponentInstancesProvider,
|
|
113
|
+
EditorNode,
|
|
114
|
+
HierarchyProvider,
|
|
115
|
+
InspectorProvider,
|
|
116
|
+
PersistenceProvider,
|
|
117
|
+
PhysicsAdapter,
|
|
118
|
+
PickProvider,
|
|
119
|
+
PropertyDescriptor,
|
|
120
|
+
RelatedSubjectsProvider,
|
|
121
|
+
SelectionProvider,
|
|
122
|
+
SelectionResolution,
|
|
123
|
+
StoriesProvider,
|
|
124
|
+
Transform,
|
|
125
|
+
TransformChannel,
|
|
126
|
+
TransformEditability,
|
|
127
|
+
TransformProvider,
|
|
128
|
+
TransformSourceCommitProvider,
|
|
129
|
+
TruthProvider,
|
|
130
|
+
WriteAnchorKind,
|
|
131
|
+
} from '@volter/editor-project/adapter';
|
|
132
|
+
import { emptyWriteAnchorKindCounts } from '@volter/editor-project/adapter';
|
|
133
|
+
import { isEditorOwnedObject } from '@volter/editor-threejs/viewport/editor-layers';
|
|
134
|
+
import { bodyOwningNode } from '@volter/threejs-runtime/adapter/body-marks';
|
|
135
|
+
import { colorMaterialOf } from '@volter/threejs-runtime/adapter/ingest/structural-ids';
|
|
136
|
+
import { object3DAuthoringSubjectOf } from '@volter/threejs-runtime/adapter/object3d-authoring-subject';
|
|
137
|
+
import { createRapierBodyEditing } from '@volter/threejs-runtime/adapter/rapier-physics-adapter';
|
|
138
|
+
import { getUserData } from '@volter/threejs-runtime/ecs/user-data';
|
|
139
|
+
import type * as THREE from 'three';
|
|
140
|
+
import {
|
|
141
|
+
createOidSourcePersistence,
|
|
142
|
+
createOidTransformSourceCommitter,
|
|
143
|
+
type OidTransformSourceCommitter,
|
|
144
|
+
} from './oid-source-persistence';
|
|
145
|
+
|
|
146
|
+
/** One node's edits this session, keyed by node id. Never written anywhere. */
|
|
147
|
+
interface ThreeEdit {
|
|
148
|
+
position?: [number, number, number];
|
|
149
|
+
rotation?: [number, number, number, number];
|
|
150
|
+
scale?: [number, number, number];
|
|
151
|
+
color?: string;
|
|
152
|
+
visible?: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface ThreeHistoryState {
|
|
156
|
+
edits: Record<string, ThreeEdit>;
|
|
157
|
+
objects: Record<
|
|
158
|
+
string,
|
|
159
|
+
{
|
|
160
|
+
name: string;
|
|
161
|
+
visible: boolean;
|
|
162
|
+
transform: Transform;
|
|
163
|
+
color?: string;
|
|
164
|
+
lightIntensity?: number;
|
|
165
|
+
lightColor?: string;
|
|
166
|
+
lightDistance?: number;
|
|
167
|
+
camera?: { fov: number; near: number; far: number };
|
|
168
|
+
castShadow: boolean;
|
|
169
|
+
receiveShadow: boolean;
|
|
170
|
+
}
|
|
171
|
+
>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Loop-gate hooks so a stable edit can pause the world that owns the rAF. */
|
|
175
|
+
export interface ThreeLoopControl {
|
|
176
|
+
pause(): void;
|
|
177
|
+
resume(): void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The scene this adapter projects: a concrete one (play adopts a specific
|
|
182
|
+
* root's scene) or a resolver, for a host whose live scene can be replaced
|
|
183
|
+
* underneath it (edit mode reads `store.scene`, which a design-time adoption
|
|
184
|
+
* swaps). A resolver returning `null` is an honest "nothing is mounted" —
|
|
185
|
+
* the hierarchy is empty, never fabricated.
|
|
186
|
+
*/
|
|
187
|
+
export type ThreeSceneRef = THREE.Scene | (() => THREE.Scene | null);
|
|
188
|
+
|
|
189
|
+
export interface ThreeAuthoringOptions {
|
|
190
|
+
/** How nodes are addressed. See `../projection/three.ts`. */
|
|
191
|
+
readonly identity: ThreeIdentity;
|
|
192
|
+
/**
|
|
193
|
+
* The UNDO axis — whose journal this mount's live edits belong to. Required
|
|
194
|
+
* because the answer differs per lane and there is no safe default:
|
|
195
|
+
* `authoringJournal` for a held world whose subject outlives every remount,
|
|
196
|
+
* `playJournal` for a run that ends on ■. See the ownership block in
|
|
197
|
+
* `history/json-history-resource.ts`.
|
|
198
|
+
*/
|
|
199
|
+
readonly journal: JournalSubject;
|
|
200
|
+
/**
|
|
201
|
+
* Where a closed gesture's value goes. ABSENT ⇒ nowhere: the edit lives on
|
|
202
|
+
* the running object, nothing is journaled, and the host injects an ephemeral
|
|
203
|
+
* persistence provider over the adapter.
|
|
204
|
+
*/
|
|
205
|
+
readonly persistence?: SourcePersistenceBackend | undefined;
|
|
206
|
+
/** Source provenance can be read even when live edits cannot persist. */
|
|
207
|
+
readonly sourceAnchor?: SourcePersistenceBackend['anchor'];
|
|
208
|
+
/** The explicit Play→source door. It is never consulted by begin/apply/end,
|
|
209
|
+
* so providing it cannot make an ordinary Play gesture persistent. */
|
|
210
|
+
readonly sourceCommit?: OidTransformSourceCommitter | undefined;
|
|
211
|
+
/** Freeze/thaw the world's own loop for the duration of a gesture. */
|
|
212
|
+
readonly loop?: ThreeLoopControl | undefined;
|
|
213
|
+
/**
|
|
214
|
+
* Who can stop the frame an edit lands in.
|
|
215
|
+
* - `'adapter'` (default) — this surface freezes the frame itself for the
|
|
216
|
+
* gesture (`loop`), or is not host-ticked at all, so an edit is stable
|
|
217
|
+
* where it lands.
|
|
218
|
+
* - `'host'` — the host's play loop owns the tick and this adapter cannot
|
|
219
|
+
* pause it, so a transform edit made while playing is overwritten before
|
|
220
|
+
* it can be seen. The honest answer is to say so and refuse.
|
|
221
|
+
*
|
|
222
|
+
* The default (`'adapter'`) is the PERMISSIVE, non-refusing value, so this
|
|
223
|
+
* axis fails OPEN: a host-ticked mount that omits `frameControl: 'host'`
|
|
224
|
+
* silently lets a mid-play edit be overwritten instead of refusing. The two
|
|
225
|
+
* shipped mounts set it correctly by their tick-ownership fact
|
|
226
|
+
* (`structuralThree` holds its own `loop`; `oidThree` sets `'host'`),
|
|
227
|
+
* so a NEW mount shape must set this explicitly from the same fact.
|
|
228
|
+
*/
|
|
229
|
+
readonly frameControl?: 'adapter' | 'host';
|
|
230
|
+
/** What the shell reports about where this surface came from. */
|
|
231
|
+
readonly provenance?: AuthoringProvenance;
|
|
232
|
+
/**
|
|
233
|
+
* Whether this world's own SOURCE can declare portable CSF — i.e. whether
|
|
234
|
+
* asking the project's story registry for a component's stories is answering
|
|
235
|
+
* about this world at all.
|
|
236
|
+
*
|
|
237
|
+
* `false` (the default) is the honest answer for a world adopted out of a
|
|
238
|
+
* running game whose source the editor never reached: there is nowhere to
|
|
239
|
+
* declare a story and no registry may stand in for one, so the provider is
|
|
240
|
+
* ABSENT rather than empty (see the absent-column note on the class).
|
|
241
|
+
*
|
|
242
|
+
* `true` is set by exactly the lane whose premise is the opposite — a world
|
|
243
|
+
* whose source the serve-time OID transform DID reach
|
|
244
|
+
* ({@link oidSourceThree}). There the component a node came from is
|
|
245
|
+
* named on the node itself (`typeLabel`), the source it came from is a real
|
|
246
|
+
* file, and a `*.stories.tsx` colocated with it is discovered like any
|
|
247
|
+
* other. Association still runs through portable CSF and nothing else.
|
|
248
|
+
*/
|
|
249
|
+
readonly sourceDeclaresStories?: boolean;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const LIVE_PROVENANCE: AuthoringProvenance = {
|
|
253
|
+
source: 'live',
|
|
254
|
+
label: 'live',
|
|
255
|
+
detail:
|
|
256
|
+
'Running objects adopted from the live scene. Edits affect this session only ' +
|
|
257
|
+
'and are discarded when play stops.',
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* What `editability` says about a node in a world whose frame the adapter
|
|
262
|
+
* cannot stop, when no body claims the pose. The edit lands — it just has no
|
|
263
|
+
* defence against the game's own code writing the same values next frame, and
|
|
264
|
+
* saying so is the whole point of the reason channel.
|
|
265
|
+
*/
|
|
266
|
+
export const LIVE_FRAME_REASON =
|
|
267
|
+
'Applied to the running world; code that drives this transform each frame may overwrite it.';
|
|
268
|
+
|
|
269
|
+
const CAPTURED_PROVENANCE: AuthoringProvenance = {
|
|
270
|
+
source: 'foreign',
|
|
271
|
+
label: 'live-only',
|
|
272
|
+
detail:
|
|
273
|
+
'Unmodified ingested game — edits apply to the running game for this session only and are never saved.',
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** An ingested game the serve-time OID transform reached: its own JSX is the
|
|
277
|
+
* destination, so this is NOT the live-only badge above. */
|
|
278
|
+
const STAMPED_INGEST_PROVENANCE: AuthoringProvenance = {
|
|
279
|
+
source: 'foreign',
|
|
280
|
+
label: 'game source',
|
|
281
|
+
detail:
|
|
282
|
+
"Ingested game whose own source carries the editor's authoring stamps — a literal JSX prop " +
|
|
283
|
+
'writes back to the game’s own file (expression-bound props stay read-only).',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** The object as a Light (intensity + color), if it is one. */
|
|
287
|
+
function lightOf(object: THREE.Object3D): THREE.Light | null {
|
|
288
|
+
return (object as THREE.Object3D & { isLight?: boolean }).isLight
|
|
289
|
+
? (object as THREE.Light)
|
|
290
|
+
: null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function rangedLightOf(object: THREE.Object3D): THREE.PointLight | THREE.SpotLight | null {
|
|
294
|
+
const light = object as (THREE.PointLight | THREE.SpotLight) & {
|
|
295
|
+
isPointLight?: boolean;
|
|
296
|
+
isSpotLight?: boolean;
|
|
297
|
+
};
|
|
298
|
+
return light.isPointLight || light.isSpotLight ? light : null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function shadowCastingLightOf(object: THREE.Object3D): THREE.Light | null {
|
|
302
|
+
const light = object as THREE.Light & {
|
|
303
|
+
isDirectionalLight?: boolean;
|
|
304
|
+
isPointLight?: boolean;
|
|
305
|
+
isSpotLight?: boolean;
|
|
306
|
+
};
|
|
307
|
+
return light.isDirectionalLight || light.isPointLight || light.isSpotLight ? light : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The object as a PerspectiveCamera (fov/near/far), if it is one. */
|
|
311
|
+
function perspectiveCameraOf(object: THREE.Object3D): THREE.PerspectiveCamera | null {
|
|
312
|
+
const camera = object as THREE.PerspectiveCamera & { isPerspectiveCamera?: boolean };
|
|
313
|
+
return camera.isPerspectiveCamera ? camera : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The object's mesh geometry, if it is a mesh. */
|
|
317
|
+
function geometryOf(object: THREE.Object3D): THREE.BufferGeometry | null {
|
|
318
|
+
const mesh = object as THREE.Mesh & { isMesh?: boolean };
|
|
319
|
+
return mesh.isMesh && mesh.geometry ? mesh.geometry : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export class ThreeAuthoringAdapter implements AuthoringAdapter {
|
|
323
|
+
private sceneRef: ThreeSceneRef;
|
|
324
|
+
/** The shared three projector — this adapter's ONE source of nodes, ids,
|
|
325
|
+
* the object index and picking. It never walks the graph itself. */
|
|
326
|
+
private readonly projector: ThreeProjector;
|
|
327
|
+
/** Objects carrying the live graph's structural event listeners. A running
|
|
328
|
+
* world can commit its authored subtree after this adapter is installed; the
|
|
329
|
+
* hierarchy panel needs a change signal when that happens, not merely a
|
|
330
|
+
* fresh walk the next time some unrelated render occurs. */
|
|
331
|
+
private readonly watchedForStructure = new Set<THREE.Object3D>();
|
|
332
|
+
private readonly changedStructureRoots = new Set<THREE.Object3D>();
|
|
333
|
+
private structureRefreshQueued = false;
|
|
334
|
+
private disposed = false;
|
|
335
|
+
private crossSurfaceStructureRevision = 0;
|
|
336
|
+
private hasCrossSurfaceStructure = false;
|
|
337
|
+
|
|
338
|
+
private readonly identity: ThreeIdentity;
|
|
339
|
+
private readonly persist: SourcePersistenceBackend | null;
|
|
340
|
+
private readonly loop: ThreeLoopControl | undefined;
|
|
341
|
+
private readonly frameControl: 'adapter' | 'host';
|
|
342
|
+
private readonly instanceLiterals = new Map<string, Record<string, CreationSiteLiteralReport>>();
|
|
343
|
+
private readonly instanceLiteralRequests = new Map<
|
|
344
|
+
string,
|
|
345
|
+
Promise<Record<string, CreationSiteLiteralReport>>
|
|
346
|
+
>();
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The `freeze → commit → unfreeze` protocol over the bodies a world marked on
|
|
350
|
+
* its own nodes (`@volter/threejs-runtime/adapter/body-marks`), for the worlds that register
|
|
351
|
+
* no `SystemAdapters.physics`.
|
|
352
|
+
*
|
|
353
|
+
* The SAME four verbs and the SAME implementation the registered seam uses —
|
|
354
|
+
* `createRapierBodyEditing` is that function, factored out for exactly this
|
|
355
|
+
* reason — differing only in where `bodyFor` looks. Every method is a no-op
|
|
356
|
+
* for an unmarked node, which is why the calls below stand beside
|
|
357
|
+
* `getActivePhysics()`'s unconditionally instead of behind a precedence rule:
|
|
358
|
+
* a node cannot be answered by both, because a world that has a registered
|
|
359
|
+
* adapter has no reason to mark, and a world that marks has no adapter.
|
|
360
|
+
*/
|
|
361
|
+
private readonly markedBodies = createRapierBodyEditing((id) => {
|
|
362
|
+
const object = this.objectOf(id);
|
|
363
|
+
if (!object) return { kind: 'unresolved' } as const;
|
|
364
|
+
const body = bodyOwningNode(object);
|
|
365
|
+
return body ? ({ kind: 'body', body } as const) : ({ kind: 'no-body' } as const);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Rigidbody ownership is a property of the mounted native object. Hierarchy
|
|
370
|
+
* rows ask the three transform channels independently, so without this cache
|
|
371
|
+
* one visible row repeats the physics adapter's scene snapshot three times
|
|
372
|
+
* on every structural notification (including every spawned projectile).
|
|
373
|
+
* A Play/Stop remount constructs a new adapter and a newly spawned object is
|
|
374
|
+
* a new WeakMap key. `unresolved` is deliberately never cached: a provider
|
|
375
|
+
* that has not indexed this id yet must get another chance on the next read.
|
|
376
|
+
*/
|
|
377
|
+
private readonly bodyOwnershipByObject = new WeakMap<THREE.Object3D, boolean>();
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The registered physics adapter, but ONLY when it recognizes this node.
|
|
381
|
+
*
|
|
382
|
+
* Its `freeze`/`commit`/`unfreeze` now REFUSE an id they cannot resolve
|
|
383
|
+
* rather than returning as if the write landed, and `getActivePhysics()` is
|
|
384
|
+
* GAME-scoped while this adapter edits nodes root by root — so a game whose
|
|
385
|
+
* physics lives in one root is routinely asked about nodes in another.
|
|
386
|
+
* Asking first is the caller's job; `pixi-live-write-target.ts` already
|
|
387
|
+
* gates its three calls the same way.
|
|
388
|
+
*/
|
|
389
|
+
private physicsFor(id: string): PhysicsAdapter | null {
|
|
390
|
+
const physics = getActivePhysics();
|
|
391
|
+
return physics && physics.ownerOf(id) !== 'unresolved' ? physics : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** This session's edits, keyed by node id. In-memory only. */
|
|
395
|
+
private edits: Record<string, ThreeEdit> = {};
|
|
396
|
+
private readonly historyResource: JsonHistoryResource<ThreeHistoryState> | null;
|
|
397
|
+
private editStartState: ThreeHistoryState | undefined;
|
|
398
|
+
/** The channel values a gizmo gesture started from — see `transforms.apply`. */
|
|
399
|
+
private editBaseline: Record<string, ChannelValue> | undefined;
|
|
400
|
+
|
|
401
|
+
// A running world's structure belongs to its source, not to a document this
|
|
402
|
+
// adapter could write: no `structure` provider, and `persist: false` — a
|
|
403
|
+
// creation-site write goes straight to the world's own file at the moment of
|
|
404
|
+
// the gesture rather than accumulating in a buffer this could flush.
|
|
405
|
+
//
|
|
406
|
+
// THE REST OF THE ABSENT COLUMN, so a reader never has to guess whether a
|
|
407
|
+
// missing provider is a decision or an oversight. These are facts about THIS
|
|
408
|
+
// ADAPTER CLASS — true for every world it is pointed at, never per-game:
|
|
409
|
+
//
|
|
410
|
+
// - `rects` / `boxEdit` / `text` / `colorSample` — DOM concepts. A rect is a
|
|
411
|
+
// screen box, a box-edit writes CSS/layout, in-place text writes a DOM text
|
|
412
|
+
// node, and the eyedropper fallback walks a CSS `background-color` chain. An
|
|
413
|
+
// `Object3D` has none of the four; its spatial seam is `transforms` above.
|
|
414
|
+
// - `assetSubject` — `AuthoringAssetSubject` admits exactly one shape today
|
|
415
|
+
// (`kind: 'image'`, `mediaType: 'image/svg+xml'`: an inline SVG whose bytes
|
|
416
|
+
// are embedded in a source document). A scene-graph node has no such
|
|
417
|
+
// representation, so answering here would mean widening the type first.
|
|
418
|
+
// - `stories` — CONDITIONAL, and the one entry here that is not a fact about
|
|
419
|
+
// every world this class is pointed at. A story is declared in a PROJECT's
|
|
420
|
+
// own portable CSF source ("Association is derived from portable CSF
|
|
421
|
+
// source, never an editor-private label or registry", ARCHITECTURE-CORE
|
|
422
|
+
// §Roots), so the question is whether THIS world's source can carry one.
|
|
423
|
+
// A world adopted out of a running game the editor never reached cannot:
|
|
424
|
+
// absent, and no registry stands in for it. A world whose source the
|
|
425
|
+
// serve-time OID transform DID reach can, and does — `oidSourceThree`
|
|
426
|
+
// sets `sourceDeclaresStories`, and `stories` is then the ordinary
|
|
427
|
+
// project-story provider joined on the component name the stamp already
|
|
428
|
+
// puts on the node (`toEditorNode`'s `typeLabel`). See `this.stories`.
|
|
429
|
+
// - `assetDrop` — this adapter has NO live-insert seam, and three separate
|
|
430
|
+
// pieces would have to exist before one could be honest: (1) ids here are
|
|
431
|
+
// minted from STRUCTURAL PATHS (`../projection/three.ts`), so inserting a
|
|
432
|
+
// node re-indexes its siblings and silently re-targets `this.edits`, which is
|
|
433
|
+
// keyed by those ids; (2) `captureHistoryState`/`restoreHistoryState` below
|
|
434
|
+
// carry property values for objects the walk already found and have no
|
|
435
|
+
// add/remove vocabulary, so a drop could not be undone; (3) an inserted node
|
|
436
|
+
// has no creation site, which `truth` is contracted to answer for
|
|
437
|
+
// every id. Building it is a HOST work order against those three, not a
|
|
438
|
+
// per-mount shim.
|
|
439
|
+
readonly capabilities: AuthoringCapabilities = {
|
|
440
|
+
transform: true,
|
|
441
|
+
inspectorFields: true,
|
|
442
|
+
persist: false,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
/** Portable CSF states for this world's component nodes — present only when
|
|
446
|
+
* the world's own source can declare them (`sourceDeclaresStories`; see the
|
|
447
|
+
* absent-column note above). The same BINDING the R3F source lane uses
|
|
448
|
+
* (`component-states-registry.ts`, over whichever package registered a
|
|
449
|
+
* source of component states): this adapter contributes the component
|
|
450
|
+
* IDENTITY (`typeLabel`, minted from the OID stamp) and nothing else. */
|
|
451
|
+
readonly stories?: StoriesProvider;
|
|
452
|
+
|
|
453
|
+
/** Construction-site defaults projected as native component instances.
|
|
454
|
+
* Present only when the backend can read the game's source. */
|
|
455
|
+
readonly instances?: ComponentInstancesProvider;
|
|
456
|
+
|
|
457
|
+
readonly provenance: AuthoringProvenance;
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Present only when this surface has a persistence backend at all — ABSENT is
|
|
461
|
+
* the honest report for a play adoption, over which the host injects
|
|
462
|
+
* `createEphemeralPersistence` instead.
|
|
463
|
+
*
|
|
464
|
+
* There is no DOCUMENT to save either way: `isDirty()` is always false and
|
|
465
|
+
* `save()` has nothing to flush, because a creation-site write goes straight
|
|
466
|
+
* to the world's own file at the moment of the gesture rather than
|
|
467
|
+
* accumulating in a buffer. `applyExternal` stays absent — there is no
|
|
468
|
+
* persisted artifact of OURS for a file-watcher to hand back.
|
|
469
|
+
*
|
|
470
|
+
* `destination` is a GETTER because the answer moves within a session:
|
|
471
|
+
* holding or releasing the world changes what an edit MEANS, and a string
|
|
472
|
+
* frozen at construction would keep naming the wrong place. It is the one-line
|
|
473
|
+
* answer
|
|
474
|
+
* to "where do edits go"; per-OBJECT honesty is finer-grained than a provider
|
|
475
|
+
* can be and lives on `transforms.editability` instead.
|
|
476
|
+
*/
|
|
477
|
+
readonly persistence?: PersistenceProvider;
|
|
478
|
+
|
|
479
|
+
constructor(
|
|
480
|
+
private readonly store: EditorShellStore,
|
|
481
|
+
scene: ThreeSceneRef,
|
|
482
|
+
readonly options: ThreeAuthoringOptions,
|
|
483
|
+
) {
|
|
484
|
+
this.sceneRef = scene;
|
|
485
|
+
this.identity = options.identity;
|
|
486
|
+
// `'projection'`: this surface's objects are not necessarily the shell's —
|
|
487
|
+
// a play adoption and a design session can have different scenes mounted,
|
|
488
|
+
// and a raycast over `store.objectMap` would answer for the wrong one.
|
|
489
|
+
this.projector = new ThreeProjector(this.identity, { pickScope: 'projection' });
|
|
490
|
+
this.persist = options.persistence ?? null;
|
|
491
|
+
this.loop = options.loop;
|
|
492
|
+
this.frameControl = options.frameControl ?? 'adapter';
|
|
493
|
+
this.provenance = options.provenance ?? LIVE_PROVENANCE;
|
|
494
|
+
if (options.sourceCommit) {
|
|
495
|
+
const sourceCommit: TransformSourceCommitProvider = {
|
|
496
|
+
availability: (id) => options.sourceCommit!.availability(authoringOidOf(this.objectOf(id))),
|
|
497
|
+
commit: async (id) => {
|
|
498
|
+
const object = this.objectOf(id);
|
|
499
|
+
const values = this.captureChannelBaseline(id) ?? {};
|
|
500
|
+
const ack = await options.sourceCommit!.commit(authoringOidOf(object), values);
|
|
501
|
+
if (ack.persisted) this.store.notifyIngestEdit();
|
|
502
|
+
return ack;
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
Object.assign(this.transforms, { sourceCommit });
|
|
506
|
+
}
|
|
507
|
+
if (options.sourceDeclaresStories) {
|
|
508
|
+
// Only a COMPONENT node has a portable-CSF identity to join on; a plain
|
|
509
|
+
// scene object honestly returns none. `typeLabel` is the component name
|
|
510
|
+
// the OID stamp put there (`toEditorNode`), which is exactly what
|
|
511
|
+
// `meta.component` names.
|
|
512
|
+
this.stories = componentStatesProvider('three', store, (nodeId) => {
|
|
513
|
+
const node = this.hierarchy.node(nodeId);
|
|
514
|
+
if (node?.role !== 'component') return null;
|
|
515
|
+
return { name: node.typeLabel ?? node.label };
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
this.refresh();
|
|
519
|
+
this.watchStructure();
|
|
520
|
+
// The session journal exists exactly where a destination does. A surface
|
|
521
|
+
// with no backend has nothing to restore INTO and must not push undo
|
|
522
|
+
// entries; one with a backend needs them, because
|
|
523
|
+
// a running world does not re-derive its scene from source and undoing only
|
|
524
|
+
// a file would leave the world showing the edit it just undid.
|
|
525
|
+
const history = store.projectHistory;
|
|
526
|
+
this.historyResource =
|
|
527
|
+
this.persist && history
|
|
528
|
+
? new JsonHistoryResource({
|
|
529
|
+
history,
|
|
530
|
+
kind: 'session-state',
|
|
531
|
+
scope: 'session',
|
|
532
|
+
// The SUBJECT's session, not this mount's — see the ownership block
|
|
533
|
+
// in `history/json-history-resource.ts`.
|
|
534
|
+
subject: { ...options.journal, id: `${options.journal.id}/live-three-edits` },
|
|
535
|
+
displayName: 'Live edits',
|
|
536
|
+
capture: () => this.captureHistoryState(),
|
|
537
|
+
// The snapshot carries a mirror of every live Object3D (the restore
|
|
538
|
+
// payload), but this is a RUNNING world that animates its own
|
|
539
|
+
// objects every frame — so the full snapshot stops matching a frame
|
|
540
|
+
// after it is taken. Only the EDIT RECORD is this resource's real
|
|
541
|
+
// content (only editor edits touch it), so it alone decides "did
|
|
542
|
+
// something else change this resource?". Without this, every
|
|
543
|
+
// transaction failed preflight with `content-conflict` and undo
|
|
544
|
+
// silently did nothing (issue #81).
|
|
545
|
+
conflictIdentity: (state) => state.edits,
|
|
546
|
+
restore: async (state) => {
|
|
547
|
+
this.restoreHistoryState(state);
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
: null;
|
|
551
|
+
const backend = this.persist;
|
|
552
|
+
if (backend) {
|
|
553
|
+
backend.attach({
|
|
554
|
+
read: (id, property) => this.readChannel(id, property),
|
|
555
|
+
apply: (id, property, value) => this.applyChannel(id, property, value),
|
|
556
|
+
});
|
|
557
|
+
this.persistence = {
|
|
558
|
+
...createEphemeralPersistence(),
|
|
559
|
+
get destination() {
|
|
560
|
+
return backend.destination();
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
if (backend.readSiteLiterals) this.instances = this.creationSiteInstances(backend);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private get scene(): THREE.Scene | null {
|
|
568
|
+
return typeof this.sceneRef === 'function' ? this.sceneRef() : this.sceneRef;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Re-walk the live graph. Called for adoption/remount/stream-settled triggers;
|
|
573
|
+
* live OID child mutations take the incremental path below. The
|
|
574
|
+
* stats are a fresh measurement, never a mount-time snapshot: a world keeps
|
|
575
|
+
* streaming objects in long after its first frame.
|
|
576
|
+
*/
|
|
577
|
+
refresh(): ThreeWalkStats {
|
|
578
|
+
const stats = this.projector.project(this.scene);
|
|
579
|
+
this.refreshCrossSurfacePresence();
|
|
580
|
+
this.crossSurfaceStructureRevision++;
|
|
581
|
+
return stats;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Cheap semantic-join input for `CompositeAuthoringAdapter`.
|
|
585
|
+
* Runtime-only children (for example cloned Unity projectiles) change the
|
|
586
|
+
* ordinary hierarchy but not this revision, so cross-surface joining can
|
|
587
|
+
* reuse its existing edges without walking this whole world. */
|
|
588
|
+
private crossSurfaceStructureSignature(): string | null {
|
|
589
|
+
return this.hasCrossSurfaceStructure ? String(this.crossSurfaceStructureRevision) : null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private objectCarriesCrossSurfaceStructure(object: THREE.Object3D): boolean {
|
|
593
|
+
return (
|
|
594
|
+
getUserData(object, 'authoringHierarchyId') !== undefined ||
|
|
595
|
+
getUserData(object, 'authoringHierarchyParentId') !== undefined ||
|
|
596
|
+
getUserData(object, 'authoringHierarchyOrder') !== undefined
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private subtreeCarriesCrossSurfaceStructure(root: THREE.Object3D): boolean {
|
|
601
|
+
let found = false;
|
|
602
|
+
root.traverse((object) => {
|
|
603
|
+
if (this.objectCarriesCrossSurfaceStructure(object)) found = true;
|
|
604
|
+
});
|
|
605
|
+
return found;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private refreshCrossSurfacePresence(): void {
|
|
609
|
+
this.hasCrossSurfaceStructure = false;
|
|
610
|
+
for (const node of this.projector.nodes.values()) {
|
|
611
|
+
if (!this.objectCarriesCrossSurfaceStructure(node.object)) continue;
|
|
612
|
+
this.hasCrossSurfaceStructure = true;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private readonly onStructureChanged = (event: { child: THREE.Object3D }): void => {
|
|
618
|
+
if (isEditorOwnedObject(event.child) || this.disposed) return;
|
|
619
|
+
this.changedStructureRoots.add(event.child);
|
|
620
|
+
if (this.structureRefreshQueued) return;
|
|
621
|
+
this.structureRefreshQueued = true;
|
|
622
|
+
queueMicrotask(() => {
|
|
623
|
+
this.structureRefreshQueued = false;
|
|
624
|
+
if (this.disposed) return;
|
|
625
|
+
const changed = [...this.changedStructureRoots];
|
|
626
|
+
this.changedStructureRoots.clear();
|
|
627
|
+
const crossSurfaceChanged = changed.some((root) =>
|
|
628
|
+
this.subtreeCarriesCrossSurfaceStructure(root),
|
|
629
|
+
);
|
|
630
|
+
for (const root of changed) this.unwatchStructureSubtree(root);
|
|
631
|
+
const delta = this.projector.projectSubtrees(this.scene, changed);
|
|
632
|
+
if (delta === null) {
|
|
633
|
+
this.refresh();
|
|
634
|
+
this.watchStructure();
|
|
635
|
+
} else {
|
|
636
|
+
for (const root of changed) {
|
|
637
|
+
if (this.structureRootIsAttached(root)) this.watchStructureSubtree(root);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (crossSurfaceChanged) {
|
|
641
|
+
this.refreshCrossSurfacePresence();
|
|
642
|
+
this.crossSurfaceStructureRevision++;
|
|
643
|
+
}
|
|
644
|
+
this.syncAdoptedObjectMap(delta ?? undefined);
|
|
645
|
+
this.store.notifyIngestObjectMapEdit(
|
|
646
|
+
delta === null
|
|
647
|
+
? undefined
|
|
648
|
+
: {
|
|
649
|
+
changedParentIds: delta.changedParentIds,
|
|
650
|
+
rootsChanged: delta.rootsChanged,
|
|
651
|
+
addedIds: delta.addedIds,
|
|
652
|
+
removedIds: delta.removedIds,
|
|
653
|
+
},
|
|
654
|
+
);
|
|
655
|
+
});
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
/** Watch the whole native graph, including transparent implementation
|
|
659
|
+
* wrappers that are not projection rows: authored children can be mounted
|
|
660
|
+
* beneath either kind of parent. One refresh per synchronous commit burst
|
|
661
|
+
* then reattaches listeners to the newly discovered subtree. */
|
|
662
|
+
private watchStructure(): void {
|
|
663
|
+
for (const object of this.watchedForStructure) {
|
|
664
|
+
object.removeEventListener('childadded', this.onStructureChanged);
|
|
665
|
+
object.removeEventListener('childremoved', this.onStructureChanged);
|
|
666
|
+
}
|
|
667
|
+
this.watchedForStructure.clear();
|
|
668
|
+
const scene = this.scene;
|
|
669
|
+
if (!scene) return;
|
|
670
|
+
this.watchStructureSubtree(scene);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private watchStructureSubtree(root: THREE.Object3D): void {
|
|
674
|
+
const visit = (object: THREE.Object3D): void => {
|
|
675
|
+
if (isEditorOwnedObject(object) || this.watchedForStructure.has(object)) return;
|
|
676
|
+
object.addEventListener('childadded', this.onStructureChanged);
|
|
677
|
+
object.addEventListener('childremoved', this.onStructureChanged);
|
|
678
|
+
this.watchedForStructure.add(object);
|
|
679
|
+
for (const child of object.children) visit(child);
|
|
680
|
+
};
|
|
681
|
+
visit(root);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private structureRootIsAttached(root: THREE.Object3D): boolean {
|
|
685
|
+
const scene = this.scene;
|
|
686
|
+
if (scene === null) return false;
|
|
687
|
+
for (let current: THREE.Object3D | null = root; current !== null; current = current.parent) {
|
|
688
|
+
if (current === scene) return true;
|
|
689
|
+
}
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private unwatchStructureSubtree(root: THREE.Object3D): void {
|
|
694
|
+
root.traverse((object) => {
|
|
695
|
+
if (!this.watchedForStructure.delete(object)) return;
|
|
696
|
+
object.removeEventListener('childadded', this.onStructureChanged);
|
|
697
|
+
object.removeEventListener('childremoved', this.onStructureChanged);
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Keep the store's adopted scene index in step with the projector so a
|
|
702
|
+
* late row is selectable immediately, not only visible in hierarchy. */
|
|
703
|
+
private syncAdoptedObjectMap(delta?: ThreeProjectionDelta): void {
|
|
704
|
+
const scene = this.scene;
|
|
705
|
+
if (!scene || this.store.scene !== scene) return;
|
|
706
|
+
const map = this.store.objectMap;
|
|
707
|
+
if (delta === undefined) {
|
|
708
|
+
map.clear();
|
|
709
|
+
for (const [id, object] of this.projector.objectMapSnapshot()) map.set(id, object);
|
|
710
|
+
} else {
|
|
711
|
+
for (const id of delta.removedIds) map.delete(id);
|
|
712
|
+
for (const id of delta.addedIds) {
|
|
713
|
+
const object = this.projector.objectOf(id);
|
|
714
|
+
if (object !== null) map.set(id, object);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const retainedSelection = [...this.store.selectedEntityIds].filter((id) => map.has(id));
|
|
718
|
+
if (retainedSelection.length !== this.store.selectedEntityIds.size) {
|
|
719
|
+
this.store.selectMultiple(retainedSelection);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Re-walk without reading the stats (host remount/adoption triggers). */
|
|
724
|
+
invalidate(): void {
|
|
725
|
+
this.refresh();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Rebind to a freshly mounted scene (HMR remount / re-adoption). */
|
|
729
|
+
adoptScene(scene: ThreeSceneRef): void {
|
|
730
|
+
this.sceneRef = scene;
|
|
731
|
+
this.refresh();
|
|
732
|
+
this.watchStructure();
|
|
733
|
+
this.syncAdoptedObjectMap();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private objectOf(id: string): THREE.Object3D | null {
|
|
737
|
+
return this.projector.objectOf(id);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Current projection for the shell viewport's native-object index. */
|
|
741
|
+
objectMapSnapshot(): Map<string, THREE.Object3D> {
|
|
742
|
+
return this.projector.objectMapSnapshot();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private record(id: string, patch: ThreeEdit): void {
|
|
746
|
+
this.edits[id] = { ...this.edits[id], ...patch };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
private toEditorNode(node: ThreeNode): EditorNode {
|
|
750
|
+
const object = node.object;
|
|
751
|
+
const crossSurfaceId = getUserData(object, 'authoringHierarchyId');
|
|
752
|
+
const crossSurfaceParentId = getUserData(object, 'authoringHierarchyParentId');
|
|
753
|
+
const crossSurfaceOrder = getUserData(object, 'authoringHierarchyOrder');
|
|
754
|
+
// The component's own label/role/type belong to the instance's ROOT ONLY.
|
|
755
|
+
// Every host element inside a component definition carries that instance's
|
|
756
|
+
// stamp (see `component-instance-root.ts`), so reading the stamp alone
|
|
757
|
+
// printed `Stage` on `Stage`'s `WorldEnvironment`, `GridMap` and `Coins`
|
|
758
|
+
// and typed all three `component`. An interior node is named by the thing
|
|
759
|
+
// it IS.
|
|
760
|
+
// A custom JSX tag is not necessarily a reusable component boundary. Importer runtimes use a
|
|
761
|
+
// project-local component as the native ENTITY constructor (for example `<UnityNode>` creates
|
|
762
|
+
// one Unity GameObject) and declare that with the existing `authoringRoot` mark. Keep it an
|
|
763
|
+
// ordinary entity here. A real prefab root can carry the same mark plus `vgaiComponentRoot`;
|
|
764
|
+
// the hierarchy mark projection applies that explicit component identity afterwards.
|
|
765
|
+
const instanceRoot =
|
|
766
|
+
isComponentInstanceRoot(object) && getUserData(object, 'authoringRoot') !== true;
|
|
767
|
+
const componentName = getUserData(object, 'authoringComponent');
|
|
768
|
+
const label =
|
|
769
|
+
(instanceRoot ? (getUserData(object, 'authoringLabel') as string | undefined) : undefined) ||
|
|
770
|
+
object.name ||
|
|
771
|
+
object.type;
|
|
772
|
+
return {
|
|
773
|
+
id: node.id,
|
|
774
|
+
label,
|
|
775
|
+
role: instanceRoot ? 'component' : 'entity',
|
|
776
|
+
kind: nativeKindOf(object),
|
|
777
|
+
// The transformed R3F callsite carries the exact component identity
|
|
778
|
+
// separately from its human label; portable CSF joins on the former.
|
|
779
|
+
// Plain Three objects retain their native Object3D type.
|
|
780
|
+
typeLabel: instanceRoot ? componentName || label : object.type,
|
|
781
|
+
parentId: node.parentId,
|
|
782
|
+
childIds: [...node.childIds],
|
|
783
|
+
...(crossSurfaceId === undefined ? {} : { crossSurfaceId }),
|
|
784
|
+
...(crossSurfaceParentId === undefined ? {} : { crossSurfaceParentId }),
|
|
785
|
+
...(crossSurfaceOrder === undefined ? {} : { crossSurfaceOrder }),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
readonly hierarchy: HierarchyProvider = {
|
|
790
|
+
crossSurfaceStructureSignature: () => this.crossSurfaceStructureSignature(),
|
|
791
|
+
roots: () => {
|
|
792
|
+
// Structural childadded/childremoved events refresh the live projection
|
|
793
|
+
// at the mutation boundary. A read must remain a pure indexed lookup:
|
|
794
|
+
// panels and protocol consumers can read several times per render, and
|
|
795
|
+
// re-walking a large imported graph here turns every gameplay frame into
|
|
796
|
+
// repeated O(world-size) authoring work even when its structure is stable.
|
|
797
|
+
return this.projector.rootNodes().map((node) => this.toEditorNode(node));
|
|
798
|
+
},
|
|
799
|
+
node: (id) => {
|
|
800
|
+
const node = this.projector.node(id);
|
|
801
|
+
return node ? this.toEditorNode(node) : null;
|
|
802
|
+
},
|
|
803
|
+
object3D: (id) => this.objectOf(id),
|
|
804
|
+
idForObject3D: (object) => this.projector.idOf(object),
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
readonly selection: SelectionProvider = {
|
|
808
|
+
get: () => [...this.store.selectedEntityIds],
|
|
809
|
+
set: (ids) => this.store.selectMultiple(ids),
|
|
810
|
+
// Component boundaries are closed by default: a normal pick resolves to the
|
|
811
|
+
// outermost component owner, a scoped pick to the next nested one, and a
|
|
812
|
+
// deep pick advances one further. Deliberately the same contract the source
|
|
813
|
+
// R3F adapter states, applied to the live projection. A graph with no
|
|
814
|
+
// component instances (every structural-path walk) has no chain, so this
|
|
815
|
+
// resolves to the raw id and is a no-op.
|
|
816
|
+
resolve: (rawId, options): SelectionResolution | null => {
|
|
817
|
+
const raw = this.hierarchy.node(rawId);
|
|
818
|
+
if (!raw) return null;
|
|
819
|
+
const innerToOuter: EditorNode[] = [];
|
|
820
|
+
let cursor: EditorNode | null = raw;
|
|
821
|
+
let guard = 0;
|
|
822
|
+
while (cursor && guard++ < 1000) {
|
|
823
|
+
if (cursor.role === 'component' || cursor.role === 'instance') innerToOuter.push(cursor);
|
|
824
|
+
cursor = cursor.parentId ? this.hierarchy.node(cursor.parentId) : null;
|
|
825
|
+
}
|
|
826
|
+
const chain = innerToOuter.reverse();
|
|
827
|
+
if (chain.length === 0) return { id: rawId };
|
|
828
|
+
const scopeIndex = options?.scopeId
|
|
829
|
+
? chain.findIndex((candidate) => candidate.id === options.scopeId)
|
|
830
|
+
: -1;
|
|
831
|
+
const normalIndex = scopeIndex >= 0 ? Math.min(scopeIndex + 1, chain.length - 1) : 0;
|
|
832
|
+
const resolvedIndex =
|
|
833
|
+
options?.intent === 'deep' ? Math.min(normalIndex + 1, chain.length - 1) : normalIndex;
|
|
834
|
+
return { id: chain[resolvedIndex]!.id };
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
/** The projector's raycast over this adapter's own projection. */
|
|
839
|
+
readonly pickable: PickProvider = {
|
|
840
|
+
pick: (clientX, clientY) => {
|
|
841
|
+
this.refresh();
|
|
842
|
+
return this.projector.pick(this.store, clientX, clientY);
|
|
843
|
+
},
|
|
844
|
+
candidates: (clientX, clientY) => {
|
|
845
|
+
this.refresh();
|
|
846
|
+
return this.projector.candidates(this.store, clientX, clientY);
|
|
847
|
+
},
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* The creation-site read surface. The index itself is a host-side
|
|
852
|
+
* `WeakMap` keyed by the live object (`creation-site-registry.ts`); this
|
|
853
|
+
* provider is only the id→object hop, so a running world is never asked
|
|
854
|
+
* anything and never observes that it was indexed.
|
|
855
|
+
*/
|
|
856
|
+
readonly truth: TruthProvider = {
|
|
857
|
+
// The READ surface answers with the same anchor the write path would use,
|
|
858
|
+
// so the inspector never shows a source line for an object whose truth is a
|
|
859
|
+
// level-data record. `position` is the property whose owner is the object
|
|
860
|
+
// itself, which is the hop this provider's id→object read already makes.
|
|
861
|
+
resolve: (id, property) => {
|
|
862
|
+
const subject = this.anchorFor(id, property);
|
|
863
|
+
return { site: subject.anchor, writeAnchorKind: subject.anchorKind };
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
/** Derived from the SAME anchor index {@link truth} answers from — the
|
|
868
|
+
* shared kit piece (`creation-site-related.ts`); this adapter contributes
|
|
869
|
+
* only its id→site hop. */
|
|
870
|
+
readonly related: RelatedSubjectsProvider = creationSiteRelated(
|
|
871
|
+
(id) => this.truth.resolve(id, 'position').site,
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
readonly transforms: TransformProvider = {
|
|
875
|
+
get: (id): Transform => readLocalTransform(this.objectOf(id)),
|
|
876
|
+
/**
|
|
877
|
+
* Every branch names a reason; none is silent. The refusals are surface
|
|
878
|
+
* facts in order of severity — there is no node, someone else holds
|
|
879
|
+
* authority — and the accepting branch cites what will actually happen to
|
|
880
|
+
* the gesture: persisted, routed to the owning body, or live-only.
|
|
881
|
+
*
|
|
882
|
+
* WHY A RUNNING FRAME IS NOT A REFUSAL. This used to answer
|
|
883
|
+
* `writable: false, 'Pause Play mode before editing live transforms.'` for
|
|
884
|
+
* EVERY node whenever `frameControl === 'host'` and the game was playing —
|
|
885
|
+
* which is every node of every adopted play mount (`oidThree` sets
|
|
886
|
+
* exactly that). Because `editor-viewport.ts` gates the gizmo's `attach` on
|
|
887
|
+
* this answer, the consequence was that play mode had no transform gizmo at
|
|
888
|
+
* all: selecting the player in a running game offered no way to move it,
|
|
889
|
+
* which is the opposite of what adopting the live scene is for.
|
|
890
|
+
*
|
|
891
|
+
* The refusal was reaching for the wrong invariant. What must be true is
|
|
892
|
+
* not "the frame is stopped" — it is "the edit lands on whatever WRITES
|
|
893
|
+
* this pose", and for a body-driven node that is now true while the frame
|
|
894
|
+
* runs (`markedBodies` / `SystemAdapters.physics` below teleport the body,
|
|
895
|
+
* and the next step puts the result back on the node). For a node nothing
|
|
896
|
+
* else writes, setting the node was always the whole edit. What remains is
|
|
897
|
+
* the third case — a pose the game's own per-frame code rewrites — and
|
|
898
|
+
* there the honest answer is the one this seam exists to give: writable,
|
|
899
|
+
* with a reason that says the running game may write it again. A refusal
|
|
900
|
+
* that also blanked the two cases that DO work bought that honesty far too
|
|
901
|
+
* dearly.
|
|
902
|
+
*/
|
|
903
|
+
editability: (id: string, channel: TransformChannel): TransformEditability => {
|
|
904
|
+
if (!this.objectOf(id)) return { writable: false, reason: NO_OBJECT_REASON };
|
|
905
|
+
const networking = getActiveNetworking();
|
|
906
|
+
if (networking?.networkId(id) && !networking.editable(id)) {
|
|
907
|
+
return {
|
|
908
|
+
writable: false,
|
|
909
|
+
reason: 'This transform is controlled by remote or server network authority.',
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
if (this.bodyOwns(id)) {
|
|
913
|
+
return {
|
|
914
|
+
writable: true,
|
|
915
|
+
reason: 'Moves the physics body that owns this node.',
|
|
916
|
+
...this.removableFlag(id, channel),
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
if (this.frameControl === 'host' && this.store.playState === 'playing') {
|
|
920
|
+
return { writable: true, reason: LIVE_FRAME_REASON, ...this.removableFlag(id, channel) };
|
|
921
|
+
}
|
|
922
|
+
if (!this.persist) return { writable: true };
|
|
923
|
+
return {
|
|
924
|
+
writable: true,
|
|
925
|
+
reason: this.persist.describe(this.anchorFor(id, channel)),
|
|
926
|
+
...this.removableFlag(id, channel),
|
|
927
|
+
};
|
|
928
|
+
},
|
|
929
|
+
// The world owns this object's transform; freeze whatever drives it so the
|
|
930
|
+
// gizmo edit isn't overwritten next frame, then thaw on release.
|
|
931
|
+
beginEdit: (id) => {
|
|
932
|
+
this.historyResource?.assertCanMutate();
|
|
933
|
+
if (!this.objectOf(id) || !this.editableByNetwork(id)) return;
|
|
934
|
+
this.loop?.pause();
|
|
935
|
+
this.physicsFor(id)?.freeze(id);
|
|
936
|
+
this.markedBodies.freeze(id);
|
|
937
|
+
this.editStartState = this.persist ? this.captureHistoryState() : undefined;
|
|
938
|
+
this.editBaseline = undefined;
|
|
939
|
+
},
|
|
940
|
+
apply: (id, transform) => {
|
|
941
|
+
const object = this.objectOf(id);
|
|
942
|
+
if (!object || !this.editableByNetwork(id)) return;
|
|
943
|
+
// The gesture's baseline is captured on its FIRST frame, not in
|
|
944
|
+
// `beginEdit` — that hook is not told which object is being dragged, and
|
|
945
|
+
// the baseline has to be the value the user started from for the
|
|
946
|
+
// creation-site planner's equality check to mean anything.
|
|
947
|
+
if (this.persist && !this.editBaseline) this.editBaseline = this.captureChannelBaseline(id);
|
|
948
|
+
this.writeTransform(id, transform);
|
|
949
|
+
// Commit the pose to the owning body so the running simulation tracks it
|
|
950
|
+
// instead of overwriting the edit on the next step.
|
|
951
|
+
//
|
|
952
|
+
// WHY EVERY FRAME OF THE DRAG, rather than suspending the world's
|
|
953
|
+
// body→node sync for the dragged node and teleporting once on release.
|
|
954
|
+
// Both stop the fight; only this one keeps the picture honest. A
|
|
955
|
+
// suspended sync shows the node where the pointer is while the simulation
|
|
956
|
+
// still has it somewhere else, so the drop is where the two RECONCILE
|
|
957
|
+
// rather than where the object was drawn — and a body that never moved
|
|
958
|
+
// during the drag arrives at release with a step of accumulated
|
|
959
|
+
// divergence (velocity, contacts, a character controller's own motion)
|
|
960
|
+
// that resolves as a visible jump. Teleporting continuously means the
|
|
961
|
+
// pose on screen is the pose the simulation has, at every frame of the
|
|
962
|
+
// gesture and at the moment of release; it also needs no suspend/restore
|
|
963
|
+
// state to leak if a drag is interrupted, and it works for a frame the
|
|
964
|
+
// adapter cannot pause, which is the whole play-mode case.
|
|
965
|
+
this.physicsFor(id)?.commit(id, transform);
|
|
966
|
+
this.markedBodies.commit(id, transform);
|
|
967
|
+
this.store.notifyIngestEdit();
|
|
968
|
+
},
|
|
969
|
+
endEdit: (id) => {
|
|
970
|
+
this.loop?.resume();
|
|
971
|
+
const object = this.objectOf(id);
|
|
972
|
+
const label = `Transform ${object?.name || 'Object'}`;
|
|
973
|
+
const before = this.editStartState;
|
|
974
|
+
const baseline = this.editBaseline;
|
|
975
|
+
this.editStartState = undefined;
|
|
976
|
+
this.editBaseline = undefined;
|
|
977
|
+
if (object) {
|
|
978
|
+
this.physicsFor(id)?.unfreeze(id);
|
|
979
|
+
this.markedBodies.unfreeze(id);
|
|
980
|
+
this.store.notifyIngestEdit();
|
|
981
|
+
}
|
|
982
|
+
// The gesture's own ack — awaited by whoever closed it, so a caller that
|
|
983
|
+
// has the ack has the byte. `undefined` when this surface has no backend
|
|
984
|
+
// at all, which is the honest "this adapter performed no write".
|
|
985
|
+
return this.persist ? this.persistOrJournal(id, label, before, baseline) : undefined;
|
|
986
|
+
},
|
|
987
|
+
/**
|
|
988
|
+
* DROP THE CHANNEL'S AUTHORED ATTRIBUTE — the same door the first-party R3F
|
|
989
|
+
* lane grew in #2218, on the lane that has the same appended-attribute
|
|
990
|
+
* shape: `pipedWrite` sends `addIfMissing`, so placing an ingested game's
|
|
991
|
+
* object whose callsite carried no `position` ADDS one, and writing the old
|
|
992
|
+
* numbers back leaves it standing. The file is then a byte away from where
|
|
993
|
+
* it started forever, and an edit/revert round trip over a lane whose
|
|
994
|
+
* writes are entirely healthy grades UNVERIFIABLE.
|
|
995
|
+
*
|
|
996
|
+
* Through the SAME pipe every other edit here takes, resolving on the
|
|
997
|
+
* REMOVAL verb rather than the write: a backend with no removal door
|
|
998
|
+
* (`SourceRemovalDoor`, `source-persistence-backend.ts`) reaches the
|
|
999
|
+
* live-only floor by name instead of acking a destination no byte left.
|
|
1000
|
+
* Both shipped backends now carry one — the OID lane's `removeProp` and
|
|
1001
|
+
* the creation-site lane's own-line-assignment delete
|
|
1002
|
+
* (`planCreationSiteRemoval`) — so the floor remains only for subjects
|
|
1003
|
+
* whose door is shut. Absent backend ⇒ no ack at all,
|
|
1004
|
+
* which is this adapter's honest "performed no write" (the play-mode
|
|
1005
|
+
* adoption's case, where `createEphemeralPersistence` is injected over it).
|
|
1006
|
+
*/
|
|
1007
|
+
remove: (id, channel) => {
|
|
1008
|
+
if (!this.persist?.removal) return;
|
|
1009
|
+
return runWritePipe(this.pipedRemove(id, channel));
|
|
1010
|
+
},
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* `TransformEditability.removable` for one channel — present only when this
|
|
1015
|
+
* surface's backend declares a removal door AND that door is open for THIS
|
|
1016
|
+
* subject. Both are read from the backend rather than inferred here: the
|
|
1017
|
+
* adapter knows the subject, the backend knows the dialect.
|
|
1018
|
+
*
|
|
1019
|
+
* Spread into the answer (`...`) rather than assigned, so a channel with no
|
|
1020
|
+
* door OMITS the key — `compose.ts` reads `removable === true`, and an
|
|
1021
|
+
* explicit `false` would be a third state nothing distinguishes.
|
|
1022
|
+
*/
|
|
1023
|
+
private removableFlag(id: string, channel: TransformChannel): { removable?: true } {
|
|
1024
|
+
const removal = this.persist?.removal;
|
|
1025
|
+
if (!removal) return {};
|
|
1026
|
+
return removal.available(this.anchorFor(id, channel)) ? { removable: true } : {};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* THIS ADAPTER'S REMOVAL PLUG INTO THE PIPE — `resolve → remove → record`.
|
|
1031
|
+
*
|
|
1032
|
+
* The resolution asks the door, not the write gate: a subject whose write is
|
|
1033
|
+
* fine can still have nowhere to express absence, and the two answers are
|
|
1034
|
+
* produced by different code (`SourceRemovalDoor.available` vs `gate`). The
|
|
1035
|
+
* `live-only` lane check stays for the same structural reason `pipedWrite`
|
|
1036
|
+
* keeps it — a subject the adapter classified `live-only` must not reach a
|
|
1037
|
+
* dialect writer even if a gate waved it through.
|
|
1038
|
+
*
|
|
1039
|
+
* NOTHING IS RECORDED IN THE SESSION JOURNAL, and nothing re-poses the live
|
|
1040
|
+
* object. There is no live half to journal: a removal changes the FILE, and
|
|
1041
|
+
* the value in force once the attribute is gone is whatever the component's
|
|
1042
|
+
* own signature declares — which only the world's re-derive can report, and
|
|
1043
|
+
* a fiber world does re-derive. The undo is the backend's own project-source
|
|
1044
|
+
* transaction (`withProjectSourceHistory` wraps `removeProp`), so a journal
|
|
1045
|
+
* entry here would make one removal two undos, one of which restores
|
|
1046
|
+
* nothing.
|
|
1047
|
+
*/
|
|
1048
|
+
private pipedRemove(id: string, channel: TransformChannel): PipedWrite {
|
|
1049
|
+
const backend = this.persist!;
|
|
1050
|
+
const removal = backend.removal!;
|
|
1051
|
+
const label = `Remove ${this.objectOf(id)?.name || 'Object'} ${channel}`;
|
|
1052
|
+
return {
|
|
1053
|
+
resolve: (): WriteResolution => {
|
|
1054
|
+
const subject = this.anchorFor(id, channel);
|
|
1055
|
+
if (!removal.available(subject)) return resolvesLiveOnly(backend.describe(subject));
|
|
1056
|
+
if (subject.anchorKind === 'live-only') return resolvesLiveOnly(backend.describe(subject));
|
|
1057
|
+
return {
|
|
1058
|
+
reaches: 'writer',
|
|
1059
|
+
anchorKind: subject.anchorKind,
|
|
1060
|
+
destination: backend.destination(),
|
|
1061
|
+
write: () => removal.perform({ ...subject, label }),
|
|
1062
|
+
};
|
|
1063
|
+
},
|
|
1064
|
+
record: () => {},
|
|
1065
|
+
report: (reason) => {
|
|
1066
|
+
if (backend.armed()) backend.report(label, reason);
|
|
1067
|
+
},
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** True when a rigid body writes this node's pose — either the game's
|
|
1072
|
+
* registered `SystemAdapters.physics` says so, or the node carries the
|
|
1073
|
+
* body mark. Both are the same question asked of the two places a world can
|
|
1074
|
+
* have answered it. */
|
|
1075
|
+
private bodyOwns(id: string): boolean {
|
|
1076
|
+
const object = this.objectOf(id);
|
|
1077
|
+
if (!object) return false;
|
|
1078
|
+
const cached = this.bodyOwnershipByObject.get(object);
|
|
1079
|
+
if (cached !== undefined) return cached;
|
|
1080
|
+
|
|
1081
|
+
const owner = getActivePhysics()?.ownerOf(id);
|
|
1082
|
+
if (owner === 'physics') {
|
|
1083
|
+
this.bodyOwnershipByObject.set(object, true);
|
|
1084
|
+
return true;
|
|
1085
|
+
}
|
|
1086
|
+
const marked = bodyOwningNode(object) !== undefined;
|
|
1087
|
+
if (marked || owner === 'editor') this.bodyOwnershipByObject.set(object, marked);
|
|
1088
|
+
return marked;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** False when the active networking adapter marks the node inspect-only.
|
|
1092
|
+
* Keyed by node id since P-4 — the seam no longer speaks `Object3D`. */
|
|
1093
|
+
private editableByNetwork(id: string): boolean {
|
|
1094
|
+
const networking = getActiveNetworking();
|
|
1095
|
+
return networking ? networking.editable(id) : true;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
readonly inspector: InspectorProvider = {
|
|
1099
|
+
// Reflected from the live object — no schema, no fabricated properties.
|
|
1100
|
+
// `name`/`visible` are the seam's reserved paths (the hierarchy rename
|
|
1101
|
+
// field and eye toggle render against them for any adapter reporting them).
|
|
1102
|
+
// Physics/Animation are absent because a live object carries no engine
|
|
1103
|
+
// body/anim-graph to reflect — that is honest absence, not a gap.
|
|
1104
|
+
properties: (id): PropertyDescriptor[] => {
|
|
1105
|
+
const object = this.objectOf(id);
|
|
1106
|
+
if (!object) return [];
|
|
1107
|
+
const properties: PropertyDescriptor[] = [
|
|
1108
|
+
{ path: 'name', label: 'Name', type: 'string' },
|
|
1109
|
+
{ path: 'visible', label: 'Visible', type: 'boolean' },
|
|
1110
|
+
{ path: 'object.type', label: 'Native type', type: 'string', readonly: true },
|
|
1111
|
+
];
|
|
1112
|
+
const authoringSubject = object3DAuthoringSubjectOf(object);
|
|
1113
|
+
if (authoringSubject?.fields) {
|
|
1114
|
+
properties.push(
|
|
1115
|
+
...authoringSubject
|
|
1116
|
+
.fields()
|
|
1117
|
+
.filter((field) => !['name', 'visible'].includes(field.path))
|
|
1118
|
+
.map(({ value: _value, ...descriptor }) => descriptor),
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
if (colorMaterialOf(object)) {
|
|
1122
|
+
properties.push({
|
|
1123
|
+
path: 'material.color',
|
|
1124
|
+
label: 'Color',
|
|
1125
|
+
type: 'color',
|
|
1126
|
+
group: 'Material',
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
const light = lightOf(object);
|
|
1130
|
+
if (light) {
|
|
1131
|
+
properties.push({ path: 'light.intensity', label: 'Intensity', type: 'number' });
|
|
1132
|
+
properties.push({ path: 'light.color', label: 'Light Color', type: 'color' });
|
|
1133
|
+
if (rangedLightOf(object)) {
|
|
1134
|
+
properties.push({ path: 'light.distance', label: 'Distance', type: 'number' });
|
|
1135
|
+
}
|
|
1136
|
+
if (shadowCastingLightOf(object)) {
|
|
1137
|
+
properties.push({ path: 'shadow.cast', label: 'Cast Shadow', type: 'boolean' });
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (perspectiveCameraOf(object)) {
|
|
1141
|
+
properties.push({ path: 'camera.fov', label: 'FOV', type: 'number' });
|
|
1142
|
+
properties.push({ path: 'camera.near', label: 'Near', type: 'number' });
|
|
1143
|
+
properties.push({ path: 'camera.far', label: 'Far', type: 'number' });
|
|
1144
|
+
}
|
|
1145
|
+
if (geometryOf(object)) {
|
|
1146
|
+
properties.push({
|
|
1147
|
+
path: 'mesh.vertices',
|
|
1148
|
+
label: 'Vertices',
|
|
1149
|
+
type: 'number',
|
|
1150
|
+
readonly: true,
|
|
1151
|
+
});
|
|
1152
|
+
properties.push({ path: 'shadow.cast', label: 'Cast Shadow', type: 'boolean' });
|
|
1153
|
+
properties.push({ path: 'shadow.receive', label: 'Receive Shadow', type: 'boolean' });
|
|
1154
|
+
}
|
|
1155
|
+
return properties;
|
|
1156
|
+
},
|
|
1157
|
+
get: (id, path) => {
|
|
1158
|
+
const object = this.objectOf(id);
|
|
1159
|
+
if (!object) return undefined;
|
|
1160
|
+
if (path === 'name') return object.name;
|
|
1161
|
+
if (path === 'visible') return object.visible;
|
|
1162
|
+
if (path === 'object.type') return object.type;
|
|
1163
|
+
const subjectField = object3DAuthoringSubjectOf(object)
|
|
1164
|
+
?.fields?.()
|
|
1165
|
+
.find((field) => field.path === path);
|
|
1166
|
+
if (subjectField) return subjectField.value();
|
|
1167
|
+
if (path === 'material.color') {
|
|
1168
|
+
const material = colorMaterialOf(object);
|
|
1169
|
+
return material ? `#${material.color.getHexString()}` : undefined;
|
|
1170
|
+
}
|
|
1171
|
+
if (path === 'light.intensity') return lightOf(object)?.intensity;
|
|
1172
|
+
if (path === 'light.distance') return rangedLightOf(object)?.distance;
|
|
1173
|
+
if (path === 'light.color') {
|
|
1174
|
+
const light = lightOf(object);
|
|
1175
|
+
return light ? `#${light.color.getHexString()}` : undefined;
|
|
1176
|
+
}
|
|
1177
|
+
if (path === 'camera.fov') return perspectiveCameraOf(object)?.fov;
|
|
1178
|
+
if (path === 'camera.near') return perspectiveCameraOf(object)?.near;
|
|
1179
|
+
if (path === 'camera.far') return perspectiveCameraOf(object)?.far;
|
|
1180
|
+
if (path === 'mesh.vertices') {
|
|
1181
|
+
const geometry = geometryOf(object);
|
|
1182
|
+
return geometry ? (geometry.getAttribute('position')?.count ?? 0) : undefined;
|
|
1183
|
+
}
|
|
1184
|
+
if (path === 'shadow.cast') return object.castShadow;
|
|
1185
|
+
if (path === 'shadow.receive') return object.receiveShadow;
|
|
1186
|
+
return undefined;
|
|
1187
|
+
},
|
|
1188
|
+
set: (id, path, value) => {
|
|
1189
|
+
this.historyResource?.assertCanMutate();
|
|
1190
|
+
if (!this.persist) {
|
|
1191
|
+
this.writeProp(id, path, value);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const before = this.captureHistoryState();
|
|
1195
|
+
// Read the baseline BEFORE the write: the creation-site planner may only
|
|
1196
|
+
// rewrite a literal it can prove is the value currently in force, and
|
|
1197
|
+
// "currently" means before this gesture touched anything.
|
|
1198
|
+
const baseline = this.readChannel(id, path);
|
|
1199
|
+
this.writeProp(id, path, value);
|
|
1200
|
+
const label = `Set ${this.objectOf(id)?.name || 'Object'} ${path}`;
|
|
1201
|
+
const next = this.readChannel(id, path);
|
|
1202
|
+
const moved =
|
|
1203
|
+
baseline !== undefined &&
|
|
1204
|
+
next !== undefined &&
|
|
1205
|
+
JSON.stringify(baseline) !== JSON.stringify(next);
|
|
1206
|
+
// A value that did not actually move has nothing to write ANYWHERE, so it
|
|
1207
|
+
// never enters the pipe and this provider performed no write to answer
|
|
1208
|
+
// for — it still journals, because `writeProp` may have touched the live
|
|
1209
|
+
// object in a way the channel read cannot see.
|
|
1210
|
+
if (!moved) {
|
|
1211
|
+
this.recordHistory(label, before);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
return persistChannelWrite({
|
|
1215
|
+
backend: this.persist!,
|
|
1216
|
+
subject: () => this.anchorFor(id, path),
|
|
1217
|
+
baseline: baseline!,
|
|
1218
|
+
next: next!,
|
|
1219
|
+
label,
|
|
1220
|
+
journal: () => this.recordHistory(label, before),
|
|
1221
|
+
});
|
|
1222
|
+
},
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
subscribe(listener: () => void): () => void {
|
|
1226
|
+
return this.store.subscribe(listener);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
dispose(): void {
|
|
1230
|
+
this.disposed = true;
|
|
1231
|
+
for (const object of this.watchedForStructure) {
|
|
1232
|
+
object.removeEventListener('childadded', this.onStructureChanged);
|
|
1233
|
+
object.removeEventListener('childremoved', this.onStructureChanged);
|
|
1234
|
+
}
|
|
1235
|
+
this.watchedForStructure.clear();
|
|
1236
|
+
this.changedStructureRoots.clear();
|
|
1237
|
+
this.historyResource?.dispose();
|
|
1238
|
+
this.persist?.dispose();
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// ─────────────────────────────────────────── live channel read/write
|
|
1242
|
+
|
|
1243
|
+
/** One property's value in force, in the shape the creation-site planner and
|
|
1244
|
+
* its history snapshots speak. */
|
|
1245
|
+
private readChannel(id: string, property: string): ChannelValue | undefined {
|
|
1246
|
+
const object = this.objectOf(id);
|
|
1247
|
+
if (!object) return undefined;
|
|
1248
|
+
if (property === 'position') return object.position.toArray() as [number, number, number];
|
|
1249
|
+
if (property === 'rotation') return [object.rotation.x, object.rotation.y, object.rotation.z];
|
|
1250
|
+
if (property === 'scale') return object.scale.toArray() as [number, number, number];
|
|
1251
|
+
return this.inspector.get(id, property) as ChannelValue | undefined;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** Put one property back on the live object — the undo/redo half of a
|
|
1255
|
+
* persisted edit. Deliberately routed through the SAME writers an ordinary
|
|
1256
|
+
* edit uses, so a restored value is indistinguishable from an authored one. */
|
|
1257
|
+
private applyChannel(id: string, property: string, value: ChannelValue): void {
|
|
1258
|
+
const object = this.objectOf(id);
|
|
1259
|
+
if (!object) return;
|
|
1260
|
+
if (property === 'position' && Array.isArray(value)) {
|
|
1261
|
+
object.position.fromArray(value as number[]);
|
|
1262
|
+
} else if (property === 'rotation' && Array.isArray(value)) {
|
|
1263
|
+
const [x, y, z] = value as number[];
|
|
1264
|
+
object.rotation.set(x ?? 0, y ?? 0, z ?? 0);
|
|
1265
|
+
} else if (property === 'scale' && Array.isArray(value)) {
|
|
1266
|
+
object.scale.fromArray(value as number[]);
|
|
1267
|
+
} else {
|
|
1268
|
+
this.writeProp(id, property, value);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
this.store.notifyIngestEdit();
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** Write a transform to the live object + record it in the session edit map
|
|
1275
|
+
* (no undo push). */
|
|
1276
|
+
private writeTransform(id: string, t: Transform): void {
|
|
1277
|
+
this.historyResource?.assertCanMutate();
|
|
1278
|
+
const object = this.objectOf(id);
|
|
1279
|
+
if (!object) return;
|
|
1280
|
+
object.position.set(t.position[0], t.position[1], t.position[2]);
|
|
1281
|
+
object.quaternion.set(t.rotation[0], t.rotation[1], t.rotation[2], t.rotation[3]);
|
|
1282
|
+
object.scale.set(t.scale[0], t.scale[1], t.scale[2]);
|
|
1283
|
+
this.record(id, { position: t.position, rotation: t.rotation, scale: t.scale });
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/** Mutate one reflectable field on the live object + record persistable ones
|
|
1287
|
+
* (no undo push). */
|
|
1288
|
+
private writeProp(id: string, path: string, value: unknown): void {
|
|
1289
|
+
const object = this.objectOf(id);
|
|
1290
|
+
if (!object) return;
|
|
1291
|
+
if (path === 'name') object.name = String(value);
|
|
1292
|
+
else if (path === 'visible') {
|
|
1293
|
+
object.visible = Boolean(value);
|
|
1294
|
+
this.record(id, { visible: object.visible });
|
|
1295
|
+
} else if (path === 'material.color') {
|
|
1296
|
+
const material = colorMaterialOf(object);
|
|
1297
|
+
if (material) {
|
|
1298
|
+
material.color.set(String(value));
|
|
1299
|
+
this.record(id, { color: `#${material.color.getHexString()}` });
|
|
1300
|
+
}
|
|
1301
|
+
} else if (path === 'light.intensity') {
|
|
1302
|
+
const light = lightOf(object);
|
|
1303
|
+
if (light) light.intensity = Number(value);
|
|
1304
|
+
} else if (path === 'light.distance') {
|
|
1305
|
+
const light = rangedLightOf(object);
|
|
1306
|
+
if (light) light.distance = Number(value);
|
|
1307
|
+
} else if (path === 'light.color') {
|
|
1308
|
+
lightOf(object)?.color.set(String(value));
|
|
1309
|
+
} else if (path === 'camera.fov' || path === 'camera.near' || path === 'camera.far') {
|
|
1310
|
+
const camera = perspectiveCameraOf(object);
|
|
1311
|
+
if (camera) {
|
|
1312
|
+
(camera as unknown as Record<string, number>)[path.split('.')[1]!] = Number(value);
|
|
1313
|
+
camera.updateProjectionMatrix();
|
|
1314
|
+
}
|
|
1315
|
+
} else if (path === 'shadow.cast') object.castShadow = Boolean(value);
|
|
1316
|
+
else if (path === 'shadow.receive') object.receiveShadow = Boolean(value);
|
|
1317
|
+
this.store.notifyIngestEdit();
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// ───────────────────────────────────────────── creation-site anchoring
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* The creation site an edit to `property` would have to be written at, plus
|
|
1324
|
+
* how many objects that site built.
|
|
1325
|
+
*
|
|
1326
|
+
* The OWNER hop is the point: a material colour lives on the material, which
|
|
1327
|
+
* has its own `new THREE.MeshBasicMaterial({ color: … })` somewhere else
|
|
1328
|
+
* entirely, so anchoring it to the MESH's line would look for a `color` that
|
|
1329
|
+
* line never mentions. `creation-site-edit.ts`'s channel table is what says
|
|
1330
|
+
* which live object owns which property.
|
|
1331
|
+
*/
|
|
1332
|
+
private anchorFor(id: string, property: string): SourceWriteSubject {
|
|
1333
|
+
const object = this.objectOf(id);
|
|
1334
|
+
const channel = channelFor(property);
|
|
1335
|
+
const target = object && channel?.owner === 'material' ? colorMaterialOf(object) : object;
|
|
1336
|
+
// THE SERVE-TIME SOURCE STAMP FIRST, when the world carries one. A fiber
|
|
1337
|
+
// world constructs every object inside `node_modules`, so the creation-site
|
|
1338
|
+
// registry below answers "constructed outside project source" for all of
|
|
1339
|
+
// them — while the game's own JSX says exactly where each one is written,
|
|
1340
|
+
// and `vite-plugin-ui-oid.ts` stamped that callsite onto the object. The
|
|
1341
|
+
// stamped answer is the SAME KIND of fact as a `new` site (a source
|
|
1342
|
+
// file:line an edit can be written at), so it belongs in the same slot
|
|
1343
|
+
// rather than beside it.
|
|
1344
|
+
//
|
|
1345
|
+
// `instances` is 1 by construction here: the address is ONE JSX element.
|
|
1346
|
+
// A repeated callsite renders many objects and a literal written on it
|
|
1347
|
+
// moves all of them — which is what the source says, and is the same rule
|
|
1348
|
+
// the first-party R3F lane applies (`r3f-source-authoring-adapter.ts`'s
|
|
1349
|
+
// `#n` authority rule), not the `new`-expression multiplicity question the
|
|
1350
|
+
// creation-site backend refuses on.
|
|
1351
|
+
const sourceOid =
|
|
1352
|
+
channel?.owner === 'material'
|
|
1353
|
+
? ownOidOf(target as { readonly userData?: Record<string, unknown> } | null | undefined)
|
|
1354
|
+
: authoringOidOf(target as THREE.Object3D | null | undefined);
|
|
1355
|
+
const stamped = sourceOid ? this.persist?.anchor?.(sourceOid) : null;
|
|
1356
|
+
if (stamped) {
|
|
1357
|
+
// TWO KINDS SHARE THIS BRANCH, and the difference is not visible in the
|
|
1358
|
+
// anchor: an ordinary JSX prop keeps the value the author wrote, while a
|
|
1359
|
+
// body-placed spawn is re-read by a simulation that then owns the node's
|
|
1360
|
+
// matrix — so only the second has to survive the re-settle. The backend
|
|
1361
|
+
// holds the source index that knows which, and answers here.
|
|
1362
|
+
const placed = sourceOid ? this.persist?.physicsPlaced?.(sourceOid, property) : false;
|
|
1363
|
+
return {
|
|
1364
|
+
entityId: id,
|
|
1365
|
+
property,
|
|
1366
|
+
anchor: stamped,
|
|
1367
|
+
anchorKind: placed ? 'physics-binding' : 'source-prop',
|
|
1368
|
+
instances: 1,
|
|
1369
|
+
sourceOid,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
// DATA NEXT, and no fallback. An object the game itself anchored to a
|
|
1373
|
+
// record in its own level data has NO honest source anchor: no `new`
|
|
1374
|
+
// expression in the game's code mentions its position, so anchoring it to
|
|
1375
|
+
// the line that happened to build its mesh would report a `file:line` the
|
|
1376
|
+
// edit could never be written at. `dataRecordAnchor` therefore answers for
|
|
1377
|
+
// every record-carrying object — with the write when a writer is reachable,
|
|
1378
|
+
// and with the reason there is none when it is not.
|
|
1379
|
+
const record = dataRecordIndexOf(target);
|
|
1380
|
+
if (record !== null) {
|
|
1381
|
+
return {
|
|
1382
|
+
entityId: id,
|
|
1383
|
+
property,
|
|
1384
|
+
anchor: dataRecordAnchor(record),
|
|
1385
|
+
anchorKind: 'data-record',
|
|
1386
|
+
instances: 1,
|
|
1387
|
+
...(sourceOid ? { sourceOid } : {}),
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
const anchor = sourceOid
|
|
1391
|
+
? (this.options.sourceAnchor?.(sourceOid) ?? {
|
|
1392
|
+
anchored: false as const,
|
|
1393
|
+
reason: 'The source index has not resolved this object’s OID.',
|
|
1394
|
+
})
|
|
1395
|
+
: creationSiteAnchor(target ?? null);
|
|
1396
|
+
return {
|
|
1397
|
+
entityId: id,
|
|
1398
|
+
property,
|
|
1399
|
+
anchor,
|
|
1400
|
+
// THE LANE IS NOT THE ADDRESS, and this branch is where the two come
|
|
1401
|
+
// apart. A stamped object whose oid the client-side index has not
|
|
1402
|
+
// resolved has NO anchor to show — but its write still travels the
|
|
1403
|
+
// source-prop lane, because `writeProp` sends the OID and the SERVER
|
|
1404
|
+
// resolves it. That is this backend's own stated behaviour, not an
|
|
1405
|
+
// inference: `oid-source-persistence.ts`'s `refreshIndex` says "without
|
|
1406
|
+
// the index this backend still WRITES (the server resolves the oid
|
|
1407
|
+
// itself); it just cannot name the file:line". Classifying it `live-only`
|
|
1408
|
+
// would therefore grade it against the wrong contract — live-only's bar
|
|
1409
|
+
// is that NO byte moves, and this lane's write moves one.
|
|
1410
|
+
anchorKind: sourceOid
|
|
1411
|
+
? this.persist?.anchor
|
|
1412
|
+
? 'source-prop'
|
|
1413
|
+
: 'live-only'
|
|
1414
|
+
: anchor.anchored
|
|
1415
|
+
? 'construction-literal'
|
|
1416
|
+
: 'live-only',
|
|
1417
|
+
instances:
|
|
1418
|
+
anchor.anchored && anchor.kind === 'source' ? (sourceOid ? 1 : instancesAtSite(anchor)) : 0,
|
|
1419
|
+
...(sourceOid ? { sourceOid } : {}),
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* How much of THIS mount's world an edit can actually be written back to —
|
|
1425
|
+
* the measurement the ingest coverage report's `persistence` row needs so it
|
|
1426
|
+
* can stop reporting `ok` over a world where no object reaches a write path.
|
|
1427
|
+
*
|
|
1428
|
+
* A folder being writable (the server's ownership answer) says nothing about
|
|
1429
|
+
* whether any OBJECT in the mounted world has a source address; a fiber world
|
|
1430
|
+
* in a writable folder had every object unanchored, and the row said `ok`.
|
|
1431
|
+
* `addressable` counts the nodes whose planned anchor for a transform edit is
|
|
1432
|
+
* a real source/data address, which is exactly the precondition every write
|
|
1433
|
+
* path here starts from.
|
|
1434
|
+
*/
|
|
1435
|
+
measureWriteReach(): {
|
|
1436
|
+
addressable: number;
|
|
1437
|
+
total: number;
|
|
1438
|
+
destination: string;
|
|
1439
|
+
byKind: Record<WriteAnchorKind, number>;
|
|
1440
|
+
} {
|
|
1441
|
+
this.refresh();
|
|
1442
|
+
let addressable = 0;
|
|
1443
|
+
// PER KIND, because `addressable` alone cannot say WHICH lane carries a
|
|
1444
|
+
// world — and "some objects reach source" is exactly the answer that let a
|
|
1445
|
+
// world's physics-placed cargo go unexercised while the row read healthy.
|
|
1446
|
+
// Derived from the same planning call, so the two can never disagree.
|
|
1447
|
+
const byKind = emptyWriteAnchorKindCounts();
|
|
1448
|
+
for (const id of this.projector.nodes.keys()) {
|
|
1449
|
+
const subject = this.anchorFor(id, 'position');
|
|
1450
|
+
byKind[subject.anchorKind]++;
|
|
1451
|
+
if (subject.anchor.anchored) addressable++;
|
|
1452
|
+
}
|
|
1453
|
+
return {
|
|
1454
|
+
addressable,
|
|
1455
|
+
total: this.projector.nodes.size,
|
|
1456
|
+
destination: this.persist?.destination() ?? LIVE_ONLY_DESTINATION,
|
|
1457
|
+
byKind,
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ───────────────────────────────────────────── gesture close-out
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* The transform channels' values as the creation-site planner reads them.
|
|
1465
|
+
*
|
|
1466
|
+
* `rotation` is an EULER here, not the quaternion `TransformProvider` speaks:
|
|
1467
|
+
* source says `object.rotation.set(x, y, z)` / `object.rotation.y =`, so an
|
|
1468
|
+
* Euler is what a literal at a creation site actually holds. The live object
|
|
1469
|
+
* keeps both in sync, so reading `object.rotation` after a quaternion write is
|
|
1470
|
+
* reading three's own conversion rather than reimplementing it.
|
|
1471
|
+
*/
|
|
1472
|
+
private captureChannelBaseline(id: string): Record<string, ChannelValue> | undefined {
|
|
1473
|
+
const object = this.objectOf(id);
|
|
1474
|
+
if (!object) return undefined;
|
|
1475
|
+
return {
|
|
1476
|
+
position: object.position.toArray() as [number, number, number],
|
|
1477
|
+
rotation: [object.rotation.x, object.rotation.y, object.rotation.z],
|
|
1478
|
+
scale: object.scale.toArray() as [number, number, number],
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Close out one gizmo gesture: write it into the world's own source if every
|
|
1484
|
+
* gate is open, and otherwise journal it live-only.
|
|
1485
|
+
*
|
|
1486
|
+
* ONE gesture becomes ONE history entry either way — the persisted path's
|
|
1487
|
+
* transaction carries both the file and the live value, so the live-only
|
|
1488
|
+
* journal is SKIPPED when it succeeds rather than added to it.
|
|
1489
|
+
*
|
|
1490
|
+
* A gesture that moved more than one channel is refused for persistence as a
|
|
1491
|
+
* whole rather than written channel-by-channel: a partial write would leave
|
|
1492
|
+
* one channel in the file and one only in the session, and one Ctrl+Z would
|
|
1493
|
+
* then undo half a drag. The transform gizmo's modes are exclusive, so this
|
|
1494
|
+
* is a soundness rule that costs nothing in practice.
|
|
1495
|
+
*/
|
|
1496
|
+
private persistOrJournal(
|
|
1497
|
+
id: string,
|
|
1498
|
+
label: string,
|
|
1499
|
+
before: ThreeHistoryState | undefined,
|
|
1500
|
+
baseline: Record<string, ChannelValue> | undefined,
|
|
1501
|
+
): Promise<WriteAck> | undefined {
|
|
1502
|
+
const backend = this.persist;
|
|
1503
|
+
if (!backend) return undefined;
|
|
1504
|
+
const after = baseline ? this.captureChannelBaseline(id) : undefined;
|
|
1505
|
+
const changed =
|
|
1506
|
+
baseline && after
|
|
1507
|
+
? Object.keys(baseline).filter(
|
|
1508
|
+
(channel) => JSON.stringify(baseline[channel]) !== JSON.stringify(after[channel]),
|
|
1509
|
+
)
|
|
1510
|
+
: [];
|
|
1511
|
+
if (changed.length > 1) backend.report(label, multiChannelRefusal(changed));
|
|
1512
|
+
const property = changed.length === 1 ? changed[0]! : null;
|
|
1513
|
+
if (property === null) {
|
|
1514
|
+
// Nothing single-channel to write, so there is no edit for the pipe to
|
|
1515
|
+
// carry — but the live values still have to be undoable.
|
|
1516
|
+
this.recordHistory(label, before);
|
|
1517
|
+
return undefined;
|
|
1518
|
+
}
|
|
1519
|
+
return persistChannelWrite({
|
|
1520
|
+
backend,
|
|
1521
|
+
subject: () => this.anchorFor(id, property),
|
|
1522
|
+
baseline: baseline![property]!,
|
|
1523
|
+
next: after![property]!,
|
|
1524
|
+
label,
|
|
1525
|
+
journal: () => this.recordHistory(label, before),
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// ───────────────────────────── construction-site component instances
|
|
1530
|
+
|
|
1531
|
+
private creationSiteKey(subject: SourceWriteSubject): string | null {
|
|
1532
|
+
const anchor = subject.anchor;
|
|
1533
|
+
return anchor.anchored && anchor.kind === 'source'
|
|
1534
|
+
? `${anchor.file}:${anchor.line}:${anchor.col}`
|
|
1535
|
+
: null;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
private instancePropertyPaths(id: string, siteKey: string): string[] {
|
|
1539
|
+
const candidates = [
|
|
1540
|
+
'position',
|
|
1541
|
+
'rotation',
|
|
1542
|
+
'scale',
|
|
1543
|
+
...this.inspector
|
|
1544
|
+
.properties(id)
|
|
1545
|
+
.filter((field) => !field.readonly)
|
|
1546
|
+
.map((field) => field.path),
|
|
1547
|
+
];
|
|
1548
|
+
return [...new Set(candidates)].filter(
|
|
1549
|
+
(property) => this.creationSiteKey(this.anchorFor(id, property)) === siteKey,
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
private loadInstanceLiterals(
|
|
1554
|
+
backend: SourcePersistenceBackend,
|
|
1555
|
+
id: string,
|
|
1556
|
+
subject: SourceWriteSubject,
|
|
1557
|
+
key: string,
|
|
1558
|
+
): Promise<Record<string, CreationSiteLiteralReport>> {
|
|
1559
|
+
const existing = this.instanceLiteralRequests.get(key);
|
|
1560
|
+
if (existing) return existing;
|
|
1561
|
+
const properties = this.instancePropertyPaths(id, key)
|
|
1562
|
+
.map((property) => ({ property, live: this.readChannel(id, property) }))
|
|
1563
|
+
.filter(
|
|
1564
|
+
(entry): entry is { property: string; live: ChannelValue } => entry.live !== undefined,
|
|
1565
|
+
);
|
|
1566
|
+
const request = backend.readSiteLiterals!({
|
|
1567
|
+
anchor: subject.anchor,
|
|
1568
|
+
instances: subject.instances,
|
|
1569
|
+
writeScope: 'creation-site',
|
|
1570
|
+
properties,
|
|
1571
|
+
}).then((answer) => {
|
|
1572
|
+
this.instanceLiterals.set(key, answer);
|
|
1573
|
+
this.store.notifyIngestEdit();
|
|
1574
|
+
return answer;
|
|
1575
|
+
});
|
|
1576
|
+
this.instanceLiteralRequests.set(key, request);
|
|
1577
|
+
return request;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
private sameInstanceValue(a: ChannelValue, b: ChannelValue): boolean {
|
|
1581
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
1582
|
+
return (
|
|
1583
|
+
a.length === b.length &&
|
|
1584
|
+
a.every((value, index) => this.sameInstanceValue(value, b[index] as ChannelValue))
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
if (typeof a === 'number' && typeof b === 'number') return Math.abs(a - b) <= 1e-6;
|
|
1588
|
+
return a === b;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
private instanceComponentName(object: THREE.Object3D): string {
|
|
1592
|
+
return object.constructor.name || object.type;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
private instanceCountLabel(count: number): string {
|
|
1596
|
+
return `${count} ${count === 1 ? 'instance' : 'instances'}`;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
private instanceOverride(
|
|
1600
|
+
backend: SourcePersistenceBackend,
|
|
1601
|
+
id: string,
|
|
1602
|
+
path: string,
|
|
1603
|
+
literals: Record<string, CreationSiteLiteralReport>,
|
|
1604
|
+
subject: SourceWriteSubject,
|
|
1605
|
+
): ComponentInstanceOverride | null {
|
|
1606
|
+
const report = literals[path];
|
|
1607
|
+
const live = this.readChannel(id, path);
|
|
1608
|
+
if (!report || report.literal === null || live === undefined) return null;
|
|
1609
|
+
if (this.sameInstanceValue(report.literal, live)) return null;
|
|
1610
|
+
const gate = backend.gate({
|
|
1611
|
+
...this.anchorFor(id, path),
|
|
1612
|
+
writeScope: 'creation-site',
|
|
1613
|
+
});
|
|
1614
|
+
const unavailable = !report.writable ? report.reason : gate.ok ? undefined : gate.reason;
|
|
1615
|
+
const label = this.inspector.properties(id).find((field) => field.path === path)?.label ?? path;
|
|
1616
|
+
return {
|
|
1617
|
+
path,
|
|
1618
|
+
label,
|
|
1619
|
+
value: live,
|
|
1620
|
+
...(report.text ? { defaultText: report.text } : {}),
|
|
1621
|
+
canApplyToComponent: unavailable === undefined,
|
|
1622
|
+
...(unavailable ? { applyUnavailableReason: unavailable } : {}),
|
|
1623
|
+
affectedInstanceCount: subject.instances,
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
private async applyInstanceDefault(
|
|
1628
|
+
backend: SourcePersistenceBackend,
|
|
1629
|
+
literalsNow: (
|
|
1630
|
+
id: string,
|
|
1631
|
+
subject: SourceWriteSubject,
|
|
1632
|
+
key: string,
|
|
1633
|
+
) => Promise<Record<string, CreationSiteLiteralReport>>,
|
|
1634
|
+
id: string,
|
|
1635
|
+
path: string,
|
|
1636
|
+
): Promise<ComponentInstanceApplyResult> {
|
|
1637
|
+
const subject = { ...this.anchorFor(id, path), writeScope: 'creation-site' as const };
|
|
1638
|
+
const key = this.creationSiteKey(subject);
|
|
1639
|
+
if (!key) return { changed: false, message: 'This object has no source site.' };
|
|
1640
|
+
const object = this.objectOf(id);
|
|
1641
|
+
if (!object) return { changed: false, message: 'This object is no longer in the live world.' };
|
|
1642
|
+
const report = (await literalsNow(id, subject, key))[path];
|
|
1643
|
+
if (!report) return { changed: false, message: `The construction site names no ${path}.` };
|
|
1644
|
+
if (report.literal === null) {
|
|
1645
|
+
return { changed: false, message: `The construction site names no ${path} default.` };
|
|
1646
|
+
}
|
|
1647
|
+
const live = this.readChannel(id, path);
|
|
1648
|
+
if (live === undefined) return { changed: false, message: `${path} has no live value.` };
|
|
1649
|
+
if (!report.writable) {
|
|
1650
|
+
return { changed: false, message: report.reason ?? `${path} cannot be rewritten.` };
|
|
1651
|
+
}
|
|
1652
|
+
const gate = backend.gate(subject);
|
|
1653
|
+
if (!gate.ok) return { changed: false, message: gate.reason };
|
|
1654
|
+
const count = subject.instances;
|
|
1655
|
+
const label =
|
|
1656
|
+
`Apply ${path} to ${this.instanceComponentName(object)} default ` +
|
|
1657
|
+
`(${this.instanceCountLabel(count)})`;
|
|
1658
|
+
const persisted = await backend.write({
|
|
1659
|
+
...subject,
|
|
1660
|
+
baseline: report.literal,
|
|
1661
|
+
next: live,
|
|
1662
|
+
label,
|
|
1663
|
+
});
|
|
1664
|
+
if (!persisted) {
|
|
1665
|
+
return { changed: false, message: `${label} did not land; the console names why.` };
|
|
1666
|
+
}
|
|
1667
|
+
this.instanceLiterals.delete(key);
|
|
1668
|
+
this.instanceLiteralRequests.delete(key);
|
|
1669
|
+
this.store.notifyIngestEdit();
|
|
1670
|
+
const destination = subject.anchor.anchored ? subject.anchor.display : 'this source site';
|
|
1671
|
+
return {
|
|
1672
|
+
changed: true,
|
|
1673
|
+
message: `${path} now applies to all ${this.instanceCountLabel(count)} from ${destination}.`,
|
|
1674
|
+
write: { destination, persisted: true },
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
private creationSiteInstances(backend: SourcePersistenceBackend): ComponentInstancesProvider {
|
|
1679
|
+
const literalsNow = async (
|
|
1680
|
+
id: string,
|
|
1681
|
+
subject: SourceWriteSubject,
|
|
1682
|
+
key: string,
|
|
1683
|
+
): Promise<Record<string, CreationSiteLiteralReport>> =>
|
|
1684
|
+
this.instanceLiterals.get(key) ?? this.loadInstanceLiterals(backend, id, subject, key);
|
|
1685
|
+
|
|
1686
|
+
return {
|
|
1687
|
+
describe: (id): ComponentInstanceDescription | null => {
|
|
1688
|
+
const subject = this.anchorFor(id, 'position');
|
|
1689
|
+
const key = this.creationSiteKey(subject);
|
|
1690
|
+
const object = this.objectOf(id);
|
|
1691
|
+
if (!key || !object) return null;
|
|
1692
|
+
const literals = this.instanceLiterals.get(key);
|
|
1693
|
+
if (!literals) {
|
|
1694
|
+
void this.loadInstanceLiterals(backend, id, subject, key);
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
if (Object.keys(literals).length === 0) return null;
|
|
1698
|
+
const overrides = this.instancePropertyPaths(id, key)
|
|
1699
|
+
.map((path) => this.instanceOverride(backend, id, path, literals, subject))
|
|
1700
|
+
.filter((override): override is ComponentInstanceOverride => override !== null);
|
|
1701
|
+
return {
|
|
1702
|
+
componentName: this.instanceComponentName(object),
|
|
1703
|
+
...(subject.anchor.anchored ? { sourcePath: subject.anchor.display } : {}),
|
|
1704
|
+
overrides,
|
|
1705
|
+
};
|
|
1706
|
+
},
|
|
1707
|
+
revert: async (id, paths) => {
|
|
1708
|
+
const subject = this.anchorFor(id, 'position');
|
|
1709
|
+
const key = this.creationSiteKey(subject);
|
|
1710
|
+
if (!key) return;
|
|
1711
|
+
const literals = await literalsNow(id, subject, key);
|
|
1712
|
+
const before = this.captureHistoryState();
|
|
1713
|
+
let changed = 0;
|
|
1714
|
+
for (const path of paths) {
|
|
1715
|
+
const literal = literals[path]?.literal;
|
|
1716
|
+
if (literal === null || literal === undefined) continue;
|
|
1717
|
+
this.applyChannel(id, path, literal);
|
|
1718
|
+
changed++;
|
|
1719
|
+
}
|
|
1720
|
+
if (changed > 0) this.recordHistory(`Revert ${changed} instance override(s)`, before);
|
|
1721
|
+
return changed > 0 ? LIVE_ONLY_ACK : undefined;
|
|
1722
|
+
},
|
|
1723
|
+
applyToComponent: (id, path) => this.applyInstanceDefault(backend, literalsNow, id, path),
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// ─────────────────────────────────────────────── session undo/redo
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Journal a before→after snapshot pair into project history, LOUDLY.
|
|
1731
|
+
*
|
|
1732
|
+
* A rejected `record(...)` means the edit IS applied to the live scene but has
|
|
1733
|
+
* no history entry: it cannot be undone. Swallowing that turned a journaling
|
|
1734
|
+
* failure into a silently un-undoable edit — the user sees their edit and
|
|
1735
|
+
* Ctrl+Z does nothing, with no indication anything went wrong.
|
|
1736
|
+
*/
|
|
1737
|
+
private recordHistory(label: string, before: ThreeHistoryState | undefined): void {
|
|
1738
|
+
if (!this.historyResource || !before) return;
|
|
1739
|
+
void this.historyResource
|
|
1740
|
+
.record(label, before, this.captureHistoryState())
|
|
1741
|
+
.catch((err: unknown) => {
|
|
1742
|
+
editorConsole.error(
|
|
1743
|
+
`[ingest] failed to journal "${label}" into project history — the edit is applied ` +
|
|
1744
|
+
`but has no history entry (it cannot be undone): ` +
|
|
1745
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
1746
|
+
);
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
private captureHistoryState(): ThreeHistoryState {
|
|
1751
|
+
const objects: ThreeHistoryState['objects'] = {};
|
|
1752
|
+
for (const [id, node] of this.projector.nodes) {
|
|
1753
|
+
const object = node.object;
|
|
1754
|
+
const material = colorMaterialOf(object);
|
|
1755
|
+
const light = lightOf(object);
|
|
1756
|
+
const camera = perspectiveCameraOf(object);
|
|
1757
|
+
objects[id] = {
|
|
1758
|
+
name: object.name,
|
|
1759
|
+
visible: object.visible,
|
|
1760
|
+
transform: this.transforms.get(id),
|
|
1761
|
+
...(material ? { color: `#${material.color.getHexString()}` } : {}),
|
|
1762
|
+
...(light
|
|
1763
|
+
? {
|
|
1764
|
+
lightIntensity: light.intensity,
|
|
1765
|
+
lightColor: `#${light.color.getHexString()}`,
|
|
1766
|
+
...(rangedLightOf(object) ? { lightDistance: rangedLightOf(object)!.distance } : {}),
|
|
1767
|
+
}
|
|
1768
|
+
: {}),
|
|
1769
|
+
...(camera ? { camera: { fov: camera.fov, near: camera.near, far: camera.far } } : {}),
|
|
1770
|
+
castShadow: object.castShadow,
|
|
1771
|
+
receiveShadow: object.receiveShadow,
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
return { edits: structuredClone(this.edits), objects };
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
private restoreHistoryState(state: ThreeHistoryState): void {
|
|
1778
|
+
for (const [id, value] of Object.entries(state.objects)) {
|
|
1779
|
+
const object = this.objectOf(id);
|
|
1780
|
+
if (!object) continue;
|
|
1781
|
+
object.name = value.name;
|
|
1782
|
+
object.visible = value.visible;
|
|
1783
|
+
object.position.fromArray(value.transform.position);
|
|
1784
|
+
object.quaternion.fromArray(value.transform.rotation);
|
|
1785
|
+
object.scale.fromArray(value.transform.scale);
|
|
1786
|
+
// Same reason the drag itself teleports (see `transforms.apply`): on a
|
|
1787
|
+
// body-driven node the node write is a value the next step overwrites, so
|
|
1788
|
+
// an undo that only restored the node would visibly un-undo itself. The
|
|
1789
|
+
// undo of a body teleport is a body teleport.
|
|
1790
|
+
this.physicsFor(id)?.commit(id, value.transform);
|
|
1791
|
+
this.markedBodies.commit(id, value.transform);
|
|
1792
|
+
if (value.color) colorMaterialOf(object)?.color.set(value.color);
|
|
1793
|
+
const light = lightOf(object);
|
|
1794
|
+
if (light) {
|
|
1795
|
+
if (value.lightIntensity !== undefined) light.intensity = value.lightIntensity;
|
|
1796
|
+
if (value.lightColor) light.color.set(value.lightColor);
|
|
1797
|
+
if (value.lightDistance !== undefined) {
|
|
1798
|
+
const ranged = rangedLightOf(object);
|
|
1799
|
+
if (ranged) ranged.distance = value.lightDistance;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
const camera = perspectiveCameraOf(object);
|
|
1803
|
+
if (camera && value.camera) {
|
|
1804
|
+
camera.fov = value.camera.fov;
|
|
1805
|
+
camera.near = value.camera.near;
|
|
1806
|
+
camera.far = value.camera.far;
|
|
1807
|
+
camera.updateProjectionMatrix();
|
|
1808
|
+
}
|
|
1809
|
+
object.castShadow = value.castShadow;
|
|
1810
|
+
object.receiveShadow = value.receiveShadow;
|
|
1811
|
+
}
|
|
1812
|
+
this.edits = structuredClone(state.edits);
|
|
1813
|
+
this.store.notifyIngestEdit();
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
export interface CapturedThreeOptions {
|
|
1818
|
+
/** A render camera outside the scene tree, surfaced as an extra root node. */
|
|
1819
|
+
readonly camera?: THREE.Camera | undefined;
|
|
1820
|
+
/** Freeze/thaw the world's own loop for the duration of a gesture. */
|
|
1821
|
+
readonly loop?: ThreeLoopControl | undefined;
|
|
1822
|
+
/**
|
|
1823
|
+
* Who can stop the frame an edit lands in — see
|
|
1824
|
+
* {@link ThreeAuthoringOptions.frameControl}, whose fail-open warning
|
|
1825
|
+
* applies verbatim here. A mount that hands this factory NEITHER a `loop`
|
|
1826
|
+
* NOR `frameControl: 'host'` is claiming it can freeze the frame itself,
|
|
1827
|
+
* and a mid-play gizmo edit on such a mount is silently overwritten instead
|
|
1828
|
+
* of honestly refused. Defaults (via that option) to `'adapter'`.
|
|
1829
|
+
*/
|
|
1830
|
+
readonly frameControl?: 'adapter' | 'host';
|
|
1831
|
+
/** Injectable creation-site writer, so a unit test can drive the write path
|
|
1832
|
+
* without a dev server. */
|
|
1833
|
+
readonly sourceWriter?: IngestSourcePersistence | undefined;
|
|
1834
|
+
/** Whose undo journal this mount shares — see
|
|
1835
|
+
* {@link ThreeAuthoringOptions.journal}. */
|
|
1836
|
+
readonly journal: JournalSubject;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* The provider combination for a live three tree the editor did not author the
|
|
1841
|
+
* source shape of: STRUCTURAL-PATH identity (its source was never stamped with
|
|
1842
|
+
* OIDs) plus CREATION-SITE persistence (its literals are the only place an edit
|
|
1843
|
+
* can honestly be written, and only when this session is authoring rather than
|
|
1844
|
+
* playing).
|
|
1845
|
+
*
|
|
1846
|
+
* Spelled once here rather than at each mount so the six mounts that want it
|
|
1847
|
+
* cannot drift apart — it is a named argument list, not a second adapter.
|
|
1848
|
+
*/
|
|
1849
|
+
export function structuralThree(
|
|
1850
|
+
store: EditorShellStore,
|
|
1851
|
+
scene: ThreeSceneRef,
|
|
1852
|
+
options: CapturedThreeOptions,
|
|
1853
|
+
): ThreeAuthoringAdapter {
|
|
1854
|
+
return new ThreeAuthoringAdapter(store, scene, {
|
|
1855
|
+
identity: structuralIdentity({ camera: options.camera }),
|
|
1856
|
+
journal: options.journal,
|
|
1857
|
+
persistence: createCreationSitePersistence({
|
|
1858
|
+
history: store.projectHistory,
|
|
1859
|
+
writer: options.sourceWriter,
|
|
1860
|
+
}),
|
|
1861
|
+
loop: options.loop,
|
|
1862
|
+
...(options.frameControl ? { frameControl: options.frameControl } : {}),
|
|
1863
|
+
provenance: CAPTURED_PROVENANCE,
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* The provider combination for an INGESTED world whose own source the
|
|
1869
|
+
* serve-time OID transform DID reach — a vendored R3F game is the case, and
|
|
1870
|
+
* the only thing that decides it is a measurement of the mounted graph (do its
|
|
1871
|
+
* objects carry `userData.oid`?), never the game's id.
|
|
1872
|
+
*
|
|
1873
|
+
* OID identity, because the stamp is a better address than a structural path
|
|
1874
|
+
* (it survives the world rebuilding part of its graph, and it is the same id
|
|
1875
|
+
* Edit↔Play continuity keys on); OID-source persistence, because the JSX
|
|
1876
|
+
* callsite the stamp names is the only place in that game's source an edit can
|
|
1877
|
+
* honestly be written — fiber constructs every object inside `node_modules`,
|
|
1878
|
+
* so the creation-site registry `structuralThree` writes through is
|
|
1879
|
+
* structurally empty for this world.
|
|
1880
|
+
*
|
|
1881
|
+
* `loop` rather than `frameControl: 'host'`: an ingest mount holds the game's
|
|
1882
|
+
* own animation loop and freezes it for the gesture, exactly as
|
|
1883
|
+
* `structuralThree` does at the same mount.
|
|
1884
|
+
*/
|
|
1885
|
+
export function oidSourceThree(
|
|
1886
|
+
store: EditorShellStore,
|
|
1887
|
+
scene: ThreeSceneRef,
|
|
1888
|
+
options: CapturedThreeOptions & {
|
|
1889
|
+
readonly worldId: string;
|
|
1890
|
+
/** Injectable source transport, so a unit test can drive the whole gesture
|
|
1891
|
+
* without a dev server (the peer of `sourceWriter` on the other lane). */
|
|
1892
|
+
readonly backend?: SourceWriteBackend | undefined;
|
|
1893
|
+
},
|
|
1894
|
+
): ThreeAuthoringAdapter {
|
|
1895
|
+
return new ThreeAuthoringAdapter(store, scene, {
|
|
1896
|
+
identity: oidIdentity(options.worldId, { camera: options.camera }),
|
|
1897
|
+
journal: options.journal,
|
|
1898
|
+
persistence: createOidSourcePersistence({
|
|
1899
|
+
history: store.projectHistory,
|
|
1900
|
+
backend: options.backend,
|
|
1901
|
+
}),
|
|
1902
|
+
loop: options.loop,
|
|
1903
|
+
...(options.frameControl ? { frameControl: options.frameControl } : {}),
|
|
1904
|
+
provenance: STAMPED_INGEST_PROVENANCE,
|
|
1905
|
+
// The stamp is what makes this lane different, and it decides this too: a
|
|
1906
|
+
// stamped node names its own component and its own source file, so a
|
|
1907
|
+
// `*.stories.tsx` colocated with that component is an ordinary discovered
|
|
1908
|
+
// story about THIS world. The unstamped lane (`structuralThree`)
|
|
1909
|
+
// deliberately omits it — see the absent-column note on the class.
|
|
1910
|
+
sourceDeclaresStories: true,
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* The provider combination for a world whose source the serve-time OID
|
|
1916
|
+
* transform reached: OID identity (so selection survives Edit→Play) and NO
|
|
1917
|
+
* persistence backend — play edits are ephemeral by architecture, and the host
|
|
1918
|
+
* injects `createEphemeralPersistence` over this adapter. The lawful
|
|
1919
|
+
* play→source write-back is an explicit per-node gesture on an OID-anchored
|
|
1920
|
+
* literal (`SourceWriteBackend.runGesture`), supplied only when the Play mount
|
|
1921
|
+
* opts into `explicitSourceCommit`; there is deliberately no silent
|
|
1922
|
+
* persistence path here, in any form.
|
|
1923
|
+
*/
|
|
1924
|
+
export function oidThree(
|
|
1925
|
+
store: EditorShellStore,
|
|
1926
|
+
scene: ThreeSceneRef,
|
|
1927
|
+
worldId: string,
|
|
1928
|
+
/** Whose undo journal this mount shares. Both callers name a DIFFERENT
|
|
1929
|
+
* subject through the same parameter, which is the whole point: edit mode
|
|
1930
|
+
* passes the world (its stack outlives every remount), play passes its run
|
|
1931
|
+
* (its stack ends on ■). */
|
|
1932
|
+
journal: JournalSubject,
|
|
1933
|
+
options: {
|
|
1934
|
+
readonly explicitSourceCommit?: boolean;
|
|
1935
|
+
readonly backend?: SourceWriteBackend | undefined;
|
|
1936
|
+
readonly sourceAnchor?: SourcePersistenceBackend['anchor'];
|
|
1937
|
+
} = {},
|
|
1938
|
+
): ThreeAuthoringAdapter {
|
|
1939
|
+
return new ThreeAuthoringAdapter(store, scene, {
|
|
1940
|
+
identity: oidIdentity(worldId),
|
|
1941
|
+
journal,
|
|
1942
|
+
frameControl: 'host',
|
|
1943
|
+
sourceAnchor: options.sourceAnchor,
|
|
1944
|
+
...(options.explicitSourceCommit
|
|
1945
|
+
? {
|
|
1946
|
+
sourceCommit: createOidTransformSourceCommitter({
|
|
1947
|
+
history: store.projectHistory,
|
|
1948
|
+
...(options.backend ? { backend: options.backend } : {}),
|
|
1949
|
+
}),
|
|
1950
|
+
}
|
|
1951
|
+
: {}),
|
|
1952
|
+
});
|
|
1953
|
+
}
|