@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,528 @@
1
+ /**
2
+ * Typed inspector fields for the three common three.js objects a scene author
3
+ * edits by hand — a LIGHT, a mesh's MATERIAL, a CAMERA, or positional AUDIO — as pure functions
4
+ * over the LIVE object.
5
+ *
6
+ * This is deliberately NOT a descriptor store, a schema, or a scene-document
7
+ * type. It is a lens: given a live `Object3D` (and, for a mesh, its material),
8
+ * it names the handful of high-value typed properties that object ACTUALLY has
9
+ * right now, reads their current values, and applies an edit back onto the live
10
+ * object. The R3F source authoring adapter wraps these into `PropertyDescriptor`
11
+ * rows and routes the write through its EXISTING source-writeback path (the same
12
+ * `writeProp` a Transform edit or a `material.color` edit already uses) — the
13
+ * three root's source stays the single truth (§Roots), and nothing here invents
14
+ * a second one.
15
+ *
16
+ * Honest absence is the whole point: `typedThreeFields` returns ONLY the fields
17
+ * the given object exposes. A `MeshStandardMaterial` yields `roughness`/
18
+ * `metalness`; a `MeshBasicMaterial` yields neither (it has no such property),
19
+ * so those rows never appear. A `THREE.PointLight` yields `distance`/`decay`
20
+ * but not `angle`/`penumbra` (those are spotlight-only). No selected object of
21
+ * the wrong kind ever shows a row it cannot honor.
22
+ *
23
+ * The property NAMES are the three.js / react-three-fiber intrinsic prop names
24
+ * verbatim (`intensity`, `roughness`, `fov`, …), so a written attribute reads
25
+ * as idiomatic R3F source (`<pointLight intensity={5} />`) and the value the
26
+ * inspector shows is the one the JSX prop would set.
27
+ */
28
+
29
+ import type { PropertyDescriptor } from '@volter/editor-project/adapter';
30
+ import * as THREE from 'three';
31
+
32
+ /** The section a typed field renders under (a titled `PropertyDescriptor.group`). */
33
+ export type TypedThreeGroup = 'Light' | 'Material' | 'Camera' | 'Audio';
34
+
35
+ /**
36
+ * One typed inspector field: its three.js prop name (also the source attribute
37
+ * name and the inspector path suffix), its widget type, the section it groups
38
+ * under, and WHICH source tag owns its attribute — the object's own JSX element
39
+ * (`self`) or its material's child element (`material`).
40
+ */
41
+ export interface TypedThreeField {
42
+ readonly prop: string;
43
+ readonly label: string;
44
+ readonly type: Extract<
45
+ PropertyDescriptor['type'],
46
+ 'number' | 'color' | 'boolean' | 'enum' | 'asset'
47
+ >;
48
+ readonly options?: readonly string[];
49
+ readonly group: TypedThreeGroup;
50
+ readonly target: 'self' | 'material';
51
+ /** Full inspector path — `${group-prefix}.${prop}`, e.g. `light.intensity`. */
52
+ readonly path: string;
53
+ /**
54
+ * For an `asset` slot only: how the texture's bytes must be INTERPRETED.
55
+ * A slot the shader reads as COLOUR (`map`, `emissiveMap`) is authored in
56
+ * sRGB and must be decoded; a slot it reads as DATA (`normalMap`,
57
+ * `roughnessMap`, `metalnessMap`, `aoMap`) carries raw numbers and must not
58
+ * be. Getting this wrong is not subtle — a decoded normal map bends light in
59
+ * the wrong direction — and three.js cannot infer it from the file, which is
60
+ * why the slot declares it here rather than the loader guessing.
61
+ */
62
+ readonly colorSpace?: 'srgb' | 'linear';
63
+ }
64
+
65
+ /** Path prefix per group, so `get`/`set` can dispatch on the path alone. */
66
+ const PREFIX: Record<TypedThreeGroup, string> = {
67
+ Light: 'light',
68
+ Material: 'material',
69
+ Camera: 'camera',
70
+ Audio: 'audio',
71
+ };
72
+
73
+ function field(
74
+ group: TypedThreeGroup,
75
+ target: TypedThreeField['target'],
76
+ prop: string,
77
+ label: string,
78
+ type: TypedThreeField['type'],
79
+ path = `${PREFIX[group]}.${prop}`,
80
+ options?: readonly string[],
81
+ ): TypedThreeField {
82
+ return { prop, label, type, group, target, path, ...(options ? { options } : {}) };
83
+ }
84
+
85
+ /** An `asset` slot on a material — a texture map, with the colour space its
86
+ * shader input demands (see {@link TypedThreeField.colorSpace}). */
87
+ function mapField(prop: string, label: string, colorSpace: 'srgb' | 'linear'): TypedThreeField {
88
+ return {
89
+ prop,
90
+ label,
91
+ type: 'asset',
92
+ group: 'Material',
93
+ target: 'material',
94
+ path: `${PREFIX.Material}.${prop}`,
95
+ colorSpace,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The texture slots this lens offers, in the order a material author reaches
101
+ * for them. Each row appears only when the CONCRETE material class carries
102
+ * that property — a `MeshBasicMaterial` has `map` and `aoMap` but no
103
+ * `normalMap`/`roughnessMap`/`metalnessMap`/`emissiveMap`, so those four never
104
+ * render for one. Honest absence, exactly as the scalar rows above.
105
+ */
106
+ const MATERIAL_MAP_SLOTS: readonly TypedThreeField[] = [
107
+ mapField('map', 'Base Color Map', 'srgb'),
108
+ mapField('normalMap', 'Normal Map', 'linear'),
109
+ mapField('roughnessMap', 'Roughness Map', 'linear'),
110
+ mapField('metalnessMap', 'Metalness Map', 'linear'),
111
+ mapField('emissiveMap', 'Emissive Map', 'srgb'),
112
+ mapField('aoMap', 'Ambient Occlusion Map', 'linear'),
113
+ ];
114
+
115
+ /** `#rrggbb` view of a three color property, or undefined when absent. */
116
+ function readColor(value: unknown): string | undefined {
117
+ const color = value as THREE.Color | undefined;
118
+ return color && (color as { isColor?: boolean }).isColor ? `#${color.getHexString()}` : undefined;
119
+ }
120
+
121
+ // ------------------------------------------------------------------ textures
122
+
123
+ /**
124
+ * `userData` key carrying the project path an assigned texture came FROM.
125
+ *
126
+ * A `THREE.Texture` does not remember its origin in any form the inspector can
127
+ * show: `image.src` is an absolute URL the browser resolved, and a texture that
128
+ * arrived inside a `.glb` has no URL at all. Stamping the path we were handed
129
+ * is what lets the slot read back the same string the picker offered.
130
+ */
131
+ const ASSET_PATH_KEY = 'vgaiAssetPath';
132
+
133
+ /** Textures THIS module loaded. Only these are disposed on reassign/clear —
134
+ * a texture that came from a GLB or the game's own code may be shared by
135
+ * other materials, and disposing it would blank them too. */
136
+ const ownedTextures = new WeakSet<THREE.Texture>();
137
+
138
+ /** One loader for the session — three's own loader manager caches per URL. */
139
+ const textureLoader = new THREE.TextureLoader();
140
+
141
+ /** How an assigned texture reads back in the inspector: the project path it
142
+ * was assigned from, else whatever identity the texture already carried. */
143
+ function readTextureRef(value: unknown): string {
144
+ const texture = value as THREE.Texture | null | undefined;
145
+ if (!texture || !(texture as { isTexture?: boolean }).isTexture) return '';
146
+ const stamped = (texture.userData as Record<string, unknown> | undefined)?.[ASSET_PATH_KEY];
147
+ if (typeof stamped === 'string' && stamped) return stamped;
148
+ if (texture.name) return texture.name;
149
+ const src = (texture.image as { src?: string } | undefined)?.src;
150
+ return typeof src === 'string' ? src : '';
151
+ }
152
+
153
+ /** The served URL for a project asset path. Public-root-relative paths are
154
+ * what the asset seams speak (`textures/brick.png`); anything already
155
+ * absolute or inline is passed through untouched. */
156
+ function assetUrlFor(path: string): string {
157
+ return /^(?:[a-z]+:|\/)/i.test(path) ? path : `/${path}`;
158
+ }
159
+
160
+ /**
161
+ * Assign (or clear) a texture slot on the LIVE material.
162
+ *
163
+ * `TextureLoader.load` hands back the `Texture` object synchronously and fills
164
+ * its image in later, setting `needsUpdate` itself — so the assignment is a
165
+ * plain property write here and the viewport picks the pixels up on the frame
166
+ * they arrive. Returns the path to persist, `''` for a cleared slot, or `null`
167
+ * when there is no material to assign to.
168
+ */
169
+ function applyAssetField(
170
+ material: THREE.Material | null,
171
+ field: TypedThreeField,
172
+ value: unknown,
173
+ ): string | null {
174
+ if (!material) return null;
175
+ const owner = material as unknown as Record<string, unknown>;
176
+ const previous = owner[field.prop] as THREE.Texture | null | undefined;
177
+ const path = value === null || value === undefined ? '' : String(value);
178
+ if (previous && ownedTextures.has(previous) && readTextureRef(previous) !== path) {
179
+ previous.dispose();
180
+ }
181
+ if (!path) {
182
+ owner[field.prop] = null;
183
+ material.needsUpdate = true;
184
+ return '';
185
+ }
186
+ const texture = textureLoader.load(assetUrlFor(path));
187
+ texture.colorSpace = field.colorSpace === 'srgb' ? THREE.SRGBColorSpace : THREE.NoColorSpace;
188
+ texture.userData[ASSET_PATH_KEY] = path;
189
+ texture.name = path;
190
+ ownedTextures.add(texture);
191
+ owner[field.prop] = texture;
192
+ // Adding or removing a map changes which shader three compiles for this
193
+ // material, so the program must be rebuilt — a bare property write is not
194
+ // enough for texture slots the way it is for a scalar.
195
+ material.needsUpdate = true;
196
+ return path;
197
+ }
198
+
199
+ // ---------------------------------------------------------------- descriptors
200
+
201
+ /** Typed fields a `THREE.Light` exposes — common to every light, then the
202
+ * point/spot falloff and the spot cone, each guarded by the concrete subtype. */
203
+ export function lightTypedFields(light: THREE.Light): readonly TypedThreeField[] {
204
+ const l = light as THREE.Light & {
205
+ isDirectionalLight?: boolean;
206
+ isPointLight?: boolean;
207
+ isSpotLight?: boolean;
208
+ };
209
+ const fields: TypedThreeField[] = [
210
+ field('Light', 'self', 'color', 'Color', 'color'),
211
+ field('Light', 'self', 'intensity', 'Intensity', 'number'),
212
+ ];
213
+ if (l.isPointLight || l.isSpotLight) {
214
+ fields.push(
215
+ field('Light', 'self', 'distance', 'Distance', 'number'),
216
+ field('Light', 'self', 'decay', 'Decay', 'number'),
217
+ );
218
+ }
219
+ if (l.isSpotLight) {
220
+ fields.push(
221
+ field('Light', 'self', 'angle', 'Angle', 'number'),
222
+ field('Light', 'self', 'penumbra', 'Penumbra', 'number'),
223
+ );
224
+ }
225
+ return fields;
226
+ }
227
+
228
+ /** Shadow casting is an Object3D flag rather than a Light member in three.js,
229
+ * so it remains a separate lens from {@link lightTypedFields}. Only the three
230
+ * light classes whose render path can honor it expose the row. */
231
+ export function lightShadowTypedField(object: THREE.Object3D): TypedThreeField | undefined {
232
+ const light = object as THREE.Light & {
233
+ isDirectionalLight?: boolean;
234
+ isPointLight?: boolean;
235
+ isSpotLight?: boolean;
236
+ };
237
+ return light.isDirectionalLight || light.isPointLight || light.isSpotLight
238
+ ? field('Light', 'self', 'castShadow', 'Cast Shadow', 'boolean', 'shadow.cast')
239
+ : undefined;
240
+ }
241
+
242
+ /** Typed fields a material exposes — each guarded by whether the concrete
243
+ * material class actually carries that property (a `MeshBasicMaterial` has no
244
+ * `roughness`/`metalness`/`emissive`, so none appear). */
245
+ export function materialTypedFields(material: THREE.Material): readonly TypedThreeField[] {
246
+ const m = material as THREE.Material & {
247
+ color?: THREE.Color;
248
+ roughness?: number;
249
+ metalness?: number;
250
+ emissive?: THREE.Color;
251
+ wireframe?: boolean;
252
+ };
253
+ const fields: TypedThreeField[] = [];
254
+ if ((m.color as { isColor?: boolean } | undefined)?.isColor)
255
+ fields.push(field('Material', 'material', 'color', 'Color', 'color'));
256
+ if (typeof m.roughness === 'number')
257
+ fields.push(field('Material', 'material', 'roughness', 'Roughness', 'number'));
258
+ if (typeof m.metalness === 'number')
259
+ fields.push(field('Material', 'material', 'metalness', 'Metalness', 'number'));
260
+ if ((m.emissive as { isColor?: boolean } | undefined)?.isColor)
261
+ fields.push(field('Material', 'material', 'emissive', 'Emissive', 'color'));
262
+ // `opacity`/`transparent` live on the base `Material` — every material has them.
263
+ fields.push(
264
+ field('Material', 'material', 'opacity', 'Opacity', 'number'),
265
+ field('Material', 'material', 'transparent', 'Transparent', 'boolean'),
266
+ );
267
+ if (typeof m.wireframe === 'boolean')
268
+ fields.push(field('Material', 'material', 'wireframe', 'Wireframe', 'boolean'));
269
+ // Texture slots last: they are the tallest rows, and reading the scalars
270
+ // first is the order every material inspector uses.
271
+ const owner = material as unknown as Record<string, unknown>;
272
+ for (const slot of MATERIAL_MAP_SLOTS) if (slot.prop in owner) fields.push(slot);
273
+ return fields;
274
+ }
275
+
276
+ /** Typed fields a camera exposes — the perspective lens, or the orthographic
277
+ * zoom + frustum, keyed off the concrete camera subtype. */
278
+ export function cameraTypedFields(camera: THREE.Camera): readonly TypedThreeField[] {
279
+ const c = camera as THREE.Camera & {
280
+ isPerspectiveCamera?: boolean;
281
+ isOrthographicCamera?: boolean;
282
+ };
283
+ if (c.isPerspectiveCamera) {
284
+ return [
285
+ field('Camera', 'self', 'fov', 'FOV', 'number'),
286
+ field('Camera', 'self', 'near', 'Near', 'number'),
287
+ field('Camera', 'self', 'far', 'Far', 'number'),
288
+ field('Camera', 'self', 'zoom', 'Zoom', 'number'),
289
+ ];
290
+ }
291
+ if (c.isOrthographicCamera) {
292
+ return [
293
+ field('Camera', 'self', 'zoom', 'Zoom', 'number'),
294
+ field('Camera', 'self', 'near', 'Near', 'number'),
295
+ field('Camera', 'self', 'far', 'Far', 'number'),
296
+ field('Camera', 'self', 'left', 'Left', 'number'),
297
+ field('Camera', 'self', 'right', 'Right', 'number'),
298
+ field('Camera', 'self', 'top', 'Top', 'number'),
299
+ field('Camera', 'self', 'bottom', 'Bottom', 'number'),
300
+ ];
301
+ }
302
+ return [];
303
+ }
304
+
305
+ /** Structural rather than `instanceof`: ingested worlds can carry a different
306
+ * three constructor while still exposing the native PositionalAudio contract. */
307
+ export function isPositionalAudioObject(object: THREE.Object3D): object is THREE.PositionalAudio {
308
+ const candidate = object as THREE.Object3D & Partial<THREE.PositionalAudio>;
309
+ return (
310
+ typeof candidate.getRefDistance === 'function' &&
311
+ typeof candidate.setRefDistance === 'function' &&
312
+ !!candidate.panner
313
+ );
314
+ }
315
+
316
+ /**
317
+ * Native positional-audio fields, expressed with the source props their R3F
318
+ * owner actually accepts. Drei's `<PositionalAudio>` names reference distance
319
+ * `distance`; the native `<positionalAudio>` intrinsic reaches the PannerNode
320
+ * through R3F's ordinary dash-piercing syntax.
321
+ */
322
+ export function audioTypedFields(sourceTag = 'PositionalAudio'): readonly TypedThreeField[] {
323
+ const refProp = sourceTag === 'PositionalAudio' ? 'distance' : 'panner-refDistance';
324
+ return [
325
+ field('Audio', 'self', refProp, 'Reference Distance', 'number', 'audio.refDistance'),
326
+ field(
327
+ 'Audio',
328
+ 'self',
329
+ 'panner-distanceModel',
330
+ 'Distance Model',
331
+ 'enum',
332
+ 'audio.distanceModel',
333
+ ['inverse', 'linear', 'exponential'],
334
+ ),
335
+ field('Audio', 'self', 'panner-rolloffFactor', 'Rolloff', 'number', 'audio.rolloffFactor'),
336
+ field('Audio', 'self', 'panner-maxDistance', 'Maximum Distance', 'number', 'audio.maxDistance'),
337
+ field('Audio', 'self', 'panner-coneInnerAngle', 'Inner Cone', 'number', 'audio.coneInnerAngle'),
338
+ field('Audio', 'self', 'panner-coneOuterAngle', 'Outer Cone', 'number', 'audio.coneOuterAngle'),
339
+ field('Audio', 'self', 'panner-coneOuterGain', 'Outside Gain', 'number', 'audio.coneOuterGain'),
340
+ ];
341
+ }
342
+
343
+ /**
344
+ * Every typed field the given object exposes right now: its light fields when it
345
+ * is a light, its camera fields when it is a camera, and its material's fields
346
+ * when a material is supplied. An object that is none of these yields `[]`.
347
+ */
348
+ export function typedThreeFields(
349
+ object: THREE.Object3D,
350
+ material: THREE.Material | null,
351
+ sourceTag?: string,
352
+ ): readonly TypedThreeField[] {
353
+ const o = object as THREE.Object3D & { isLight?: boolean; isCamera?: boolean };
354
+ const fields: TypedThreeField[] = [];
355
+ if (o.isLight) fields.push(...lightTypedFields(object as unknown as THREE.Light));
356
+ if (o.isCamera) fields.push(...cameraTypedFields(object as THREE.Camera));
357
+ if (isPositionalAudioObject(object)) fields.push(...audioTypedFields(sourceTag));
358
+ if (material) fields.push(...materialTypedFields(material));
359
+ return fields;
360
+ }
361
+
362
+ /** The typed field a path names for this object, or undefined when the object
363
+ * has no such field (honest absence — a stale/foreign path reads as nothing). */
364
+ export function typedFieldForPath(
365
+ object: THREE.Object3D,
366
+ material: THREE.Material | null,
367
+ path: string,
368
+ sourceTag?: string,
369
+ ): TypedThreeField | undefined {
370
+ const shadow = lightShadowTypedField(object);
371
+ return (
372
+ typedThreeFields(object, material, sourceTag).find((f) => f.path === path) ??
373
+ (shadow?.path === path ? shadow : undefined)
374
+ );
375
+ }
376
+
377
+ // --------------------------------------------------------------- read / apply
378
+
379
+ /** The property owner a field targets: the object itself, or its material. */
380
+ function ownerOf(
381
+ object: THREE.Object3D,
382
+ material: THREE.Material | null,
383
+ field: TypedThreeField,
384
+ ): Record<string, unknown> | null {
385
+ return (field.target === 'material' ? material : object) as Record<string, unknown> | null;
386
+ }
387
+
388
+ /** The inspector value for a typed field, read from the LIVE object. Color
389
+ * fields read as `#rrggbb`; number/boolean fields read their raw value. */
390
+ export function readTypedField(
391
+ object: THREE.Object3D,
392
+ material: THREE.Material | null,
393
+ field: TypedThreeField,
394
+ ): unknown {
395
+ if (field.group === 'Audio' && isPositionalAudioObject(object)) {
396
+ const panner = object.panner;
397
+ switch (field.path) {
398
+ case 'audio.refDistance':
399
+ return object.getRefDistance();
400
+ case 'audio.distanceModel':
401
+ return object.getDistanceModel();
402
+ case 'audio.rolloffFactor':
403
+ return object.getRolloffFactor();
404
+ case 'audio.maxDistance':
405
+ return object.getMaxDistance();
406
+ case 'audio.coneInnerAngle':
407
+ return panner.coneInnerAngle;
408
+ case 'audio.coneOuterAngle':
409
+ return panner.coneOuterAngle;
410
+ case 'audio.coneOuterGain':
411
+ return panner.coneOuterGain;
412
+ }
413
+ }
414
+ const owner = ownerOf(object, material, field);
415
+ if (!owner) return undefined;
416
+ const raw = owner[field.prop];
417
+ if (field.type === 'color') return readColor(raw);
418
+ if (field.type === 'asset') return readTextureRef(raw);
419
+ return raw;
420
+ }
421
+
422
+ function applyAudioField(
423
+ object: THREE.PositionalAudio,
424
+ field: TypedThreeField,
425
+ value: unknown,
426
+ ): string | number | null {
427
+ if (field.path === 'audio.distanceModel') {
428
+ const model = String(value);
429
+ if (model !== 'inverse' && model !== 'linear' && model !== 'exponential') return null;
430
+ object.setDistanceModel(model);
431
+ return model;
432
+ }
433
+ const num = Number(value);
434
+ if (!Number.isFinite(num)) return null;
435
+ switch (field.path) {
436
+ case 'audio.refDistance':
437
+ object.setRefDistance(Math.max(0.01, num));
438
+ return Math.max(0.01, num);
439
+ case 'audio.rolloffFactor':
440
+ object.setRolloffFactor(Math.max(0, num));
441
+ return Math.max(0, num);
442
+ case 'audio.maxDistance':
443
+ object.setMaxDistance(Math.max(object.getRefDistance() + 0.01, num));
444
+ return Math.max(object.getRefDistance() + 0.01, num);
445
+ case 'audio.coneInnerAngle': {
446
+ const inner = Math.max(0, Math.min(object.panner.coneOuterAngle, num));
447
+ object.setDirectionalCone(inner, object.panner.coneOuterAngle, object.panner.coneOuterGain);
448
+ return inner;
449
+ }
450
+ case 'audio.coneOuterAngle': {
451
+ const outer = Math.max(object.panner.coneInnerAngle, Math.min(360, num));
452
+ object.setDirectionalCone(object.panner.coneInnerAngle, outer, object.panner.coneOuterGain);
453
+ return outer;
454
+ }
455
+ case 'audio.coneOuterGain': {
456
+ const gain = Math.max(0, Math.min(1, num));
457
+ object.setDirectionalCone(object.panner.coneInnerAngle, object.panner.coneOuterAngle, gain);
458
+ return gain;
459
+ }
460
+ default:
461
+ return null;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Apply a typed edit onto the LIVE object (optimistic preview) and return the
467
+ * value to persist to source — a `#rrggbb` string for a color, the number for a
468
+ * number, the boolean for a boolean, the project asset path (or `''` for a
469
+ * cleared slot) for an `asset` — or `null` when the edit cannot be applied
470
+ * (no owner, or a non-finite number). The caller serializes and writes it back
471
+ * through the source-write backend; nothing here touches source.
472
+ *
473
+ * An `asset` value is NOT a JSX-attribute literal — a texture reaches R3F
474
+ * source through a loader hook, not a string prop — so the lane that owns
475
+ * source persistence refuses that half by name rather than writing a string
476
+ * where a `THREE.Texture` belongs. The live half above is real either way.
477
+ */
478
+ export function applyTypedField(
479
+ object: THREE.Object3D,
480
+ material: THREE.Material | null,
481
+ field: TypedThreeField,
482
+ value: unknown,
483
+ ): string | number | boolean | null {
484
+ if (field.group === 'Audio' && isPositionalAudioObject(object)) {
485
+ return applyAudioField(object, field, value);
486
+ }
487
+ if (field.type === 'asset') return applyAssetField(material, field, value);
488
+ const owner = ownerOf(object, material, field);
489
+ if (!owner) return null;
490
+ if (field.type === 'color') {
491
+ const hex = String(value);
492
+ const color = owner[field.prop] as THREE.Color | undefined;
493
+ color?.set(hex);
494
+ return hex;
495
+ }
496
+ if (field.type === 'boolean') {
497
+ const bool = Boolean(value);
498
+ owner[field.prop] = bool;
499
+ markDirty(object, material, field);
500
+ return bool;
501
+ }
502
+ const num = Number(value);
503
+ if (!Number.isFinite(num)) return null;
504
+ owner[field.prop] = num;
505
+ markDirty(object, material, field);
506
+ return num;
507
+ }
508
+
509
+ /** Re-derive state that three.js does not recompute on a bare property write:
510
+ * a camera's projection matrix, and a material's `needsUpdate` for flags that
511
+ * change how it compiles (`transparent`/`wireframe`). */
512
+ function markDirty(
513
+ object: THREE.Object3D,
514
+ material: THREE.Material | null,
515
+ field: TypedThreeField,
516
+ ): void {
517
+ if (field.group === 'Camera') {
518
+ const cam = object as THREE.Camera & { updateProjectionMatrix?: () => void };
519
+ cam.updateProjectionMatrix?.();
520
+ }
521
+ if (
522
+ field.target === 'material' &&
523
+ material &&
524
+ (field.prop === 'transparent' || field.prop === 'wireframe')
525
+ ) {
526
+ material.needsUpdate = true;
527
+ }
528
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * "Extract Component…" — the native row's structural extraction, registered
3
+ * through the host's hierarchy contribution seam.
4
+ *
5
+ * The fork menu's mirror (`fork-menu.ts`), reusing the host's locator
6
+ * resolution for the same reason it does: the extract accessors are plain
7
+ * methods on the R3F adapter rather than routed contract members, so ownership
8
+ * is resolved through the composite's public `ownerOf` walk and the owning
9
+ * child is probed directly. The applicability rule and every sentence are pure
10
+ * and live in `@editor/instance-extract-actions`; the source rewrite is pure
11
+ * and lives server-side in the host's `ui-source/plan-extract-component.ts`.
12
+ */
13
+
14
+ import { getActiveAuthoring } from '@volter/editor-core/authoring/active-adapter';
15
+ import { instanceSourceLocatorFor } from '@volter/editor-core/authoring/instance-source-menu';
16
+ import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
17
+ import { registerHierarchyMenuItems } from '@volter/editor-core/hierarchy-menu-registry';
18
+ import {
19
+ canExtractNode,
20
+ EXTRACT_COMPONENT_LABEL,
21
+ type InstanceExtractSource,
22
+ } from '../../host/instance-extract-actions';
23
+ import { showTransientHint } from '@volter/editor-core/transient-hint';
24
+ import type { AuthoringAdapter } from '@volter/editor-project/adapter';
25
+
26
+ /** The extract surface for `nodeId`, or `null` — the host's own owner walk. */
27
+ export function instanceExtractSourceFor(
28
+ adapter: AuthoringAdapter,
29
+ nodeId: string,
30
+ ): InstanceExtractSource | null {
31
+ return instanceSourceLocatorFor(adapter, nodeId) as InstanceExtractSource | null;
32
+ }
33
+
34
+ function extractable(store: EditorShellStore, nodeId: string): boolean {
35
+ const adapter = getActiveAuthoring(store);
36
+ return canExtractNode(
37
+ adapter.hierarchy.node(nodeId),
38
+ instanceExtractSourceFor(adapter, nodeId),
39
+ nodeId,
40
+ );
41
+ }
42
+
43
+ let unregister: (() => void) | null = null;
44
+
45
+ /** Idempotent install of the extract item (see `component-verbs.service.ts`). */
46
+ export function ensureInstanceExtractMenuRegistered(): void {
47
+ if (unregister) return;
48
+ unregister = registerHierarchyMenuItems(
49
+ ({ nodeId, store }) => extractable(store, nodeId),
50
+ ({ nodeId, store }) => {
51
+ const source = instanceExtractSourceFor(getActiveAuthoring(store), nodeId);
52
+ return [
53
+ {
54
+ label: EXTRACT_COMPONENT_LABEL,
55
+ action: () => {
56
+ // Every outcome — created, refused by the plan, refused by
57
+ // transport, or created-but-not-replaced — comes back as a
58
+ // sentence from the adapter. No click is silent.
59
+ void source?.extractComponent?.(nodeId).then(showTransientHint);
60
+ },
61
+ },
62
+ ];
63
+ },
64
+ );
65
+ }
66
+
67
+ /** Drop the registration — the service contribution's stop. */
68
+ export function unregisterInstanceExtractMenu(): void {
69
+ unregister?.();
70
+ unregister = null;
71
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * "Fork Component…" — the instance row's definition copy, registered through
3
+ * the host's hierarchy contribution seam.
4
+ *
5
+ * Same split as `extract-menu.ts`, and the SAME structural locator probe:
6
+ * `GameHierarchy` stays contract-only and never learns that a three world has
7
+ * a component definition to copy; THIS module resolves the owning child
8
+ * adapter (through the composite's `ownerOf`, not by decoding the id) and
9
+ * probes it for the fork accessors. The applicability rule and every sentence
10
+ * are pure and live in `@editor/instance-fork-actions`; the source rewrite
11
+ * itself is pure and lives server-side in the host's
12
+ * `ui-source/plan-fork-component.ts`.
13
+ *
14
+ * WHY IT REUSES THE SOURCE MENU's LOCATOR RESOLUTION rather than the
15
+ * composite's routing (which `internals-menu.ts` can use): the fork accessors
16
+ * are plain methods on the R3F adapter rather than routed contract members, so
17
+ * asking the composite directly would ask the wrong object.
18
+ */
19
+
20
+ import { getActiveAuthoring } from '@volter/editor-core/authoring/active-adapter';
21
+ import { instanceSourceLocatorFor } from '@volter/editor-core/authoring/instance-source-menu';
22
+ import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
23
+ import { registerHierarchyMenuItems } from '@volter/editor-core/hierarchy-menu-registry';
24
+ import {
25
+ canForkInstance,
26
+ FORK_COMPONENT_LABEL,
27
+ type InstanceForkSource,
28
+ } from '../../host/instance-fork-actions';
29
+ import { showTransientHint } from '@volter/editor-core/transient-hint';
30
+ import type { AuthoringAdapter } from '@volter/editor-project/adapter';
31
+
32
+ /** The fork surface for `nodeId`, or `null` — the host's own owner walk. */
33
+ export function instanceForkSourceFor(
34
+ adapter: AuthoringAdapter,
35
+ nodeId: string,
36
+ ): InstanceForkSource | null {
37
+ return instanceSourceLocatorFor(adapter, nodeId) as InstanceForkSource | null;
38
+ }
39
+
40
+ function forkable(store: EditorShellStore, nodeId: string): boolean {
41
+ const adapter = getActiveAuthoring(store);
42
+ return canForkInstance(
43
+ adapter.hierarchy.node(nodeId),
44
+ instanceForkSourceFor(adapter, nodeId),
45
+ nodeId,
46
+ );
47
+ }
48
+
49
+ let unregister: (() => void) | null = null;
50
+
51
+ /** Idempotent install of the fork item (see `component-verbs.service.ts`). */
52
+ export function ensureInstanceForkMenuRegistered(): void {
53
+ if (unregister) return;
54
+ unregister = registerHierarchyMenuItems(
55
+ ({ nodeId, store }) => forkable(store, nodeId),
56
+ ({ nodeId, store }) => {
57
+ const source = instanceForkSourceFor(getActiveAuthoring(store), nodeId);
58
+ return [
59
+ {
60
+ label: FORK_COMPONENT_LABEL,
61
+ action: () => {
62
+ // Every outcome — created, refused by the plan, refused by
63
+ // transport, or created-but-not-retargeted — comes back as a
64
+ // sentence from the adapter. Nothing here invents wording, and no
65
+ // click is silent.
66
+ void source?.forkComponent?.(nodeId).then(showTransientHint);
67
+ },
68
+ },
69
+ ];
70
+ },
71
+ );
72
+ }
73
+
74
+ /** Drop the registration — the service contribution's stop. */
75
+ export function unregisterInstanceForkMenu(): void {
76
+ unregister?.();
77
+ unregister = null;
78
+ }