@vgai/editor 0.2.0

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 (519) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +35 -0
  3. package/dist/a/b/c/test-nested.json +1 -0
  4. package/dist/assets/CCDIKSolver-Dz-ysxZF.js +1 -0
  5. package/dist/assets/SelectionBox-BXsv_6aH.js +1 -0
  6. package/dist/assets/browser-project-scripts-DOru4L1x.js +2 -0
  7. package/dist/assets/browserAll-CEkWYlFk.js +1 -0
  8. package/dist/assets/collision-world-DniMB0qq.glb +0 -0
  9. package/dist/assets/create-runtime-DYIWr3va.js +1 -0
  10. package/dist/assets/data-asset-Bb9sIM7i.js +5 -0
  11. package/dist/assets/esbuild-BHljloGq.wasm +0 -0
  12. package/dist/assets/fake-three-BMTQ9rl_.js +1 -0
  13. package/dist/assets/game-8SDZPPgH.js +1 -0
  14. package/dist/assets/game-BI111o5f.js +10 -0
  15. package/dist/assets/game-CevorxyV.js +1 -0
  16. package/dist/assets/game-Di1H0gFj.js +1 -0
  17. package/dist/assets/game-Di7XlywD.js +1 -0
  18. package/dist/assets/game-DjnCaw8R.js +1 -0
  19. package/dist/assets/game-Q9A0aXSV.js +1 -0
  20. package/dist/assets/game-pt_1lQLv.js +1 -0
  21. package/dist/assets/game-source-CvidN7AB.js +1 -0
  22. package/dist/assets/game-xsOho_wg.js +83 -0
  23. package/dist/assets/index-Bp5cecD6.js +12 -0
  24. package/dist/assets/index-CPVV6pjw.js +1 -0
  25. package/dist/assets/index-CpOnYyeD.js +213 -0
  26. package/dist/assets/index-Cs8xwv3e.js +1 -0
  27. package/dist/assets/index-DL7RcQ0n.js +27 -0
  28. package/dist/assets/index-Dr8H_6Rl.js +1 -0
  29. package/dist/assets/index-_bCaipUk.js +13650 -0
  30. package/dist/assets/ingest-siblings-Dz8eEqrV.js +1 -0
  31. package/dist/assets/mount-manifest-Bhk0ohsl.js +1 -0
  32. package/dist/assets/recast-navigation.wasm-compat-CvP4UGK6.js +1 -0
  33. package/dist/assets/scene-query-BivAHhJu.js +1 -0
  34. package/dist/assets/stats.module--VATS4Kh.js +1 -0
  35. package/dist/assets/webworkerAll-BLrH0F6F.js +1 -0
  36. package/dist/assets/xstate-animation-binding-1NGLmw6y.js +1 -0
  37. package/dist/assets/xstate.esm-BgUD33nl.js +3 -0
  38. package/dist/index.html +37 -0
  39. package/dist/ingest/racing-game/UPSTREAM.md +68 -0
  40. package/dist/ingest/racing-game/a/Inter-Black.woff +0 -0
  41. package/dist/ingest/racing-game/a/Inter-Black.woff2 +0 -0
  42. package/dist/ingest/racing-game/a/Inter-BlackItalic.woff +0 -0
  43. package/dist/ingest/racing-game/a/Inter-BlackItalic.woff2 +0 -0
  44. package/dist/ingest/racing-game/a/Inter-Bold.woff +0 -0
  45. package/dist/ingest/racing-game/a/Inter-Bold.woff2 +0 -0
  46. package/dist/ingest/racing-game/a/Inter-BoldItalic.woff +0 -0
  47. package/dist/ingest/racing-game/a/Inter-BoldItalic.woff2 +0 -0
  48. package/dist/ingest/racing-game/a/Inter-ExtraBold.woff +0 -0
  49. package/dist/ingest/racing-game/a/Inter-ExtraBold.woff2 +0 -0
  50. package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff +0 -0
  51. package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff2 +0 -0
  52. package/dist/ingest/racing-game/a/Inter-ExtraLight.woff +0 -0
  53. package/dist/ingest/racing-game/a/Inter-ExtraLight.woff2 +0 -0
  54. package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff +0 -0
  55. package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff2 +0 -0
  56. package/dist/ingest/racing-game/a/Inter-Italic.woff +0 -0
  57. package/dist/ingest/racing-game/a/Inter-Italic.woff2 +0 -0
  58. package/dist/ingest/racing-game/a/Inter-Light.woff +0 -0
  59. package/dist/ingest/racing-game/a/Inter-Light.woff2 +0 -0
  60. package/dist/ingest/racing-game/a/Inter-LightItalic.woff +0 -0
  61. package/dist/ingest/racing-game/a/Inter-LightItalic.woff2 +0 -0
  62. package/dist/ingest/racing-game/a/Inter-Medium.woff +0 -0
  63. package/dist/ingest/racing-game/a/Inter-Medium.woff2 +0 -0
  64. package/dist/ingest/racing-game/a/Inter-MediumItalic.woff +0 -0
  65. package/dist/ingest/racing-game/a/Inter-MediumItalic.woff2 +0 -0
  66. package/dist/ingest/racing-game/a/Inter-Regular.woff +0 -0
  67. package/dist/ingest/racing-game/a/Inter-Regular.woff2 +0 -0
  68. package/dist/ingest/racing-game/a/Inter-SemiBold.woff +0 -0
  69. package/dist/ingest/racing-game/a/Inter-SemiBold.woff2 +0 -0
  70. package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff +0 -0
  71. package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff2 +0 -0
  72. package/dist/ingest/racing-game/a/Inter-Thin.woff +0 -0
  73. package/dist/ingest/racing-game/a/Inter-Thin.woff2 +0 -0
  74. package/dist/ingest/racing-game/a/Inter-ThinItalic.woff +0 -0
  75. package/dist/ingest/racing-game/a/Inter-ThinItalic.woff2 +0 -0
  76. package/dist/ingest/racing-game/a/Inter-italic.var.woff2 +0 -0
  77. package/dist/ingest/racing-game/a/Inter-roman.var.woff2 +0 -0
  78. package/dist/ingest/racing-game/a/Inter.var.woff2 +0 -0
  79. package/dist/ingest/racing-game/a/index.css +1 -0
  80. package/dist/ingest/racing-game/images/bronze.png +0 -0
  81. package/dist/ingest/racing-game/images/gold.png +0 -0
  82. package/dist/ingest/racing-game/images/silver.png +0 -0
  83. package/dist/ingest/racing-game/index.html +15 -0
  84. package/dist/ingest/racing-game/models/chassis-draco.glb +0 -0
  85. package/dist/ingest/racing-game/models/track-draco.glb +0 -0
  86. package/dist/ingest/racing-game/models/wheel-draco.glb +0 -0
  87. package/dist/ingest/racing-game/rg-entry.js +1 -0
  88. package/dist/ingest/racing-game/sounds/accelerate.mp3 +0 -0
  89. package/dist/ingest/racing-game/sounds/boost.mp3 +0 -0
  90. package/dist/ingest/racing-game/sounds/crash.mp3 +0 -0
  91. package/dist/ingest/racing-game/sounds/engine.mp3 +0 -0
  92. package/dist/ingest/racing-game/sounds/honk.mp3 +0 -0
  93. package/dist/ingest/racing-game/sounds/tire-brake.mp3 +0 -0
  94. package/dist/ingest/racing-game/sounds/train.mp3 +0 -0
  95. package/dist/ingest/racing-game/sounds/water.mp3 +0 -0
  96. package/dist/ingest/racing-game/textures/cursor.svg +3 -0
  97. package/dist/ingest/racing-game/textures/dikhololo_night_1k.hdr +0 -0
  98. package/dist/ingest/racing-game/textures/heightmap_1024.png +0 -0
  99. package/dist/ingest/racing-game/textures/mask.svg +11 -0
  100. package/dist/ingest/racing-game/textures/no_sound.png +0 -0
  101. package/dist/ingest/racing-game/three-r139.module.js +50617 -0
  102. package/dist/ingest/racing-game/vendor.js +179 -0
  103. package/dist/ingest/racing-game/vgai.game.json +20 -0
  104. package/dist/ingest/simcity/UPSTREAM.md +65 -0
  105. package/dist/ingest/simcity/favicon.ico +0 -0
  106. package/dist/ingest/simcity/fonts/Bebas-Regular.otf +0 -0
  107. package/dist/ingest/simcity/fonts/Digitalt.otf +0 -0
  108. package/dist/ingest/simcity/fonts/Stanberry.ttf +0 -0
  109. package/dist/ingest/simcity/icons/bulldozer-color.png +0 -0
  110. package/dist/ingest/simcity/icons/bulldozer.png +0 -0
  111. package/dist/ingest/simcity/icons/calendar.png +0 -0
  112. package/dist/ingest/simcity/icons/factory-color.png +0 -0
  113. package/dist/ingest/simcity/icons/factory.png +0 -0
  114. package/dist/ingest/simcity/icons/house-color.png +0 -0
  115. package/dist/ingest/simcity/icons/house.png +0 -0
  116. package/dist/ingest/simcity/icons/job.png +0 -0
  117. package/dist/ingest/simcity/icons/office.png +0 -0
  118. package/dist/ingest/simcity/icons/pause-color.png +0 -0
  119. package/dist/ingest/simcity/icons/pause.png +0 -0
  120. package/dist/ingest/simcity/icons/person.png +0 -0
  121. package/dist/ingest/simcity/icons/play-color.png +0 -0
  122. package/dist/ingest/simcity/icons/play.png +0 -0
  123. package/dist/ingest/simcity/icons/power-color.png +0 -0
  124. package/dist/ingest/simcity/icons/power-line-color.png +0 -0
  125. package/dist/ingest/simcity/icons/power.png +0 -0
  126. package/dist/ingest/simcity/icons/road-color.png +0 -0
  127. package/dist/ingest/simcity/icons/road.png +0 -0
  128. package/dist/ingest/simcity/icons/select-color.png +0 -0
  129. package/dist/ingest/simcity/icons/select.png +0 -0
  130. package/dist/ingest/simcity/icons/store-color.png +0 -0
  131. package/dist/ingest/simcity/index.html +94 -0
  132. package/dist/ingest/simcity/index.js +58 -0
  133. package/dist/ingest/simcity/main.css +233 -0
  134. package/dist/ingest/simcity/models/armored-truck.glb +0 -0
  135. package/dist/ingest/simcity/models/building-block-4floor-corner.glb +0 -0
  136. package/dist/ingest/simcity/models/building-block-4floor-short.glb +0 -0
  137. package/dist/ingest/simcity/models/building-block-5floor.glb +0 -0
  138. package/dist/ingest/simcity/models/building-burger-joint.glb +0 -0
  139. package/dist/ingest/simcity/models/building-cafe.glb +0 -0
  140. package/dist/ingest/simcity/models/building-casino.glb +0 -0
  141. package/dist/ingest/simcity/models/building-cinema.glb +0 -0
  142. package/dist/ingest/simcity/models/building-house-block-big.glb +0 -0
  143. package/dist/ingest/simcity/models/building-house-family-large.glb +0 -0
  144. package/dist/ingest/simcity/models/building-house-family-small.glb +0 -0
  145. package/dist/ingest/simcity/models/building-office-balcony.glb +0 -0
  146. package/dist/ingest/simcity/models/building-office-big.glb +0 -0
  147. package/dist/ingest/simcity/models/building-office-pyramid.glb +0 -0
  148. package/dist/ingest/simcity/models/building-office-tall.glb +0 -0
  149. package/dist/ingest/simcity/models/building-office.glb +0 -0
  150. package/dist/ingest/simcity/models/building-restaurant.glb +0 -0
  151. package/dist/ingest/simcity/models/building-skyscraper.glb +0 -0
  152. package/dist/ingest/simcity/models/car-ambulance-pickup.glb +0 -0
  153. package/dist/ingest/simcity/models/car-baywatch.glb +0 -0
  154. package/dist/ingest/simcity/models/car-hippie-van.glb +0 -0
  155. package/dist/ingest/simcity/models/car-passenger-race.glb +0 -0
  156. package/dist/ingest/simcity/models/car-passenger.glb +0 -0
  157. package/dist/ingest/simcity/models/car-police.glb +0 -0
  158. package/dist/ingest/simcity/models/car-taxi.glb +0 -0
  159. package/dist/ingest/simcity/models/car-tow-truck.glb +0 -0
  160. package/dist/ingest/simcity/models/car-truck-dump.glb +0 -0
  161. package/dist/ingest/simcity/models/car-veteran.glb +0 -0
  162. package/dist/ingest/simcity/models/construction-small.glb +0 -0
  163. package/dist/ingest/simcity/models/data-center.glb +0 -0
  164. package/dist/ingest/simcity/models/industry-factory-old.glb +0 -0
  165. package/dist/ingest/simcity/models/industry-factory.glb +0 -0
  166. package/dist/ingest/simcity/models/industry-refinery.glb +0 -0
  167. package/dist/ingest/simcity/models/industry-warehouse.glb +0 -0
  168. package/dist/ingest/simcity/models/power_line_pole_modified.glb +0 -0
  169. package/dist/ingest/simcity/models/tile-plain_grass.glb +0 -0
  170. package/dist/ingest/simcity/models/tile-road-curve.glb +0 -0
  171. package/dist/ingest/simcity/models/tile-road-end.glb +0 -0
  172. package/dist/ingest/simcity/models/tile-road-intersection-t.glb +0 -0
  173. package/dist/ingest/simcity/models/tile-road-intersection.glb +0 -0
  174. package/dist/ingest/simcity/models/tile-road-straight.glb +0 -0
  175. package/dist/ingest/simcity/models/truck.glb +0 -0
  176. package/dist/ingest/simcity/statusIcons/no-power.png +0 -0
  177. package/dist/ingest/simcity/statusIcons/no-road-access.png +0 -0
  178. package/dist/ingest/simcity/textures/base.png +0 -0
  179. package/dist/ingest/simcity/textures/grid.png +0 -0
  180. package/dist/ingest/simcity/textures/specular.png +0 -0
  181. package/dist/ingest/simcity/three-r155.module.js +51466 -0
  182. package/dist/ingest/simcity/vgai.game.json +20 -0
  183. package/dist/ingest/tanks/UPSTREAM.md +69 -0
  184. package/dist/ingest/tanks/index.html +214 -0
  185. package/dist/ingest/tanks/models/pixelTank.fbx +0 -0
  186. package/dist/ingest/tanks/tanks-entry.js +92 -0
  187. package/dist/ingest/tanks/three-r170.module.js +54571 -0
  188. package/dist/ingest/tanks/vgai.game.json +20 -0
  189. package/dist/inputmaps/default.inputmap.json +41 -0
  190. package/dist/jsm/libs/draco/gltf/draco_decoder.js +33 -0
  191. package/dist/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
  192. package/dist/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
  193. package/dist/scenes/untitled.vscn.json +29 -0
  194. package/dist/textures/waternormals.jpg +0 -0
  195. package/dist-server/packaged.mjs +18155 -0
  196. package/package.json +36 -0
  197. package/server/asset-library-routes.ts +833 -0
  198. package/server/canonical-path.ts +50 -0
  199. package/server/cloud-asset-catalog.ts +101 -0
  200. package/server/dev.ts +350 -0
  201. package/server/editor-server.ts +1537 -0
  202. package/server/editor-sse.ts +73 -0
  203. package/server/game-globals-shadow.ts +58 -0
  204. package/server/local-asset-catalog.ts +163 -0
  205. package/server/packaged.ts +571 -0
  206. package/server/prod.ts +75 -0
  207. package/server/project-optimize-deps-entries.ts +149 -0
  208. package/server/project-view.ts +65 -0
  209. package/server/server-utils.ts +430 -0
  210. package/server/session-registry.ts +93 -0
  211. package/src/EditorContext.tsx +87 -0
  212. package/src/achieved-tier.ts +126 -0
  213. package/src/action-registry.ts +268 -0
  214. package/src/adapter-resolver.ts +1858 -0
  215. package/src/asset-events.ts +100 -0
  216. package/src/authoring/active-adapter.ts +98 -0
  217. package/src/authoring/active-systems.ts +50 -0
  218. package/src/authoring/boundary-authoring-adapter.ts +178 -0
  219. package/src/authoring/composite-authoring-adapter.ts +891 -0
  220. package/src/authoring/design-time-layers.ts +776 -0
  221. package/src/authoring/edit-mode-authoring.ts +643 -0
  222. package/src/authoring/embed-only-adapter.ts +57 -0
  223. package/src/authoring/ephemeral-persistence.ts +26 -0
  224. package/src/authoring/eyedropper-session.ts +69 -0
  225. package/src/authoring/first-party-hierarchy-menu.ts +74 -0
  226. package/src/authoring/first-party-inspector/AddSectionMenu.tsx +118 -0
  227. package/src/authoring/first-party-inspector/EnvironmentSection.tsx +550 -0
  228. package/src/authoring/first-party-inspector/HeaderSection.tsx +329 -0
  229. package/src/authoring/first-party-inspector/is-first-party-selection.ts +44 -0
  230. package/src/authoring/first-party-inspector/register.tsx +108 -0
  231. package/src/authoring/ingest-authoring-adapter.ts +475 -0
  232. package/src/authoring/ingest-capture.ts +94 -0
  233. package/src/authoring/ingest-game-adapter.ts +291 -0
  234. package/src/authoring/ingest-iframe-adapter.ts +38 -0
  235. package/src/authoring/ingest-iframe-reachable-adapter.ts +401 -0
  236. package/src/authoring/ingest-overlay.ts +193 -0
  237. package/src/authoring/layered-pick.ts +87 -0
  238. package/src/authoring/mount-failure-report.ts +112 -0
  239. package/src/authoring/overlay-report.ts +52 -0
  240. package/src/authoring/project-scenes.ts +111 -0
  241. package/src/authoring/react-dom-authoring-adapter.ts +795 -0
  242. package/src/authoring/react-inspector-section.tsx +1041 -0
  243. package/src/authoring/react-world-authoring-adapter.ts +2400 -0
  244. package/src/authoring/root-activation.ts +61 -0
  245. package/src/authoring/scene-ui-layer.ts +485 -0
  246. package/src/authoring/story-overlay.ts +48 -0
  247. package/src/authoring/ui-authoring-adapter.ts +325 -0
  248. package/src/authoring/ui-hierarchy-menu.ts +40 -0
  249. package/src/authoring/ui-inspector-sections.tsx +156 -0
  250. package/src/authoring/undo-timeline.ts +145 -0
  251. package/src/authoring/vgai-scene-authoring-adapter.ts +746 -0
  252. package/src/authoring/viewport-pick-context.ts +31 -0
  253. package/src/authoring/viewport-raycast.ts +80 -0
  254. package/src/authoring/world-overlay.ts +76 -0
  255. package/src/authoring/world-pan-state.ts +115 -0
  256. package/src/authoring/world-session-state.ts +102 -0
  257. package/src/authoring/world2d-authoring-adapter.ts +313 -0
  258. package/src/browser-external-watch.ts +98 -0
  259. package/src/browser-play-entry.ts +112 -0
  260. package/src/browser-project-scripts.ts +112 -0
  261. package/src/browser-transpile.ts +246 -0
  262. package/src/browser-watch-reload.ts +100 -0
  263. package/src/command-listener.ts +326 -0
  264. package/src/components/AlignToolbar.tsx +189 -0
  265. package/src/components/AppRoot.tsx +73 -0
  266. package/src/components/AssetBrowser.tsx +502 -0
  267. package/src/components/BottomPanel.tsx +326 -0
  268. package/src/components/BuildPanel.tsx +455 -0
  269. package/src/components/CameraInfo.tsx +56 -0
  270. package/src/components/CommandPalette.tsx +265 -0
  271. package/src/components/ConsolePanel.tsx +272 -0
  272. package/src/components/DataPanel.tsx +1268 -0
  273. package/src/components/DefaultEditorLayout.tsx +509 -0
  274. package/src/components/FolderTree.tsx +179 -0
  275. package/src/components/GameHierarchy.tsx +1349 -0
  276. package/src/components/Inspector.tsx +671 -0
  277. package/src/components/InspectorToolSection.tsx +74 -0
  278. package/src/components/MountFailureBanner.tsx +110 -0
  279. package/src/components/NetworkSection.tsx +109 -0
  280. package/src/components/OnlineAssetBrowser.tsx +448 -0
  281. package/src/components/OverlayReportBanner.tsx +122 -0
  282. package/src/components/PlayBar.tsx +356 -0
  283. package/src/components/PrefabSaveDialog.tsx +201 -0
  284. package/src/components/ProjectHeader.tsx +174 -0
  285. package/src/components/ProjectScreen.tsx +1004 -0
  286. package/src/components/RightPanel.tsx +166 -0
  287. package/src/components/RootActivateDialog.tsx +105 -0
  288. package/src/components/SaveDialog.tsx +197 -0
  289. package/src/components/SaveStatus.tsx +69 -0
  290. package/src/components/StatsOverlay.tsx +35 -0
  291. package/src/components/ToolHost.tsx +80 -0
  292. package/src/components/Toolbar.tsx +307 -0
  293. package/src/components/ViewportOverlay.tsx +234 -0
  294. package/src/components/ViewportPanel.tsx +369 -0
  295. package/src/components/WorldContextMenu.tsx +257 -0
  296. package/src/components/WorldSelectionOverlay.tsx +2003 -0
  297. package/src/components/WorldTextEditor.tsx +86 -0
  298. package/src/components/asset-viewers/AssetViewer.tsx +38 -0
  299. package/src/components/asset-viewers/AudioViewer.tsx +149 -0
  300. package/src/components/asset-viewers/GenericJsonViewer.tsx +77 -0
  301. package/src/components/asset-viewers/ImageViewer.tsx +65 -0
  302. package/src/components/asset-viewers/InputMapViewer.tsx +206 -0
  303. package/src/components/asset-viewers/ModelViewer.tsx +180 -0
  304. package/src/components/asset-viewers/OnlineAssetDetail.tsx +236 -0
  305. package/src/components/data-csv.ts +301 -0
  306. package/src/components/entity-name-commit.ts +18 -0
  307. package/src/components/inspector-property-grouping.ts +65 -0
  308. package/src/components/inspector-widgets/AlignmentGrid.tsx +188 -0
  309. package/src/components/inspector-widgets/BorderEditor.tsx +305 -0
  310. package/src/components/inspector-widgets/ColorPicker.tsx +548 -0
  311. package/src/components/inspector-widgets/FilterEditor.tsx +108 -0
  312. package/src/components/inspector-widgets/FontPicker.tsx +196 -0
  313. package/src/components/inspector-widgets/GradientEditor.tsx +277 -0
  314. package/src/components/inspector-widgets/ScrubbableInput.tsx +152 -0
  315. package/src/components/inspector-widgets/ShadowEditor.tsx +332 -0
  316. package/src/components/inspector-widgets/color-utils.ts +202 -0
  317. package/src/components/inspector-widgets/gallery-main.tsx +167 -0
  318. package/src/components/inspector-widgets/index.ts +22 -0
  319. package/src/components/inspector-widgets/shared.tsx +135 -0
  320. package/src/components/inspectors/AnimationSection.tsx +143 -0
  321. package/src/components/inspectors/AudioSection.tsx +215 -0
  322. package/src/components/inspectors/BoneAttachmentSection.tsx +82 -0
  323. package/src/components/inspectors/CameraSection.tsx +105 -0
  324. package/src/components/inspectors/ComponentsSection.tsx +358 -0
  325. package/src/components/inspectors/JointsSection.tsx +255 -0
  326. package/src/components/inspectors/LightSection.tsx +116 -0
  327. package/src/components/inspectors/MaterialSection.tsx +1189 -0
  328. package/src/components/inspectors/MeshSection.tsx +99 -0
  329. package/src/components/inspectors/NavigationSection.tsx +58 -0
  330. package/src/components/inspectors/ParticleSection.tsx +681 -0
  331. package/src/components/inspectors/PhysicsSection.tsx +326 -0
  332. package/src/components/inspectors/PostProcessingSection.tsx +1290 -0
  333. package/src/components/inspectors/RenderingSection.tsx +109 -0
  334. package/src/components/inspectors/ShadowSection.tsx +84 -0
  335. package/src/components/inspectors/SplineSection.tsx +191 -0
  336. package/src/components/inspectors/TransformSection.tsx +191 -0
  337. package/src/components/inspectors/json-schema-fields.ts +281 -0
  338. package/src/components/inspectors/multi-edit-utils.ts +49 -0
  339. package/src/components/inspectors/zod-fields.ts +184 -0
  340. package/src/components/primitives/ColorInput.tsx +109 -0
  341. package/src/components/primitives/JsonInput.tsx +98 -0
  342. package/src/components/primitives/NumberInput.tsx +164 -0
  343. package/src/components/primitives/Panel.tsx +68 -0
  344. package/src/components/primitives/SectionHeader.tsx +45 -0
  345. package/src/components/primitives/Tooltip.tsx +82 -0
  346. package/src/components/primitives/Vec3Input.tsx +62 -0
  347. package/src/components/world-overlay-gestures.ts +1438 -0
  348. package/src/doctor/data-checks.ts +285 -0
  349. package/src/doctor/report.ts +386 -0
  350. package/src/editor-api.ts +700 -0
  351. package/src/editor-console.ts +194 -0
  352. package/src/editor-hotkeys.ts +441 -0
  353. package/src/editor-layers.ts +2 -0
  354. package/src/editor-mode.ts +28 -0
  355. package/src/editor-store.ts +2225 -0
  356. package/src/editor-viewport.ts +1924 -0
  357. package/src/entity-factory.ts +221 -0
  358. package/src/game-globals-prelude.ts +27 -0
  359. package/src/gated-globals.ts +131 -0
  360. package/src/generated/setup-by-example.generated.ts +53 -0
  361. package/src/gizmo-registry.ts +608 -0
  362. package/src/handle-system.ts +221 -0
  363. package/src/hierarchy-menu-registry.ts +70 -0
  364. package/src/hierarchy-rows.ts +73 -0
  365. package/src/hosted-example.ts +160 -0
  366. package/src/hotkeys.ts +142 -0
  367. package/src/ingest/discovery-react.ts +132 -0
  368. package/src/ingest/discovery.ts +205 -0
  369. package/src/ingest/discovery2d.ts +204 -0
  370. package/src/ingest/esm-entry-resolution.ts +102 -0
  371. package/src/ingest/games/bloom-composer/game.js +99 -0
  372. package/src/ingest/games/bloom-composer/vgai.game.json +21 -0
  373. package/src/ingest/games/embed-bundle/embed.html +10 -0
  374. package/src/ingest/games/embed-bundle/vgai.game.json +21 -0
  375. package/src/ingest/games/games-fps/assets/collision-world.glb +0 -0
  376. package/src/ingest/games/games-fps/game.js +479 -0
  377. package/src/ingest/games/games-fps/vgai.game.json +24 -0
  378. package/src/ingest/games/iframe-reachable/game-source.js +58 -0
  379. package/src/ingest/games/iframe-reachable/vgai.game.json +21 -0
  380. package/src/ingest/games/interactive-cubes/game.js +139 -0
  381. package/src/ingest/games/interactive-cubes/vgai.game.json +21 -0
  382. package/src/ingest/games/mismatched-three/fake-three.js +38 -0
  383. package/src/ingest/games/mismatched-three/game.js +10 -0
  384. package/src/ingest/games/mismatched-three/vgai.game.json +22 -0
  385. package/src/ingest/games/own-three-bundled/game.js +43 -0
  386. package/src/ingest/games/own-three-bundled/vgai.game.json +21 -0
  387. package/src/ingest/games/pointerlock-maze/game.js +288 -0
  388. package/src/ingest/games/pointerlock-maze/vgai.game.json +22 -0
  389. package/src/ingest/games-2d/_fixtures/first-party-scene.scn2d.json +124 -0
  390. package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +25 -0
  391. package/src/ingest/games-2d/bunnymark/game.ts +88 -0
  392. package/src/ingest/games-2d/bunnymark/vgai.game.json +21 -0
  393. package/src/ingest/games-2d/flappy/game.ts +148 -0
  394. package/src/ingest/games-2d/flappy/vgai.game.json +21 -0
  395. package/src/ingest/games-2d/flappy-pixi/vgai.game.json +25 -0
  396. package/src/ingest/games-2d/puzzling-potions/vgai.game.json +25 -0
  397. package/src/ingest/games-2d/tilemap-camera/game.ts +115 -0
  398. package/src/ingest/games-2d/tilemap-camera/vgai.game.json +21 -0
  399. package/src/ingest/games-react/react-rpg/entry.tsx +185 -0
  400. package/src/ingest/games-react/react-rpg/shims.d.ts +26 -0
  401. package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +129 -0
  402. package/src/ingest/games-react/react-rpg/vgai.game.json +21 -0
  403. package/src/ingest/host-namespace-registry.ts +196 -0
  404. package/src/ingest/types.ts +127 -0
  405. package/src/ingest-hierarchy-rows.ts +207 -0
  406. package/src/ingest-mode.ts +2092 -0
  407. package/src/ingest-siblings.ts +466 -0
  408. package/src/initial-scene.ts +194 -0
  409. package/src/inspector-section-registry.ts +117 -0
  410. package/src/interactive-edit-scope.ts +34 -0
  411. package/src/main.tsx +23 -0
  412. package/src/manifest-project.ts +43 -0
  413. package/src/model-thumbnail.ts +156 -0
  414. package/src/module-mode.ts +513 -0
  415. package/src/navmesh-handler.ts +94 -0
  416. package/src/packaged-runtime.ts +49 -0
  417. package/src/play-mode.ts +1148 -0
  418. package/src/playground/main.ts +206 -0
  419. package/src/project-manager.ts +173 -0
  420. package/src/scene-diff.ts +112 -0
  421. package/src/scene-serializer.ts +144 -0
  422. package/src/scene-sync.ts +587 -0
  423. package/src/scene-ui-mode/active-scene-ui.ts +58 -0
  424. package/src/scene-ui-mode/scene-ui-palette-panel.tsx +152 -0
  425. package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +29 -0
  426. package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +74 -0
  427. package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +49 -0
  428. package/src/scene-ui-mode/ui-prefab-io.ts +76 -0
  429. package/src/script-loader.ts +143 -0
  430. package/src/storage/fsa-storage.ts +55 -0
  431. package/src/storage/handle-storage.ts +150 -0
  432. package/src/storage/handle-store.ts +116 -0
  433. package/src/storage/http-storage.ts +137 -0
  434. package/src/storage/index.ts +147 -0
  435. package/src/storage/mem-storage.ts +135 -0
  436. package/src/storage/opfs-storage.ts +25 -0
  437. package/src/storage/paths.ts +26 -0
  438. package/src/storage/seed.ts +106 -0
  439. package/src/storage/types.ts +110 -0
  440. package/src/tool-loader.ts +291 -0
  441. package/src/ui-editor/inspector.tsx +563 -0
  442. package/src/ui-editor/node-ops.ts +266 -0
  443. package/src/ui-editor/overlay.tsx +380 -0
  444. package/src/ui-editor/palette.tsx +114 -0
  445. package/src/ui-editor/react-store.ts +78 -0
  446. package/src/ui-editor/timeline-panel.tsx +443 -0
  447. package/src/ui-editor/ui-edit-store.ts +552 -0
  448. package/src/ui-editor/ui-prefab.ts +95 -0
  449. package/src/ui-source/inspect.ts +544 -0
  450. package/src/ui-source/oid-transform.ts +133 -0
  451. package/src/ui-source/source-write-backend.ts +203 -0
  452. package/src/ui-source/tailwind-map.ts +174 -0
  453. package/src/ui-source/writer.ts +1175 -0
  454. package/src/widgets/index.ts +73 -0
  455. package/template/.claude/skills/editor/SKILL.md +252 -0
  456. package/template/CLAUDE.md +512 -0
  457. package/template/LICENSE +30 -0
  458. package/template/emit-schemas.ts +42 -0
  459. package/template/index.html +17 -0
  460. package/template/package.json +53 -0
  461. package/template/project.json +9 -0
  462. package/template/public/audio/ambient-arena.wav +0 -0
  463. package/template/public/audio/ambient-music.wav +0 -0
  464. package/template/public/audio/ambient-nature.wav +0 -0
  465. package/template/public/audio/ambient-wind.wav +0 -0
  466. package/template/public/inputmaps/default.inputmap.json +41 -0
  467. package/template/public/jsm/libs/draco/gltf/draco_decoder.js +33 -0
  468. package/template/public/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
  469. package/template/public/jsm/libs/draco/gltf/draco_encoder.js +33 -0
  470. package/template/public/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
  471. package/template/public/scenes/default.vscn.json +43 -0
  472. package/template/public/scenes/untitled.vscn.json +29 -0
  473. package/template/public/textures/waternormals.jpg +0 -0
  474. package/template/server/colyseus-setup.ts +85 -0
  475. package/template/server/main.ts +34 -0
  476. package/template/server/rooms/arena-room.ts +420 -0
  477. package/template/server/rooms/game-room.ts +162 -0
  478. package/template/server/rooms.ts +15 -0
  479. package/template/server/tsconfig.json +19 -0
  480. package/template/src/data/README.md +40 -0
  481. package/template/src/data/assets.ts +31 -0
  482. package/template/src/data/tuning.data.json +5 -0
  483. package/template/src/data/tuning.schema.json +21 -0
  484. package/template/src/data/tuning.schema.ts +34 -0
  485. package/template/src/data/tuning.ts +24 -0
  486. package/template/src/main.ts +157 -0
  487. package/template/src/scripts/components/data-spinner.ts +45 -0
  488. package/template/src/scripts/components/scene-camera.ts +40 -0
  489. package/template/src/scripts/main.ts +94 -0
  490. package/template/src/scripts/registry.ts +46 -0
  491. package/template/src/tools/README.md +14 -0
  492. package/template/src/tools/example.tool.tsx +91 -0
  493. package/template/src/tools/spin.tool.tsx +101 -0
  494. package/template/src/ui/Button.tsx +27 -0
  495. package/template/src/ui/DefaultHud.tsx +40 -0
  496. package/template/src/ui/KeyHint.tsx +25 -0
  497. package/template/src/ui/Panel.tsx +52 -0
  498. package/template/src/ui/RebindPanel.tsx +129 -0
  499. package/template/src/ui/Slider.tsx +28 -0
  500. package/template/src/ui/StatusDot.tsx +22 -0
  501. package/template/src/ui/Toggle.tsx +19 -0
  502. package/template/src/ui/game-state.tsx +37 -0
  503. package/template/src/ui/index.ts +7 -0
  504. package/template/src/ui/mount-default-hud.tsx +19 -0
  505. package/template/src/ui/mount-ui.ts +15 -0
  506. package/template/tsconfig.json +36 -0
  507. package/template/validate-asset-content.ts +78 -0
  508. package/template/validate-manifest.ts +55 -0
  509. package/template/validate-scenes.ts +211 -0
  510. package/template/vgai.game.json +21 -0
  511. package/template/vite.config.ts +128 -0
  512. package/vite-plugin-example-assets.ts +60 -0
  513. package/vite-plugin-game-globals.ts +46 -0
  514. package/vite-plugin-game-static.ts +71 -0
  515. package/vite-plugin-project-game-static.ts +84 -0
  516. package/vite-plugin-react-game-static.ts +98 -0
  517. package/vite-plugin-react-world-provider.ts +81 -0
  518. package/vite-plugin-react-world-runtime.ts +76 -0
  519. package/vite-plugin-ui-oid.ts +561 -0
@@ -0,0 +1,1349 @@
1
+ /**
2
+ * GameHierarchy — the ONE hierarchy panel (A2,
3
+ * docs/unified-world-editor/26-unified-editor-spec.md §2 A2). Replaces the
4
+ * former pair of per-format hierarchy panels (first-party single-document,
5
+ * ingested-game) — a single contract-only shell driven ENTIRELY through the
6
+ * active {@link AuthoringAdapter}: `hierarchy`/`selection`/`inspector`/
7
+ * `structure`/`assetDrop`. Every row affordance is gated on the PRESENCE of a
8
+ * provider or a reserved inspector path, never on which concrete adapter is
9
+ * active.
10
+ *
11
+ * Rule zero (spec §0): this file imports ONLY from `@engine/adapter` types
12
+ * and editor-shell modules — no private first-party entity type, no direct
13
+ * 3D-library value import, no first-party document-format string literal.
14
+ * First-party-only affordances not yet contract-expressible (prefab
15
+ * unpack/open/save-as) live behind `hierarchy-menu-registry.ts`'s escape
16
+ * hatch, registered elsewhere (`authoring/first-party-hierarchy-menu.ts`).
17
+ *
18
+ * Shape handling (the manager's reconciliation constraint): the ACTIVE
19
+ * adapter is either a bare per-scene adapter (single-world edit, an ingested
20
+ * game, …) — `adapter.hierarchy.roots()` returns real entity roots directly —
21
+ * or `CompositeAuthoringAdapter` (2+ worlds) — roots are synthetic
22
+ * `world:<id>` GROUP nodes (`flags.runtimeOnly`), each world's own roots
23
+ * nested beneath. Both shapes are walked by the SAME generic row engine
24
+ * (`flattenIngestRows` — the format-neutral `EditorNode`-typed row
25
+ * flattener, not the legacy first-party-typed sibling module); a group node
26
+ * only ever gets a small set of cosmetic extras (kind/zOrder badge, a
27
+ * session-local eye, `flags.inactiveRoot` dimming) layered on TOP of the
28
+ * same row component.
29
+ *
30
+ * Zero-regression testids (landmine a): every row carries BOTH the
31
+ * `data-entity-name`/`data-testid="scene-name"` family (`EditorPage.getEntityNames`/
32
+ * 09-editor-interaction.spec.ts) and the full `ingest-*` family (`ingest-row`,
33
+ * `ingest-row-caret`, `ingest-row-visibility`, `ingest-search`,
34
+ * `ingest-hierarchy`, `data-ingest-name`, `ingest-context-menu`) so the 13
35
+ * existing ingest-family e2e specs keep passing unchanged.
36
+ */
37
+
38
+ import type { AuthoringAdapter } from '@engine/adapter';
39
+ import {
40
+ faCaretDown,
41
+ faCaretRight,
42
+ faCrosshairs,
43
+ faEye,
44
+ faEyeSlash,
45
+ faHandPointer,
46
+ faLock,
47
+ faLockOpen,
48
+ faPlus,
49
+ } from '@fortawesome/free-solid-svg-icons';
50
+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
51
+ import { useCallback, useEffect, useReducer, useRef, useState, useSyncExternalStore } from 'react';
52
+ import { getActiveAuthoring, hasAuthoringOverride } from '../authoring/active-adapter';
53
+ import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
54
+ import { requestRootActivation } from '../authoring/root-activation';
55
+ import {
56
+ isWorldHidden,
57
+ isWorldInteractive,
58
+ isWorldPickLocked,
59
+ toggleWorldHidden,
60
+ toggleWorldInteractive,
61
+ toggleWorldPickLock,
62
+ } from '../authoring/world-session-state';
63
+ import { useEditorStore } from '../EditorContext';
64
+ import type { EditorStore } from '../editor-store';
65
+ import { getHierarchyMenuItems } from '../hierarchy-menu-registry';
66
+ import { CHILD_CAP } from '../hierarchy-rows';
67
+ import { setActiveScope } from '../hotkeys';
68
+ import {
69
+ flattenIngestRows,
70
+ type IngestRow,
71
+ ingestRowKey,
72
+ ingestRowWindow,
73
+ } from '../ingest-hierarchy-rows';
74
+ import { OverlayReportBanner } from './OverlayReportBanner';
75
+ import { Panel } from './primitives/Panel';
76
+
77
+ const ASSET_MIME = 'application/x-editor-asset';
78
+ const REORDER_MIME = 'application/x-hierarchy-reorder';
79
+ const ROW_HEIGHT = 24;
80
+ const INDENT = 14;
81
+
82
+ // Wave-18-ported scroll-window sizing (docs/WAVE18-INGEST-HIERARCHY-VIRTUALIZATION-DESIGN.md),
83
+ // adapted from `IngestHierarchy`'s pre-A2 windowing: `ROW_HEIGHT` above (24)
84
+ // is THIS panel's row height, not IngestHierarchy's old 22 — every rendered
85
+ // row (Row/MoreRow) is already fixed at that height, so the spacer-div trick
86
+ // only keeps the scrollbar honest as long as this constant matches. OVERSCAN
87
+ // keeps a small buffer of already-rendered rows on each side of the visible
88
+ // band so a fast scroll/keyboard-nav doesn't flash empty space before the
89
+ // next paint catches up. DEFAULT_VIEWPORT_PX is used whenever the container
90
+ // hasn't reported a real (nonzero) `clientHeight` yet — jsdom never performs
91
+ // real layout, so unit tests never measure anything unless they explicitly
92
+ // mock it; this generous fallback also keeps every pre-existing small-tree
93
+ // render byte-identical without any test needing to.
94
+ const OVERSCAN = 8;
95
+ const DEFAULT_VIEWPORT_PX = 2400;
96
+ // Only scroll-window ABOVE this row count. Small/medium trees (every real
97
+ // multi-world browse tree — world-group rows + CHILD_CAP-bounded children)
98
+ // render in full, immune to a transient/small `clientHeight` measurement
99
+ // windowing out later world-group rows (which broke 19-multi-world/22-tri-world
100
+ // when virtualization first landed). Windowing only kicks in for pathologically
101
+ // large lists (e.g. an ingested game's uncapped SEARCH result — react-rpg's
102
+ // dungeon is ~1900 rows), which is exactly the case wave-18 targeted.
103
+ const VIRTUALIZE_MIN_ROWS = 300;
104
+
105
+ const KIND_GLYPH: Record<string, string> = {
106
+ mesh: '◧',
107
+ light: '☀',
108
+ camera: '▣',
109
+ group: '▾',
110
+ object: '•',
111
+ // B3 — a react world's catalog component node (`kind:'component'`, the
112
+ // registry entry, not the entry-preview's DOM subtree). A neutral glyph
113
+ // (NOT react-branded) — this shell only ever knows the format-neutral
114
+ // `EditorNode.kind` string, never react internals.
115
+ component: '❖',
116
+ };
117
+
118
+ type DropZone = 'before' | 'child' | 'after';
119
+ interface DropTarget {
120
+ nodeId: string;
121
+ zone: DropZone;
122
+ }
123
+
124
+ /** Walk UP from `targetId` via `parentId` — true if any ancestor (or itself)
125
+ * is in `dragIds` (prevents dropping a subtree into its own descendant). */
126
+ function isDescendantOfAny(
127
+ targetId: string,
128
+ dragIds: string[],
129
+ adapter: AuthoringAdapter,
130
+ ): boolean {
131
+ let current: string | null = targetId;
132
+ let guard = 0;
133
+ while (current && guard++ < 1000) {
134
+ if (dragIds.includes(current)) return true;
135
+ current = adapter.hierarchy.node(current)?.parentId ?? null;
136
+ }
137
+ return false;
138
+ }
139
+
140
+ /** The ids of `parentId`'s children, in order — `null` parentId means "the
141
+ * adapter's own roots" (NOT necessarily the document root of a composite —
142
+ * callers pass the group-node id for "this world's roots"). */
143
+ function siblingIdsOf(adapter: AuthoringAdapter, parentId: string | null): string[] {
144
+ if (parentId === null) return adapter.hierarchy.roots().map((n) => n.id);
145
+ return adapter.hierarchy.node(parentId)?.childIds ?? [];
146
+ }
147
+
148
+ /**
149
+ * `CompositeAuthoringAdapter`'s zOrder badge for a `world:<id>` group row —
150
+ * `null` when the active adapter isn't a composite (bare shape) or the id
151
+ * isn't one of its synthetic group nodes.
152
+ *
153
+ * IMPORTANT: a composite GROUP node is NOT the same as `flags.runtimeOnly`.
154
+ * Six different adapters (ingest / react-dom / react-world / world2d /
155
+ * embed-only, plus the composite's own group nodes) all set
156
+ * `flags.runtimeOnly: true` on their nodes — so `runtimeOnly` means "not an
157
+ * authored first-party entity", NOT "a composite world wrapper". Only the
158
+ * composite manufactures the synthetic `world:<id>` group rows this panel
159
+ * treats specially (session-local eye per D9, kind/zOrder badge, non-draggable);
160
+ * an ingested game's own rows are ordinary rows whose eye/lock/rename route
161
+ * through the contract like any other. Keying "is a group node" off
162
+ * `runtimeOnly` (an earlier bug) wrongly treated every ingest/react row as a
163
+ * world wrapper, regressing the ingest eye toggle to a session-local shell
164
+ * hide instead of the adapter's `inspector.set(id,'visible',…)`.
165
+ */
166
+ function compositeGroupBadge(
167
+ adapter: AuthoringAdapter,
168
+ nodeId: string,
169
+ ): { zOrder: number; worldId: string } | null {
170
+ if (!(adapter instanceof CompositeAuthoringAdapter)) return null;
171
+ for (const child of adapter.childAdapters()) {
172
+ if (adapter.groupNodeId(child.worldId) === nodeId) {
173
+ // A4 (D8) — prefer the LIVE manifest zOrder (editable on the game root,
174
+ // `composite.inspector.get(groupId, 'zOrder')`) over the array-index
175
+ // fallback `child.zOrder`, so editing zOrder through the generic
176
+ // inspector is reflected on this badge immediately (the write path
177
+ // calls `store.notifyIngestEdit()`, which re-renders this panel).
178
+ // Falls back to the array index when there's no manifest surface
179
+ // (play-mode composites, or any adapter that doesn't report a
180
+ // `zOrder` inspector property for its own group-node id) — unchanged
181
+ // from before A4.
182
+ const manifestZOrder = adapter.inspector?.get(nodeId, 'zOrder');
183
+ return {
184
+ zOrder: typeof manifestZOrder === 'number' ? manifestZOrder : child.zOrder,
185
+ // B1 — the RAW manifest world id (`CompositeChild.worldId`), so
186
+ // callers can key session-local world state (`world-session-state.ts`
187
+ // — eye/interactive/pick-lock) consistently with `design-time-layers.ts`,
188
+ // which reads the SAME id off `composite.childAdapters()` directly.
189
+ worldId: child.worldId,
190
+ };
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+
196
+ /** True only for a composite's synthetic `world:<id>` group row (see
197
+ * {@link compositeGroupBadge}) — never for an ordinary (even runtime-only)
198
+ * ingest/react/world2d node. */
199
+ function isCompositeGroupNode(adapter: AuthoringAdapter, nodeId: string): boolean {
200
+ return compositeGroupBadge(adapter, nodeId) !== null;
201
+ }
202
+
203
+ /**
204
+ * A4 (D8) — true only for the composite's synthetic game-root row. Asks the
205
+ * adapter for its reserved id (`CompositeAuthoringAdapter.gameRootId`) rather
206
+ * than hardcoding the string here — same "ask, don't sniff" discipline as
207
+ * `groupNodeId()`/`compositeGroupBadge` above (Rule zero: this file never
208
+ * decodes an adapter-minted id itself).
209
+ */
210
+ function isGameRootNode(adapter: AuthoringAdapter, nodeId: string): boolean {
211
+ return adapter instanceof CompositeAuthoringAdapter && adapter.gameRootId === nodeId;
212
+ }
213
+
214
+ /**
215
+ * A4 (D8) — every root-group MEMBER id (radio row — a data-file root), keyed
216
+ * by node id, built fresh from `adapter.rootGroups.groups()` each render.
217
+ * This is the ONLY way `GameHierarchy` learns which rows are members/which
218
+ * is active — never by decoding the opaque id string itself (Rule zero:
219
+ * this file's ids stay format-neutral, no first-party document vocabulary).
220
+ */
221
+ function buildRootMembers(
222
+ adapter: AuthoringAdapter,
223
+ ): Map<string, { groupId: string; active: boolean }> {
224
+ const map = new Map<string, { groupId: string; active: boolean }>();
225
+ for (const group of adapter.rootGroups?.groups() ?? []) {
226
+ for (const id of group.memberRootIds) {
227
+ map.set(id, { groupId: group.id, active: group.activeRootId === id });
228
+ }
229
+ }
230
+ return map;
231
+ }
232
+
233
+ // --- Context menu ---
234
+
235
+ interface ContextMenuState {
236
+ x: number;
237
+ y: number;
238
+ nodeId: string;
239
+ }
240
+
241
+ function ContextMenu({
242
+ state,
243
+ adapter,
244
+ store,
245
+ isGroupNode,
246
+ onRename,
247
+ onClose,
248
+ }: {
249
+ state: ContextMenuState;
250
+ adapter: AuthoringAdapter;
251
+ store: EditorStore;
252
+ isGroupNode: boolean;
253
+ onRename: (id: string) => void;
254
+ onClose: () => void;
255
+ }) {
256
+ const menuRef = useRef<HTMLDivElement>(null);
257
+ const [createOpen, setCreateOpen] = useState(false);
258
+ // Same at-most-once-per-open guard as CreateMenu: `onClose()` on select is
259
+ // async, so a double-dispatched click/Enter on a create item could otherwise
260
+ // double-invoke `create` before the menu unmounts. Reset on each open.
261
+ const createGuardRef = useRef(false);
262
+
263
+ useEffect(() => {
264
+ if (createOpen) createGuardRef.current = false;
265
+ }, [createOpen]);
266
+
267
+ useEffect(() => {
268
+ const handler = (e: MouseEvent) => {
269
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
270
+ };
271
+ const keyHandler = (e: KeyboardEvent) => {
272
+ if (e.key === 'Escape') onClose();
273
+ };
274
+ document.addEventListener('mousedown', handler);
275
+ document.addEventListener('keydown', keyHandler);
276
+ return () => {
277
+ document.removeEventListener('mousedown', handler);
278
+ document.removeEventListener('keydown', keyHandler);
279
+ };
280
+ }, [onClose]);
281
+
282
+ const node = adapter.hierarchy.node(state.nodeId);
283
+ if (!node) return null;
284
+
285
+ const creatableKinds = adapter.structure?.creatableKinds?.(state.nodeId) ?? [];
286
+ const hasStructure = !!adapter.structure;
287
+ const nameKnown = adapter.inspector?.get(state.nodeId, 'name') !== undefined;
288
+ const lockedKnown = adapter.inspector?.get(state.nodeId, 'locked') !== undefined;
289
+ const locked = adapter.inspector?.get(state.nodeId, 'locked') === true;
290
+ const visibleKnown = adapter.inspector !== undefined;
291
+ const visible = adapter.inspector?.get(state.nodeId, 'visible') !== false;
292
+
293
+ const items: { label: string; action: () => void; color?: string }[] = [];
294
+
295
+ if (!isGroupNode) {
296
+ if (nameKnown) {
297
+ items.push({
298
+ label: 'Rename',
299
+ action: () => {
300
+ onRename(state.nodeId);
301
+ onClose();
302
+ },
303
+ });
304
+ }
305
+ if (hasStructure) {
306
+ items.push({
307
+ label: 'Duplicate',
308
+ action: () => {
309
+ adapter.structure!.duplicate(state.nodeId);
310
+ onClose();
311
+ },
312
+ });
313
+ }
314
+ if (visibleKnown) {
315
+ items.push({
316
+ label: visible ? 'Hide' : 'Show',
317
+ action: () => {
318
+ adapter.inspector!.set(state.nodeId, 'visible', !visible);
319
+ store.notifyIngestEdit();
320
+ onClose();
321
+ },
322
+ });
323
+ }
324
+ if (lockedKnown) {
325
+ items.push({
326
+ label: locked ? 'Unlock' : 'Lock',
327
+ action: () => {
328
+ adapter.inspector!.set(state.nodeId, 'locked', !locked);
329
+ store.notifyIngestEdit();
330
+ onClose();
331
+ },
332
+ });
333
+ }
334
+ // First-party escape-hatch extras (prefab unpack/open/save-as, …) —
335
+ // permanent adapter-module contribution seam (see hierarchy-menu-registry.ts).
336
+ for (const extra of getHierarchyMenuItems({ nodeId: state.nodeId, store })) {
337
+ items.push({
338
+ label: extra.label,
339
+ ...(extra.color !== undefined ? { color: extra.color } : {}),
340
+ action: () => {
341
+ extra.action();
342
+ onClose();
343
+ },
344
+ });
345
+ }
346
+ if (hasStructure) {
347
+ items.push({
348
+ label: 'Delete',
349
+ color: '#ff6b6b',
350
+ action: () => {
351
+ adapter.structure!.remove(state.nodeId);
352
+ onClose();
353
+ },
354
+ });
355
+ }
356
+ }
357
+
358
+ return (
359
+ <div
360
+ ref={menuRef}
361
+ data-testid="ingest-context-menu"
362
+ style={{
363
+ position: 'fixed',
364
+ left: state.x,
365
+ top: state.y,
366
+ background: '#2c2c2c',
367
+ border: '1px solid #444',
368
+ borderRadius: 4,
369
+ padding: '4px 0',
370
+ zIndex: 1000,
371
+ minWidth: 150,
372
+ maxWidth: 260,
373
+ boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
374
+ fontSize: 12,
375
+ color: '#c5c8ce',
376
+ }}
377
+ >
378
+ {/* A4 (D8): also suppressed for a root-group MEMBER row, same as an
379
+ existing composite group row — `isGroupNode` covers both (see the
380
+ caller's computation below). Create▸ on a member id would silently
381
+ land at the scene root instead of doing anything meaningful. */}
382
+ {!isGroupNode && creatableKinds.length > 0 && (
383
+ <div>
384
+ <div
385
+ role="menuitem"
386
+ tabIndex={0}
387
+ onClick={() => setCreateOpen((v) => !v)}
388
+ onKeyDown={(e) => {
389
+ if (e.key === 'Enter') setCreateOpen((v) => !v);
390
+ }}
391
+ style={{ padding: '5px 12px', cursor: 'pointer' }}
392
+ >
393
+ Create ▸
394
+ </div>
395
+ {createOpen &&
396
+ creatableKinds.map((k) => (
397
+ <div
398
+ key={k.kind}
399
+ role="menuitem"
400
+ tabIndex={0}
401
+ onClick={() => {
402
+ if (createGuardRef.current) return;
403
+ createGuardRef.current = true;
404
+ adapter.structure!.create(k.kind, state.nodeId);
405
+ onClose();
406
+ }}
407
+ onKeyDown={(e) => {
408
+ if (e.key === 'Enter') {
409
+ if (createGuardRef.current) return;
410
+ createGuardRef.current = true;
411
+ adapter.structure!.create(k.kind, state.nodeId);
412
+ onClose();
413
+ }
414
+ }}
415
+ style={{ padding: '5px 12px 5px 24px', cursor: 'pointer' }}
416
+ onMouseEnter={(e) => {
417
+ (e.currentTarget as HTMLDivElement).style.background = '#3a3a3a';
418
+ }}
419
+ onMouseLeave={(e) => {
420
+ (e.currentTarget as HTMLDivElement).style.background = 'transparent';
421
+ }}
422
+ >
423
+ {k.label}
424
+ </div>
425
+ ))}
426
+ </div>
427
+ )}
428
+ {items.map((item) => (
429
+ <div
430
+ key={item.label}
431
+ role="menuitem"
432
+ tabIndex={0}
433
+ onClick={item.action}
434
+ onKeyDown={(e) => {
435
+ if (e.key === 'Enter') item.action();
436
+ }}
437
+ style={{ padding: '5px 12px', cursor: 'pointer', color: item.color ?? '#c5c8ce' }}
438
+ onMouseEnter={(e) => {
439
+ (e.currentTarget as HTMLDivElement).style.background = '#3a3a3a';
440
+ }}
441
+ onMouseLeave={(e) => {
442
+ (e.currentTarget as HTMLDivElement).style.background = 'transparent';
443
+ }}
444
+ >
445
+ {item.label}
446
+ </div>
447
+ ))}
448
+ {/* Bounded-authoring honest degrade (landmine f): the ACTIVE adapter has
449
+ no `structure` provider at all (an ingested/foreign game owns its
450
+ own scene graph) — Rename/Show-Hide/Lock above still work (they're
451
+ gated independently, on `inspector`), but Create/Duplicate/Delete/
452
+ Reparent are not, so say so plainly instead of hiding the menu. */}
453
+ {!hasStructure && (
454
+ <div style={{ padding: '8px 12px', color: '#c9a227', maxWidth: 240, lineHeight: 1.4 }}>
455
+ <strong>Create / Delete / Reparent — not available.</strong> This game owns its own scene
456
+ graph, so structural authoring isn't reapplicable. You can edit transform / material /
457
+ visibility, or capture a subtree into the first-party editable format.
458
+ </div>
459
+ )}
460
+ </div>
461
+ );
462
+ }
463
+
464
+ // --- Add / Create menu (shared by the toolbar "+" and reused create list) ---
465
+
466
+ function CreateMenu({ adapter, parentId }: { adapter: AuthoringAdapter; parentId: string | null }) {
467
+ const [open, setOpen] = useState(false);
468
+ const ref = useRef<HTMLDivElement>(null);
469
+ // At-most-once-per-open guard. `setOpen(false)` on select is async — the menu
470
+ // item stays mounted until the next render, so under CI input jank a single
471
+ // menu-open can receive two `click` dispatches on the same item before it
472
+ // unmounts, double-invoking `create` (the "expected 3, saw 8" overshoot in
473
+ // 29-game-hierarchy). React state can't gate this because both clicks land in
474
+ // the same commit; a ref reset on each open bounds it to one create per open.
475
+ const creatingRef = useRef(false);
476
+ const kinds = adapter.structure?.creatableKinds?.(parentId) ?? [];
477
+
478
+ useEffect(() => {
479
+ if (!open) return;
480
+ creatingRef.current = false; // fresh open → allow exactly one create
481
+ const handler = (e: MouseEvent) => {
482
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
483
+ };
484
+ const keyHandler = (e: KeyboardEvent) => {
485
+ if (e.key === 'Escape') setOpen(false);
486
+ };
487
+ document.addEventListener('mousedown', handler);
488
+ document.addEventListener('keydown', keyHandler);
489
+ return () => {
490
+ document.removeEventListener('mousedown', handler);
491
+ document.removeEventListener('keydown', keyHandler);
492
+ };
493
+ }, [open]);
494
+
495
+ if (kinds.length === 0) return null;
496
+
497
+ return (
498
+ <div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
499
+ <span
500
+ role="button"
501
+ title="Add Entity"
502
+ data-testid="hierarchy-add-menu"
503
+ onClick={() => setOpen(!open)}
504
+ style={{
505
+ cursor: 'pointer',
506
+ fontSize: 13,
507
+ padding: '1px 4px',
508
+ borderRadius: 3,
509
+ background: open ? '#3a3a3a' : 'transparent',
510
+ userSelect: 'none',
511
+ color: '#c5c8ce',
512
+ }}
513
+ >
514
+ <FontAwesomeIcon icon={faPlus} style={{ fontSize: 10 }} />{' '}
515
+ <FontAwesomeIcon icon={faCaretDown} style={{ fontSize: 9 }} />
516
+ </span>
517
+ {open && (
518
+ <div
519
+ style={{
520
+ position: 'absolute',
521
+ top: '100%',
522
+ right: 0,
523
+ marginTop: 4,
524
+ background: '#2c2c2c',
525
+ border: '1px solid #444',
526
+ borderRadius: 4,
527
+ padding: '4px 0',
528
+ zIndex: 1000,
529
+ minWidth: 130,
530
+ boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
531
+ }}
532
+ >
533
+ {kinds.map((k) => (
534
+ <div
535
+ key={k.kind}
536
+ data-testid={`hierarchy-add-${k.kind}`}
537
+ onClick={() => {
538
+ if (creatingRef.current) return; // ignore a double-dispatched click
539
+ creatingRef.current = true;
540
+ adapter.structure!.create(k.kind, parentId ?? undefined);
541
+ setOpen(false);
542
+ }}
543
+ style={{ padding: '5px 12px', cursor: 'pointer', fontSize: 12, color: '#c5c8ce' }}
544
+ onMouseEnter={(e) => {
545
+ (e.currentTarget as HTMLDivElement).style.background = '#3a3a3a';
546
+ }}
547
+ onMouseLeave={(e) => {
548
+ (e.currentTarget as HTMLDivElement).style.background = 'transparent';
549
+ }}
550
+ >
551
+ {k.label}
552
+ </div>
553
+ ))}
554
+ </div>
555
+ )}
556
+ </div>
557
+ );
558
+ }
559
+
560
+ // --- Row ---
561
+
562
+ function MoreRow({ row, onReveal }: { row: IngestRow; onReveal: (parentPathKey: string) => void }) {
563
+ const hidden = row.more!.hidden;
564
+ return (
565
+ <button
566
+ type="button"
567
+ data-testid="ingest-row-more"
568
+ onClick={() => onReveal(row.more!.parentPathKey)}
569
+ style={{
570
+ display: 'flex',
571
+ alignItems: 'center',
572
+ width: '100%',
573
+ height: ROW_HEIGHT,
574
+ paddingLeft: row.depth * INDENT + INDENT,
575
+ border: 'none',
576
+ background: 'transparent',
577
+ color: '#7d828c',
578
+ fontSize: 12,
579
+ fontStyle: 'italic',
580
+ textAlign: 'left',
581
+ cursor: 'pointer',
582
+ }}
583
+ >
584
+ … {hidden} more
585
+ </button>
586
+ );
587
+ }
588
+
589
+ /**
590
+ * D4 (spec27 §6 D4, layer-tree "search filters + HIGHLIGHTS") — split
591
+ * `label` into `<mark>`-wrapped/plain segments around every case-insensitive
592
+ * occurrence of `term`, or the plain label unchanged when `term` is empty
593
+ * (the pre-D4 render, byte-identical). Pure text math — no adapter/DOM
594
+ * access — factored out purely so `Row` itself stays simple.
595
+ */
596
+ function highlightSegments(label: string, term: string): React.ReactNode {
597
+ if (!term) return label;
598
+ const lower = label.toLowerCase();
599
+ const needle = term.toLowerCase();
600
+ const parts: React.ReactNode[] = [];
601
+ let cursor = 0;
602
+ let idx = lower.indexOf(needle, cursor);
603
+ if (idx < 0) return label;
604
+ let key = 0;
605
+ while (idx >= 0) {
606
+ if (idx > cursor) parts.push(label.slice(cursor, idx));
607
+ parts.push(
608
+ <mark
609
+ key={key++}
610
+ data-testid="ingest-row-search-highlight"
611
+ style={{ background: '#e0a030', color: '#1a1a1a', borderRadius: 2 }}
612
+ >
613
+ {label.slice(idx, idx + needle.length)}
614
+ </mark>,
615
+ );
616
+ cursor = idx + needle.length;
617
+ idx = lower.indexOf(needle, cursor);
618
+ }
619
+ if (cursor < label.length) parts.push(label.slice(cursor));
620
+ return parts;
621
+ }
622
+
623
+ function Row({
624
+ row,
625
+ adapter,
626
+ store,
627
+ isSelected,
628
+ isEditing,
629
+ isCollapsed,
630
+ dropZone,
631
+ isDragged,
632
+ rootMember,
633
+ searchTerm,
634
+ onToggleOpen,
635
+ onStartEditing,
636
+ onContextMenu,
637
+ onDragStart,
638
+ onClick,
639
+ onActivateRoot,
640
+ }: {
641
+ row: IngestRow;
642
+ adapter: AuthoringAdapter;
643
+ store: EditorStore;
644
+ isSelected: boolean;
645
+ isEditing: boolean;
646
+ isCollapsed: boolean;
647
+ dropZone: DropZone | null;
648
+ isDragged: boolean;
649
+ /** A4 (D8) — set when this row is a root-group member (a scene-file
650
+ * root); `active` is whether it's the currently-loaded one. */
651
+ rootMember: { groupId: string; active: boolean } | undefined;
652
+ /** D4 — the trimmed, lowercased-by-the-caller search term (empty string
653
+ * when the search box is empty), for {@link highlightSegments}. */
654
+ searchTerm: string;
655
+ onToggleOpen: (pathKey: string) => void;
656
+ onStartEditing: (id: string) => void;
657
+ onContextMenu: (e: React.MouseEvent, id: string) => void;
658
+ onDragStart: (e: React.DragEvent, id: string) => void;
659
+ onClick: (e: React.MouseEvent, id: string) => void;
660
+ onActivateRoot: (groupId: string, rootId: string) => void;
661
+ }) {
662
+ const { node, depth, hasChildren, pathKey } = row;
663
+ // A composite `world:<id>` group row (session-local eye per D9, zOrder badge,
664
+ // non-draggable) — NOT merely a runtime-only node (see `isCompositeGroupNode`).
665
+ const badge = compositeGroupBadge(adapter, node.id);
666
+ const isGroupNode = badge !== null;
667
+ // A4 (D8) — the synthetic game root, and a root-group member (scene-file
668
+ // root) row. Neither owns a real name/visible/locked path, isn't
669
+ // draggable, and carries no `data-entity-name` (landmine: every project
670
+ // ships 2+ scene-file members — they must not pollute `getEntityNames()`).
671
+ const isGameRootRow = isGameRootNode(adapter, node.id);
672
+ const isMemberRow = rootMember !== undefined;
673
+ const isStructural = isGroupNode || isGameRootRow || isMemberRow;
674
+ // Reserved-path reads, resolved ONCE per row and only for non-structural
675
+ // rows: an adapter that doesn't support a reserved path returns
676
+ // `undefined` (its affordance simply doesn't render). Cheap for every
677
+ // stock adapter — the ingest/react adapters early-return `undefined` for a
678
+ // non-style path before touching their tree; the first-party adapter
679
+ // reads a plain descriptor.
680
+ const lockedValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'locked');
681
+ const nameValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'name');
682
+ const visibleKnown = !isStructural && adapter.inspector !== undefined;
683
+ // B1 (D9) — a group row's eye/interactive/pick-lock read the LIFTED,
684
+ // module-level session store (`world-session-state.ts`), keyed by the
685
+ // RAW manifest world id `badge.worldId` — not the group-node id itself.
686
+ const worldHidden = badge ? isWorldHidden(badge.worldId) : false;
687
+ const worldInteractive = badge ? isWorldInteractive(badge.worldId) : false;
688
+ const worldPickLocked = badge ? isWorldPickLocked(badge.worldId) : false;
689
+ const visible = isGroupNode ? !worldHidden : adapter.inspector?.get(node.id, 'visible') !== false;
690
+ const lockedKnown = lockedValue !== undefined;
691
+ const locked = lockedValue === true;
692
+ const nameKnown = nameValue !== undefined;
693
+
694
+ const bg =
695
+ dropZone === 'child' ? 'rgba(87, 158, 255, 0.25)' : isSelected ? '#2a4a7a' : 'transparent';
696
+
697
+ return (
698
+ <div
699
+ data-entity-name={isMemberRow || isGameRootRow ? undefined : node.label}
700
+ data-testid="ingest-row"
701
+ data-ingest-name={node.label}
702
+ draggable={!isStructural}
703
+ onDragStart={(e) => onDragStart(e, node.id)}
704
+ onClick={(e) => onClick(e, node.id)}
705
+ onDoubleClick={() => {
706
+ if (rootMember && !rootMember.active) {
707
+ onActivateRoot(rootMember.groupId, node.id);
708
+ } else if (nameKnown) {
709
+ onStartEditing(node.id);
710
+ }
711
+ }}
712
+ onContextMenu={(e) => {
713
+ e.preventDefault();
714
+ onContextMenu(e, node.id);
715
+ }}
716
+ style={{
717
+ position: 'relative',
718
+ display: 'flex',
719
+ alignItems: 'center',
720
+ height: ROW_HEIGHT,
721
+ paddingLeft: depth * INDENT,
722
+ paddingRight: 4,
723
+ gap: 4,
724
+ cursor: 'pointer',
725
+ background: bg,
726
+ outline: dropZone === 'child' ? '1px dashed #579EFF' : 'none',
727
+ whiteSpace: 'nowrap',
728
+ overflow: 'hidden',
729
+ opacity:
730
+ isDragged || (!isStructural && !visible) || (rootMember && !rootMember.active) ? 0.45 : 1,
731
+ fontSize: 13,
732
+ color: '#c5c8ce',
733
+ userSelect: 'none',
734
+ }}
735
+ >
736
+ {dropZone === 'before' && (
737
+ <div
738
+ style={{
739
+ position: 'absolute',
740
+ top: 0,
741
+ left: depth * INDENT,
742
+ right: 0,
743
+ height: 2,
744
+ background: '#579EFF',
745
+ pointerEvents: 'none',
746
+ zIndex: 10,
747
+ }}
748
+ />
749
+ )}
750
+ {dropZone === 'after' && (
751
+ <div
752
+ style={{
753
+ position: 'absolute',
754
+ bottom: 0,
755
+ left: depth * INDENT,
756
+ right: 0,
757
+ height: 2,
758
+ background: '#579EFF',
759
+ pointerEvents: 'none',
760
+ zIndex: 10,
761
+ }}
762
+ />
763
+ )}
764
+
765
+ <span
766
+ data-testid="ingest-row-caret"
767
+ onClick={(e) => {
768
+ e.stopPropagation();
769
+ onToggleOpen(pathKey);
770
+ }}
771
+ style={{
772
+ width: 12,
773
+ fontSize: 8,
774
+ textAlign: 'center',
775
+ flexShrink: 0,
776
+ cursor: hasChildren ? 'pointer' : 'default',
777
+ color: '#8c8c8c',
778
+ }}
779
+ >
780
+ {hasChildren ? <FontAwesomeIcon icon={isCollapsed ? faCaretRight : faCaretDown} /> : ''}
781
+ </span>
782
+
783
+ {rootMember && (
784
+ <span
785
+ data-testid="root-radio"
786
+ data-root-active={rootMember.active ? 'true' : 'false'}
787
+ title={rootMember.active ? 'Active root' : 'Inactive root — double-click to activate'}
788
+ style={{ fontSize: 9, opacity: 0.8, flexShrink: 0 }}
789
+ >
790
+ {rootMember.active ? '●' : '○'}
791
+ </span>
792
+ )}
793
+
794
+ <span style={{ fontSize: 10, opacity: 0.6, flexShrink: 0 }}>
795
+ {KIND_GLYPH[node.kind] ?? '•'}
796
+ </span>
797
+
798
+ {isEditing ? (
799
+ <input
800
+ autoFocus
801
+ defaultValue={node.label}
802
+ onBlur={(e) => {
803
+ adapter.inspector!.set(node.id, 'name', e.currentTarget.value);
804
+ store.notifyIngestEdit();
805
+ onStartEditing('');
806
+ }}
807
+ onKeyDown={(e) => {
808
+ if (e.key === 'Enter') {
809
+ adapter.inspector!.set(node.id, 'name', e.currentTarget.value);
810
+ store.notifyIngestEdit();
811
+ onStartEditing('');
812
+ }
813
+ if (e.key === 'Escape') onStartEditing('');
814
+ }}
815
+ onClick={(e) => e.stopPropagation()}
816
+ style={{
817
+ flex: 1,
818
+ minWidth: 0,
819
+ background: '#1a1a1a',
820
+ border: '1px solid #579EFF',
821
+ borderRadius: 2,
822
+ color: '#c5c8ce',
823
+ fontSize: 13,
824
+ padding: '0 4px',
825
+ outline: 'none',
826
+ }}
827
+ />
828
+ ) : (
829
+ <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
830
+ {highlightSegments(node.label, searchTerm)}
831
+ {badge && (
832
+ <span style={{ marginLeft: 6, fontSize: 10, opacity: 0.6 }}>z{badge.zOrder}</span>
833
+ )}
834
+ </span>
835
+ )}
836
+
837
+ {lockedKnown && (
838
+ <span
839
+ data-testid="ingest-row-lock"
840
+ title="Toggle Lock"
841
+ onClick={(e) => {
842
+ e.stopPropagation();
843
+ adapter.inspector!.set(node.id, 'locked', !locked);
844
+ store.notifyIngestEdit();
845
+ }}
846
+ style={{ fontSize: 9, cursor: 'pointer', flexShrink: 0, opacity: locked ? 0.8 : 0.35 }}
847
+ >
848
+ <FontAwesomeIcon icon={locked ? faLock : faLockOpen} />
849
+ </span>
850
+ )}
851
+
852
+ {(visibleKnown || isGroupNode) && (
853
+ <button
854
+ type="button"
855
+ data-testid="ingest-row-visibility"
856
+ title={visible ? 'Hide' : 'Show'}
857
+ onClick={(e) => {
858
+ e.stopPropagation();
859
+ if (badge) {
860
+ toggleWorldHidden(badge.worldId);
861
+ store.notifyIngestEdit();
862
+ } else {
863
+ adapter.inspector!.set(node.id, 'visible', !visible);
864
+ store.notifyIngestEdit();
865
+ }
866
+ }}
867
+ style={{
868
+ width: 20,
869
+ height: 20,
870
+ border: 'none',
871
+ background: 'transparent',
872
+ color: visible ? '#9aa0a6' : '#e0a030',
873
+ cursor: 'pointer',
874
+ fontSize: 11,
875
+ flexShrink: 0,
876
+ }}
877
+ >
878
+ <FontAwesomeIcon icon={visible ? faEye : faEyeSlash} />
879
+ </button>
880
+ )}
881
+
882
+ {/* B1 (D9) — session-local, per-world design-time toggles: `interactive`
883
+ * forwards real pointer events to the world's layer (design-time
884
+ * hover/press testing, `design-time-layers.ts`); `pickLock` is stored
885
+ * for B4's layered viewport picking to consume. Group rows only —
886
+ * neither concept applies to an ordinary entity row. */}
887
+ {badge && (
888
+ <span
889
+ data-testid="world-interactive-toggle"
890
+ title={
891
+ worldInteractive
892
+ ? 'Interactive (layer forwards pointer events)'
893
+ : 'Not interactive (layer is pointer-events:none)'
894
+ }
895
+ onClick={(e) => {
896
+ e.stopPropagation();
897
+ toggleWorldInteractive(badge.worldId);
898
+ store.notifyIngestEdit();
899
+ }}
900
+ style={{
901
+ fontSize: 9,
902
+ cursor: 'pointer',
903
+ flexShrink: 0,
904
+ opacity: worldInteractive ? 0.9 : 0.35,
905
+ }}
906
+ >
907
+ <FontAwesomeIcon icon={faHandPointer} />
908
+ </span>
909
+ )}
910
+ {badge && (
911
+ <span
912
+ data-testid="world-picklock-toggle"
913
+ title={
914
+ worldPickLocked ? 'Pick-locked (skipped by topmost-layer picking)' : 'Not pick-locked'
915
+ }
916
+ onClick={(e) => {
917
+ e.stopPropagation();
918
+ toggleWorldPickLock(badge.worldId);
919
+ store.notifyIngestEdit();
920
+ }}
921
+ style={{
922
+ fontSize: 9,
923
+ cursor: 'pointer',
924
+ flexShrink: 0,
925
+ opacity: worldPickLocked ? 0.9 : 0.35,
926
+ }}
927
+ >
928
+ <FontAwesomeIcon icon={faCrosshairs} />
929
+ </span>
930
+ )}
931
+ </div>
932
+ );
933
+ }
934
+
935
+ // --- Main panel ---
936
+
937
+ export function GameHierarchy() {
938
+ const store = useEditorStore();
939
+ useSyncExternalStore(store.subscribe, store.getSnapshot);
940
+ const adapter = getActiveAuthoring(store);
941
+
942
+ const [search, setSearch] = useState('');
943
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
944
+ const [editingId, setEditingId] = useState<string | null>(null);
945
+ const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
946
+ const [tailDrop, setTailDrop] = useState(false);
947
+ const [draggedIds, setDraggedIds] = useState<string[] | null>(null);
948
+ // D9 — world-group visibility (eye) is SESSION-LOCAL shell state, never
949
+ // routed through `inspector`/serialized. B1 LIFTED this out of a local
950
+ // `useState` (which the design-time layer stack could never see) into the
951
+ // module-level `world-session-state.ts` singleton, so `design-time-layers.ts`
952
+ // can actually hide/show a world's DOM layer, not just dim its hierarchy
953
+ // row — see that module's header comment.
954
+ // Wave-18-ported scroll-window state — the live `scrollTop`, updated by
955
+ // `onScroll` below; `clientHeight` is read fresh from `containerRef` every
956
+ // render rather than cached in state (see the constants' doc comment).
957
+ const [scrollTop, setScrollTop] = useState(0);
958
+
959
+ const containerRef = useRef<HTMLDivElement>(null);
960
+ const collapsedKeysRef = useRef(new Set<string>());
961
+ const revealedRef = useRef(new Map<string, number>());
962
+ const lastClickedRef = useRef<string | null>(null);
963
+ const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
964
+
965
+ const term = search.trim().toLowerCase();
966
+ // Cap browse-view children (D-C1) whenever a non-first-party/composite
967
+ // session is active (an ingested/multi-world tree may be huge), OR while
968
+ // playing (a live scene graph can also balloon) — matches the exact
969
+ // capping policy the former SceneHierarchy (play-only) / IngestHierarchy
970
+ // (always, unless searching) components each had on their own.
971
+ const capEnabled = term.length === 0 && (hasAuthoringOverride() || store.playState !== 'stopped');
972
+
973
+ const allRows = flattenIngestRows(
974
+ adapter.hierarchy,
975
+ collapsedKeysRef.current,
976
+ revealedRef.current,
977
+ capEnabled,
978
+ );
979
+ // Session-local world-hide (D9): drop every row belonging to a hidden world
980
+ // group (the group row itself stays, dimmed by its own `visible` flag above).
981
+ const worldFiltered = allRows.filter((row) => {
982
+ if (isCompositeGroupNode(adapter, row.node.id)) return true; // the group row itself always shows
983
+ let current: string | null = row.node.parentId;
984
+ while (current) {
985
+ const badge = compositeGroupBadge(adapter, current);
986
+ if (badge && isWorldHidden(badge.worldId)) return false;
987
+ current = adapter.hierarchy.node(current)?.parentId ?? null;
988
+ }
989
+ return true;
990
+ });
991
+ const rows = term
992
+ ? worldFiltered.filter((r) => r.node.label.toLowerCase().includes(term))
993
+ : worldFiltered.filter((r) => !r.hiddenByCollapse);
994
+
995
+ // Wave-18-ported: a filtered-down result set shouldn't leave the viewport
996
+ // parked at a stale deep scroll offset from a previous (larger) list.
997
+ useEffect(() => {
998
+ setScrollTop(0);
999
+ if (containerRef.current) containerRef.current.scrollTop = 0;
1000
+ }, [term]);
1001
+
1002
+ // Wave-18-ported: render only the windowed slice of `rows` — everything
1003
+ // above (cap/collapse/search/world-hide filtering) is unchanged; only the
1004
+ // final `.map()` further down is restricted. The drag/reorder + tailDrop
1005
+ // math below index/position against the FULL `rows` array (and read
1006
+ // `containerRef.current.scrollTop` directly), so windowing this slice
1007
+ // alone doesn't disturb them.
1008
+ const viewportPx = containerRef.current?.clientHeight || DEFAULT_VIEWPORT_PX;
1009
+ // Below the threshold, render every row (no window) — see VIRTUALIZE_MIN_ROWS.
1010
+ const { start, end } =
1011
+ rows.length > VIRTUALIZE_MIN_ROWS
1012
+ ? ingestRowWindow(rows.length, scrollTop, viewportPx, ROW_HEIGHT, OVERSCAN)
1013
+ : { start: 0, end: rows.length };
1014
+ const windowRows = rows.slice(start, end);
1015
+ const topSpacerPx = start * ROW_HEIGHT;
1016
+ const bottomSpacerPx = (rows.length - end) * ROW_HEIGHT;
1017
+
1018
+ const toggleOpen = useCallback((pathKey: string) => {
1019
+ const set = collapsedKeysRef.current;
1020
+ if (set.has(pathKey)) set.delete(pathKey);
1021
+ else set.add(pathKey);
1022
+ forceUpdate();
1023
+ }, []);
1024
+
1025
+ const revealMore = useCallback((parentPathKey: string) => {
1026
+ const m = revealedRef.current;
1027
+ m.set(parentPathKey, (m.get(parentPathKey) ?? CHILD_CAP) + CHILD_CAP);
1028
+ forceUpdate();
1029
+ }, []);
1030
+
1031
+ // A4 (D8) — every root-group member, rebuilt each render straight from
1032
+ // `rootGroups.groups()` (never from the id string — see `buildRootMembers`'s
1033
+ // doc comment). `handleActivateRoot` is the ONE choke point a double-click
1034
+ // on an inactive member row goes through, shared with the asset-browser's
1035
+ // scene double-click (`DefaultEditorLayout.tsx`'s `handleOpenScene`) via
1036
+ // `requestRootActivation` so the dirty-gate/confirmation UX can't drift
1037
+ // between the two call sites.
1038
+ const rootMembers = buildRootMembers(adapter);
1039
+ const handleActivateRoot = useCallback(
1040
+ (groupId: string, rootId: string) => {
1041
+ requestRootActivation(adapter, groupId, rootId);
1042
+ },
1043
+ [adapter],
1044
+ );
1045
+
1046
+ const selectedIds = new Set(adapter.selection?.get() ?? []);
1047
+
1048
+ const handleRowClick = useCallback(
1049
+ (e: React.MouseEvent, nodeId: string) => {
1050
+ if (!adapter.selection) return;
1051
+ if (e.metaKey || e.ctrlKey) {
1052
+ const ids = new Set(selectedIds);
1053
+ if (ids.has(nodeId)) ids.delete(nodeId);
1054
+ else ids.add(nodeId);
1055
+ adapter.selection.set([...ids]);
1056
+ } else if (e.shiftKey && lastClickedRef.current) {
1057
+ const lastIdx = rows.findIndex((r) => r.node.id === lastClickedRef.current);
1058
+ const curIdx = rows.findIndex((r) => r.node.id === nodeId);
1059
+ if (lastIdx >= 0 && curIdx >= 0) {
1060
+ const start = Math.min(lastIdx, curIdx);
1061
+ const end = Math.max(lastIdx, curIdx);
1062
+ const rangeIds = rows
1063
+ .slice(start, end + 1)
1064
+ .filter((r) => !r.more)
1065
+ .map((r) => r.node.id);
1066
+ adapter.selection.set(rangeIds);
1067
+ }
1068
+ } else {
1069
+ adapter.selection.set([nodeId]);
1070
+ }
1071
+ lastClickedRef.current = nodeId;
1072
+ },
1073
+ [adapter, rows, selectedIds],
1074
+ );
1075
+
1076
+ const handleRowDragStart = useCallback(
1077
+ (e: React.DragEvent, nodeId: string) => {
1078
+ const ids = selectedIds.has(nodeId) ? [...selectedIds] : [nodeId];
1079
+ setDraggedIds(ids);
1080
+ e.dataTransfer.effectAllowed = 'move';
1081
+ e.dataTransfer.setData(REORDER_MIME, JSON.stringify(ids));
1082
+ e.dataTransfer.setData('text/plain', nodeId);
1083
+ },
1084
+ [selectedIds],
1085
+ );
1086
+
1087
+ const handleDragOver = useCallback(
1088
+ (e: React.DragEvent) => {
1089
+ const isInternal = e.dataTransfer.types.includes(REORDER_MIME);
1090
+ const isAsset = e.dataTransfer.types.includes(ASSET_MIME);
1091
+ if (!isInternal && !isAsset) return;
1092
+ e.preventDefault();
1093
+ e.dataTransfer.dropEffect = isInternal ? 'move' : 'copy';
1094
+
1095
+ const rect = containerRef.current!.getBoundingClientRect();
1096
+ const scrollTop = containerRef.current!.scrollTop;
1097
+ const y = e.clientY - rect.top + scrollTop;
1098
+ const rowIndex = Math.floor(y / ROW_HEIGHT);
1099
+
1100
+ if (rowIndex >= rows.length) {
1101
+ setDropTarget(null);
1102
+ setTailDrop(true);
1103
+ return;
1104
+ }
1105
+ setTailDrop(false);
1106
+
1107
+ const row = rows[rowIndex]!;
1108
+ if (row.more) {
1109
+ setDropTarget(null);
1110
+ return;
1111
+ }
1112
+ const rowY = y - rowIndex * ROW_HEIGHT;
1113
+ let zone: DropZone;
1114
+ if (rowY < ROW_HEIGHT * 0.25) zone = 'before';
1115
+ else if (rowY > ROW_HEIGHT * 0.75) zone = 'after';
1116
+ else zone = 'child';
1117
+
1118
+ if (isInternal && draggedIds) {
1119
+ const targetId = row.node.id;
1120
+ if (draggedIds.includes(targetId)) return;
1121
+ if (isDescendantOfAny(targetId, draggedIds, adapter)) return;
1122
+ }
1123
+ if (isCompositeGroupNode(adapter, row.node.id) && zone !== 'child') {
1124
+ // A composite world-group row has no siblings of its own kind to
1125
+ // reorder against — only "drop into this world" makes sense.
1126
+ zone = 'child';
1127
+ }
1128
+
1129
+ setDropTarget({ nodeId: row.node.id, zone });
1130
+ },
1131
+ [rows, draggedIds, adapter],
1132
+ );
1133
+
1134
+ const handleDragEnd = useCallback(() => {
1135
+ setDropTarget(null);
1136
+ setTailDrop(false);
1137
+ setDraggedIds(null);
1138
+ }, []);
1139
+
1140
+ const handleDrop = useCallback(
1141
+ (e: React.DragEvent) => {
1142
+ e.preventDefault();
1143
+ e.stopPropagation();
1144
+ const currentDropTarget = dropTarget;
1145
+ const isTail = tailDrop;
1146
+ setDropTarget(null);
1147
+ setTailDrop(false);
1148
+ setDraggedIds(null);
1149
+
1150
+ const structure = adapter.structure;
1151
+
1152
+ // Internal reorder/reparent.
1153
+ const reorderData = e.dataTransfer.getData(REORDER_MIME);
1154
+ if (reorderData && structure) {
1155
+ const ids = JSON.parse(reorderData) as string[];
1156
+ if (isTail) {
1157
+ for (const id of ids) structure.reparent(id, null);
1158
+ return;
1159
+ }
1160
+ if (!currentDropTarget) return;
1161
+ const targetRow = rows.find((r) => r.node.id === currentDropTarget.nodeId);
1162
+ if (!targetRow) return;
1163
+ const targetId = targetRow.node.id;
1164
+ if (currentDropTarget.zone === 'child') {
1165
+ for (const id of ids) structure.reparent(id, targetId);
1166
+ return;
1167
+ }
1168
+ const targetParentId = targetRow.node.parentId;
1169
+ const siblings = siblingIdsOf(adapter, targetParentId).filter((id) => !ids.includes(id));
1170
+ const idx = siblings.indexOf(targetId);
1171
+ const beforeSiblingId =
1172
+ currentDropTarget.zone === 'before'
1173
+ ? targetId
1174
+ : idx >= 0
1175
+ ? (siblings[idx + 1] ?? null)
1176
+ : null;
1177
+ for (const id of ids) {
1178
+ const currentParentId = adapter.hierarchy.node(id)?.parentId ?? null;
1179
+ if (currentParentId !== targetParentId) structure.reparent(id, targetParentId ?? null);
1180
+ structure.reorder?.(id, beforeSiblingId);
1181
+ }
1182
+ return;
1183
+ }
1184
+
1185
+ // External asset drop.
1186
+ const assetData = e.dataTransfer.getData(ASSET_MIME);
1187
+ if (assetData && adapter.assetDrop) {
1188
+ try {
1189
+ const data = JSON.parse(assetData) as { path: string; kind: string };
1190
+ if (data.kind === 'scene') return; // scene-file drop: A4/deferred, not this shell's job
1191
+ if (isTail) {
1192
+ if (adapter.assetDrop.accepts('', data.path)) adapter.assetDrop.drop('', data.path);
1193
+ return;
1194
+ }
1195
+ if (!currentDropTarget) return;
1196
+ const targetRow = rows.find((r) => r.node.id === currentDropTarget.nodeId);
1197
+ if (!targetRow) return;
1198
+ const targetId = targetRow.node.id;
1199
+ if (adapter.assetDrop.accepts(targetId, data.path))
1200
+ adapter.assetDrop.drop(targetId, data.path);
1201
+ } catch {
1202
+ /* ignore malformed drag payload */
1203
+ }
1204
+ }
1205
+ },
1206
+ [dropTarget, tailDrop, rows, adapter],
1207
+ );
1208
+
1209
+ const panelName = adapter.persistence?.destination ?? 'Untitled';
1210
+
1211
+ return (
1212
+ <Panel
1213
+ name={
1214
+ <span data-testid="scene-name" data-scene={panelName}>
1215
+ {panelName}
1216
+ </span>
1217
+ }
1218
+ onPointerDown={() => setActiveScope('hierarchy')}
1219
+ onDragOver={(e) => {
1220
+ if (e.dataTransfer.types.includes(ASSET_MIME)) {
1221
+ e.preventDefault();
1222
+ e.dataTransfer.dropEffect = 'copy';
1223
+ }
1224
+ }}
1225
+ style={{ width: 240, borderRight: '1px solid #333' }}
1226
+ >
1227
+ <OverlayReportBanner />
1228
+ <div
1229
+ style={{
1230
+ padding: '4px 8px',
1231
+ borderBottom: '1px solid #333',
1232
+ display: 'flex',
1233
+ alignItems: 'center',
1234
+ gap: 4,
1235
+ flexShrink: 0,
1236
+ }}
1237
+ >
1238
+ <input
1239
+ type="text"
1240
+ data-testid="ingest-search"
1241
+ placeholder="Search..."
1242
+ value={search}
1243
+ onChange={(e) => setSearch(e.target.value)}
1244
+ style={{
1245
+ flex: 1,
1246
+ minWidth: 0,
1247
+ background: '#1e1e1e',
1248
+ border: '1px solid #333',
1249
+ borderRadius: 3,
1250
+ color: '#c5c8ce',
1251
+ fontSize: 11,
1252
+ padding: '3px 6px',
1253
+ outline: 'none',
1254
+ }}
1255
+ />
1256
+ <CreateMenu adapter={adapter} parentId={null} />
1257
+ </div>
1258
+
1259
+ <div
1260
+ ref={containerRef}
1261
+ data-testid="ingest-hierarchy"
1262
+ style={{
1263
+ flex: 1,
1264
+ minHeight: 0,
1265
+ overflow: 'auto',
1266
+ position: 'relative',
1267
+ pointerEvents: 'auto',
1268
+ }}
1269
+ onDragOver={handleDragOver}
1270
+ onDrop={handleDrop}
1271
+ onDragEnd={handleDragEnd}
1272
+ onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
1273
+ onDragLeave={(e) => {
1274
+ if (!containerRef.current?.contains(e.relatedTarget as Node)) {
1275
+ setDropTarget(null);
1276
+ setTailDrop(false);
1277
+ }
1278
+ }}
1279
+ >
1280
+ <div data-testid="game-hierarchy-spacer-top" style={{ height: topSpacerPx }} />
1281
+ {windowRows.map((row) =>
1282
+ row.more ? (
1283
+ <MoreRow key={ingestRowKey(row)} row={row} onReveal={revealMore} />
1284
+ ) : (
1285
+ <Row
1286
+ key={ingestRowKey(row)}
1287
+ row={row}
1288
+ adapter={adapter}
1289
+ store={store}
1290
+ isSelected={selectedIds.has(row.node.id)}
1291
+ isEditing={editingId === row.node.id}
1292
+ isCollapsed={collapsedKeysRef.current.has(row.pathKey)}
1293
+ dropZone={dropTarget && dropTarget.nodeId === row.node.id ? dropTarget.zone : null}
1294
+ isDragged={draggedIds?.includes(row.node.id) ?? false}
1295
+ rootMember={rootMembers.get(row.node.id)}
1296
+ searchTerm={term}
1297
+ onToggleOpen={toggleOpen}
1298
+ onStartEditing={setEditingId}
1299
+ onContextMenu={(e, id) => setContextMenu({ x: e.clientX, y: e.clientY, nodeId: id })}
1300
+ onDragStart={handleRowDragStart}
1301
+ onClick={handleRowClick}
1302
+ onActivateRoot={handleActivateRoot}
1303
+ />
1304
+ ),
1305
+ )}
1306
+ <div data-testid="game-hierarchy-spacer-bottom" style={{ height: bottomSpacerPx }} />
1307
+ <div style={{ minHeight: 40 }} />
1308
+ {tailDrop && (
1309
+ <div
1310
+ style={{
1311
+ position: 'absolute',
1312
+ left: 0,
1313
+ right: 0,
1314
+ top: rows.length * ROW_HEIGHT,
1315
+ height: 2,
1316
+ background: '#579EFF',
1317
+ pointerEvents: 'none',
1318
+ zIndex: 10,
1319
+ }}
1320
+ />
1321
+ )}
1322
+ </div>
1323
+
1324
+ {contextMenu &&
1325
+ (() => {
1326
+ const menuNode = adapter.hierarchy.node(contextMenu.nodeId);
1327
+ // A4 (D8): a root-group member or the game root gets the same
1328
+ // "no structural affordances" treatment as an existing composite
1329
+ // group row — none of the three own a name/visible/locked path or
1330
+ // a meaningful Create/Duplicate/Delete target.
1331
+ const isGroupNode = menuNode
1332
+ ? isCompositeGroupNode(adapter, menuNode.id) ||
1333
+ isGameRootNode(adapter, menuNode.id) ||
1334
+ rootMembers.has(menuNode.id)
1335
+ : false;
1336
+ return (
1337
+ <ContextMenu
1338
+ state={contextMenu}
1339
+ adapter={adapter}
1340
+ store={store}
1341
+ isGroupNode={isGroupNode}
1342
+ onRename={setEditingId}
1343
+ onClose={() => setContextMenu(null)}
1344
+ />
1345
+ );
1346
+ })()}
1347
+ </Panel>
1348
+ );
1349
+ }