@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,2225 @@
1
+ /**
2
+ * EditorStore — central editor state backed by the Three.js scene graph.
3
+ *
4
+ * The scene graph (THREE.Scene) is the single source of truth for entity data.
5
+ * Each entity Object3D carries its full SceneEntity descriptor in
6
+ * `userData['entity']`. The store reads from and writes to these descriptors,
7
+ * and triggers per-entity Three.js rebuilds when properties change.
8
+ *
9
+ * Full scene rebuilds (clearPreview + buildPreview) only happen on:
10
+ * - Scene load
11
+ * - Undo / redo
12
+ * - Prefab resolution
13
+ * - Asset path rename
14
+ */
15
+
16
+ import type { PersistenceProvider } from '@engine/adapter';
17
+ import { applyAssetRename } from '@engine/scene/asset-paths';
18
+ import {
19
+ mergePrefabInstance,
20
+ type PrefabFile,
21
+ type SceneEntity,
22
+ type SceneEnvironment,
23
+ type SceneFile,
24
+ } from '@engine/scene/scene-types';
25
+ import { getUserData, setUserData } from '@engine/scene/user-data';
26
+ import * as THREE from 'three';
27
+ import type { BatchedRenderer } from 'three.quarks';
28
+ import {
29
+ activeOverrideIsEditMode,
30
+ getActiveAuthoring,
31
+ hasAuthoringOverride,
32
+ } from './authoring/active-adapter';
33
+ import { notifyUndoableOp, registerUndoAdapter } from './authoring/undo-timeline';
34
+ import { loadEditorState, saveEditorState, saveFile, saveThumbnail } from './editor-api';
35
+ import { createEntityObject } from './entity-factory';
36
+ import { diffScene } from './scene-diff';
37
+ import type { SceneMetadata } from './scene-serializer';
38
+ import { cleanDocument, serializeScene } from './scene-serializer';
39
+ import {
40
+ applyEnvironment,
41
+ applyShadingOverride,
42
+ buildPreview,
43
+ registerParticles,
44
+ unregisterParticles,
45
+ updateEntityPreview,
46
+ } from './scene-sync';
47
+
48
+ /** Like Partial but explicitly allows setting fields to undefined (to clear them). */
49
+ export type EntityUpdate = { [K in keyof SceneEntity]?: SceneEntity[K] | undefined };
50
+
51
+ export type TransformMode = 'translate' | 'rotate' | 'scale';
52
+ export type TransformSpace = 'world' | 'local';
53
+ export type PivotMode = 'active-element' | 'median-point' | 'individual-origins';
54
+
55
+ export type ShadingMode = 'solid' | 'wireframe' | 'unlit';
56
+
57
+ /**
58
+ * Which persistence regime the CURRENT play session's edits are under —
59
+ * a pure readout of the decision `play-mode.ts` already makes to pick a
60
+ * provider (docs/MULTI-WORLD-DESIGN.md §1.F / MASTER-ARCHITECTURE-REVIEW §7.4
61
+ * gap 4): `'ephemeral'` for a single-world session (edits discarded on stop,
62
+ * `createEphemeralPersistence`), `'overlay'` for a multi-world (≥2) session
63
+ * (composite of per-world overlay-persisted adapters, edits survive the
64
+ * session). `null` while not playing. Never derive this independently of the
65
+ * real provider decision — see `play-mode.ts`'s `enterPlayMode`, which sets it
66
+ * from the SAME `_isMultiWorldSession` boolean that selects
67
+ * `installMultiWorldAuthoring` vs `installPlayAuthoring`.
68
+ */
69
+ export type PlayEditRegime = 'ephemeral' | 'overlay' | null;
70
+
71
+ export type AssetKind =
72
+ | 'model'
73
+ | 'image'
74
+ | 'audio'
75
+ | 'json'
76
+ | 'scene'
77
+ | 'prefab'
78
+ | 'material'
79
+ | 'source';
80
+
81
+ export interface OnlineAssetInfo {
82
+ source: 'polyhaven' | 'ambientcg' | 'local';
83
+ id: string;
84
+ name: string;
85
+ thumbnailUrl: string;
86
+ type: string;
87
+ tags: string[];
88
+ license?: string;
89
+ author?: string;
90
+ sourceUrl?: string;
91
+ }
92
+
93
+ export interface AssetTab {
94
+ key: string;
95
+ label: string;
96
+ assetPath: string;
97
+ kind: AssetKind;
98
+ /** Present when this tab represents an online (not-yet-downloaded) asset. */
99
+ online?: OnlineAssetInfo;
100
+ }
101
+
102
+ export interface HelperVisibility {
103
+ lights: boolean;
104
+ cameras: boolean;
105
+ colliders: boolean;
106
+ audio: boolean;
107
+ splines: boolean;
108
+ navmesh: boolean;
109
+ }
110
+
111
+ export type ViewportAction =
112
+ | { type: 'focus-entity'; id: string }
113
+ | { type: 'focus-selection' }
114
+ | { type: 'set-view-preset'; preset: 'top' | 'front' | 'right' | 'perspective' }
115
+ | {
116
+ type: 'set-camera-pose';
117
+ position: { x: number; y: number; z: number };
118
+ target: { x: number; y: number; z: number };
119
+ fov?: number;
120
+ };
121
+
122
+ const MAX_UNDO = 50;
123
+ /** Cooldown (ms) to prevent tight feedback loops between auto-save and external updates. */
124
+ const SYNC_COOLDOWN_MS = 500;
125
+
126
+ function deepClone<T>(obj: T): T {
127
+ return JSON.parse(JSON.stringify(obj)) as T;
128
+ }
129
+
130
+ /** Ensure every entity has a UUID and a children array. Mutates in place. */
131
+ export function assignMissingIds(entities: SceneEntity[]): void {
132
+ const walk = (list: SceneEntity[]) => {
133
+ for (const e of list) {
134
+ if (!e.id) e.id = crypto.randomUUID();
135
+ if (!e.children) e.children = [];
136
+ walk(e.children);
137
+ }
138
+ };
139
+ walk(entities);
140
+ }
141
+
142
+ /** Walk entity Object3Ds in the scene graph and call fn on each. */
143
+ function walkEntityObjects(
144
+ parent: THREE.Object3D,
145
+ fn: (obj: THREE.Object3D, entity: SceneEntity) => void,
146
+ ): void {
147
+ for (const child of parent.children) {
148
+ if (getUserData(child, 'entityId') === undefined) continue;
149
+ if (getUserData(child, 'editorHelper')) continue;
150
+ const entity = getUserData(child, 'entity') as SceneEntity | undefined;
151
+ if (!entity) continue;
152
+ fn(child, entity);
153
+ walkEntityObjects(child, fn);
154
+ }
155
+ }
156
+
157
+ /** Walk a SceneEntity tree (JSON). */
158
+ function walkEntities(entities: SceneEntity[], fn: (e: SceneEntity) => void): void {
159
+ for (const e of entities) {
160
+ fn(e);
161
+ if (e.children) walkEntities(e.children, fn);
162
+ }
163
+ }
164
+
165
+ export class EditorStore {
166
+ /**
167
+ * Register with the editor-global undo TIMELINE (T3.2 slice 2) once, for the
168
+ * lifetime of this store — first-party never "disposes" mid-session, so unlike
169
+ * ingest/UI sessions there is no matching `unregisterUndoAdapter` call. Safe for
170
+ * headless test instances too: the timeline only ever calls INTO
171
+ * `this.undo()`/`this.redo()` when notified, never the reverse.
172
+ */
173
+ constructor() {
174
+ registerUndoAdapter(this);
175
+ }
176
+
177
+ // --- Scene graph references (set via bindScene) ---
178
+ private _scene: THREE.Scene | null = null;
179
+ private _editorScene: THREE.Scene | null = null; // stored while game scene is active
180
+ private _editorObjectMap: Map<string, THREE.Object3D> | null = null; // stored during play
181
+ /** Pre-play snapshot of `_sceneMeta`, restored by exitPlayScene (see enterPlayScene). */
182
+ private _editorSceneMeta: SceneMetadata | null = null;
183
+ private _objectMap = new Map<string, THREE.Object3D>();
184
+ private _renderer: THREE.WebGLRenderer | null = null;
185
+ private _camera: THREE.Camera | null = null;
186
+ private _batchedRenderer: BatchedRenderer | null = null;
187
+ private _lastThumbnailTime = 0;
188
+ private _lastEditorStateSaveTime = 0;
189
+ private _orbitTarget: THREE.Vector3 | null = null;
190
+
191
+ /** Scene-level metadata (version, name, environment). Not per-entity. */
192
+ private _sceneMeta: SceneMetadata = { version: 1, name: 'Untitled' };
193
+
194
+ // --- UI state (not stored in scene graph) ---
195
+ private _selectedEntityIds = new Set<string>();
196
+ private _clipboard: SceneEntity[] = [];
197
+ private _transformMode: TransformMode = 'translate';
198
+ private _transformSpace: TransformSpace = 'world';
199
+ private _snapEnabled = false;
200
+ private _snapValues = { translate: 1, rotate: 15, scale: 0.25 };
201
+ private _snapToSurface = false;
202
+ private _pivotMode: PivotMode = 'active-element';
203
+ private _showGrid = true;
204
+ private _showHelpers = true;
205
+ private _helperVisibility: HelperVisibility = {
206
+ lights: true,
207
+ cameras: true,
208
+ colliders: true,
209
+ audio: true,
210
+ splines: true,
211
+ navmesh: true,
212
+ };
213
+ private _showStats = false;
214
+ private _shadingMode: ShadingMode = 'solid';
215
+ private _isDirty = false;
216
+ /**
217
+ * Bumped on every document mutation that sets `_isDirty`. A save captures
218
+ * the epoch when it serializes; its completion callback only clears
219
+ * `_isDirty` if the epoch is unchanged — a save resolving AFTER a newer
220
+ * mutation (e.g. an in-flight autosave racing an undo) must not mark the
221
+ * newer, still-unpersisted state as saved.
222
+ */
223
+ private _docEpoch = 0;
224
+ /**
225
+ * Exact content of the last write we issued, for own-echo detection: the
226
+ * file watcher reports our own saves back via SSE, and when that echo
227
+ * arrives after `_ignoreExternalUntil` (slow watcher under load) it must
228
+ * not be applied as an "external" update — the document may have moved on
229
+ * (undo/redo), and applying the echo would clobber the newer state.
230
+ */
231
+ private _lastSavedContent: string | null = null;
232
+ /** True when the most recent auto-save / manual save attempt failed. */
233
+ private _lastSaveFailed = false;
234
+ /** True while a coalesced interactive edit gesture is in progress. */
235
+ private _interactiveActive = false;
236
+ private _navMeshBaked = false;
237
+ private _savePath: string | null = null;
238
+ private _undoStack: string[] = [];
239
+ private _redoStack: string[] = [];
240
+ private _activeTabKey = '__inspector__';
241
+ private _assetTabs: AssetTab[] = [];
242
+ private _documentType: 'scene' | 'prefab' = 'scene';
243
+ private _playState: 'stopped' | 'playing' | 'paused' = 'stopped';
244
+ /** See {@link PlayEditRegime} — set by play-mode.ts on play-enter, cleared on play-exit. */
245
+ private _playEditRegime: PlayEditRegime = null;
246
+ private _activeViewportTab: 'scene' | 'game' = 'scene';
247
+ private _commandPaletteOpen = false;
248
+ private _vertexSnapActive = false;
249
+ private _listeners = new Set<() => void>();
250
+ private _viewportActionListeners = new Set<(action: ViewportAction) => void>();
251
+ private _version = 0;
252
+ /**
253
+ * Bumped only when post-processing-relevant state changes (environment / scene
254
+ * structure / play-stop scene swap). The viewport rebuilds its EffectComposer
255
+ * only when this changes — NOT on every notify — so a transform drag/scrub (which
256
+ * notifies per frame but never touches these) no longer recompiles shaders 60×/s.
257
+ */
258
+ private _composerVersion = 0;
259
+ private _autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
260
+ /** Timestamp after which external updates are accepted (cooldown after auto-save). */
261
+ private _ignoreExternalUntil = 0;
262
+ /** Timestamp after which auto-save is allowed (cooldown after external update). */
263
+ private _suppressAutoSaveUntil = 0;
264
+ /** Rolling counter for sync cycles — trips a circuit breaker if too many in a short window. */
265
+ private _syncCycleTimestamps: number[] = [];
266
+ /**
267
+ * Deferred file-watcher/asset-move inputs received while play/ingest is
268
+ * active (latest wins per kind). Applied once play/ingest stops — see
269
+ * `setPlayState`/`_applyPendingExternalMutations`, `applyExternalUpdate`,
270
+ * `applyAssetMove`.
271
+ */
272
+ private _pendingExternalUpdate: SceneFile | null = null;
273
+ private _pendingAssetMove: { oldPath: string; newPath: string } | null = null;
274
+
275
+ // --- Bind to scene graph (call once at editor init) ---
276
+
277
+ bindScene(
278
+ scene: THREE.Scene,
279
+ renderer?: THREE.WebGLRenderer,
280
+ batchedRenderer?: BatchedRenderer | null,
281
+ camera?: THREE.Camera,
282
+ ): void {
283
+ this._scene = scene;
284
+ this._renderer = renderer ?? null;
285
+ this._batchedRenderer = batchedRenderer ?? null;
286
+ if (camera) this._camera = camera;
287
+ }
288
+
289
+ /** Set the orbit controls target reference for camera state persistence. */
290
+ setOrbitTarget(target: THREE.Vector3): void {
291
+ this._orbitTarget = target;
292
+ }
293
+
294
+ // --- React useSyncExternalStore integration ---
295
+ subscribe = (listener: () => void): (() => void) => {
296
+ this._listeners.add(listener);
297
+ return () => this._listeners.delete(listener);
298
+ };
299
+
300
+ getSnapshot = (): number => this._version;
301
+
302
+ private _notify(): void {
303
+ this._version++;
304
+ for (const fn of this._listeners) fn();
305
+ }
306
+
307
+ // --- Viewport action dispatch ---
308
+ // Panels call these methods; ViewportPanel subscribes and executes them.
309
+
310
+ onViewportAction = (fn: (action: ViewportAction) => void): (() => void) => {
311
+ this._viewportActionListeners.add(fn);
312
+ return () => this._viewportActionListeners.delete(fn);
313
+ };
314
+
315
+ private _emitViewportAction(action: ViewportAction): void {
316
+ for (const fn of this._viewportActionListeners) fn(action);
317
+ }
318
+
319
+ /** Focus the viewport camera on a specific entity. */
320
+ focusOnEntity(id: string): void {
321
+ this._emitViewportAction({ type: 'focus-entity', id });
322
+ }
323
+
324
+ /** Focus the viewport camera on the current selection. */
325
+ focusOnSelection(): void {
326
+ this._emitViewportAction({ type: 'focus-selection' });
327
+ }
328
+
329
+ /** Switch the viewport camera to a preset view (top, front, right, perspective). */
330
+ setViewPreset(preset: 'top' | 'front' | 'right' | 'perspective'): void {
331
+ this._emitViewportAction({ type: 'set-view-preset', preset });
332
+ }
333
+
334
+ /** Move the viewport camera to an arbitrary position/target/fov pose. */
335
+ setCameraPose(
336
+ position: { x: number; y: number; z: number },
337
+ target: { x: number; y: number; z: number },
338
+ fov?: number,
339
+ ): void {
340
+ this._emitViewportAction({
341
+ type: 'set-camera-pose',
342
+ position,
343
+ target,
344
+ ...(fov !== undefined ? { fov } : {}),
345
+ });
346
+ }
347
+
348
+ /**
349
+ * Live viewport camera pose, read straight from the bound Three.js camera +
350
+ * orbit target (`bindScene`/`setOrbitTarget` — wired by `ViewportPanel.tsx`).
351
+ * Null only when nothing has bound a camera yet (e.g. a headless store in a
352
+ * unit test that never mounted `ViewportPanel`).
353
+ */
354
+ get cameraPose(): { position: THREE.Vector3; target: THREE.Vector3; fov: number } | null {
355
+ if (!this._camera) return null;
356
+ const fov = (this._camera as THREE.PerspectiveCamera).fov;
357
+ return {
358
+ position: this._camera.position.clone(),
359
+ target: this._orbitTarget ? this._orbitTarget.clone() : new THREE.Vector3(),
360
+ fov: typeof fov === 'number' ? fov : 0,
361
+ };
362
+ }
363
+
364
+ // --- Scene graph accessors ---
365
+
366
+ /** The object map: entity ID → Three.js Object3D. */
367
+ get objectMap(): Map<string, THREE.Object3D> {
368
+ return this._objectMap;
369
+ }
370
+
371
+ /** The bound Three.js scene. */
372
+ get scene(): THREE.Scene | null {
373
+ return this._scene;
374
+ }
375
+
376
+ /** Monotonic counter bumped when post-processing inputs change (see field doc). */
377
+ get composerVersion(): number {
378
+ return this._composerVersion;
379
+ }
380
+
381
+ /**
382
+ * Build the entity tree from the live scene graph.
383
+ * Returns a nested SceneEntity[] matching the Three.js hierarchy.
384
+ * Used by SceneHierarchy and any code that needs the full tree.
385
+ */
386
+ get entities(): SceneEntity[] {
387
+ if (!this._scene) return [];
388
+ return this._buildEntityTree(this._scene);
389
+ }
390
+
391
+ /**
392
+ * Flattened id/name/childIds rows for the whole entity tree — the wire
393
+ * shape `editor.hierarchy.inspect` reports (see
394
+ * `command-listener.ts`'s `collectState`). Kept here (not in
395
+ * command-listener.ts) so that file — outside the `SceneEntity` allowlist
396
+ * (`test/authoring-inversion-guard.test.ts`) — never needs to import
397
+ * `SceneEntity` itself; this is the one place that walks the real tree.
398
+ * Entities missing an `id` (should not happen post `assignMissingIds`, but
399
+ * the type is optional) are skipped — there is no stable key to report.
400
+ */
401
+ hierarchyRows(): { id: string; name: string; childIds: string[] }[] {
402
+ const rows: { id: string; name: string; childIds: string[] }[] = [];
403
+ const walkTree = (list: SceneEntity[]) => {
404
+ for (const e of list) {
405
+ if (!e.id) continue;
406
+ rows.push({
407
+ id: e.id,
408
+ name: e.name,
409
+ childIds: (e.children ?? []).map((c) => c.id).filter((id): id is string => Boolean(id)),
410
+ });
411
+ if (e.children) walkTree(e.children);
412
+ }
413
+ };
414
+ walkTree(this.entities);
415
+ return rows;
416
+ }
417
+
418
+ /** Scene-level environment settings. */
419
+ get environment(): SceneEnvironment | undefined {
420
+ return this._sceneMeta.environment;
421
+ }
422
+
423
+ /** The scene's UI roots (the `ui` field) — read by UI-edit mode (K5). */
424
+ getSceneUI(): import('@engine/scene/schema').UIRoot[] {
425
+ return this._sceneMeta.ui ?? [];
426
+ }
427
+ /** Replace the scene's UI roots (UI-edit commit), mark dirty + autosave (C9). */
428
+ setSceneUI(roots: import('@engine/scene/schema').UIRoot[]): void {
429
+ this._sceneMeta.ui = roots;
430
+ this._isDirty = true;
431
+ this._docEpoch++;
432
+ this._autoSave();
433
+ this._notify();
434
+ }
435
+
436
+ /** Scene name. */
437
+ get sceneName(): string {
438
+ return this._sceneMeta.name;
439
+ }
440
+
441
+ setSceneName(name: string): void {
442
+ this._sceneMeta.name = name;
443
+ // No undo — name changes are lightweight, same as old behavior
444
+ }
445
+
446
+ // --- Selection ---
447
+ get selectedEntityId(): string | null {
448
+ if (this._selectedEntityIds.size === 0) return null;
449
+ return [...this._selectedEntityIds].at(-1)!;
450
+ }
451
+ get selectedEntityIds(): ReadonlySet<string> {
452
+ return this._selectedEntityIds;
453
+ }
454
+ get selectedEntity(): SceneEntity | null {
455
+ const id = this.selectedEntityId;
456
+ return id ? this.getEntityById(id) : null;
457
+ }
458
+ get selectedEntities(): SceneEntity[] {
459
+ return [...this._selectedEntityIds]
460
+ .map((id) => this.getEntityById(id))
461
+ .filter((e): e is SceneEntity => e !== null);
462
+ }
463
+ get transformMode(): TransformMode {
464
+ return this._transformMode;
465
+ }
466
+ get transformSpace(): TransformSpace {
467
+ return this._transformSpace;
468
+ }
469
+ get snapEnabled(): boolean {
470
+ return this._snapEnabled;
471
+ }
472
+ get snapValues() {
473
+ return this._snapValues;
474
+ }
475
+ get snapToSurface(): boolean {
476
+ return this._snapToSurface;
477
+ }
478
+ get pivotMode(): PivotMode {
479
+ return this._pivotMode;
480
+ }
481
+ get isDirty(): boolean {
482
+ return this._isDirty;
483
+ }
484
+ /**
485
+ * Coarse save status for UI affordances:
486
+ * 'failed' — last save attempt failed; changes are NOT on disk.
487
+ * 'unsaved' — there are dirty changes not yet persisted.
488
+ * 'saved' — in sync with disk.
489
+ */
490
+ get saveState(): 'saved' | 'unsaved' | 'failed' {
491
+ if (this._lastSaveFailed) return 'failed';
492
+ if (this._isDirty) return 'unsaved';
493
+ return 'saved';
494
+ }
495
+ get canUndo(): boolean {
496
+ return this._undoStack.length > 0;
497
+ }
498
+ get canRedo(): boolean {
499
+ return this._redoStack.length > 0;
500
+ }
501
+ get showGrid(): boolean {
502
+ return this._showGrid;
503
+ }
504
+ get showHelpers(): boolean {
505
+ return this._showHelpers;
506
+ }
507
+ get helperVisibility(): Readonly<HelperVisibility> {
508
+ return this._helperVisibility;
509
+ }
510
+ get showStats(): boolean {
511
+ return this._showStats;
512
+ }
513
+ get shadingMode(): ShadingMode {
514
+ return this._shadingMode;
515
+ }
516
+ get hasClipboard(): boolean {
517
+ return this._clipboard.length > 0;
518
+ }
519
+ get savePath(): string | null {
520
+ return this._savePath;
521
+ }
522
+ setSavePath(path: string | null): void {
523
+ this._savePath = path;
524
+ this._notify();
525
+ }
526
+ get documentType(): 'scene' | 'prefab' {
527
+ return this._documentType;
528
+ }
529
+
530
+ /** Notify subscribers after an ingest adapter mutated a live foreign object
531
+ * (material/visibility). Runtime-only — no dirty/autosave (edits live on the
532
+ * Object3D, not a .vscn descriptor). */
533
+ notifyIngestEdit(): void {
534
+ this._notify();
535
+ }
536
+
537
+ // --- Play mode scene swap ---
538
+
539
+ /** Callback to sync transform edits to game ECS. Set by play-mode, cleared on exit. */
540
+ private _ecsSyncTransform: ((obj: THREE.Object3D) => void) | null = null;
541
+
542
+ /** Set a callback that syncs editor transform changes to the game's ECS state. */
543
+ setEcsSyncTransform(fn: ((obj: THREE.Object3D) => void) | null): void {
544
+ this._ecsSyncTransform = fn;
545
+ }
546
+
547
+ /**
548
+ * Swap the store's active scene to the game scene. Editor scene is preserved.
549
+ *
550
+ * Also snapshots `_sceneMeta` (env/name/ui — see `getDocument`/`updateEnvironment`/
551
+ * `setSceneName`/`setSceneUI`). Those setters keep mutating the SAME live
552
+ * `_sceneMeta` object during play so the environment inspector / scene name
553
+ * field keep working and `getDocument()` stays consistent while playing —
554
+ * but any such edit must be discarded when play stops (probe: F5 in the
555
+ * round-1 review — `updateEnvironment` during play silently persisted with
556
+ * `isDirty` false). `exitPlayScene` restores this snapshot.
557
+ */
558
+ enterPlayScene(gameScene: THREE.Scene, opts: { liveHierarchy?: boolean } = {}): void {
559
+ if (!this._scene) return;
560
+ this._editorScene = this._scene;
561
+ this._editorObjectMap = this._objectMap;
562
+ this._editorSceneMeta = deepClone(this._sceneMeta);
563
+ this._scene = gameScene;
564
+
565
+ // In play mode the hierarchy is a LIVE view of the running scene graph, not
566
+ // the authored document — so surface every object, not just the ones that
567
+ // came from the .vscn. Objects the scene-loader tagged keep their rich
568
+ // descriptor (full inspector); procedurally-created / runtime-spawned
569
+ // objects (the ship hull, instanced asteroids, spawned bullets…) get a
570
+ // synthetic descriptor so they appear in the tree, are selectable, and can
571
+ // be nudged via the gizmo to test the game. These edits are ephemeral —
572
+ // nothing is written back to the .vscn (play mode never serializes).
573
+ //
574
+ // Synthesis is OPT-IN (`liveHierarchy: true` — first-party play mode only).
575
+ // Ingest/module mounts adopt a FOREIGN scene through this same method, and
576
+ // the anti-shim rule forbids fabricating a SceneEntity descriptor onto an
577
+ // object we don't own: their hierarchies come from the AuthoringAdapter
578
+ // seam (OID-derived), never from synthesized userData. Untagged foreign
579
+ // objects therefore stay out of the objectMap entirely by default.
580
+ const gameMap = new Map<string, THREE.Object3D>();
581
+ gameScene.traverse((obj) => {
582
+ if (obj === gameScene) return;
583
+ if (getUserData(obj, 'editorHelper')) return;
584
+ // Engine-owned infra (particle renderer, debug-draw) is not a game entity.
585
+ if (getUserData(obj, 'engineInternal')) return;
586
+ let eid = getUserData(obj, 'entityId') as string | undefined;
587
+ if (eid === undefined) {
588
+ if (!opts.liveHierarchy) return;
589
+ eid = obj.uuid;
590
+ setUserData(obj, 'entityId', eid);
591
+ setUserData(obj, 'entity', { id: eid, name: obj.name || obj.type } as SceneEntity);
592
+ setUserData(obj, 'liveObject', true); // marks a non-authored runtime object
593
+ }
594
+ gameMap.set(eid, obj);
595
+ });
596
+ this._objectMap = gameMap;
597
+
598
+ this._selectedEntityIds.clear();
599
+ this._composerVersion++; // scene swapped → composer must rebuild against the game scene
600
+ this._notify();
601
+ }
602
+
603
+ /** Restore the editor scene after play mode ends (see enterPlayScene). */
604
+ exitPlayScene(): void {
605
+ if (!this._editorScene || !this._editorObjectMap) return;
606
+ this._scene = this._editorScene;
607
+ this._objectMap = this._editorObjectMap;
608
+ this._editorScene = null;
609
+ this._editorObjectMap = null;
610
+ // Discard any play-time env/name/ui edits — restore the pre-play snapshot.
611
+ if (this._editorSceneMeta) {
612
+ this._sceneMeta = this._editorSceneMeta;
613
+ this._editorSceneMeta = null;
614
+ }
615
+ this._selectedEntityIds.clear();
616
+ this._composerVersion++; // scene swapped back → composer must rebuild against the editor scene
617
+ this._notify();
618
+ }
619
+
620
+ // --- Play mode state ---
621
+ get playState(): 'stopped' | 'playing' | 'paused' {
622
+ return this._playState;
623
+ }
624
+
625
+ /**
626
+ * Guard structural/history mutations while a game is running. During play the
627
+ * store's `_scene` is the LIVE game scene (see enterPlayScene), so undo/redo
628
+ * (which call clearPreview + buildPreview and rebuild the whole scene) or
629
+ * delete/duplicate (which splice the scene graph and objectMap) would tear the
630
+ * scene out from under the running GameSession. Returns true (and warns) when
631
+ * the named op must be blocked. The intended live-edit path during play is the
632
+ * incremental ECS-sync transform path, not these full-scene mutations.
633
+ */
634
+ private _blockedDuringPlay(op: string): boolean {
635
+ if (this._playState === 'stopped') return false;
636
+ console.warn(`EditorStore: "${op}" is disabled during play mode — stop the game first.`);
637
+ return true;
638
+ }
639
+ setPlayState(state: 'stopped' | 'playing' | 'paused'): void {
640
+ const wasActive = this._playState !== 'stopped';
641
+ this._playState = state;
642
+ if (state === 'playing' && this._activeViewportTab === 'scene') {
643
+ this._activeViewportTab = 'game';
644
+ }
645
+ if (state === 'stopped') {
646
+ this._activeViewportTab = 'scene';
647
+ // Apply anything deferred while play/ingest was active. play-mode.ts /
648
+ // ingest-mode.ts call exitPlayScene() (restoring the editor scene) BEFORE
649
+ // this — so by the time we get here `_scene` is already the editor scene,
650
+ // never the just-torn-down game scene (see applyExternalUpdate/applyAssetMove).
651
+ if (wasActive) this._applyPendingExternalMutations();
652
+ }
653
+ this._notify();
654
+ }
655
+
656
+ /** See {@link PlayEditRegime}. Null while not playing. */
657
+ get playEditRegime(): PlayEditRegime {
658
+ return this._playEditRegime;
659
+ }
660
+
661
+ /** Set by play-mode.ts's enterPlayMode/exitPlayMode — see {@link PlayEditRegime}. */
662
+ setPlayEditRegime(regime: PlayEditRegime): void {
663
+ this._playEditRegime = regime;
664
+ this._notify();
665
+ }
666
+
667
+ /** Apply deferred external-update/asset-move inputs after play/ingest stops. */
668
+ private _applyPendingExternalMutations(): void {
669
+ if (this._pendingExternalUpdate) {
670
+ const doc = this._pendingExternalUpdate;
671
+ this._pendingExternalUpdate = null;
672
+ this.applyExternalUpdate(doc);
673
+ }
674
+ if (this._pendingAssetMove) {
675
+ const { oldPath, newPath } = this._pendingAssetMove;
676
+ this._pendingAssetMove = null;
677
+ this.applyAssetMove(oldPath, newPath);
678
+ }
679
+ }
680
+
681
+ // --- Viewport tab ---
682
+ get activeViewportTab(): 'scene' | 'game' {
683
+ return this._activeViewportTab;
684
+ }
685
+ setActiveViewportTab(tab: 'scene' | 'game'): void {
686
+ this._activeViewportTab = tab;
687
+ this._notify();
688
+ }
689
+ // --- Command palette ---
690
+ get commandPaletteOpen(): boolean {
691
+ return this._commandPaletteOpen;
692
+ }
693
+ toggleCommandPalette(): void {
694
+ this._commandPaletteOpen = !this._commandPaletteOpen;
695
+ this._notify();
696
+ }
697
+ closeCommandPalette(): void {
698
+ if (!this._commandPaletteOpen) return;
699
+ this._commandPaletteOpen = false;
700
+ this._notify();
701
+ }
702
+
703
+ // --- Vertex snap ---
704
+ get vertexSnapActive(): boolean {
705
+ return this._vertexSnapActive;
706
+ }
707
+ setVertexSnapActive(active: boolean): void {
708
+ this._vertexSnapActive = active;
709
+ this._notify();
710
+ }
711
+
712
+ get activeTabKey(): string {
713
+ return this._activeTabKey;
714
+ }
715
+ get assetTabs(): readonly AssetTab[] {
716
+ return this._assetTabs;
717
+ }
718
+
719
+ /** True if any entities have a navigation role. */
720
+ hasNavigationEntities(): boolean {
721
+ if (!this._scene) return false;
722
+ let found = false;
723
+ walkEntityObjects(this._scene, (_, entity) => {
724
+ if (entity.navigation?.role) found = true;
725
+ });
726
+ return found;
727
+ }
728
+
729
+ get navMeshStale(): boolean {
730
+ return !this._navMeshBaked && this.hasNavigationEntities();
731
+ }
732
+
733
+ markNavMeshBaked(): void {
734
+ this._navMeshBaked = true;
735
+ this._notify();
736
+ }
737
+
738
+ // --- Selection ---
739
+ select(id: string | null): void {
740
+ this._selectedEntityIds.clear();
741
+ if (id) this._selectedEntityIds.add(id);
742
+ this._notify();
743
+ }
744
+
745
+ selectMultiple(ids: string[]): void {
746
+ this._selectedEntityIds = new Set(ids);
747
+ this._notify();
748
+ }
749
+
750
+ addToSelection(id: string): void {
751
+ this._selectedEntityIds.add(id);
752
+ this._notify();
753
+ }
754
+
755
+ toggleSelection(id: string): void {
756
+ if (this._selectedEntityIds.has(id)) {
757
+ this._selectedEntityIds.delete(id);
758
+ } else {
759
+ this._selectedEntityIds.add(id);
760
+ }
761
+ this._notify();
762
+ }
763
+
764
+ selectAll(): void {
765
+ const all = this.allEntities();
766
+ this._selectedEntityIds = new Set(all.map((e) => e.id!).filter(Boolean));
767
+ this._notify();
768
+ }
769
+
770
+ // --- Entity lookup ---
771
+
772
+ getEntityById(id: string): SceneEntity | null {
773
+ const obj = this._objectMap.get(id);
774
+ return (getUserData(obj, 'entity') as SceneEntity) ?? null;
775
+ }
776
+
777
+ /** Flat list of all entities in scene. */
778
+ allEntities(): SceneEntity[] {
779
+ if (!this._scene) return [];
780
+ const result: SceneEntity[] = [];
781
+ walkEntityObjects(this._scene, (_, entity) => result.push(entity));
782
+ return result;
783
+ }
784
+
785
+ // --- Undo stack ---
786
+
787
+ /** Public: push a snapshot before a drag begins (called by viewport). */
788
+ pushUndoSnapshot(): void {
789
+ this._pushUndo();
790
+ }
791
+
792
+ private _pushUndo(): void {
793
+ if (!this._scene) return;
794
+ // Play-mode guard (T1.6): while playing/paused, `_scene`/`_objectMap` point
795
+ // at the LIVE game scene (see enterPlayScene) — a fresh object graph loaded
796
+ // from a snapshot of the document, whose entity descriptors are runtime-only
797
+ // copies discarded on exitPlayScene. Pushing a snapshot here would serialize
798
+ // that runtime state onto the REAL undo stack: a later edit's snapshot could
799
+ // capture an intermediate runtime pose, and a post-stop Ctrl+Z would then
800
+ // restore that runtime pose into the restored editor scene. Marking dirty /
801
+ // scheduling autosave would similarly persist runtime state over the on-disk
802
+ // `.vscn`. Skip all three; the property mutation itself (already applied by
803
+ // the caller to the live game-scene descriptor/Object3D) still takes effect
804
+ // for the running game — it's just never recorded in editor history or saved.
805
+ if (this._playState !== 'stopped') return;
806
+ // During an interactive gesture (a NumberInput scrub or color-picker drag),
807
+ // beginInteractiveEdit() already captured ONE pre-gesture snapshot. Skip the
808
+ // per-event snapshot so the whole gesture collapses to a single undo step,
809
+ // but still mark dirty + schedule autosave so the live value persists.
810
+ if (!this._interactiveActive) {
811
+ const doc = serializeScene(this._scene, this._sceneMeta);
812
+ this._undoStack.push(JSON.stringify(doc));
813
+ if (this._undoStack.length > MAX_UNDO) this._undoStack.shift();
814
+ this._redoStack.length = 0;
815
+ // T3.2 slice 2: tag this op on the editor-global undo timeline too (only
816
+ // when a snapshot was actually pushed — the interactive-gesture branch
817
+ // above collapses a whole drag into the ONE snapshot already notified by
818
+ // beginInteractiveEdit's own _pushUndo call).
819
+ notifyUndoableOp(this);
820
+ }
821
+ this._isDirty = true;
822
+ this._docEpoch++;
823
+ this._navMeshBaked = false;
824
+ this._scheduleAutoSave();
825
+ }
826
+
827
+ /**
828
+ * Begin a coalesced interactive edit (continuous input gesture). Pushes exactly
829
+ * ONE undo snapshot for the whole gesture; intermediate mutations during the
830
+ * gesture do not push more. Idempotent — safe to call on every pointermove /
831
+ * onChange. Pair with endInteractiveEdit(). Mirrors the viewport gizmo's live
832
+ * drag (pushUndoSnapshot at start, no-undo live updates, commit at end).
833
+ */
834
+ beginInteractiveEdit(): void {
835
+ if (this._interactiveActive) return;
836
+ this._pushUndo();
837
+ this._interactiveActive = true;
838
+ }
839
+
840
+ /** End a coalesced interactive edit gesture. */
841
+ endInteractiveEdit(): void {
842
+ this._interactiveActive = false;
843
+ }
844
+
845
+ private _scheduleAutoSave(): void {
846
+ if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer);
847
+ this._autoSaveTimer = setTimeout(() => this._autoSave(), 300);
848
+ }
849
+
850
+ /** Returns true if too many sync cycles happened recently (circuit breaker). */
851
+ private _isSyncLooping(): boolean {
852
+ const now = Date.now();
853
+ this._syncCycleTimestamps.push(now);
854
+ // Keep only timestamps from the last 5 seconds
855
+ while (this._syncCycleTimestamps.length > 0 && this._syncCycleTimestamps[0]! < now - 5000) {
856
+ this._syncCycleTimestamps.shift();
857
+ }
858
+ if (this._syncCycleTimestamps.length > 20) {
859
+ console.warn(
860
+ '[EditorStore] Sync loop detected — too many save/update cycles. Pausing sync for 5s.',
861
+ );
862
+ this._ignoreExternalUntil = now + 5000;
863
+ this._suppressAutoSaveUntil = now + 5000;
864
+ this._syncCycleTimestamps.length = 0;
865
+ return true;
866
+ }
867
+ return false;
868
+ }
869
+
870
+ /**
871
+ * Whether and where an edit persists is decided by the ACTIVE authoring
872
+ * adapter's `persistence` provider — one place, not a store-wide flag per
873
+ * session kind (design `docs/PERSISTENCE-PROVIDER-DESIGN.md` §2). Absent
874
+ * provider, or an adapter that declares `capabilities.persist === false`
875
+ * (e.g. world2d, embed-only), means "nothing to autosave" here.
876
+ */
877
+ private _activePersistence(): PersistenceProvider | null {
878
+ const adapter = getActiveAuthoring(this);
879
+ if (!adapter.persistence || !adapter.capabilities.persist) return null;
880
+ return adapter.persistence;
881
+ }
882
+
883
+ private _autoSave(): void {
884
+ this._autoSaveTimer = null;
885
+ // Play-mode guard (T1.6, belt-and-braces): `_scene` is the live game scene
886
+ // while playing/paused (see enterPlayScene). `_pushUndo`/`commitLiveTransform`
887
+ // already skip scheduling autosave during play, and play-mode.ts installs an
888
+ // ephemeral persistence provider (isDirty() always false) via
889
+ // `setActiveAuthoring` for the duration — so this check is now a backstop
890
+ // against a timer that was already in flight the instant play started, not
891
+ // the structural decision (design §2/§7 slice 1).
892
+ if (this._playState !== 'stopped') return;
893
+ const provider = this._activePersistence();
894
+ if (!provider) return;
895
+ if (!this._scene) return;
896
+ // Suppressed (sync cooldown / circuit breaker): RESCHEDULE, don't drop —
897
+ // a dropped autosave silently strands dirty changes off-disk. The retry
898
+ // hits the `_suppressAutoSaveUntil` check first, so it does not feed the
899
+ // `_isSyncLooping` counter while waiting out the window.
900
+ if (Date.now() < this._suppressAutoSaveUntil) {
901
+ this._scheduleAutoSave();
902
+ return;
903
+ }
904
+ if (this._isSyncLooping()) {
905
+ this._scheduleAutoSave();
906
+ return;
907
+ }
908
+ // Delegate the actual write to the provider (first-party's `save()` calls
909
+ // back into `saveNow()` below — the store's existing write path/epoch
910
+ // discipline; an ingest/other provider writes its own destination). Failure
911
+ // handling for the first-party path already lives inside `saveNow()`; this
912
+ // catch only guards a provider whose `save()` itself throws.
913
+ void provider.save().catch((err) => {
914
+ this._lastSaveFailed = true;
915
+ console.warn('[EditorStore] Auto-save threw — changes are NOT saved to disk.', err);
916
+ this._notify();
917
+ });
918
+ }
919
+
920
+ /**
921
+ * Guard manual save (Ctrl/Cmd+S — `saveNow()`, which unconditionally writes
922
+ * `this.getDocument()` to `this._savePath`) against play mode AND any other
923
+ * authoring session (ingest, world2d, UI-edit, …).
924
+ *
925
+ * `_autoSave()` routes through the active adapter's persistence provider (see
926
+ * `_activePersistence()`) so it never reaches this method's write path for a
927
+ * non-first-party session. `saveNow()` itself is still the FIRST-PARTY write
928
+ * (the first-party provider's `save()` calls back into it) — bypassing the
929
+ * debounce entirely — so it needs its own guard: `hasAuthoringOverride()` is
930
+ * true whenever a session installed a DIFFERENT adapter via
931
+ * `setActiveAuthoring` (ingest/world2d/UI-edit/play), meaning `this._scene`
932
+ * and `this.getDocument()` no longer describe the first-party `.vscn` (or, for
933
+ * play, must route through the ephemeral provider instead — see design
934
+ * `docs/PERSISTENCE-PROVIDER-DESIGN.md` §2). During ingest specifically, an
935
+ * ingested foreign object carries only `userData.entityId`, never the
936
+ * `userData['entity']` descriptor `serializeScene` requires, so an unguarded
937
+ * write here would silently overwrite the previously-open first-party scene
938
+ * with an empty entity list (probe: F1b in the round-1 review).
939
+ */
940
+ private _blockedForSave(): boolean {
941
+ if (this._playState !== 'stopped') {
942
+ console.warn('EditorStore: "save" is disabled during play mode — stop the game first.');
943
+ return true;
944
+ }
945
+ // A1: the edit-mode composite is NOT a foreign override — its focused
946
+ // child IS the store's own first-party `.vscn` document, writing through
947
+ // this very path. Blocking it would silently strand the focused world's
948
+ // edits off-disk (isDirty stuck true forever). Only a TRUE foreign
949
+ // override (ingest/world2d/UI-edit/play) blocks here.
950
+ if (hasAuthoringOverride() && !activeOverrideIsEditMode()) {
951
+ console.warn(
952
+ 'EditorStore: "save" is disabled while another authoring adapter is active — exit it first.',
953
+ );
954
+ return true;
955
+ }
956
+ return false;
957
+ }
958
+
959
+ /**
960
+ * Force an immediate save (e.g. Ctrl/Cmd+S), bypassing the debounce. Resolves
961
+ * to whether the write succeeded and surfaces failures via saveState.
962
+ */
963
+ async saveNow(): Promise<boolean> {
964
+ if (this._blockedForSave()) return false;
965
+ if (this._autoSaveTimer) {
966
+ clearTimeout(this._autoSaveTimer);
967
+ this._autoSaveTimer = null;
968
+ }
969
+ if (!this._savePath || !this._scene) return false;
970
+ const content = JSON.stringify(this.getDocument(), null, 2);
971
+ const epoch = this._docEpoch;
972
+ this._ignoreExternalUntil = Date.now() + SYNC_COOLDOWN_MS;
973
+ this._lastSavedContent = content;
974
+ try {
975
+ const ok = await saveFile(this._savePath, content);
976
+ // Same epoch discipline as `_autoSave`: don't mark a newer, unpersisted
977
+ // mutation clean because an older save resolved.
978
+ if (ok && this._docEpoch === epoch) this._isDirty = false;
979
+ else if (!ok) this._isDirty = true;
980
+ this._lastSaveFailed = !ok;
981
+ this._notify();
982
+ if (ok) {
983
+ this._captureThumbnail();
984
+ this._saveEditorStateIfDue();
985
+ } else {
986
+ console.warn('[EditorStore] Save failed — changes are NOT saved to disk.');
987
+ }
988
+ return ok;
989
+ } catch (err) {
990
+ this._lastSaveFailed = true;
991
+ this._notify();
992
+ console.warn('[EditorStore] Save threw — changes are NOT saved to disk.', err);
993
+ return false;
994
+ }
995
+ }
996
+
997
+ private static readonly THUMBNAIL_INTERVAL_MS = 60_000;
998
+ private static readonly THUMBNAIL_SIZE = 256;
999
+
1000
+ /**
1001
+ * Render the bound camera's current view to a PNG data URL at `size`x`size`
1002
+ * (game content only — editor-layer grid/helpers/gizmos excluded, same as
1003
+ * the thumbnail path). Shared by the throttled autosave thumbnail
1004
+ * (`_captureThumbnail`) and the on-demand `captureViewportImage` (backs the
1005
+ * `capture-viewport` relay command / `editor.viewport.screenshot.capture`'s
1006
+ * fresh-capture mode) — same rendering technique (offscreen render target +
1007
+ * `readRenderTargetPixels`), so it needs no `preserveDrawingBuffer` on the
1008
+ * live canvas's WebGL context. Returns null if no renderer/scene/camera is
1009
+ * bound yet.
1010
+ */
1011
+ private _renderViewportImage(size: number): string | null {
1012
+ if (!this._renderer || !this._scene || !this._camera) return null;
1013
+
1014
+ const renderSize = size * 2; // render at 2x then downscale for crisp result
1015
+ const renderer = this._renderer;
1016
+ const renderTarget = new THREE.WebGLRenderTarget(renderSize, renderSize);
1017
+ const prevTarget = renderer.getRenderTarget();
1018
+ const prevPixelRatio = renderer.getPixelRatio();
1019
+
1020
+ // Render without editor-layer objects (grid, helpers, gizmos)
1021
+ const camera = this._camera as THREE.PerspectiveCamera;
1022
+ const prevLayers = camera.layers.mask;
1023
+ camera.layers.disableAll();
1024
+ camera.layers.enable(0); // game content only
1025
+
1026
+ renderer.setPixelRatio(1);
1027
+ renderer.setRenderTarget(renderTarget);
1028
+ renderer.render(this._scene, camera);
1029
+ renderer.setRenderTarget(prevTarget);
1030
+ renderer.setPixelRatio(prevPixelRatio);
1031
+
1032
+ camera.layers.mask = prevLayers;
1033
+
1034
+ // Read pixels from the render target
1035
+ const pixels = new Uint8Array(renderSize * renderSize * 4);
1036
+ renderer.readRenderTargetPixels(renderTarget, 0, 0, renderSize, renderSize, pixels);
1037
+ renderTarget.dispose();
1038
+
1039
+ // Flip vertically (WebGL reads bottom-up) and write to a full-size canvas
1040
+ const fullCanvas = document.createElement('canvas');
1041
+ fullCanvas.width = renderSize;
1042
+ fullCanvas.height = renderSize;
1043
+ const fullCtx = fullCanvas.getContext('2d')!;
1044
+ const imageData = fullCtx.createImageData(renderSize, renderSize);
1045
+ for (let y = 0; y < renderSize; y++) {
1046
+ const srcRow = (renderSize - 1 - y) * renderSize * 4;
1047
+ const dstRow = y * renderSize * 4;
1048
+ imageData.data.set(pixels.subarray(srcRow, srcRow + renderSize * 4), dstRow);
1049
+ }
1050
+ fullCtx.putImageData(imageData, 0, 0);
1051
+
1052
+ // Downscale to final thumbnail size
1053
+ const canvas = document.createElement('canvas');
1054
+ canvas.width = size;
1055
+ canvas.height = size;
1056
+ const ctx2d = canvas.getContext('2d')!;
1057
+ ctx2d.drawImage(fullCanvas, 0, 0, size, size);
1058
+
1059
+ return canvas.toDataURL('image/png');
1060
+ }
1061
+
1062
+ private _captureThumbnail(): void {
1063
+ const now = Date.now();
1064
+ if (now - this._lastThumbnailTime < EditorStore.THUMBNAIL_INTERVAL_MS) return;
1065
+ this._lastThumbnailTime = now;
1066
+ const dataUrl = this._renderViewportImage(EditorStore.THUMBNAIL_SIZE);
1067
+ if (dataUrl) saveThumbnail(dataUrl);
1068
+ }
1069
+
1070
+ /**
1071
+ * Fresh, on-demand viewport capture (unthrottled, unlike the periodic
1072
+ * autosave thumbnail above) — backs the `capture-viewport` relay command.
1073
+ * Returns null when no renderer/scene/camera is bound yet (e.g. before
1074
+ * `ViewportPanel` has mounted).
1075
+ */
1076
+ captureViewportImage(size = EditorStore.THUMBNAIL_SIZE): string | null {
1077
+ return this._renderViewportImage(size);
1078
+ }
1079
+
1080
+ // --- Editor state persistence ---
1081
+
1082
+ private static readonly EDITOR_STATE_INTERVAL_MS = 60_000;
1083
+
1084
+ private _saveEditorStateIfDue(): void {
1085
+ const now = Date.now();
1086
+ if (now - this._lastEditorStateSaveTime < EditorStore.EDITOR_STATE_INTERVAL_MS) return;
1087
+ this._lastEditorStateSaveTime = now;
1088
+
1089
+ const state: Record<string, unknown> = {
1090
+ transformMode: this._transformMode,
1091
+ transformSpace: this._transformSpace,
1092
+ snapEnabled: this._snapEnabled,
1093
+ snapValues: this._snapValues,
1094
+ pivotMode: this._pivotMode,
1095
+ showGrid: this._showGrid,
1096
+ showHelpers: this._showHelpers,
1097
+ helperVisibility: { ...this._helperVisibility },
1098
+ showStats: this._showStats,
1099
+ shadingMode: this._shadingMode,
1100
+ };
1101
+
1102
+ if (this._savePath) {
1103
+ state['lastScene'] = this._savePath;
1104
+ }
1105
+
1106
+ if (this._camera) {
1107
+ const cam = this._camera as THREE.PerspectiveCamera;
1108
+ state['camera'] = {
1109
+ position: cam.position.toArray(),
1110
+ target: this._orbitTarget ? this._orbitTarget.toArray() : [0, 0, 0],
1111
+ };
1112
+ }
1113
+
1114
+ saveEditorState(state);
1115
+ }
1116
+
1117
+ /** Restore editor state from server. Call after bindScene + loadInitialScene. */
1118
+ async restoreEditorState(): Promise<void> {
1119
+ const state = await loadEditorState();
1120
+ if (!state || Object.keys(state).length === 0) return;
1121
+
1122
+ if (typeof state['transformMode'] === 'string') {
1123
+ this._transformMode = state['transformMode'] as TransformMode;
1124
+ }
1125
+ if (typeof state['transformSpace'] === 'string') {
1126
+ this._transformSpace = state['transformSpace'] as TransformSpace;
1127
+ }
1128
+ if (typeof state['snapEnabled'] === 'boolean') {
1129
+ this._snapEnabled = state['snapEnabled'];
1130
+ }
1131
+ if (state['snapValues'] && typeof state['snapValues'] === 'object') {
1132
+ Object.assign(this._snapValues, state['snapValues']);
1133
+ }
1134
+ if (typeof state['pivotMode'] === 'string') {
1135
+ this._pivotMode = state['pivotMode'] as PivotMode;
1136
+ }
1137
+ if (typeof state['showGrid'] === 'boolean') {
1138
+ this._showGrid = state['showGrid'];
1139
+ }
1140
+ if (typeof state['showHelpers'] === 'boolean') {
1141
+ this._showHelpers = state['showHelpers'];
1142
+ }
1143
+ if (state['helperVisibility'] && typeof state['helperVisibility'] === 'object') {
1144
+ Object.assign(this._helperVisibility, state['helperVisibility']);
1145
+ }
1146
+ if (typeof state['showStats'] === 'boolean') {
1147
+ this._showStats = state['showStats'];
1148
+ }
1149
+ if (typeof state['shadingMode'] === 'string') {
1150
+ this._shadingMode = state['shadingMode'] as ShadingMode;
1151
+ }
1152
+
1153
+ // Restore camera
1154
+ if (state['camera'] && typeof state['camera'] === 'object') {
1155
+ const cam = state['camera'] as { position?: number[]; target?: number[] };
1156
+ if (this._camera && cam.position && cam.position.length === 3) {
1157
+ (this._camera as THREE.PerspectiveCamera).position.set(
1158
+ cam.position[0]!,
1159
+ cam.position[1]!,
1160
+ cam.position[2]!,
1161
+ );
1162
+ }
1163
+ if (this._orbitTarget && cam.target && cam.target.length === 3) {
1164
+ this._orbitTarget.set(cam.target[0]!, cam.target[1]!, cam.target[2]!);
1165
+ }
1166
+ }
1167
+
1168
+ this._notify();
1169
+ }
1170
+
1171
+ // --- Mutations ---
1172
+
1173
+ updateEntity(id: string, partial: EntityUpdate): void {
1174
+ const obj = this._objectMap.get(id);
1175
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1176
+ if (!entity || !obj) return;
1177
+ this._pushUndo();
1178
+ Object.assign(entity, partial);
1179
+ this._rebuildEntity(id);
1180
+ this._notify();
1181
+ }
1182
+
1183
+ updateTransform(
1184
+ id: string,
1185
+ pos: [number, number, number],
1186
+ rot: [number, number, number, number],
1187
+ scl: [number, number, number],
1188
+ ): void {
1189
+ const obj = this._objectMap.get(id);
1190
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1191
+ if (!entity || !obj) return;
1192
+ this._pushUndo();
1193
+ entity.transform = { position: pos, rotation: rot, scale: scl };
1194
+ obj.position.set(pos[0], pos[1], pos[2]);
1195
+ obj.quaternion.set(rot[0], rot[1], rot[2], rot[3]);
1196
+ obj.scale.set(scl[0], scl[1], scl[2]);
1197
+ this._notify();
1198
+ }
1199
+
1200
+ /** Update transform without pushing undo — used for live gizmo drag preview. */
1201
+ updateTransformLive(
1202
+ id: string,
1203
+ pos: [number, number, number],
1204
+ rot: [number, number, number, number],
1205
+ scl: [number, number, number],
1206
+ ): void {
1207
+ const obj = this._objectMap.get(id);
1208
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1209
+ if (!entity || !obj) return;
1210
+ // The gizmo has already moved the Object3D — just sync the descriptor
1211
+ entity.transform = { position: pos, rotation: rot, scale: scl };
1212
+ // During play, also sync to ECS so the game loop doesn't overwrite
1213
+ if (this._ecsSyncTransform) this._ecsSyncTransform(obj);
1214
+ this._notify();
1215
+ }
1216
+
1217
+ /** Rebuild an entity's Three.js preview (for handle drags where the descriptor is already mutated). */
1218
+ rebuildEntityLive(id: string): void {
1219
+ this._rebuildEntity(id);
1220
+ this._notify();
1221
+ }
1222
+
1223
+ /**
1224
+ * Commit a completed live drag (gizmo or handle) (ED2).
1225
+ *
1226
+ * During a drag only `updateTransformLive`/`rebuildEntityLive` run, which
1227
+ * mutate the descriptor but never mark dirty or schedule a save. The undo
1228
+ * snapshot was already pushed at drag start, so we must NOT push another
1229
+ * here — but we must persist the FINAL transform: mark dirty and (re)schedule
1230
+ * the autosave so the last position is written, even if a mid-drag autosave
1231
+ * already fired and cleared the dirty flag.
1232
+ *
1233
+ * Play-mode guard (T1.6): while playing/paused, the drag only ever moved the
1234
+ * live GAME scene's Object3D (via `_ecsSyncTransform`) — there is nothing of
1235
+ * the editor's own document to persist, so skip marking dirty / scheduling
1236
+ * autosave entirely. Still notify so any subscribed UI refreshes.
1237
+ */
1238
+ commitLiveTransform(): void {
1239
+ if (this._playState === 'stopped') {
1240
+ this._isDirty = true;
1241
+ this._docEpoch++;
1242
+ this._navMeshBaked = false;
1243
+ this._scheduleAutoSave();
1244
+ }
1245
+ this._notify();
1246
+ }
1247
+
1248
+ addEntity(parentId: string | null, entity: SceneEntity, index?: number): void {
1249
+ if (this._blockedDuringPlay('add entity')) return;
1250
+ if (!this._scene) return;
1251
+ if (!entity.id) entity.id = crypto.randomUUID();
1252
+ assignMissingIds(entity.children ?? []);
1253
+ this._pushUndo();
1254
+
1255
+ const obj = createEntityObject(entity);
1256
+ obj.visible = entity.visible !== false;
1257
+
1258
+ const parent = parentId ? this._objectMap.get(parentId) : null;
1259
+ const target = parent ?? this._scene;
1260
+
1261
+ if (index !== undefined) {
1262
+ // Insert at specific index among entity children
1263
+ const entityChildren = target.children.filter(
1264
+ (c) => getUserData(c, 'entityId') !== undefined && !getUserData(c, 'editorHelper'),
1265
+ );
1266
+ const clampedIndex = Math.min(index, entityChildren.length);
1267
+ const insertBefore = entityChildren[clampedIndex];
1268
+ const threeIndex = insertBefore
1269
+ ? target.children.indexOf(insertBefore)
1270
+ : target.children.length;
1271
+ target.children.splice(threeIndex, 0, obj);
1272
+ obj.parent = target;
1273
+ } else {
1274
+ target.add(obj);
1275
+ }
1276
+ this._objectMap.set(entity.id, obj);
1277
+ registerParticles(obj, this._batchedRenderer);
1278
+
1279
+ // If entity has children, add them recursively
1280
+ if (entity.children) {
1281
+ this._addChildEntities(entity.children, obj);
1282
+ }
1283
+
1284
+ this._selectedEntityIds.clear();
1285
+ this._selectedEntityIds.add(entity.id);
1286
+ this._notify();
1287
+ }
1288
+
1289
+ /** Recursively create child entity Object3Ds and add to parent. */
1290
+ private _addChildEntities(children: SceneEntity[], parent: THREE.Object3D): void {
1291
+ for (const child of children) {
1292
+ if (!child.id) child.id = crypto.randomUUID();
1293
+ const childObj = createEntityObject(child);
1294
+ childObj.visible = child.visible !== false;
1295
+ parent.add(childObj);
1296
+ this._objectMap.set(child.id, childObj);
1297
+ registerParticles(childObj, this._batchedRenderer);
1298
+ if (child.children) {
1299
+ this._addChildEntities(child.children, childObj);
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ removeEntity(id: string): void {
1305
+ if (this._blockedDuringPlay('delete entity')) return;
1306
+ const obj = this._objectMap.get(id);
1307
+ if (!obj) return;
1308
+ this._pushUndo();
1309
+ this._removeObjectFromScene(obj);
1310
+ this._selectedEntityIds.delete(id);
1311
+ this._notify();
1312
+ }
1313
+
1314
+ /** Remove an Object3D and all its entity children from the scene + objectMap. */
1315
+ private _removeObjectFromScene(obj: THREE.Object3D): void {
1316
+ // Recursively remove entity children from objectMap
1317
+ for (const child of [...obj.children]) {
1318
+ if (getUserData(child, 'entityId') !== undefined && !getUserData(child, 'editorHelper')) {
1319
+ this._removeObjectFromScene(child);
1320
+ }
1321
+ }
1322
+ unregisterParticles(obj, this._batchedRenderer);
1323
+ const eid = getUserData(obj, 'entityId') as string | undefined;
1324
+ if (eid) this._objectMap.delete(eid);
1325
+ obj.removeFromParent();
1326
+ }
1327
+
1328
+ duplicateEntity(id: string): void {
1329
+ if (this._blockedDuringPlay('duplicate entity')) return;
1330
+ if (!this._scene) return;
1331
+ const obj = this._objectMap.get(id);
1332
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1333
+ if (!entity || !obj) return;
1334
+
1335
+ // Deep clone the entity descriptor with full subtree
1336
+ const cloneEntity = this._serializeSubtree(obj);
1337
+ const reassign = (e: SceneEntity) => {
1338
+ e.id = crypto.randomUUID();
1339
+ if (e.children) e.children.forEach(reassign);
1340
+ };
1341
+ reassign(cloneEntity);
1342
+ cloneEntity.name = `${entity.name} (copy)`;
1343
+
1344
+ this._pushUndo();
1345
+
1346
+ // Create object and insert after original
1347
+ const cloneObj = createEntityObject(cloneEntity);
1348
+ cloneObj.visible = cloneEntity.visible !== false;
1349
+ const parent = obj.parent ?? this._scene;
1350
+ const index = parent.children.indexOf(obj);
1351
+ if (index >= 0) {
1352
+ parent.children.splice(index + 1, 0, cloneObj);
1353
+ cloneObj.parent = parent;
1354
+ } else {
1355
+ parent.add(cloneObj);
1356
+ }
1357
+ this._objectMap.set(cloneEntity.id!, cloneObj);
1358
+ registerParticles(cloneObj, this._batchedRenderer);
1359
+
1360
+ // Recursively add child entities
1361
+ if (cloneEntity.children) {
1362
+ this._addChildEntities(cloneEntity.children, cloneObj);
1363
+ }
1364
+
1365
+ this._selectedEntityIds.clear();
1366
+ this._selectedEntityIds.add(cloneEntity.id!);
1367
+ this._notify();
1368
+ }
1369
+
1370
+ /** Serialize an entity + its scene-graph children into a nested SceneEntity. */
1371
+ private _serializeSubtree(obj: THREE.Object3D): SceneEntity {
1372
+ const stored = getUserData(obj, 'entity') as SceneEntity;
1373
+ const entity = deepClone(stored);
1374
+ const children: SceneEntity[] = [];
1375
+ for (const child of obj.children) {
1376
+ if (getUserData(child, 'entityId') !== undefined && !getUserData(child, 'editorHelper')) {
1377
+ children.push(this._serializeSubtree(child));
1378
+ }
1379
+ }
1380
+ if (children.length > 0) {
1381
+ entity.children = children;
1382
+ } else {
1383
+ delete entity.children;
1384
+ }
1385
+ return entity;
1386
+ }
1387
+
1388
+ // --- Reparent / reorder (react-arborist onMove) ---
1389
+ moveEntity(dragIds: string[], parentId: string | null, index: number): void {
1390
+ if (this._blockedDuringPlay('move entity')) return;
1391
+ if (!this._scene) return;
1392
+
1393
+ // Validate before pushing undo — a rejected move must not leave a
1394
+ // no-op undo entry on the stack (T1.12c: complete or roll back, no
1395
+ // undo-then-bail).
1396
+ const targetParent = parentId ? this._objectMap.get(parentId) : this._scene;
1397
+ if (!targetParent) return;
1398
+
1399
+ this._pushUndo();
1400
+
1401
+ // Collect objects and remove from old locations
1402
+ const objects: THREE.Object3D[] = [];
1403
+ for (const id of dragIds) {
1404
+ const obj = this._objectMap.get(id);
1405
+ if (!obj) continue;
1406
+ obj.removeFromParent();
1407
+ objects.push(obj);
1408
+ }
1409
+
1410
+ // Find the insertion point among entity children of the target
1411
+ const entityChildren = targetParent.children.filter(
1412
+ (c) => getUserData(c, 'entityId') !== undefined && !getUserData(c, 'editorHelper'),
1413
+ );
1414
+ const clampedIndex = Math.min(index, entityChildren.length);
1415
+ const insertBefore = entityChildren[clampedIndex];
1416
+ const threeIndex = insertBefore
1417
+ ? targetParent.children.indexOf(insertBefore)
1418
+ : targetParent.children.length;
1419
+
1420
+ // Insert at the computed position
1421
+ for (let i = 0; i < objects.length; i++) {
1422
+ const obj = objects[i]!;
1423
+ targetParent.children.splice(threeIndex + i, 0, obj);
1424
+ obj.parent = targetParent;
1425
+ }
1426
+ this._notify();
1427
+ }
1428
+
1429
+ // --- Rename ---
1430
+ renameEntity(id: string, name: string): void {
1431
+ const obj = this._objectMap.get(id);
1432
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1433
+ if (!entity || !obj) return;
1434
+ this._pushUndo();
1435
+ entity.name = name;
1436
+ obj.name = name;
1437
+ this._notify();
1438
+ }
1439
+
1440
+ // --- Visibility ---
1441
+ toggleVisibility(id: string): void {
1442
+ const obj = this._objectMap.get(id);
1443
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1444
+ if (!entity || !obj) return;
1445
+ this._pushUndo();
1446
+ if (entity.visible === false) {
1447
+ delete entity.visible;
1448
+ obj.visible = true;
1449
+ } else {
1450
+ entity.visible = false;
1451
+ obj.visible = false;
1452
+ }
1453
+ this._notify();
1454
+ }
1455
+
1456
+ // --- Locking (viewport pickability) ---
1457
+ toggleLocked(id: string): void {
1458
+ const obj = this._objectMap.get(id);
1459
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1460
+ if (!entity) return;
1461
+ this._pushUndo();
1462
+ if (entity.locked) {
1463
+ delete entity.locked;
1464
+ } else {
1465
+ entity.locked = true;
1466
+ }
1467
+ this._notify();
1468
+ }
1469
+
1470
+ /** Check if an entity or any ancestor is locked. */
1471
+ isEntityLocked(id: string): boolean {
1472
+ const obj = this._objectMap.get(id);
1473
+ if (!obj) return false;
1474
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1475
+ if (!entity) return false;
1476
+ if (entity.locked) return true;
1477
+
1478
+ // Walk up the Three.js parent chain
1479
+ let parent = obj.parent;
1480
+ while (parent) {
1481
+ const parentEntity = getUserData(parent, 'entity') as SceneEntity | undefined;
1482
+ if (parentEntity?.locked) return true;
1483
+ parent = parent.parent;
1484
+ }
1485
+ return false;
1486
+ }
1487
+
1488
+ // --- Bulk delete ---
1489
+ deleteEntities(ids: string[]): void {
1490
+ if (this._blockedDuringPlay('delete entities')) return;
1491
+ this._pushUndo();
1492
+ for (const id of ids) {
1493
+ const obj = this._objectMap.get(id);
1494
+ if (!obj) continue;
1495
+ this._removeObjectFromScene(obj);
1496
+ this._selectedEntityIds.delete(id);
1497
+ }
1498
+ this._notify();
1499
+ }
1500
+
1501
+ // --- Transform mode ---
1502
+ setTransformMode(mode: TransformMode): void {
1503
+ this._transformMode = mode;
1504
+ this._notify();
1505
+ }
1506
+
1507
+ setTransformSpace(space: TransformSpace): void {
1508
+ this._transformSpace = space;
1509
+ this._notify();
1510
+ }
1511
+
1512
+ toggleSnap(): void {
1513
+ this._snapEnabled = !this._snapEnabled;
1514
+ this._notify();
1515
+ }
1516
+
1517
+ setSnapValues(values: Partial<{ translate: number; rotate: number; scale: number }>): void {
1518
+ this._snapValues = { ...this._snapValues, ...values };
1519
+ this._notify();
1520
+ }
1521
+
1522
+ toggleSnapToSurface(): void {
1523
+ this._snapToSurface = !this._snapToSurface;
1524
+ this._notify();
1525
+ }
1526
+
1527
+ setPivotMode(mode: PivotMode): void {
1528
+ this._pivotMode = mode;
1529
+ this._notify();
1530
+ }
1531
+
1532
+ updatePivot(id: string, pivot: [number, number, number]): void {
1533
+ const obj = this._objectMap.get(id);
1534
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1535
+ if (!entity) return;
1536
+ this._pushUndo();
1537
+ const isZero = pivot[0] === 0 && pivot[1] === 0 && pivot[2] === 0;
1538
+ if (isZero) {
1539
+ delete entity.pivot;
1540
+ } else {
1541
+ entity.pivot = pivot;
1542
+ }
1543
+ this._rebuildEntity(id);
1544
+ this._notify();
1545
+ }
1546
+
1547
+ toggleGrid(): void {
1548
+ this._showGrid = !this._showGrid;
1549
+ this._notify();
1550
+ }
1551
+
1552
+ toggleHelpers(): void {
1553
+ const anyOn = Object.values(this._helperVisibility).some((v) => v);
1554
+ const newVal = !anyOn;
1555
+ this._showHelpers = newVal;
1556
+ for (const key of Object.keys(this._helperVisibility) as (keyof HelperVisibility)[]) {
1557
+ this._helperVisibility[key] = newVal;
1558
+ }
1559
+ this._notify();
1560
+ }
1561
+
1562
+ toggleHelperType(type: keyof HelperVisibility): void {
1563
+ this._helperVisibility[type] = !this._helperVisibility[type];
1564
+ this._showHelpers = Object.values(this._helperVisibility).some((v) => v);
1565
+ this._notify();
1566
+ }
1567
+
1568
+ toggleStats(): void {
1569
+ this._showStats = !this._showStats;
1570
+ this._notify();
1571
+ }
1572
+
1573
+ setShadingMode(mode: ShadingMode): void {
1574
+ this._shadingMode = mode;
1575
+ // Re-shade live meshes immediately (ED1) — don't wait for a full rebuild.
1576
+ if (this._scene) applyShadingOverride(this._scene, mode);
1577
+ this._notify();
1578
+ }
1579
+
1580
+ // --- Clipboard ---
1581
+ copyEntities(): void {
1582
+ // Blocked during play for a subtler reason than its siblings: copying a
1583
+ // live, runtime-mutated descriptor into the clipboard lets a post-stop
1584
+ // paste insert that runtime snapshot into the real document (the Ctrl+C
1585
+ // variant of the cut-during-play hazard).
1586
+ if (this._blockedDuringPlay('copy entities')) return;
1587
+ this._clipboard = this.selectedEntities.map((e) => deepClone(e));
1588
+ }
1589
+
1590
+ cutEntities(): void {
1591
+ if (this._blockedDuringPlay('cut entities')) return;
1592
+ this.copyEntities();
1593
+ const ids = [...this._selectedEntityIds];
1594
+ this._pushUndo();
1595
+ for (const id of ids) {
1596
+ const obj = this._objectMap.get(id);
1597
+ if (!obj) continue;
1598
+ this._removeObjectFromScene(obj);
1599
+ }
1600
+ this._selectedEntityIds.clear();
1601
+ this._notify();
1602
+ }
1603
+
1604
+ pasteEntities(): void {
1605
+ if (this._blockedDuringPlay('paste')) return;
1606
+ if (this._clipboard.length === 0 || !this._scene) return;
1607
+ this._pushUndo();
1608
+ const newIds: string[] = [];
1609
+ for (const template of this._clipboard) {
1610
+ const clone = deepClone(template);
1611
+ const reassign = (e: SceneEntity) => {
1612
+ e.id = crypto.randomUUID();
1613
+ if (e.children) e.children.forEach(reassign);
1614
+ };
1615
+ reassign(clone);
1616
+ clone.name = `${clone.name} (copy)`;
1617
+
1618
+ const obj = createEntityObject(clone);
1619
+ obj.visible = clone.visible !== false;
1620
+ this._scene.add(obj);
1621
+ this._objectMap.set(clone.id!, obj);
1622
+ registerParticles(obj, this._batchedRenderer);
1623
+ if (clone.children) {
1624
+ this._addChildEntities(clone.children, obj);
1625
+ }
1626
+ newIds.push(clone.id!);
1627
+ }
1628
+ this._selectedEntityIds = new Set(newIds);
1629
+ this._notify();
1630
+ }
1631
+
1632
+ // --- Tags ---
1633
+ addTag(id: string, tag: string): void {
1634
+ const entity = this.getEntityById(id);
1635
+ if (!entity) return;
1636
+ const trimmed = tag.trim();
1637
+ if (!trimmed) return;
1638
+ if (entity.tags?.includes(trimmed)) return;
1639
+ this._pushUndo();
1640
+ if (!entity.tags) entity.tags = [];
1641
+ entity.tags.push(trimmed);
1642
+ this._notify();
1643
+ }
1644
+
1645
+ removeTag(id: string, tag: string): void {
1646
+ const entity = this.getEntityById(id);
1647
+ if (!entity?.tags) return;
1648
+ const idx = entity.tags.indexOf(tag);
1649
+ if (idx < 0) return;
1650
+ this._pushUndo();
1651
+ entity.tags.splice(idx, 1);
1652
+ if (entity.tags.length === 0) delete entity.tags;
1653
+ this._notify();
1654
+ }
1655
+
1656
+ /** Collect all unique tags used in the scene. */
1657
+ allTags(): string[] {
1658
+ const tags = new Set<string>();
1659
+ if (this._scene) {
1660
+ walkEntityObjects(this._scene, (_, entity) => {
1661
+ if (entity.tags) for (const t of entity.tags) tags.add(t);
1662
+ });
1663
+ }
1664
+ return [...tags].sort();
1665
+ }
1666
+
1667
+ // --- Batch mutations (multi-entity editing) ---
1668
+ /** Push undo once, then mutate each entity by id and rebuild. */
1669
+ batchMutate(ids: string[], mutator: (entity: SceneEntity) => void): void {
1670
+ this._pushUndo();
1671
+ for (const id of ids) {
1672
+ const obj = this._objectMap.get(id);
1673
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1674
+ if (entity) {
1675
+ mutator(entity);
1676
+ this._rebuildEntity(id);
1677
+ }
1678
+ }
1679
+ this._notify();
1680
+ }
1681
+
1682
+ // --- Environment ---
1683
+ updateEnvironment(partial: Partial<SceneEnvironment>): void {
1684
+ this._pushUndo();
1685
+ this._sceneMeta.environment = { ...this._sceneMeta.environment, ...partial };
1686
+ this._applyEnvironment();
1687
+ this._notify();
1688
+ }
1689
+
1690
+ /** Replace the entire environment object (for removing keys). */
1691
+ setEnvironment(env: SceneEnvironment): void {
1692
+ this._pushUndo();
1693
+ this._sceneMeta.environment = env;
1694
+ this._applyEnvironment();
1695
+ this._notify();
1696
+ }
1697
+
1698
+ // --- External update (live sync from disk) ---
1699
+
1700
+ /**
1701
+ * Apply an external SceneFile update (e.g. from a file watcher) without
1702
+ * pushing undo or triggering auto-save. Diffs against current state and
1703
+ * applies only the changes, preserving selection, undo history, and camera.
1704
+ *
1705
+ * `rawContent`, when provided (the watcher SSE carries the file's exact
1706
+ * bytes), enables own-echo detection: an event whose content is exactly
1707
+ * what we last wrote is our own save reflected back, and is skipped even
1708
+ * if it arrives after `_ignoreExternalUntil` — the document may have moved
1709
+ * on (undo/redo) and applying the echo would clobber the newer state.
1710
+ */
1711
+ applyExternalUpdate(incoming: SceneFile, rawContent?: string): void {
1712
+ if (rawContent !== undefined && rawContent === this._lastSavedContent) return;
1713
+ // Play-mode / ingest guard: `_scene` is the LIVE game/ingested scene while
1714
+ // playing (see enterPlayScene) — diffing and patching it here would rebuild
1715
+ // the RUNNING scene out from under the game/ingested adapter, and the disk
1716
+ // change would be silently lost the moment exitPlayScene restores the real
1717
+ // editor scene (probe: F6 in the round-1 review). Defer: keep only the
1718
+ // latest incoming doc and apply it once play/ingest stops (see setPlayState).
1719
+ if (this._playState !== 'stopped') {
1720
+ this._pendingExternalUpdate = incoming;
1721
+ return;
1722
+ }
1723
+ if (!this._scene) return;
1724
+ if (Date.now() < this._ignoreExternalUntil) return;
1725
+ assignMissingIds(incoming.entities);
1726
+ const current = this.getDocument();
1727
+ const diff = diffScene(current, incoming);
1728
+
1729
+ // No-op polls must not count toward the circuit breaker (ED6) — only a
1730
+ // poll that actually changes something is a real sync cycle.
1731
+ if (diff.ops.length === 0 && !diff.environmentChanged && !diff.metaChanged) return;
1732
+ if (this._isSyncLooping()) return;
1733
+
1734
+ this._suppressAutoSaveUntil = Date.now() + SYNC_COOLDOWN_MS;
1735
+
1736
+ // Process removals first (deepest-first ordering is handled by diffScene)
1737
+ for (const op of diff.ops) {
1738
+ if (op.type !== 'remove') continue;
1739
+ const obj = this._objectMap.get(op.id);
1740
+ if (!obj) continue;
1741
+ this._removeObjectFromScene(obj);
1742
+ this._selectedEntityIds.delete(op.id);
1743
+ }
1744
+
1745
+ // Process additions
1746
+ for (const op of diff.ops) {
1747
+ if (op.type !== 'add') continue;
1748
+ const entity = { ...op.entity };
1749
+ if (!entity.id) entity.id = crypto.randomUUID();
1750
+ const obj = createEntityObject(entity);
1751
+ obj.visible = entity.visible !== false;
1752
+ const parent = op.parentId ? this._objectMap.get(op.parentId) : this._scene;
1753
+ if (!parent) continue;
1754
+ parent.add(obj);
1755
+ this._objectMap.set(entity.id, obj);
1756
+ registerParticles(obj, this._batchedRenderer);
1757
+ }
1758
+
1759
+ // Process updates (property changes)
1760
+ for (const op of diff.ops) {
1761
+ if (op.type !== 'update') continue;
1762
+ const obj = this._objectMap.get(op.id);
1763
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1764
+ if (!entity || !obj) continue;
1765
+ // Replace properties (preserve id and children). Fields absent from the
1766
+ // incoming entity must be deleted, not left stale (ED3) — Object.assign
1767
+ // alone would keep removed material/physics/etc.
1768
+ const { id: _, children: __, ...fields } = op.entity;
1769
+ for (const key of Object.keys(entity) as (keyof SceneEntity)[]) {
1770
+ if (key === 'id' || key === 'children') continue;
1771
+ if (!(key in fields)) delete entity[key];
1772
+ }
1773
+ Object.assign(entity, fields);
1774
+ // Sync transform to Three.js object
1775
+ if (entity.transform) {
1776
+ const t = entity.transform;
1777
+ if (t.position) obj.position.set(t.position[0], t.position[1], t.position[2]);
1778
+ if (t.rotation)
1779
+ obj.quaternion.set(t.rotation[0], t.rotation[1], t.rotation[2], t.rotation[3]);
1780
+ if (t.scale) obj.scale.set(t.scale[0], t.scale[1], t.scale[2]);
1781
+ }
1782
+ this._rebuildEntity(op.id);
1783
+ }
1784
+
1785
+ // Process moves (reparent/reorder). Incremental splices against a mutating
1786
+ // sibling list can land multi-sibling reorders wrong (ED7). Instead:
1787
+ // 1) reparent every moved object (append for now), then
1788
+ // 2) sort each affected parent's entity children to match the FINAL target
1789
+ // sibling order from the incoming document.
1790
+ const movedParents = new Set<THREE.Object3D>();
1791
+ for (const op of diff.ops) {
1792
+ if (op.type !== 'move') continue;
1793
+ const obj = this._objectMap.get(op.id);
1794
+ if (!obj) continue;
1795
+ obj.removeFromParent();
1796
+ const newParent = op.parentId ? this._objectMap.get(op.parentId) : this._scene;
1797
+ if (!newParent) continue;
1798
+ newParent.add(obj);
1799
+ movedParents.add(newParent);
1800
+ }
1801
+ if (movedParents.size > 0) {
1802
+ // Build target sibling order (parentId | null) → [childId...] from incoming.
1803
+ const targetOrder = new Map<string | null, string[]>();
1804
+ const collectOrder = (list: SceneEntity[], pid: string | null): void => {
1805
+ targetOrder.set(
1806
+ pid,
1807
+ list.map((e) => e.id).filter((id): id is string => Boolean(id)),
1808
+ );
1809
+ for (const e of list) if (e.children) collectOrder(e.children, e.id ?? null);
1810
+ };
1811
+ collectOrder(incoming.entities, null);
1812
+
1813
+ for (const parent of movedParents) {
1814
+ const pid =
1815
+ parent === this._scene
1816
+ ? null
1817
+ : ((getUserData(parent, 'entityId') as string | undefined) ?? null);
1818
+ const order = targetOrder.get(pid);
1819
+ if (!order) continue;
1820
+ const rankOf = (id: string | undefined): number => {
1821
+ const i = id ? order.indexOf(id) : -1;
1822
+ return i < 0 ? Number.MAX_SAFE_INTEGER : i;
1823
+ };
1824
+ // Pull out entity children, sort to target order, re-append.
1825
+ const entityChildren = parent.children.filter(
1826
+ (c) => getUserData(c, 'entityId') !== undefined && !getUserData(c, 'editorHelper'),
1827
+ );
1828
+ for (const c of entityChildren) {
1829
+ parent.children.splice(parent.children.indexOf(c), 1);
1830
+ }
1831
+ entityChildren.sort(
1832
+ (a, b) =>
1833
+ rankOf(getUserData(a, 'entityId') as string | undefined) -
1834
+ rankOf(getUserData(b, 'entityId') as string | undefined),
1835
+ );
1836
+ for (const c of entityChildren) {
1837
+ parent.children.push(c);
1838
+ c.parent = parent;
1839
+ }
1840
+ }
1841
+ }
1842
+
1843
+ // Environment
1844
+ if (diff.environmentChanged) {
1845
+ if (diff.newEnvironment) {
1846
+ this._sceneMeta.environment = diff.newEnvironment;
1847
+ } else {
1848
+ delete this._sceneMeta.environment;
1849
+ }
1850
+ this._applyEnvironment();
1851
+ }
1852
+
1853
+ // Metadata
1854
+ if (diff.metaChanged && diff.newName) {
1855
+ this._sceneMeta.name = diff.newName;
1856
+ }
1857
+
1858
+ this._notify();
1859
+ }
1860
+
1861
+ // --- Undo / Redo ---
1862
+ undo(): boolean {
1863
+ // `false` tells the undo timeline "couldn't act — put the entry back"
1864
+ // (see UndoAdapter in authoring/undo-timeline.ts).
1865
+ if (this._blockedDuringPlay('undo')) return false;
1866
+ if (this._undoStack.length === 0 || !this._scene) return false;
1867
+ const currentDoc = serializeScene(this._scene, this._sceneMeta);
1868
+ this._redoStack.push(JSON.stringify(currentDoc));
1869
+ const doc = JSON.parse(this._undoStack.pop()!) as SceneFile;
1870
+ this._loadFromDoc(doc);
1871
+ // The restored state is a document mutation like any other: mark dirty and
1872
+ // schedule autosave. Without this, an undo after a completed autosave left
1873
+ // the store claiming "saved" while the pre-undo state stayed on disk.
1874
+ this._isDirty = true;
1875
+ this._docEpoch++;
1876
+ this._navMeshBaked = false;
1877
+ this._scheduleAutoSave();
1878
+ // Re-validate selection
1879
+ for (const id of [...this._selectedEntityIds]) {
1880
+ if (!this._objectMap.has(id)) this._selectedEntityIds.delete(id);
1881
+ }
1882
+ this._notify();
1883
+ return true;
1884
+ }
1885
+
1886
+ redo(): boolean {
1887
+ if (this._blockedDuringPlay('redo')) return false;
1888
+ if (this._redoStack.length === 0 || !this._scene) return false;
1889
+ const currentDoc = serializeScene(this._scene, this._sceneMeta);
1890
+ this._undoStack.push(JSON.stringify(currentDoc));
1891
+ const doc = JSON.parse(this._redoStack.pop()!) as SceneFile;
1892
+ this._loadFromDoc(doc);
1893
+ // Same as undo(): the restored state must be persisted.
1894
+ this._isDirty = true;
1895
+ this._docEpoch++;
1896
+ this._navMeshBaked = false;
1897
+ this._scheduleAutoSave();
1898
+ for (const id of [...this._selectedEntityIds]) {
1899
+ if (!this._objectMap.has(id)) this._selectedEntityIds.delete(id);
1900
+ }
1901
+ this._notify();
1902
+ return true;
1903
+ }
1904
+
1905
+ // --- Document load/save ---
1906
+
1907
+ getDocument(): SceneFile {
1908
+ if (!this._scene) {
1909
+ return { version: this._sceneMeta.version, name: this._sceneMeta.name, entities: [] };
1910
+ }
1911
+ const doc = serializeScene(this._scene, this._sceneMeta);
1912
+ return cleanDocument(doc);
1913
+ }
1914
+
1915
+ loadDocument(doc: SceneFile, savePath?: string): void {
1916
+ if (this._blockedDuringPlay('load document')) return;
1917
+ assignMissingIds(doc.entities);
1918
+ this._sceneMeta = { version: doc.version, name: doc.name };
1919
+ if (doc.environment) this._sceneMeta.environment = doc.environment;
1920
+ if (doc.ui) this._sceneMeta.ui = doc.ui; // preserve scene-graph UI (A1/C9)
1921
+ this._documentType = 'scene';
1922
+ this._selectedEntityIds.clear();
1923
+ this._undoStack.length = 0;
1924
+ this._redoStack.length = 0;
1925
+ this._isDirty = false;
1926
+ this._savePath = savePath ?? null;
1927
+
1928
+ if (this._scene) {
1929
+ this._rebuildScene(doc);
1930
+ }
1931
+ this._notify();
1932
+
1933
+ // Capture a thumbnail after assets have had time to load
1934
+ if (savePath) {
1935
+ setTimeout(() => this._captureThumbnail(), 2000);
1936
+ }
1937
+ }
1938
+
1939
+ markClean(): void {
1940
+ this._isDirty = false;
1941
+ this._notify();
1942
+ }
1943
+
1944
+ // --- Prefab operations ---
1945
+
1946
+ extractAsPrefab(id: string): PrefabFile | null {
1947
+ const obj = this._objectMap.get(id);
1948
+ if (!obj) return null;
1949
+ const root = this._serializeSubtree(obj);
1950
+ // Strip instance-specific fields from the prefab root
1951
+ delete root.id;
1952
+ delete root.transform;
1953
+ delete root.prefab;
1954
+ // Strip IDs from children too
1955
+ walkEntities(root.children ?? [], (e) => {
1956
+ delete e.id;
1957
+ });
1958
+ walkEntities([root], (e) => {
1959
+ if (e.children && e.children.length === 0) delete e.children;
1960
+ });
1961
+ return { version: 1, name: root.name, root };
1962
+ }
1963
+
1964
+ convertToPrefabInstance(id: string, prefabPath: string): void {
1965
+ const obj = this._objectMap.get(id);
1966
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1967
+ if (!entity) return;
1968
+ this._pushUndo();
1969
+ const keepName = entity.name;
1970
+ const keepId = entity.id;
1971
+ const keepTransform = entity.transform;
1972
+ // Clear all properties
1973
+ for (const key of Object.keys(entity) as (keyof SceneEntity)[]) {
1974
+ delete entity[key];
1975
+ }
1976
+ // Set instance properties
1977
+ entity.name = keepName;
1978
+ if (keepId) entity.id = keepId;
1979
+ entity.prefab = prefabPath;
1980
+ if (keepTransform) entity.transform = keepTransform;
1981
+ this._rebuildEntity(id);
1982
+ this._notify();
1983
+ }
1984
+
1985
+ unpackPrefab(id: string, prefabRoot: SceneEntity): void {
1986
+ const obj = this._objectMap.get(id);
1987
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1988
+ if (!entity || !entity.prefab) return;
1989
+ // Merge BEFORE pushing undo: since T1.10 an invalid prefab+instance combo
1990
+ // throws (schema re-validation of the merged entity), and pushing first
1991
+ // would leave a no-op undo entry (the undo-then-bail defect class — see
1992
+ // moveEntity/T1.12).
1993
+ let merged: SceneEntity;
1994
+ try {
1995
+ merged = mergePrefabInstance(prefabRoot, entity);
1996
+ } catch (err) {
1997
+ console.error(`EditorStore: cannot unpack invalid prefab instance "${entity.name}":`, err);
1998
+ return;
1999
+ }
2000
+ this._pushUndo();
2001
+ for (const key of Object.keys(entity) as (keyof SceneEntity)[]) {
2002
+ delete entity[key];
2003
+ }
2004
+ Object.assign(entity, merged);
2005
+ assignMissingIds(entity.children ?? []);
2006
+ this._rebuildEntity(id);
2007
+ this._notify();
2008
+ }
2009
+
2010
+ loadPrefab(prefab: PrefabFile, savePath?: string): void {
2011
+ if (this._blockedDuringPlay('load prefab')) return;
2012
+ const root = deepClone(prefab.root);
2013
+ const doc: SceneFile = {
2014
+ version: prefab.version,
2015
+ name: prefab.name,
2016
+ entities: [root],
2017
+ };
2018
+ assignMissingIds(doc.entities);
2019
+ this._sceneMeta = {
2020
+ version: prefab.version,
2021
+ name: prefab.name,
2022
+ };
2023
+ this._documentType = 'prefab';
2024
+ this._selectedEntityIds.clear();
2025
+ this._undoStack.length = 0;
2026
+ this._redoStack.length = 0;
2027
+ this._isDirty = false;
2028
+ this._savePath = savePath ?? null;
2029
+
2030
+ if (this._scene) {
2031
+ this._rebuildScene(doc);
2032
+ }
2033
+ this._notify();
2034
+ }
2035
+
2036
+ getPrefabDocument(): PrefabFile | null {
2037
+ if (this._documentType !== 'prefab') return null;
2038
+ const doc = this.getDocument();
2039
+ const root = doc.entities[0];
2040
+ if (!root) return null;
2041
+ const cleanRoot = deepClone(root);
2042
+ walkEntities([cleanRoot], (e) => {
2043
+ delete e.id;
2044
+ if (e.children && e.children.length === 0) delete e.children;
2045
+ });
2046
+ return { version: doc.version, name: doc.name, root: cleanRoot };
2047
+ }
2048
+
2049
+ /** Update all references to an asset path across the live scene. */
2050
+ applyAssetMove(oldPath: string, newPath: string): void {
2051
+ // Play-mode / ingest guard: same rationale as applyExternalUpdate — this
2052
+ // does a full serialize+rebuild of `_scene`, which is the LIVE game/ingested
2053
+ // scene while playing (probe: F6b in the round-1 review — the running
2054
+ // game's scene was torn down and rebuilt from under it). Defer to stop.
2055
+ if (this._playState !== 'stopped') {
2056
+ this._pendingAssetMove = { oldPath, newPath };
2057
+ return;
2058
+ }
2059
+ if (!this._scene) return;
2060
+ this._pushUndo();
2061
+ // Serialize, rename, rebuild
2062
+ const doc = serializeScene(this._scene, this._sceneMeta);
2063
+ const renamed = applyAssetRename(doc, oldPath, newPath);
2064
+ this._sceneMeta = { version: renamed.version, name: renamed.name };
2065
+ if (renamed.environment) this._sceneMeta.environment = renamed.environment;
2066
+ if (renamed.ui) this._sceneMeta.ui = renamed.ui; // preserve scene-graph UI (T1.5)
2067
+ this._rebuildScene(renamed);
2068
+ this._notify();
2069
+ }
2070
+
2071
+ // --- Asset tabs ---
2072
+
2073
+ setActiveTab(key: string): void {
2074
+ this._activeTabKey = key;
2075
+ this._notify();
2076
+ }
2077
+
2078
+ openAssetTab(assetPath: string, kind: AssetKind): void {
2079
+ const existing = this._assetTabs.find((t) => t.key === assetPath);
2080
+ if (existing) {
2081
+ this._activeTabKey = existing.key;
2082
+ this._notify();
2083
+ return;
2084
+ }
2085
+ const label = assetPath.split('/').pop() ?? assetPath;
2086
+ const tab: AssetTab = { key: assetPath, label, assetPath, kind };
2087
+ this._assetTabs.push(tab);
2088
+ this._activeTabKey = tab.key;
2089
+ this._notify();
2090
+ }
2091
+
2092
+ openOnlineAssetTab(online: OnlineAssetInfo): void {
2093
+ const key = `online:${online.source}:${online.id}`;
2094
+ const existing = this._assetTabs.find((t) => t.key === key);
2095
+ if (existing) {
2096
+ this._activeTabKey = existing.key;
2097
+ this._notify();
2098
+ return;
2099
+ }
2100
+ const kind: AssetKind =
2101
+ online.type === 'model'
2102
+ ? 'model'
2103
+ : online.type === 'animation' || online.type === 'source'
2104
+ ? 'source'
2105
+ : 'image';
2106
+ const tab: AssetTab = { key, label: online.name, assetPath: '', kind, online };
2107
+ this._assetTabs.push(tab);
2108
+ this._activeTabKey = tab.key;
2109
+ this._notify();
2110
+ }
2111
+
2112
+ /** Update an online asset tab to point at a local path after download. */
2113
+ resolveOnlineAssetTab(onlineKey: string, localPath: string): void {
2114
+ const tab = this._assetTabs.find((t) => t.key === onlineKey);
2115
+ if (!tab) return;
2116
+ tab.assetPath = localPath;
2117
+ delete tab.online;
2118
+ this._notify();
2119
+ }
2120
+
2121
+ closeAssetTab(key: string): void {
2122
+ const idx = this._assetTabs.findIndex((t) => t.key === key);
2123
+ if (idx < 0) return;
2124
+ this._assetTabs.splice(idx, 1);
2125
+ if (this._activeTabKey === key) {
2126
+ this._activeTabKey = '__inspector__';
2127
+ }
2128
+ this._notify();
2129
+ }
2130
+
2131
+ // --- Internal helpers ---
2132
+
2133
+ /**
2134
+ * Rebuild a single entity's Three.js representation after its descriptor
2135
+ * changed. This is the single choke point for every property edit that
2136
+ * requires tearing down and recreating the entity's Object3D (mesh/material/
2137
+ * physics/component shape changes — see updateEntity, batchMutate,
2138
+ * updatePivot, convertToPrefabInstance, unpackPrefab).
2139
+ *
2140
+ * Play-mode guard: while playing/paused, `_scene`/`_objectMap` point at the
2141
+ * LIVE game scene (see enterPlayScene). `updateEntityPreview` disposes the
2142
+ * old Object3D (geometry/material) and swaps in a fresh, component-less
2143
+ * preview object — which would rip a running GameComponent's Object3D out
2144
+ * from under it and orphan any physics body/writer targeting it (probe:
2145
+ * F4 in the round-1 review). The descriptor mutation the caller already
2146
+ * applied still stands (and is discarded on exitPlayScene, same as any
2147
+ * other play-time edit) — only the destructive rebuild is skipped.
2148
+ */
2149
+ private _rebuildEntity(id: string): void {
2150
+ if (!this._scene) return;
2151
+ if (this._playState !== 'stopped') {
2152
+ console.warn(
2153
+ 'EditorStore: property change requiring a rebuild is disabled during play mode — stop the game first.',
2154
+ );
2155
+ return;
2156
+ }
2157
+ // (Ingested unmodified-game objects are edited LIVE through the
2158
+ // IngestAuthoringAdapter — they never flow through this first-party
2159
+ // descriptor-rebuild path.)
2160
+ const entity = this.getEntityById(id);
2161
+ if (!entity) return;
2162
+ updateEntityPreview(entity, this._objectMap, this._scene, this._batchedRenderer);
2163
+ }
2164
+
2165
+ /** Full scene rebuild from a SceneFile (for load, undo, redo). */
2166
+ private _rebuildScene(doc: SceneFile): void {
2167
+ if (!this._scene) return;
2168
+ this._objectMap = buildPreview(
2169
+ doc,
2170
+ this._scene,
2171
+ this._shadingMode,
2172
+ this._renderer ?? undefined,
2173
+ this._batchedRenderer,
2174
+ );
2175
+ // Structure (and thus composer inputs: tagged objects / selective effects) changed.
2176
+ this._composerVersion++;
2177
+ }
2178
+
2179
+ /** Apply environment from sceneMeta to the live scene. */
2180
+ private _applyEnvironment(): void {
2181
+ if (!this._scene) return;
2182
+ // Remove existing environment objects
2183
+ const envObjs: THREE.Object3D[] = [];
2184
+ this._scene.traverse((obj) => {
2185
+ if (getUserData(obj, 'envObject')) envObjs.push(obj);
2186
+ });
2187
+ for (const obj of envObjs) obj.removeFromParent();
2188
+ this._scene.fog = null;
2189
+ this._scene.environment = null;
2190
+
2191
+ applyEnvironment(this._sceneMeta.environment, this._scene, this._renderer ?? undefined);
2192
+ this._composerVersion++;
2193
+ }
2194
+
2195
+ /** Load a SceneFile into the scene without changing undo/selection state. */
2196
+ private _loadFromDoc(doc: SceneFile): void {
2197
+ this._sceneMeta = { version: doc.version, name: doc.name };
2198
+ if (doc.environment) this._sceneMeta.environment = doc.environment;
2199
+ if (doc.ui) this._sceneMeta.ui = doc.ui; // preserve scene-graph UI through undo/redo (T1.5)
2200
+ this._rebuildScene(doc);
2201
+ }
2202
+
2203
+ /** Build nested SceneEntity[] from the Three.js hierarchy (for react-arborist). */
2204
+ private _buildEntityTree(parent: THREE.Object3D): SceneEntity[] {
2205
+ const result: SceneEntity[] = [];
2206
+ for (const child of parent.children) {
2207
+ if (getUserData(child, 'entityId') === undefined) continue;
2208
+ if (getUserData(child, 'editorHelper')) continue;
2209
+ const stored = getUserData(child, 'entity') as SceneEntity | undefined;
2210
+ if (!stored) continue;
2211
+
2212
+ const childEntities = this._buildEntityTree(child);
2213
+ if (childEntities.length > 0) {
2214
+ result.push({ ...stored, children: childEntities });
2215
+ } else if (stored.children && stored.children.length > 0) {
2216
+ // Entity descriptor has stale children — strip them
2217
+ const { children: _, ...rest } = stored;
2218
+ result.push(rest as SceneEntity);
2219
+ } else {
2220
+ result.push(stored);
2221
+ }
2222
+ }
2223
+ return result;
2224
+ }
2225
+ }