@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.
Files changed (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. 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
+ }