@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,1924 @@
1
+ import { resolveUrl } from '@engine/loader';
2
+ import { tickCustomMaterials } from '@engine/scene/material-registry';
3
+ import type { SceneEntity } from '@engine/scene/scene-types';
4
+ import { getUserData, setUserData } from '@engine/scene/user-data';
5
+ import * as THREE from 'three';
6
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
7
+ import { TransformControls } from 'three/addons/controls/TransformControls.js';
8
+ import { BatchedRenderer } from 'three.quarks';
9
+ import { getActiveAuthoring } from './authoring/active-adapter';
10
+ import { pickTopmost } from './authoring/layered-pick';
11
+ import { setViewportPickContext } from './authoring/viewport-pick-context';
12
+ import { downloadOnlineAsset, getDownloadedAssetPath, getOnlineAssetFiles } from './editor-api';
13
+ import { EDITOR_LAYER } from './editor-layers';
14
+ import type { EditorStore } from './editor-store';
15
+ import { applyEntityGizmos } from './gizmo-registry';
16
+ import {
17
+ createHandleDefs,
18
+ createHandleMesh,
19
+ type HandleMesh,
20
+ scaleHandle,
21
+ setHandleHovered,
22
+ } from './handle-system';
23
+
24
+ /** A BoxHelper or LineSegments wireframe tagged with its source entity object. */
25
+ interface TaggedHelper extends THREE.BoxHelper {
26
+ isBoxHelper: boolean;
27
+ _entityObj?: THREE.Object3D;
28
+ }
29
+
30
+ /** View directions indexed by click-target userData. */
31
+ const VC_DIRS: THREE.Vector3[] = [
32
+ /* 0 +X */ new THREE.Vector3(1, 0, 0),
33
+ /* 1 -X */ new THREE.Vector3(-1, 0, 0),
34
+ /* 2 +Y */ new THREE.Vector3(0, 1, 0),
35
+ /* 3 -Y */ new THREE.Vector3(0, -1, 0),
36
+ /* 4 +Z */ new THREE.Vector3(0, 0, 1),
37
+ /* 5 -Z */ new THREE.Vector3(0, 0, -1),
38
+ ];
39
+
40
+ export class EditorViewport {
41
+ readonly camera: THREE.PerspectiveCamera;
42
+ readonly orbitControls: OrbitControls;
43
+ readonly transformControls: TransformControls;
44
+ readonly raycaster = (() => {
45
+ const r = new THREE.Raycaster();
46
+ r.layers.enableAll();
47
+ return r;
48
+ })();
49
+ readonly grid: THREE.GridHelper;
50
+ private _boxHelpers = new Map<string, THREE.BoxHelper>();
51
+ private _gizmoHelper: THREE.Object3D;
52
+ private _editorAmbient: THREE.AmbientLight;
53
+ private _editorDirLight: THREE.DirectionalLight;
54
+
55
+ private _scene: THREE.Scene;
56
+ /** Editor infrastructure objects that must move with the active scene. */
57
+ private _editorObjects: THREE.Object3D[] = [];
58
+ private _store: EditorStore;
59
+ private _objectMap = new Map<string, THREE.Object3D>();
60
+ private _canvas: HTMLCanvasElement;
61
+ private _pointerDownPos = new THREE.Vector2();
62
+ private _skipNextSync = false;
63
+
64
+ // -- Marquee select --
65
+ private _marqueeActive = false;
66
+ private _marqueeDiv: HTMLDivElement | null = null;
67
+ private _pointerDownButton = -1;
68
+ private _altDragOrbit = false;
69
+
70
+ // -- Orientation gizmo --
71
+ private _vcScene = new THREE.Scene();
72
+ private _vcCamera: THREE.OrthographicCamera;
73
+ private _vcClickTargets: THREE.Mesh[] = [];
74
+ private _vcSize = 110;
75
+ private _vcMarginRight = 16;
76
+ private _vcMarginTop = 36 + 16;
77
+ /** Scratch dir vector reused each frame by renderViewCube (avoids per-frame alloc). */
78
+ private _vcDir = new THREE.Vector3();
79
+ /** Scratch color reused by renderCameraPreview (avoids per-frame alloc). */
80
+ private _previewClearColor = new THREE.Color();
81
+ /** Last objectMap + visibility signature applied by syncFromStore's gizmo/helper
82
+ * passes — lets those O(scene) loops skip when their inputs are unchanged (e.g.
83
+ * during a transform drag, which notifies per frame but changes none of them). */
84
+ private _lastGizmoObjectMap: Map<string, THREE.Object3D> | null = null;
85
+ private _lastGizmoSig = '';
86
+ private _vcRaycaster = new THREE.Raycaster();
87
+
88
+ // -- Asset drop --
89
+ private _dropOverlay: HTMLDivElement | null = null;
90
+ private _groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
91
+
92
+ // -- Multi-drag: track primary object's transform at drag start --
93
+ private _dragStartPos = new THREE.Vector3();
94
+ private _dragStartQuat = new THREE.Quaternion();
95
+ private _dragStartScale = new THREE.Vector3();
96
+ private _otherDragObjects: {
97
+ obj: THREE.Object3D;
98
+ startPos: THREE.Vector3;
99
+ startQuat: THREE.Quaternion;
100
+ startScale: THREE.Vector3;
101
+ }[] = [];
102
+ private _allDragObjects: {
103
+ obj: THREE.Object3D;
104
+ startPos: THREE.Vector3;
105
+ startQuat: THREE.Quaternion;
106
+ startScale: THREE.Vector3;
107
+ }[] = [];
108
+ private _pivotDummy = new THREE.Object3D();
109
+ private _pivotPos = new THREE.Vector3();
110
+
111
+ // -- Surface snap --
112
+ private _surfaceSnapRaycaster = new THREE.Raycaster();
113
+ private _surfaceSnapIndicator: THREE.Mesh;
114
+
115
+ // -- Vertex snap --
116
+ private _vertexSnapTargets: THREE.Vector3[] = [];
117
+ private _vertexSnapIndicator!: THREE.Mesh;
118
+ private _vertexSnapThreshold = 20; // pixels
119
+
120
+ // -- Component handles (interactive gizmos for light range, audio distance, etc.) --
121
+ private _handles: HandleMesh[] = [];
122
+ private _handleRaycaster = new THREE.Raycaster();
123
+ private _handleDragActive = false;
124
+ private _activeHandle: HandleMesh | null = null;
125
+ private _handleDragPlane = new THREE.Plane();
126
+ private _handleDragOffset = new THREE.Vector3();
127
+ private _hoveredHandle: HandleMesh | null = null;
128
+
129
+ // -- Particles --
130
+ readonly batchedRenderer: BatchedRenderer;
131
+
132
+ // -- NavMesh helper (scene-root, toggled separately) --
133
+ private _navMeshHelper: THREE.Object3D | null = null;
134
+
135
+ // -- Snap-to-view animation --
136
+ private _snapAnimating = false;
137
+ private _snapStartTime = 0;
138
+ private _snapDuration = 400;
139
+ private _snapQ1 = new THREE.Quaternion();
140
+ private _snapQ2 = new THREE.Quaternion();
141
+ private _snapDist = 0;
142
+
143
+ // -- Fly camera (right-click + WASD) --
144
+ private _flyActive = false;
145
+ private _flyKeys = new Set<string>();
146
+ private _flySpeed = 5;
147
+ private _flyEuler = new THREE.Euler(0, 0, 0, 'YXZ');
148
+ private _rightMouseDown = false;
149
+
150
+ constructor(canvas: HTMLCanvasElement, scene: THREE.Scene, store: EditorStore) {
151
+ this._canvas = canvas;
152
+ this._scene = scene;
153
+ this._store = store;
154
+
155
+ // Camera — enableAll so editor sees both game content (layer 0) and editor objects (layer 31)
156
+ this.camera = new THREE.PerspectiveCamera(
157
+ 60,
158
+ canvas.clientWidth / canvas.clientHeight,
159
+ 0.1,
160
+ 1000,
161
+ );
162
+ this.camera.position.set(10, 10, 10);
163
+ this.camera.lookAt(0, 0, 0);
164
+ this.camera.layers.enableAll();
165
+
166
+ // Grid
167
+ this.grid = new THREE.GridHelper(50, 50, 0x999999, 0x999999);
168
+ this.grid.layers.set(EDITOR_LAYER);
169
+ scene.add(this.grid);
170
+
171
+ // Editor lighting (on EDITOR_LAYER so game camera doesn't see them)
172
+ this._editorAmbient = new THREE.AmbientLight(0xffffff, 0.5);
173
+ this._editorAmbient.layers.set(EDITOR_LAYER);
174
+ scene.add(this._editorAmbient);
175
+ this._editorDirLight = new THREE.DirectionalLight(0xffffff, 1.0);
176
+ this._editorDirLight.position.set(10, 20, 10);
177
+ this._editorDirLight.layers.set(EDITOR_LAYER);
178
+ scene.add(this._editorDirLight);
179
+
180
+ // Particle BatchedRenderer
181
+ this.batchedRenderer = new BatchedRenderer();
182
+ this.batchedRenderer.layers.set(EDITOR_LAYER);
183
+ scene.add(this.batchedRenderer);
184
+
185
+ // OrbitControls – Unity-style: right-drag = orbit, middle-drag = pan, scroll = zoom
186
+ this.orbitControls = new OrbitControls(this.camera, canvas);
187
+ this.orbitControls.enableDamping = true;
188
+ this.orbitControls.dampingFactor = 0.1;
189
+ this.orbitControls.mouseButtons = {
190
+ LEFT: -1 as THREE.MOUSE,
191
+ MIDDLE: THREE.MOUSE.PAN,
192
+ RIGHT: THREE.MOUSE.ROTATE,
193
+ };
194
+
195
+ // TransformControls
196
+ this.transformControls = new TransformControls(this.camera, canvas);
197
+ // Enable all layers on the internal raycaster so it can pick gizmo parts on EDITOR_LAYER
198
+ this.transformControls.getRaycaster().layers.enableAll();
199
+ this.transformControls.addEventListener('dragging-changed', (event) => {
200
+ const dragging = (event as unknown as { value: boolean }).value;
201
+ this.orbitControls.enabled = !dragging;
202
+ if (dragging) {
203
+ const gizmoObj = this.transformControls.object;
204
+ const primaryDragId =
205
+ (gizmoObj && (getUserData(gizmoObj, 'entityId') as string | undefined)) || '';
206
+ // Gizmo-commit routes through the AuthoringAdapter: beginEdit snapshots
207
+ // for undo and is the hook where a physics-owned object gets frozen for a
208
+ // stable edit (ingest / Phase E); endEdit commits at drag-end.
209
+ getActiveAuthoring(this._store).transforms?.beginEdit(primaryDragId);
210
+ if (gizmoObj) {
211
+ this._dragStartPos.copy(gizmoObj.position);
212
+ this._dragStartQuat.copy(gizmoObj.quaternion);
213
+ this._dragStartScale.copy(gizmoObj.scale);
214
+ this._otherDragObjects = [];
215
+ this._allDragObjects = [];
216
+ this._pivotPos.copy(gizmoObj.position);
217
+ const primaryId = getUserData(gizmoObj, 'entityId') as string | undefined;
218
+ if (!primaryId) {
219
+ // Gizmo on pivot dummy (median-point mode or per-entity pivot)
220
+ for (const id of this._store.selectedEntityIds) {
221
+ const obj = this._objectMap.get(id);
222
+ if (!obj) continue;
223
+ this._allDragObjects.push({
224
+ obj,
225
+ startPos: obj.position.clone(),
226
+ startQuat: obj.quaternion.clone(),
227
+ startScale: obj.scale.clone(),
228
+ });
229
+ }
230
+ } else {
231
+ for (const id of this._store.selectedEntityIds) {
232
+ if (id === primaryId) continue;
233
+ const obj = this._objectMap.get(id);
234
+ if (!obj) continue;
235
+ this._otherDragObjects.push({
236
+ obj,
237
+ startPos: obj.position.clone(),
238
+ startQuat: obj.quaternion.clone(),
239
+ startScale: obj.scale.clone(),
240
+ });
241
+ }
242
+ }
243
+ }
244
+ } else {
245
+ // Hide snap indicators when drag ends
246
+ this._surfaceSnapIndicator.visible = false;
247
+ this._vertexSnapIndicator.visible = false;
248
+ // After drag ends, re-sync for median-point pivot repositioning
249
+ this.syncFromStore();
250
+ }
251
+ });
252
+ this.transformControls.addEventListener('objectChange', () => {
253
+ const gizmoObj = this.transformControls.object;
254
+ if (!gizmoObj) return;
255
+ const mode = this.transformControls.mode;
256
+ const isIndividual = this._store.pivotMode === 'individual-origins';
257
+ const isMedian = this._allDragObjects.length > 0;
258
+ const targets = isMedian ? this._allDragObjects : this._otherDragObjects;
259
+ if (targets.length > 0) {
260
+ if (mode === 'translate') {
261
+ const delta = gizmoObj.position.clone().sub(this._dragStartPos);
262
+ for (const { obj, startPos } of targets) {
263
+ obj.position.copy(startPos).add(delta);
264
+ }
265
+ } else if (mode === 'rotate') {
266
+ const deltaQuat = gizmoObj.quaternion
267
+ .clone()
268
+ .multiply(this._dragStartQuat.clone().invert());
269
+ if (isIndividual) {
270
+ // Individual origins: only rotate, no position orbiting
271
+ for (const { obj, startQuat } of targets) {
272
+ obj.quaternion.copy(deltaQuat).multiply(startQuat);
273
+ }
274
+ } else {
275
+ // Active element / Median: orbit positions around pivot + rotate
276
+ for (const { obj, startPos, startQuat } of targets) {
277
+ const offset = startPos.clone().sub(this._pivotPos);
278
+ offset.applyQuaternion(deltaQuat);
279
+ obj.position.copy(this._pivotPos).add(offset);
280
+ obj.quaternion.copy(deltaQuat).multiply(startQuat);
281
+ }
282
+ }
283
+ } else if (mode === 'scale') {
284
+ const deltaScale = new THREE.Vector3(
285
+ this._dragStartScale.x !== 0 ? gizmoObj.scale.x / this._dragStartScale.x : 1,
286
+ this._dragStartScale.y !== 0 ? gizmoObj.scale.y / this._dragStartScale.y : 1,
287
+ this._dragStartScale.z !== 0 ? gizmoObj.scale.z / this._dragStartScale.z : 1,
288
+ );
289
+ if (isIndividual) {
290
+ // Individual origins: only scale, no position change
291
+ for (const { obj, startScale } of targets) {
292
+ obj.scale.copy(startScale).multiply(deltaScale);
293
+ }
294
+ } else {
295
+ // Active element / Median: scale from pivot + scale objects
296
+ for (const { obj, startPos, startScale } of targets) {
297
+ const offset = startPos.clone().sub(this._pivotPos);
298
+ offset.multiply(deltaScale);
299
+ obj.position.copy(this._pivotPos).add(offset);
300
+ obj.scale.copy(startScale).multiply(deltaScale);
301
+ }
302
+ }
303
+ }
304
+ }
305
+ // Vertex snap takes priority over surface snap
306
+ if (
307
+ mode === 'translate' &&
308
+ this._store.vertexSnapActive &&
309
+ this._vertexSnapTargets.length > 0
310
+ ) {
311
+ this._applyVertexSnap(gizmoObj, targets);
312
+ } else if (mode === 'translate' && this._store.snapToSurface) {
313
+ this._applySurfaceSnap(gizmoObj, targets);
314
+ }
315
+
316
+ for (const helper of this._boxHelpers.values()) {
317
+ if ('update' in helper) (helper as THREE.BoxHelper).update();
318
+ }
319
+ this._flushGizmoToStoreLive();
320
+ });
321
+ this.transformControls.addEventListener('mouseUp', () => {
322
+ // Final flush (with skip flag so viewport doesn't rebuild)
323
+ this._flushGizmoToStore();
324
+ });
325
+ this._gizmoHelper = this.transformControls.getHelper();
326
+ this._gizmoHelper.traverse((child) => child.layers.set(EDITOR_LAYER));
327
+ scene.add(this._gizmoHelper);
328
+
329
+ // Pivot dummy for median-point mode (invisible, just a transform target)
330
+ this._pivotDummy.layers.set(EDITOR_LAYER);
331
+ scene.add(this._pivotDummy);
332
+
333
+ // Surface snap indicator (small ring shown at snap point)
334
+ this._surfaceSnapIndicator = new THREE.Mesh(
335
+ new THREE.RingGeometry(0.08, 0.12, 16),
336
+ new THREE.MeshBasicMaterial({ color: 0x00ff88, side: THREE.DoubleSide, depthTest: false }),
337
+ );
338
+ this._surfaceSnapIndicator.rotation.x = -Math.PI / 2;
339
+ this._surfaceSnapIndicator.visible = false;
340
+ this._surfaceSnapIndicator.renderOrder = 999;
341
+ this._surfaceSnapIndicator.layers.set(EDITOR_LAYER);
342
+ scene.add(this._surfaceSnapIndicator);
343
+
344
+ // Vertex snap indicator (small green sphere shown at snap target vertex)
345
+ this._vertexSnapIndicator = new THREE.Mesh(
346
+ new THREE.SphereGeometry(1, 8, 8),
347
+ new THREE.MeshBasicMaterial({ color: 0x00ffaa, depthTest: false }),
348
+ );
349
+ this._vertexSnapIndicator.visible = false;
350
+ this._vertexSnapIndicator.renderOrder = 999;
351
+ this._vertexSnapIndicator.layers.set(EDITOR_LAYER);
352
+ scene.add(this._vertexSnapIndicator);
353
+
354
+ // Patch gizmo: recolor handles to Godot axis colors and fix plane geometry.
355
+ this._patchGizmo(this._gizmoHelper);
356
+
357
+ // Orientation gizmo (axis arrows overlay)
358
+ this._vcCamera = new THREE.OrthographicCamera(-1.5, 1.5, 1.5, -1.5, 0.1, 10);
359
+ this._initOrientationGizmo();
360
+
361
+ // -- Fly camera input (right-click + WASD) --
362
+ window.addEventListener('keydown', this._onFlyKeyDown);
363
+ window.addEventListener('keyup', this._onFlyKeyUp);
364
+ canvas.addEventListener('pointerdown', this._onFlyPointerDown);
365
+ canvas.addEventListener('pointerup', this._onFlyPointerUp);
366
+ canvas.addEventListener('pointermove', this._onFlyPointerMove);
367
+ canvas.addEventListener('wheel', this._onFlyWheel, { passive: false });
368
+
369
+ // Alt+Left-drag = orbit (Unity-style), via capture phase so it fires before OrbitControls
370
+ canvas.addEventListener(
371
+ 'pointerdown',
372
+ (e) => {
373
+ if (e.altKey && e.button === 0) {
374
+ this._altDragOrbit = true;
375
+ this.orbitControls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
376
+ }
377
+ },
378
+ { capture: true },
379
+ );
380
+ canvas.addEventListener(
381
+ 'pointerup',
382
+ () => {
383
+ if (this._altDragOrbit) {
384
+ this._altDragOrbit = false;
385
+ this.orbitControls.mouseButtons.LEFT = -1 as THREE.MOUSE;
386
+ }
387
+ },
388
+ { capture: true },
389
+ );
390
+
391
+ // Click to select + marquee
392
+ canvas.addEventListener('pointerdown', this._onPointerDown);
393
+ canvas.addEventListener('pointermove', this._onPointerMove);
394
+ canvas.addEventListener('pointerup', this._onPointerUp);
395
+
396
+ // Asset drag-and-drop onto viewport
397
+ canvas.addEventListener('dragover', this._onDragOver);
398
+ canvas.addEventListener('dragleave', this._onDragLeave);
399
+ canvas.addEventListener('drop', this._onDrop);
400
+
401
+ // Collect editor infrastructure objects so they can be moved between scenes
402
+ this._editorObjects = [
403
+ this.grid,
404
+ this._editorAmbient,
405
+ this._editorDirLight,
406
+ this.batchedRenderer,
407
+ this._gizmoHelper,
408
+ this._pivotDummy,
409
+ this._surfaceSnapIndicator,
410
+ this._vertexSnapIndicator,
411
+ ];
412
+
413
+ // D12 (B4) — publish the camera/canvas seam `VgaiSceneAuthoringAdapter.pickable`
414
+ // needs (it has no other way to reach them). Cleared in `dispose()`.
415
+ setViewportPickContext({ camera: this.camera, canvas: this._canvas });
416
+ }
417
+
418
+ /** Swap the scene the viewport renders. Moves editor infrastructure objects to the new scene. */
419
+ setScene(newScene: THREE.Scene): void {
420
+ if (newScene === this._scene) return;
421
+ // Move editor infrastructure to new scene
422
+ for (const obj of this._editorObjects) {
423
+ obj.removeFromParent();
424
+ newScene.add(obj);
425
+ }
426
+ // Move any active handles
427
+ for (const h of this._handles) {
428
+ h.removeFromParent();
429
+ newScene.add(h);
430
+ }
431
+ // Move box helpers
432
+ for (const helper of this._boxHelpers.values()) {
433
+ helper.removeFromParent();
434
+ newScene.add(helper);
435
+ }
436
+ this._scene = newScene;
437
+ }
438
+
439
+ get currentScene(): THREE.Scene {
440
+ return this._scene;
441
+ }
442
+
443
+ get objectMap(): Map<string, THREE.Object3D> {
444
+ return this._objectMap;
445
+ }
446
+ set objectMap(map: Map<string, THREE.Object3D>) {
447
+ this._objectMap = map;
448
+ }
449
+
450
+ /** Attach gizmo to entity by id. */
451
+ attach(id: string): void {
452
+ const obj = this._objectMap.get(id);
453
+ if (!obj) return;
454
+ this.transformControls.attach(obj);
455
+ }
456
+
457
+ /** Detach gizmo. */
458
+ detach(): void {
459
+ this.transformControls.detach();
460
+ }
461
+
462
+ /** Clear and rebuild box helpers for all selected entities. */
463
+ private _syncBoxHelpers(): void {
464
+ // Remove old + dispose geometry/materials on rebuild (LK6).
465
+ for (const helper of this._boxHelpers.values()) {
466
+ this._scene.remove(helper);
467
+ helper.geometry.dispose();
468
+ (helper.material as THREE.Material).dispose();
469
+ }
470
+ this._boxHelpers.clear();
471
+
472
+ const MIN = 0.5;
473
+ const color = new THREE.Color(0x579eff);
474
+
475
+ // Add new
476
+ for (const id of this._store.selectedEntityIds) {
477
+ const obj = this._objectMap.get(id);
478
+ if (!obj) continue;
479
+
480
+ // Check if the entity has enough non-helper geometry for BoxHelper to work.
481
+ // Cameras, lights, and audio sources only have editorHelper children
482
+ // whose geometry BoxHelper ignores or produces a degenerate box.
483
+ let hasVisibleGeometry = false;
484
+ obj.traverse((child) => {
485
+ if (getUserData(child, 'editorHelper')) return;
486
+ if ((child as THREE.Mesh).isMesh || ((child as THREE.Line).isLine && child !== obj)) {
487
+ hasVisibleGeometry = true;
488
+ }
489
+ });
490
+
491
+ if (hasVisibleGeometry) {
492
+ const helper = new THREE.BoxHelper(obj, 0x579eff);
493
+ helper.layers.set(EDITOR_LAYER);
494
+ this._scene.add(helper);
495
+ this._boxHelpers.set(id, helper);
496
+ } else {
497
+ // Create a fixed-size wireframe cube for non-mesh entities
498
+ const geo = new THREE.BoxGeometry(MIN, MIN, MIN);
499
+ const edges = new THREE.EdgesGeometry(geo);
500
+ geo.dispose();
501
+ const mat = new THREE.LineBasicMaterial({ color, toneMapped: false });
502
+ const wire = new THREE.LineSegments(edges, mat) as unknown as TaggedHelper;
503
+ wire.layers.set(EDITOR_LAYER);
504
+ obj.getWorldPosition(wire.position);
505
+ // Tag so we can update/dispose like BoxHelper
506
+ wire.isBoxHelper = true;
507
+ wire._entityObj = obj;
508
+ this._scene.add(wire);
509
+ this._boxHelpers.set(id, wire);
510
+ }
511
+ }
512
+ }
513
+
514
+ /** Sync mode/space/snap from store. */
515
+ syncFromStore(): void {
516
+ // Ensure all gizmo children stay on EDITOR_LAYER (TransformControls rebuilds internally)
517
+ this._gizmoHelper.traverse((child) => child.layers.set(EDITOR_LAYER));
518
+
519
+ const mode = this._store.transformMode;
520
+ if (this.transformControls.mode !== mode) {
521
+ this.transformControls.setMode(mode);
522
+ }
523
+ this.transformControls.setSpace(this._store.transformSpace);
524
+ const snap = this._store.snapEnabled;
525
+ const vals = this._store.snapValues;
526
+ this.transformControls.setTranslationSnap(snap ? vals.translate : null);
527
+ this.transformControls.setRotationSnap(snap ? THREE.MathUtils.degToRad(vals.rotate) : null);
528
+ this.transformControls.setScaleSnap(snap ? vals.scale : null);
529
+
530
+ // Grid visibility
531
+ this.grid.visible = this._store.showGrid;
532
+
533
+ // The two passes below (apply gizmos + per-type helper/icon visibility) are
534
+ // idempotent and O(total scene nodes). Their only inputs are the objectMap
535
+ // membership and the visibility toggles — none of which change during a
536
+ // transform drag/scrub (which still fires syncFromStore every frame). Gate
537
+ // them on a cheap signature so a drag doesn't re-walk the whole graph 60×/s.
538
+ const vis = this._store.helperVisibility;
539
+ const gizmoSig = `${this._objectMap.size}|${this._store.showHelpers}|${JSON.stringify(vis)}`;
540
+ if (this._objectMap !== this._lastGizmoObjectMap || gizmoSig !== this._lastGizmoSig) {
541
+ this._lastGizmoObjectMap = this._objectMap;
542
+ this._lastGizmoSig = gizmoSig;
543
+
544
+ // Ensure gizmos (icon billboards + wireframe helpers) exist on all objects.
545
+ // Idempotent — needed for game scene objects created by the runtime without gizmos.
546
+ for (const obj of this._objectMap.values()) {
547
+ applyEntityGizmos(obj);
548
+ }
549
+
550
+ // Helper + icon visibility — per-type filtering
551
+ for (const obj of this._objectMap.values()) {
552
+ obj.traverse((child) => {
553
+ if (getUserData(child, 'editorIcon')) {
554
+ // Icon billboards follow the global helpers toggle
555
+ child.visible = this._store.showHelpers;
556
+ return;
557
+ }
558
+ if (!getUserData(child, 'editorHelper')) return;
559
+ const type = getUserData(child, 'editorHelperType') as string | undefined;
560
+ if (type && type in vis) {
561
+ child.visible = vis[type as keyof typeof vis];
562
+ } else {
563
+ child.visible = this._store.showHelpers;
564
+ }
565
+ });
566
+ }
567
+ // NavMesh helper is a scene-root object, toggle separately
568
+ if (this._navMeshHelper) {
569
+ this._navMeshHelper.visible = vis.navmesh;
570
+ }
571
+ }
572
+
573
+ // Attach gizmo based on pivot mode
574
+ const selectedId = this._store.selectedEntityId;
575
+ if (selectedId) {
576
+ if (!this.transformControls.dragging) {
577
+ if (this._store.pivotMode === 'median-point' && this._store.selectedEntityIds.size > 1) {
578
+ const median = new THREE.Vector3();
579
+ let count = 0;
580
+ for (const id of this._store.selectedEntityIds) {
581
+ const obj = this._objectMap.get(id);
582
+ if (obj) {
583
+ median.add(obj.position);
584
+ count++;
585
+ }
586
+ }
587
+ if (count > 0) median.divideScalar(count);
588
+ this._pivotDummy.position.copy(median);
589
+ this._pivotDummy.quaternion.identity();
590
+ this._pivotDummy.scale.set(1, 1, 1);
591
+ this.transformControls.attach(this._pivotDummy);
592
+ } else {
593
+ // Check for per-entity pivot
594
+ const obj = this._objectMap.get(selectedId);
595
+ const entityPivot = getUserData(obj, 'pivot') as [number, number, number] | undefined;
596
+ const hasPivot =
597
+ entityPivot && (entityPivot[0] !== 0 || entityPivot[1] !== 0 || entityPivot[2] !== 0);
598
+
599
+ if (hasPivot && obj) {
600
+ // Position dummy at pivot's world location
601
+ const pivotLocal = new THREE.Vector3(entityPivot[0], entityPivot[1], entityPivot[2]);
602
+ pivotLocal.applyQuaternion(obj.quaternion);
603
+ this._pivotDummy.position.copy(obj.position).add(pivotLocal);
604
+ this._pivotDummy.quaternion.copy(obj.quaternion);
605
+ this._pivotDummy.scale.copy(obj.scale);
606
+ this.transformControls.attach(this._pivotDummy);
607
+ } else {
608
+ this.attach(selectedId);
609
+ }
610
+ }
611
+ }
612
+ } else {
613
+ this.detach();
614
+ }
615
+
616
+ // Box helpers for all selected
617
+ this._syncBoxHelpers();
618
+
619
+ // Component handles for selected entities
620
+ if (!this._handleDragActive) {
621
+ this._syncHandles();
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Re-add editor infrastructure objects to the scene after play mode
627
+ * has cleared all scene children. Call this after loadDocument restores
628
+ * entities so the grid, lights, gizmos, and particle renderer are present.
629
+ */
630
+ /** Move all editor gizmos from the editor scene into a target scene (e.g. the live game scene). */
631
+ moveGizmosTo(target: THREE.Scene): void {
632
+ const objs = this._gizmoObjects();
633
+ for (const obj of objs) target.add(obj);
634
+ }
635
+
636
+ /** Move all editor gizmos back to the editor scene. */
637
+ restoreGizmos(): void {
638
+ const objs = this._gizmoObjects();
639
+ for (const obj of objs) this._scene.add(obj);
640
+ }
641
+
642
+ /** All editor-layer objects that should travel with the gizmos. */
643
+ private _gizmoObjects(): THREE.Object3D[] {
644
+ return [
645
+ this.grid,
646
+ this._editorAmbient,
647
+ this._editorDirLight,
648
+ this.batchedRenderer,
649
+ this._gizmoHelper,
650
+ this._pivotDummy,
651
+ this._surfaceSnapIndicator,
652
+ ];
653
+ }
654
+
655
+ /** Focus camera on a given object. */
656
+ focusOn(obj: THREE.Object3D): void {
657
+ const box = new THREE.Box3().setFromObject(obj);
658
+ const center = box.getCenter(new THREE.Vector3());
659
+ const size = box.getSize(new THREE.Vector3()).length();
660
+ // Ensure a minimum zoom distance for entities without visible geometry
661
+ const distance = Math.max(size * 2, 3);
662
+
663
+ const dir = this.camera.position.clone().sub(this.orbitControls.target).normalize();
664
+ this.camera.position.copy(center).add(dir.multiplyScalar(distance));
665
+ this.orbitControls.target.copy(center);
666
+ }
667
+
668
+ /** Activate vertex snap: collect target vertices from non-selected entities. */
669
+ activateVertexSnap(): void {
670
+ this._vertexSnapTargets = this._collectVertices(this._store.selectedEntityIds);
671
+ }
672
+
673
+ /** Deactivate vertex snap: clear collected vertices and hide indicator. */
674
+ deactivateVertexSnap(): void {
675
+ this._vertexSnapTargets = [];
676
+ this._vertexSnapIndicator.visible = false;
677
+ }
678
+
679
+ /** Focus camera on multiple objects. */
680
+ focusOnMultiple(objects: THREE.Object3D[]): void {
681
+ if (objects.length === 0) return;
682
+ if (objects.length === 1) {
683
+ this.focusOn(objects[0]!);
684
+ return;
685
+ }
686
+ const box = new THREE.Box3();
687
+ for (const obj of objects) box.expandByObject(obj);
688
+ const center = box.getCenter(new THREE.Vector3());
689
+ const size = box.getSize(new THREE.Vector3()).length();
690
+ const distance = size * 2;
691
+
692
+ const dir = this.camera.position.clone().sub(this.orbitControls.target).normalize();
693
+ this.camera.position.copy(center).add(dir.multiplyScalar(distance));
694
+ this.orbitControls.target.copy(center);
695
+ }
696
+
697
+ /** Set or clear the navmesh debug helper (managed by editor main). */
698
+ setNavMeshHelper(helper: THREE.Object3D | null): void {
699
+ if (this._navMeshHelper) {
700
+ this._scene.remove(this._navMeshHelper);
701
+ }
702
+ this._navMeshHelper = helper;
703
+ if (helper) {
704
+ setUserData(helper, 'editorHelper', true);
705
+ setUserData(helper, 'editorHelperType', 'navmesh');
706
+ this._scene.add(helper);
707
+ }
708
+ }
709
+
710
+ /** Snap camera to a preset view direction, preserving current zoom distance. */
711
+ setViewPreset(preset: 'top' | 'front' | 'right' | 'perspective'): void {
712
+ const target = this.orbitControls.target.clone();
713
+ const distance = this.camera.position.distanceTo(target);
714
+
715
+ const dir = new THREE.Vector3();
716
+ switch (preset) {
717
+ case 'top':
718
+ dir.set(0, 1, 0);
719
+ break;
720
+ case 'front':
721
+ dir.set(0, 0, 1);
722
+ break;
723
+ case 'right':
724
+ dir.set(1, 0, 0);
725
+ break;
726
+ case 'perspective':
727
+ dir.set(1, 1, 1).normalize();
728
+ break;
729
+ }
730
+
731
+ this.camera.position.copy(target).add(dir.multiplyScalar(distance));
732
+ this.camera.lookAt(target);
733
+ this.orbitControls.update();
734
+ }
735
+
736
+ /**
737
+ * Move the camera to an arbitrary position/target/fov pose — the general
738
+ * case `setViewPreset`/`focusOn` don't cover (an exact xyz position, not a
739
+ * preset direction or a computed frame-to-fit). Backs the `set-camera`
740
+ * relay command (`editor.viewport.camera.set`'s free-pose mode).
741
+ */
742
+ setPose(
743
+ position: { x: number; y: number; z: number },
744
+ target: { x: number; y: number; z: number },
745
+ fov?: number,
746
+ ): void {
747
+ this.camera.position.set(position.x, position.y, position.z);
748
+ this.orbitControls.target.set(target.x, target.y, target.z);
749
+ this.camera.lookAt(this.orbitControls.target);
750
+ if (fov !== undefined) {
751
+ this.camera.fov = fov;
752
+ this.camera.updateProjectionMatrix();
753
+ }
754
+ this.orbitControls.update();
755
+ }
756
+
757
+ /** Handle resize. */
758
+ resize(width: number, height: number): void {
759
+ this.camera.aspect = Math.max(1, width) / Math.max(1, height);
760
+ this.camera.updateProjectionMatrix();
761
+ }
762
+
763
+ update(dt = 0): void {
764
+ // Animate custom (shader) materials in edit mode — same tick the runtime
765
+ // preRender phase runs, so authored shaders preview live in the viewport.
766
+ tickCustomMaterials(this._scene, dt);
767
+
768
+ // Fly camera movement (before orbit so it takes priority)
769
+ this._updateFlyCamera(dt);
770
+
771
+ this.orbitControls.update();
772
+
773
+ // Snap-to-view animation (runs after orbit update so we have final say on position)
774
+ if (this._snapAnimating) {
775
+ const elapsed = performance.now() - this._snapStartTime;
776
+ let t = Math.min(elapsed / this._snapDuration, 1);
777
+ // easeInOutCubic
778
+ t = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
779
+
780
+ const qt = new THREE.Quaternion().slerpQuaternions(this._snapQ1, this._snapQ2, t);
781
+ const dir = new THREE.Vector3(0, 0, 1).applyQuaternion(qt);
782
+ this.camera.position.copy(this.orbitControls.target).add(dir.multiplyScalar(this._snapDist));
783
+ this.camera.lookAt(this.orbitControls.target);
784
+
785
+ if (t >= 1) this._snapAnimating = false;
786
+ }
787
+
788
+ for (const helper of this._boxHelpers.values()) {
789
+ const tagged = helper as TaggedHelper;
790
+ if (tagged._entityObj) {
791
+ // Fixed-size wireframe cube — follow the entity's world position
792
+ tagged._entityObj.getWorldPosition(helper.position);
793
+ } else {
794
+ helper.update();
795
+ }
796
+ }
797
+
798
+ // Keep handles at constant screen size
799
+ for (const handle of this._handles) {
800
+ scaleHandle(handle, this.camera);
801
+ }
802
+ }
803
+
804
+ /** Render the orientation gizmo overlaid in the top-right corner. */
805
+ renderViewCube(renderer: THREE.WebGLRenderer): void {
806
+ const w = renderer.domElement.clientWidth;
807
+ const h = renderer.domElement.clientHeight;
808
+ const size = this._vcSize;
809
+
810
+ // Sync gizmo camera with main camera orientation (reuse scratch — runs every frame)
811
+ const dir = this._vcDir.subVectors(this.camera.position, this.orbitControls.target).normalize();
812
+ this._vcCamera.position.copy(dir.multiplyScalar(4));
813
+ this._vcCamera.up.copy(this.camera.up);
814
+ this._vcCamera.lookAt(0, 0, 0);
815
+
816
+ // GL viewport coords (origin bottom-left)
817
+ const px = w - size - this._vcMarginRight;
818
+ const py = h - size - this._vcMarginTop;
819
+
820
+ renderer.setScissorTest(true);
821
+ renderer.setViewport(px, py, size, size);
822
+ renderer.setScissor(px, py, size, size);
823
+ // Only clear depth so main scene shows through; disable autoClear so
824
+ // renderer.render() doesn't wipe the color buffer.
825
+ renderer.clear(false, true, false);
826
+ renderer.autoClear = false;
827
+ renderer.render(this._vcScene, this._vcCamera);
828
+ renderer.autoClear = true;
829
+
830
+ renderer.setScissorTest(false);
831
+ renderer.setViewport(0, 0, w, h);
832
+ }
833
+
834
+ /** Render a small camera preview in the bottom-right corner if a camera entity is selected. */
835
+ renderCameraPreview(renderer: THREE.WebGLRenderer): void {
836
+ const selectedEntity = this._store.selectedEntity;
837
+ if (!selectedEntity?.camera) return;
838
+
839
+ const entityObj = this._objectMap.get(selectedEntity.id!);
840
+ if (!entityObj) return;
841
+ const entityCamera = getUserData(entityObj, '_camera') as THREE.Camera | undefined;
842
+ if (!entityCamera) return;
843
+
844
+ const w = renderer.domElement.clientWidth;
845
+ const h = renderer.domElement.clientHeight;
846
+ const camW = selectedEntity.camera.width ?? 1920;
847
+ const camH = selectedEntity.camera.height ?? 1080;
848
+ const pw = Math.floor(w * 0.25);
849
+ const ph = Math.floor(pw * (camH / camW));
850
+ const marginRight = 12;
851
+ const marginBottom = 26 + 12;
852
+ const border = 2;
853
+
854
+ // Hide editor-only objects for the preview
855
+ this.grid.visible = false;
856
+ this._gizmoHelper.visible = false;
857
+ for (const helper of this._boxHelpers.values()) helper.visible = false;
858
+ // Hide the frustum helper lines so they don't appear in the camera's own view
859
+ const hiddenHelpers: THREE.Object3D[] = [];
860
+ entityObj.traverse((child) => {
861
+ if (child instanceof THREE.LineSegments && child.visible) {
862
+ child.visible = false;
863
+ hiddenHelpers.push(child);
864
+ }
865
+ });
866
+
867
+ // Save and override clear color for border
868
+ const prevClearColor = renderer.getClearColor(this._previewClearColor);
869
+ const prevClearAlpha = renderer.getClearAlpha();
870
+
871
+ renderer.setScissorTest(true);
872
+
873
+ // Draw border area
874
+ const px = w - pw - marginRight;
875
+ const py = marginBottom;
876
+ renderer.setViewport(px - border, py - border, pw + border * 2, ph + border * 2);
877
+ renderer.setScissor(px - border, py - border, pw + border * 2, ph + border * 2);
878
+ renderer.setClearColor(0x666666, 1);
879
+ renderer.clear();
880
+
881
+ // Render preview
882
+ renderer.setViewport(px, py, pw, ph);
883
+ renderer.setScissor(px, py, pw, ph);
884
+ renderer.render(this._scene, entityCamera);
885
+
886
+ // Restore
887
+ renderer.setScissorTest(false);
888
+ renderer.setViewport(0, 0, w, h);
889
+ renderer.setClearColor(prevClearColor, prevClearAlpha);
890
+
891
+ // Restore visibility
892
+ this.grid.visible = true;
893
+ this._gizmoHelper.visible = true;
894
+ for (const helper of this._boxHelpers.values()) helper.visible = true;
895
+ for (const helper of hiddenHelpers) helper.visible = true;
896
+ }
897
+
898
+ /** True when the fly camera is active (right-click + movement key held). */
899
+ get isFlying(): boolean {
900
+ return this._flyActive;
901
+ }
902
+
903
+ dispose(): void {
904
+ setViewportPickContext(null);
905
+ window.removeEventListener('keydown', this._onFlyKeyDown);
906
+ window.removeEventListener('keyup', this._onFlyKeyUp);
907
+ this._canvas.removeEventListener('pointerdown', this._onFlyPointerDown);
908
+ this._canvas.removeEventListener('pointerup', this._onFlyPointerUp);
909
+ this._canvas.removeEventListener('pointermove', this._onFlyPointerMove);
910
+ this._canvas.removeEventListener('wheel', this._onFlyWheel);
911
+ this._canvas.removeEventListener('pointerdown', this._onPointerDown);
912
+ this._canvas.removeEventListener('pointermove', this._onPointerMove);
913
+ this._canvas.removeEventListener('pointerup', this._onPointerUp);
914
+ this._canvas.removeEventListener('dragover', this._onDragOver);
915
+ this._canvas.removeEventListener('dragleave', this._onDragLeave);
916
+ this._canvas.removeEventListener('drop', this._onDrop);
917
+ this._hideDropOverlay();
918
+ for (const h of this._handles) {
919
+ this._scene.remove(h);
920
+ (h.material as THREE.Material).dispose();
921
+ }
922
+ this._handles = [];
923
+ for (const helper of this._boxHelpers.values()) {
924
+ this._scene.remove(helper);
925
+ helper.geometry.dispose();
926
+ (helper.material as THREE.Material).dispose();
927
+ }
928
+ this._boxHelpers.clear();
929
+ this.orbitControls.dispose();
930
+ this.transformControls.dispose();
931
+ }
932
+
933
+ // --- Private ---
934
+
935
+ /** Build the orientation gizmo: colored axis arrows with cone tips, labels, and click targets. */
936
+ private _initOrientationGizmo(): void {
937
+ this._vcScene.add(new THREE.AmbientLight(0xffffff, 0.8));
938
+ const light = new THREE.DirectionalLight(0xffffff, 0.5);
939
+ light.position.set(1, 2, 3);
940
+ this._vcScene.add(light);
941
+
942
+ // Godot axis colors
943
+ const colors = [
944
+ new THREE.Color(0.96, 0.2, 0.32), // X
945
+ new THREE.Color(0.53, 0.84, 0.01), // Y
946
+ new THREE.Color(0.16, 0.55, 0.96), // Z
947
+ ];
948
+ const axes = [
949
+ new THREE.Vector3(1, 0, 0),
950
+ new THREE.Vector3(0, 1, 0),
951
+ new THREE.Vector3(0, 0, 1),
952
+ ];
953
+ const names = ['X', 'Y', 'Z'];
954
+ const shaft = 0.9;
955
+ const tip = 0.15;
956
+ const targetGeo = new THREE.SphereGeometry(0.2, 8, 8);
957
+ const targetMat = new THREE.MeshBasicMaterial({ visible: false });
958
+
959
+ for (let i = 0; i < 3; i++) {
960
+ const c = colors[i]!;
961
+ const a = axes[i]!;
962
+
963
+ // --- Positive axis ---
964
+
965
+ // Shaft line
966
+ this._vcScene.add(
967
+ new THREE.Line(
968
+ new THREE.BufferGeometry().setFromPoints([
969
+ new THREE.Vector3(),
970
+ a.clone().multiplyScalar(shaft),
971
+ ]),
972
+ new THREE.LineBasicMaterial({ color: c }),
973
+ ),
974
+ );
975
+
976
+ // Cone arrowhead
977
+ const cone = new THREE.Mesh(
978
+ new THREE.ConeGeometry(0.055, tip, 8),
979
+ new THREE.MeshBasicMaterial({ color: c }),
980
+ );
981
+ cone.position.copy(a.clone().multiplyScalar(shaft + tip / 2));
982
+ if (i === 0) cone.rotation.z = -Math.PI / 2;
983
+ if (i === 2) cone.rotation.x = Math.PI / 2;
984
+ this._vcScene.add(cone);
985
+
986
+ // Label sprite
987
+ this._vcScene.add(
988
+ this._makeAxisLabel(names[i]!, c, a.clone().multiplyScalar(shaft + tip + 0.15)),
989
+ );
990
+
991
+ // Click target (invisible sphere)
992
+ const posTarget = new THREE.Mesh(targetGeo, targetMat);
993
+ posTarget.position.copy(a.clone().multiplyScalar(shaft * 0.6));
994
+ setUserData(posTarget, 'vcDirIdx', i * 2); // 0=+X, 2=+Y, 4=+Z
995
+ this._vcScene.add(posTarget);
996
+ this._vcClickTargets.push(posTarget);
997
+
998
+ // --- Negative axis ---
999
+
1000
+ // Faint line for negative direction
1001
+ this._vcScene.add(
1002
+ new THREE.Line(
1003
+ new THREE.BufferGeometry().setFromPoints([
1004
+ new THREE.Vector3(),
1005
+ a.clone().multiplyScalar(-0.45),
1006
+ ]),
1007
+ new THREE.LineBasicMaterial({ color: c, transparent: true, opacity: 0.3 }),
1008
+ ),
1009
+ );
1010
+
1011
+ // Small dot at negative end
1012
+ const negDot = new THREE.Mesh(
1013
+ new THREE.SphereGeometry(0.06, 8, 8),
1014
+ new THREE.MeshBasicMaterial({ color: c, transparent: true, opacity: 0.4 }),
1015
+ );
1016
+ negDot.position.copy(a.clone().multiplyScalar(-0.5));
1017
+ this._vcScene.add(negDot);
1018
+
1019
+ // Click target for negative axis
1020
+ const negTarget = new THREE.Mesh(targetGeo, targetMat);
1021
+ negTarget.position.copy(a.clone().multiplyScalar(-0.45));
1022
+ setUserData(negTarget, 'vcDirIdx', i * 2 + 1); // 1=-X, 3=-Y, 5=-Z
1023
+ this._vcScene.add(negTarget);
1024
+ this._vcClickTargets.push(negTarget);
1025
+ }
1026
+
1027
+ // Center dot
1028
+ this._vcScene.add(
1029
+ new THREE.Mesh(
1030
+ new THREE.SphereGeometry(0.05, 8, 8),
1031
+ new THREE.MeshBasicMaterial({ color: 0x888888 }),
1032
+ ),
1033
+ );
1034
+ }
1035
+
1036
+ /** Create a sprite with a single axis letter. */
1037
+ private _makeAxisLabel(text: string, color: THREE.Color, position: THREE.Vector3): THREE.Sprite {
1038
+ const size = 64;
1039
+ const c = document.createElement('canvas');
1040
+ c.width = size;
1041
+ c.height = size;
1042
+ const ctx = c.getContext('2d')!;
1043
+ ctx.fillStyle = `#${color.getHexString()}`;
1044
+ ctx.font = 'bold 44px system-ui, -apple-system, sans-serif';
1045
+ ctx.textAlign = 'center';
1046
+ ctx.textBaseline = 'middle';
1047
+ ctx.fillText(text, size / 2, size / 2);
1048
+
1049
+ const texture = new THREE.CanvasTexture(c);
1050
+ const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false });
1051
+ const sprite = new THREE.Sprite(mat);
1052
+ sprite.position.copy(position);
1053
+ sprite.scale.set(0.25, 0.25, 0.25);
1054
+ return sprite;
1055
+ }
1056
+
1057
+ /** Returns true if the click was inside the gizmo area (consumed). */
1058
+ private _handleViewCubeClick(e: PointerEvent): boolean {
1059
+ const rect = this._canvas.getBoundingClientRect();
1060
+ const x = e.clientX - rect.left;
1061
+ const y = e.clientY - rect.top;
1062
+ const w = rect.width;
1063
+
1064
+ // Screen-space gizmo rect
1065
+ const vcLeft = w - this._vcSize - this._vcMarginRight;
1066
+ const vcTop = this._vcMarginTop;
1067
+
1068
+ if (x < vcLeft || x > vcLeft + this._vcSize || y < vcTop || y > vcTop + this._vcSize) {
1069
+ return false;
1070
+ }
1071
+
1072
+ // Convert to NDC for the gizmo camera
1073
+ const ndcX = ((x - vcLeft) / this._vcSize) * 2 - 1;
1074
+ const ndcY = -(((y - vcTop) / this._vcSize) * 2 - 1);
1075
+
1076
+ this._vcRaycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), this._vcCamera);
1077
+ const hits = this._vcRaycaster.intersectObjects(this._vcClickTargets);
1078
+ if (hits.length === 0) return true; // In gizmo area but missed — still consume
1079
+
1080
+ const dirIdx = getUserData(hits[0]!.object, 'vcDirIdx') as number;
1081
+ const targetDir = VC_DIRS[dirIdx]!;
1082
+
1083
+ // Set up slerp animation
1084
+ const startDir = new THREE.Vector3()
1085
+ .subVectors(this.camera.position, this.orbitControls.target)
1086
+ .normalize();
1087
+
1088
+ this._snapDist = this.camera.position.distanceTo(this.orbitControls.target);
1089
+ this._snapQ1.setFromUnitVectors(new THREE.Vector3(0, 0, 1), startDir);
1090
+ this._snapQ2.setFromUnitVectors(new THREE.Vector3(0, 0, 1), targetDir);
1091
+ this._snapStartTime = performance.now();
1092
+ this._snapAnimating = true;
1093
+
1094
+ return true;
1095
+ }
1096
+
1097
+ /** Live flush during drag — updates inspector without undo or scene rebuild. */
1098
+ private _flushGizmoToStoreLive(): void {
1099
+ this._skipNextSync = true;
1100
+ // Live transform edits flow through the AuthoringAdapter (the gizmo already
1101
+ // moved the Object3D; apply syncs the descriptor without pushing undo).
1102
+ const transforms = getActiveAuthoring(this._store).transforms;
1103
+ const applyLive = (o: THREE.Object3D): void => {
1104
+ const id = getUserData(o, 'entityId') as string | undefined;
1105
+ if (!id) return;
1106
+ transforms?.apply(id, {
1107
+ position: o.position.toArray() as [number, number, number],
1108
+ rotation: o.quaternion.toArray() as [number, number, number, number],
1109
+ scale: o.scale.toArray() as [number, number, number],
1110
+ });
1111
+ };
1112
+ // Primary (if it's a real entity, not pivot dummy)
1113
+ const obj = this.transformControls.object;
1114
+ if (obj) applyLive(obj);
1115
+ // Other selected objects (active-element / individual-origins)
1116
+ for (const { obj: other } of this._otherDragObjects) applyLive(other);
1117
+ // All selected objects (median-point mode — gizmo is on dummy)
1118
+ for (const { obj: mo } of this._allDragObjects) applyLive(mo);
1119
+ }
1120
+
1121
+ /**
1122
+ * Surface snap: raycast downward from the primary object to find
1123
+ * a surface and adjust Y positions accordingly.
1124
+ */
1125
+ private _applySurfaceSnap(
1126
+ gizmoObj: THREE.Object3D,
1127
+ targets: {
1128
+ obj: THREE.Object3D;
1129
+ startPos: THREE.Vector3;
1130
+ startQuat: THREE.Quaternion;
1131
+ startScale: THREE.Vector3;
1132
+ }[],
1133
+ ): void {
1134
+ // Collect all dragged entity IDs to exclude from raycast
1135
+ const draggedIds = new Set<string>();
1136
+ const gizmoId = getUserData(gizmoObj, 'entityId') as string | undefined;
1137
+ if (gizmoId) draggedIds.add(gizmoId);
1138
+ for (const { obj } of targets) {
1139
+ const id = getUserData(obj, 'entityId') as string | undefined;
1140
+ if (id) draggedIds.add(id);
1141
+ }
1142
+
1143
+ // Collect scene objects that are NOT being dragged
1144
+ const sceneTargets: THREE.Object3D[] = [];
1145
+ for (const [id, obj] of this._objectMap) {
1146
+ if (!draggedIds.has(id)) sceneTargets.push(obj);
1147
+ }
1148
+ // Also include the grid as a snap target
1149
+ sceneTargets.push(this.grid);
1150
+
1151
+ if (sceneTargets.length === 0) {
1152
+ this._surfaceSnapIndicator.visible = false;
1153
+ return;
1154
+ }
1155
+
1156
+ // Raycast downward from the gizmo object position
1157
+ const origin = gizmoObj.position.clone();
1158
+ origin.y += 50; // Start high above
1159
+ this._surfaceSnapRaycaster.set(origin, new THREE.Vector3(0, -1, 0));
1160
+ this._surfaceSnapRaycaster.far = 200;
1161
+
1162
+ const hits = this._surfaceSnapRaycaster.intersectObjects(sceneTargets, true);
1163
+ if (hits.length > 0) {
1164
+ const hitPoint = hits[0]!.point;
1165
+ const snapY = hitPoint.y;
1166
+ const deltaY = snapY - gizmoObj.position.y;
1167
+
1168
+ gizmoObj.position.y = snapY;
1169
+ for (const { obj } of targets) {
1170
+ obj.position.y += deltaY;
1171
+ }
1172
+
1173
+ // Show snap indicator at hit point
1174
+ this._surfaceSnapIndicator.position.copy(hitPoint);
1175
+ this._surfaceSnapIndicator.position.y += 0.01; // Slight offset to avoid z-fighting
1176
+ // Orient ring to match surface normal
1177
+ const normal = hits[0]!.face?.normal;
1178
+ if (normal) {
1179
+ const worldNormal = normal.clone().transformDirection(hits[0]!.object.matrixWorld);
1180
+ this._surfaceSnapIndicator.quaternion.setFromUnitVectors(
1181
+ new THREE.Vector3(0, 1, 0),
1182
+ worldNormal,
1183
+ );
1184
+ }
1185
+ this._surfaceSnapIndicator.visible = true;
1186
+ } else {
1187
+ this._surfaceSnapIndicator.visible = false;
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Collect world-space vertex positions from entity meshes.
1193
+ * Excludes entities in `excludeIds` and editor helper children.
1194
+ */
1195
+ private _collectVertices(excludeIds: ReadonlySet<string>): THREE.Vector3[] {
1196
+ const vertices: THREE.Vector3[] = [];
1197
+ const MAX_PER_MESH = 1000;
1198
+ for (const [id, obj] of this._objectMap) {
1199
+ if (excludeIds.has(id)) continue;
1200
+ obj.traverse((child) => {
1201
+ if (getUserData(child, 'editorHelper')) return;
1202
+ const mesh = child as THREE.Mesh;
1203
+ if (!mesh.isMesh || !mesh.geometry) return;
1204
+ const posAttr = mesh.geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
1205
+ if (!posAttr) return;
1206
+ const count = Math.min(posAttr.count, MAX_PER_MESH);
1207
+ const worldMatrix = mesh.matrixWorld;
1208
+ for (let i = 0; i < count; i++) {
1209
+ const v = new THREE.Vector3(posAttr.getX(i), posAttr.getY(i), posAttr.getZ(i));
1210
+ v.applyMatrix4(worldMatrix);
1211
+ vertices.push(v);
1212
+ }
1213
+ });
1214
+ }
1215
+ return vertices;
1216
+ }
1217
+
1218
+ /**
1219
+ * Vertex snap: snap the gizmo object to the nearest vertex on non-selected geometry.
1220
+ */
1221
+ private _applyVertexSnap(
1222
+ gizmoObj: THREE.Object3D,
1223
+ targets: { obj: THREE.Object3D; startPos: THREE.Vector3 }[],
1224
+ ): void {
1225
+ const entityPos = gizmoObj.position;
1226
+ const projected = entityPos.clone().project(this.camera);
1227
+ const rect = this._canvas.getBoundingClientRect();
1228
+ const entityScreenX = ((projected.x + 1) / 2) * rect.width;
1229
+ const entityScreenY = ((-projected.y + 1) / 2) * rect.height;
1230
+
1231
+ let bestDist = this._vertexSnapThreshold;
1232
+ let bestVertex: THREE.Vector3 | null = null;
1233
+
1234
+ for (const vert of this._vertexSnapTargets) {
1235
+ const vs = vert.clone().project(this.camera);
1236
+ // Skip vertices behind camera
1237
+ if (vs.z > 1) continue;
1238
+ const vertScreenX = ((vs.x + 1) / 2) * rect.width;
1239
+ const vertScreenY = ((-vs.y + 1) / 2) * rect.height;
1240
+ const dx = entityScreenX - vertScreenX;
1241
+ const dy = entityScreenY - vertScreenY;
1242
+ const dist = Math.sqrt(dx * dx + dy * dy);
1243
+ if (dist < bestDist) {
1244
+ bestDist = dist;
1245
+ bestVertex = vert;
1246
+ }
1247
+ }
1248
+
1249
+ if (bestVertex) {
1250
+ const delta = bestVertex.clone().sub(entityPos);
1251
+ gizmoObj.position.copy(bestVertex);
1252
+ for (const { obj } of targets) {
1253
+ obj.position.add(delta);
1254
+ }
1255
+ this._vertexSnapIndicator.position.copy(bestVertex);
1256
+ this._vertexSnapIndicator.visible = true;
1257
+ // Constant screen size for indicator
1258
+ const dist = bestVertex.distanceTo(this.camera.position);
1259
+ this._vertexSnapIndicator.scale.setScalar(dist * 0.008);
1260
+ } else {
1261
+ this._vertexSnapIndicator.visible = false;
1262
+ }
1263
+ }
1264
+
1265
+ /** Final flush on drag end — no undo push (already pushed on drag start). */
1266
+ private _flushGizmoToStore(): void {
1267
+ this._flushGizmoToStoreLive();
1268
+ this._otherDragObjects = [];
1269
+ this._allDragObjects = [];
1270
+ // Persist the final transform + mark dirty (ED2). A mid-drag autosave may
1271
+ // have already fired and cleared dirty; this re-schedules the final write.
1272
+ getActiveAuthoring(this._store).transforms?.endEdit('');
1273
+ }
1274
+
1275
+ /** Check if we should skip a sync (gizmo already moved the object). */
1276
+ consumeSkipSync(): boolean {
1277
+ if (this._skipNextSync) {
1278
+ this._skipNextSync = false;
1279
+ return true;
1280
+ }
1281
+ return false;
1282
+ }
1283
+
1284
+ /** Recolor gizmo handles to Godot axis colors & shift plane geometry to meet at center. */
1285
+ private _patchGizmo(gizmoHelper: THREE.Object3D): void {
1286
+ // Godot axis colors: Color(0.96,0.20,0.32), Color(0.53,0.84,0.01), Color(0.16,0.55,0.96)
1287
+ const axisX = new THREE.Color(0.96, 0.2, 0.32);
1288
+ const axisY = new THREE.Color(0.53, 0.84, 0.01);
1289
+ const axisZ = new THREE.Color(0.16, 0.55, 0.96);
1290
+
1291
+ // Handle color: axis handles use axis color, plane handles use normal-axis color
1292
+ const colorMap: Record<string, THREE.Color> = {
1293
+ X: axisX,
1294
+ Y: axisY,
1295
+ Z: axisZ,
1296
+ XY: axisZ,
1297
+ YZ: axisX,
1298
+ XZ: axisY,
1299
+ };
1300
+
1301
+ // Plane geometry offsets to bring inner corners to object center
1302
+ const planeOffsets: Record<string, [number, number, number]> = {
1303
+ XY: [-0.075, -0.075, 0],
1304
+ YZ: [0, -0.075, -0.075],
1305
+ XZ: [-0.075, 0, -0.075],
1306
+ };
1307
+
1308
+ gizmoHelper.traverse((child) => {
1309
+ const mesh = child as THREE.Mesh;
1310
+
1311
+ // Recolor handle materials
1312
+ const newColor = colorMap[child.name];
1313
+ if (newColor && mesh.material) {
1314
+ const mat = mesh.material as THREE.MeshBasicMaterial | THREE.LineBasicMaterial;
1315
+ if (mat.color) mat.color.copy(newColor);
1316
+ }
1317
+
1318
+ // Shift plane handle geometry
1319
+ const offset = planeOffsets[child.name];
1320
+ if (offset && mesh.geometry) {
1321
+ mesh.geometry.translate(offset[0], offset[1], offset[2]);
1322
+ }
1323
+ });
1324
+ }
1325
+
1326
+ /** Raycast against component handles and return the hit handle, or null. */
1327
+ private _raycastHandle(e: PointerEvent): HandleMesh | null {
1328
+ if (this._handles.length === 0) return null;
1329
+ const rect = this._canvas.getBoundingClientRect();
1330
+ const mouse = new THREE.Vector2(
1331
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
1332
+ -((e.clientY - rect.top) / rect.height) * 2 + 1,
1333
+ );
1334
+ this._handleRaycaster.setFromCamera(mouse, this.camera);
1335
+ const intersects = this._handleRaycaster.intersectObjects(this._handles);
1336
+ return intersects.length > 0 ? (intersects[0]!.object as HandleMesh) : null;
1337
+ }
1338
+
1339
+ /** Rebuild component handles for the current selection. */
1340
+ private _syncHandles(): void {
1341
+ // Remove old handles + dispose their materials (LK6). The geometry is a
1342
+ // shared module-level sphere, so it must NOT be disposed.
1343
+ for (const h of this._handles) {
1344
+ this._scene.remove(h);
1345
+ (h.material as THREE.Material).dispose();
1346
+ }
1347
+ this._handles = [];
1348
+
1349
+ // Create new handles for selected entities
1350
+ for (const id of this._store.selectedEntityIds) {
1351
+ const obj = this._objectMap.get(id);
1352
+ const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1353
+ if (!obj || !entity) continue;
1354
+
1355
+ const defs = createHandleDefs(entity);
1356
+ for (const def of defs) {
1357
+ const mesh = createHandleMesh(def);
1358
+ mesh.position.copy(def.getWorldPosition(obj, entity));
1359
+ scaleHandle(mesh, this.camera);
1360
+ this._scene.add(mesh);
1361
+ this._handles.push(mesh);
1362
+ }
1363
+ }
1364
+ }
1365
+
1366
+ /** Project entity center to screen coords. */
1367
+ private _projectToScreen(obj: THREE.Object3D): THREE.Vector2 {
1368
+ const pos = new THREE.Vector3();
1369
+ obj.getWorldPosition(pos);
1370
+ pos.project(this.camera);
1371
+ const rect = this._canvas.getBoundingClientRect();
1372
+ return new THREE.Vector2(
1373
+ ((pos.x + 1) / 2) * rect.width + rect.left,
1374
+ ((-pos.y + 1) / 2) * rect.height + rect.top,
1375
+ );
1376
+ }
1377
+
1378
+ // -- Fly camera handlers --
1379
+
1380
+ private static _FLY_KEYS = new Set(['w', 'a', 's', 'd', 'q', 'e']);
1381
+
1382
+ private _enterFlyMode(): void {
1383
+ if (this._flyActive) return;
1384
+ this._flyActive = true;
1385
+ this._snapAnimating = false; // cancel any view snap
1386
+ this.orbitControls.enabled = false;
1387
+ // Sync euler from current camera orientation
1388
+ this._flyEuler.setFromQuaternion(this.camera.quaternion, 'YXZ');
1389
+ this._canvas.requestPointerLock?.();
1390
+ }
1391
+
1392
+ private _exitFlyMode(): void {
1393
+ if (!this._flyActive) return;
1394
+ this._flyActive = false;
1395
+ this._flyKeys.clear();
1396
+ this.orbitControls.enabled = true;
1397
+ document.exitPointerLock();
1398
+ // Sync orbit target to a point in front of the camera
1399
+ const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.camera.quaternion);
1400
+ const dist = this.camera.position.distanceTo(this.orbitControls.target);
1401
+ this.orbitControls.target
1402
+ .copy(this.camera.position)
1403
+ .add(forward.multiplyScalar(Math.max(dist, 5)));
1404
+ }
1405
+
1406
+ /** Apply fly camera movement each frame. Called from update(). */
1407
+ private _updateFlyCamera(dt: number): void {
1408
+ if (!this._flyActive || this._flyKeys.size === 0) return;
1409
+
1410
+ const speed = this._flySpeed * (this._flyKeys.has('shift') ? 3 : 1);
1411
+ const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.camera.quaternion);
1412
+ const right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.camera.quaternion);
1413
+ const move = new THREE.Vector3();
1414
+
1415
+ if (this._flyKeys.has('w')) move.add(forward);
1416
+ if (this._flyKeys.has('s')) move.sub(forward);
1417
+ if (this._flyKeys.has('d')) move.add(right);
1418
+ if (this._flyKeys.has('a')) move.sub(right);
1419
+ if (this._flyKeys.has('e')) move.y += 1;
1420
+ if (this._flyKeys.has('q')) move.y -= 1;
1421
+
1422
+ if (move.lengthSq() > 0) {
1423
+ move.normalize().multiplyScalar(speed * dt);
1424
+ this.camera.position.add(move);
1425
+ }
1426
+ }
1427
+
1428
+ private _onFlyKeyDown = (e: KeyboardEvent): void => {
1429
+ if (!this._rightMouseDown) return;
1430
+ // Skip when typing in form inputs
1431
+ if (
1432
+ e.target instanceof HTMLInputElement ||
1433
+ e.target instanceof HTMLTextAreaElement ||
1434
+ e.target instanceof HTMLSelectElement
1435
+ )
1436
+ return;
1437
+
1438
+ const key = e.key.toLowerCase();
1439
+ if (key === 'shift') {
1440
+ this._flyKeys.add('shift');
1441
+ return;
1442
+ }
1443
+ if (!EditorViewport._FLY_KEYS.has(key)) return;
1444
+ e.preventDefault();
1445
+ e.stopPropagation();
1446
+ this._flyKeys.add(key);
1447
+ if (!this._flyActive) this._enterFlyMode();
1448
+ };
1449
+
1450
+ private _onFlyKeyUp = (e: KeyboardEvent): void => {
1451
+ const key = e.key.toLowerCase();
1452
+ if (key === 'shift') {
1453
+ this._flyKeys.delete('shift');
1454
+ return;
1455
+ }
1456
+ this._flyKeys.delete(key);
1457
+ };
1458
+
1459
+ private _onFlyPointerDown = (e: PointerEvent): void => {
1460
+ if (e.button === 2) this._rightMouseDown = true;
1461
+ };
1462
+
1463
+ private _onFlyPointerUp = (e: PointerEvent): void => {
1464
+ if (e.button === 2) {
1465
+ this._rightMouseDown = false;
1466
+ if (this._flyActive) this._exitFlyMode();
1467
+ }
1468
+ };
1469
+
1470
+ private _onFlyPointerMove = (e: PointerEvent): void => {
1471
+ if (!this._flyActive) return;
1472
+ const sensitivity = 0.002;
1473
+ this._flyEuler.y -= e.movementX * sensitivity;
1474
+ this._flyEuler.x -= e.movementY * sensitivity;
1475
+ // Clamp pitch to avoid gimbal flip
1476
+ this._flyEuler.x = Math.max(
1477
+ -Math.PI / 2 + 0.01,
1478
+ Math.min(Math.PI / 2 - 0.01, this._flyEuler.x),
1479
+ );
1480
+ this.camera.quaternion.setFromEuler(this._flyEuler);
1481
+ };
1482
+
1483
+ private _onFlyWheel = (e: WheelEvent): void => {
1484
+ if (!this._flyActive) return;
1485
+ e.preventDefault();
1486
+ // Scroll adjusts fly speed (multiplicative, Godot-style)
1487
+ const factor = e.deltaY > 0 ? 0.85 : 1.18;
1488
+ this._flySpeed = Math.max(0.5, Math.min(100, this._flySpeed * factor));
1489
+ };
1490
+
1491
+ private _onPointerDown = (e: PointerEvent): void => {
1492
+ this._pointerDownPos.set(e.clientX, e.clientY);
1493
+ this._pointerDownButton = e.button;
1494
+ this._marqueeActive = false;
1495
+
1496
+ // Check for handle click (left-button only)
1497
+ if (e.button === 0 && this._handles.length > 0 && !e.altKey) {
1498
+ const hit = this._raycastHandle(e);
1499
+ if (hit) {
1500
+ e.stopPropagation();
1501
+ this._handleDragActive = true;
1502
+ this._activeHandle = hit;
1503
+ this.orbitControls.enabled = false;
1504
+ this.transformControls.enabled = false;
1505
+ getActiveAuthoring(this._store).transforms?.beginEdit('');
1506
+
1507
+ // Compute constraint plane perpendicular to camera through handle position
1508
+ const camDir = this.camera.getWorldDirection(new THREE.Vector3());
1509
+ this._handleDragPlane.setFromNormalAndCoplanarPoint(camDir, hit.position);
1510
+
1511
+ // Compute offset so handle doesn't jump
1512
+ const rect = this._canvas.getBoundingClientRect();
1513
+ const mouse = new THREE.Vector2(
1514
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
1515
+ -((e.clientY - rect.top) / rect.height) * 2 + 1,
1516
+ );
1517
+ this._handleRaycaster.setFromCamera(mouse, this.camera);
1518
+ const target = new THREE.Vector3();
1519
+ this._handleRaycaster.ray.intersectPlane(this._handleDragPlane, target);
1520
+ this._handleDragOffset.subVectors(hit.position, target);
1521
+ this._canvas.style.cursor = 'grabbing';
1522
+ }
1523
+ }
1524
+ };
1525
+
1526
+ private _onPointerMove = (e: PointerEvent): void => {
1527
+ // Handle drag
1528
+ if (this._handleDragActive && this._activeHandle) {
1529
+ const rect = this._canvas.getBoundingClientRect();
1530
+ const mouse = new THREE.Vector2(
1531
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
1532
+ -((e.clientY - rect.top) / rect.height) * 2 + 1,
1533
+ );
1534
+ this._handleRaycaster.setFromCamera(mouse, this.camera);
1535
+ const target = new THREE.Vector3();
1536
+ if (this._handleRaycaster.ray.intersectPlane(this._handleDragPlane, target)) {
1537
+ const newPos = target.add(this._handleDragOffset);
1538
+ const def = this._activeHandle.userData.handleDef;
1539
+ const entityObj = this._objectMap.get(def.entityId);
1540
+ const entity = getUserData(entityObj, 'entity') as SceneEntity | undefined;
1541
+ if (entityObj && entity) {
1542
+ def.onDrag(newPos, entityObj, entity);
1543
+ // Rebuild the entity helpers to reflect the new value
1544
+ this._store.rebuildEntityLive(def.entityId);
1545
+ // Re-sync handle positions (helpers were rebuilt)
1546
+ this._syncHandles();
1547
+ }
1548
+ }
1549
+ return;
1550
+ }
1551
+
1552
+ // Handle hover detection (when not dragging anything)
1553
+ if (!(e.buttons & 1) && this._handles.length > 0) {
1554
+ const hit = this._raycastHandle(e);
1555
+ if (hit !== this._hoveredHandle) {
1556
+ if (this._hoveredHandle) setHandleHovered(this._hoveredHandle, false);
1557
+ this._hoveredHandle = hit;
1558
+ if (hit) {
1559
+ setHandleHovered(hit, true);
1560
+ this._canvas.style.cursor = 'grab';
1561
+ } else {
1562
+ this._canvas.style.cursor = '';
1563
+ }
1564
+ }
1565
+ }
1566
+
1567
+ // Only left button drag starts marquee (skip if Alt-orbiting)
1568
+ if (!(e.buttons & 1) || this._pointerDownButton !== 0) return;
1569
+ if (this.transformControls.dragging) return;
1570
+ if (this._altDragOrbit) return;
1571
+
1572
+ const dx = e.clientX - this._pointerDownPos.x;
1573
+ const dy = e.clientY - this._pointerDownPos.y;
1574
+ if (!this._marqueeActive && Math.sqrt(dx * dx + dy * dy) > 5) {
1575
+ // Check we're not dragging from the viewcube area
1576
+ const rect = this._canvas.getBoundingClientRect();
1577
+ const sx = this._pointerDownPos.x - rect.left;
1578
+ const sy = this._pointerDownPos.y - rect.top;
1579
+ const vcLeft = rect.width - this._vcSize - this._vcMarginRight;
1580
+ if (
1581
+ sx >= vcLeft &&
1582
+ sx <= vcLeft + this._vcSize &&
1583
+ sy >= this._vcMarginTop &&
1584
+ sy <= this._vcMarginTop + this._vcSize
1585
+ )
1586
+ return;
1587
+
1588
+ this._marqueeActive = true;
1589
+ // Disable orbit controls while marquee is active
1590
+ this.orbitControls.enabled = false;
1591
+
1592
+ if (!this._marqueeDiv) {
1593
+ this._marqueeDiv = document.createElement('div');
1594
+ Object.assign(this._marqueeDiv.style, {
1595
+ position: 'fixed',
1596
+ border: '1px solid #579EFF',
1597
+ background: 'rgba(87, 158, 255, 0.15)',
1598
+ pointerEvents: 'none',
1599
+ zIndex: '999',
1600
+ });
1601
+ document.body.appendChild(this._marqueeDiv);
1602
+ }
1603
+ }
1604
+
1605
+ if (this._marqueeActive && this._marqueeDiv) {
1606
+ const left = Math.min(this._pointerDownPos.x, e.clientX);
1607
+ const top = Math.min(this._pointerDownPos.y, e.clientY);
1608
+ const w = Math.abs(e.clientX - this._pointerDownPos.x);
1609
+ const h = Math.abs(e.clientY - this._pointerDownPos.y);
1610
+ Object.assign(this._marqueeDiv.style, {
1611
+ left: `${left}px`,
1612
+ top: `${top}px`,
1613
+ width: `${w}px`,
1614
+ height: `${h}px`,
1615
+ display: 'block',
1616
+ });
1617
+ }
1618
+ };
1619
+
1620
+ private _onPointerUp = (e: PointerEvent): void => {
1621
+ // Handle drag end
1622
+ if (this._handleDragActive) {
1623
+ this._handleDragActive = false;
1624
+ this._activeHandle = null;
1625
+ this.orbitControls.enabled = true;
1626
+ this.transformControls.enabled = true;
1627
+ this._canvas.style.cursor = '';
1628
+ // Persist the final handle-driven edit + mark dirty (ED2).
1629
+ getActiveAuthoring(this._store).transforms?.endEdit('');
1630
+ return;
1631
+ }
1632
+
1633
+ // Handle marquee select. D12 (B4) scope note: marquee stays THREEJS-ONLY —
1634
+ // it only ever scans `this._objectMap` (the focused threejs world's live
1635
+ // objects), never the composite's other (react/pixi) layered children. A
1636
+ // layered/rect-based marquee across every visible layer is out of scope
1637
+ // for this task (D12) — single-click picking is layered (below), marquee
1638
+ // is not.
1639
+ if (this._marqueeActive) {
1640
+ this._marqueeActive = false;
1641
+ this.orbitControls.enabled = true;
1642
+ if (this._marqueeDiv) {
1643
+ this._marqueeDiv.style.display = 'none';
1644
+ }
1645
+
1646
+ // Find all entities whose screen projection falls within the marquee
1647
+ const left = Math.min(this._pointerDownPos.x, e.clientX);
1648
+ const right = Math.max(this._pointerDownPos.x, e.clientX);
1649
+ const top = Math.min(this._pointerDownPos.y, e.clientY);
1650
+ const bottom = Math.max(this._pointerDownPos.y, e.clientY);
1651
+
1652
+ const hitIds: string[] = [];
1653
+ for (const [id, obj] of this._objectMap) {
1654
+ // Skip locked entities in marquee selection
1655
+ if (this._store.isEntityLocked(id)) continue;
1656
+ const screen = this._projectToScreen(obj);
1657
+ if (screen.x >= left && screen.x <= right && screen.y >= top && screen.y <= bottom) {
1658
+ hitIds.push(id);
1659
+ }
1660
+ }
1661
+
1662
+ if (e.shiftKey) {
1663
+ // Add to existing selection (additive modifier — SelectionProvider
1664
+ // extension is Phase-F; first-party store op for now).
1665
+ for (const id of hitIds) this._store.addToSelection(id);
1666
+ } else {
1667
+ getActiveAuthoring(this._store).selection?.set(hitIds);
1668
+ }
1669
+ return;
1670
+ }
1671
+
1672
+ // Only left-click triggers selection (right-drag = orbit, not select)
1673
+ if (e.button !== 0) return;
1674
+
1675
+ // Only treat as click if pointer didn't move much (not a drag)
1676
+ const dx = e.clientX - this._pointerDownPos.x;
1677
+ const dy = e.clientY - this._pointerDownPos.y;
1678
+ if (Math.sqrt(dx * dx + dy * dy) > 5) return;
1679
+
1680
+ // Don't interfere with gizmo clicks
1681
+ if (this.transformControls.dragging) return;
1682
+
1683
+ // Check orientation gizmo click first
1684
+ if (this._handleViewCubeClick(e)) return;
1685
+
1686
+ // D12 (B4) — topmost-first layered pick: the composite's non-threejs
1687
+ // children (react/pixi design-time layers) are tried topmost-first before
1688
+ // falling through to the threejs raycast (now `VgaiSceneAuthoringAdapter
1689
+ // .pickable`) — see `pickTopmost`'s doc comment. Note: an INTERACTIVE
1690
+ // layer (session `interactive` toggle, B1) is mutually exclusive with this
1691
+ // walk running at all, by construction — its `pointer-events:auto` DOM
1692
+ // intercepts the event before it ever reaches this canvas listener, so
1693
+ // there is no special-case here for it.
1694
+ const entityId = pickTopmost(this._store, e.clientX, e.clientY);
1695
+
1696
+ if (e.shiftKey && entityId) {
1697
+ this._store.addToSelection(entityId);
1698
+ } else if ((e.metaKey || e.ctrlKey) && entityId) {
1699
+ this._store.toggleSelection(entityId);
1700
+ } else {
1701
+ getActiveAuthoring(this._store).selection?.set(entityId ? [entityId] : []);
1702
+ }
1703
+ };
1704
+
1705
+ // --- Asset drag-and-drop onto viewport ---
1706
+
1707
+ /** Raycast from screen coordinates to the ground plane (y=0). Returns world position or null. */
1708
+ private _raycastGroundPlane(e: DragEvent): THREE.Vector3 | null {
1709
+ const rect = this._canvas.getBoundingClientRect();
1710
+ const mouse = new THREE.Vector2(
1711
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
1712
+ -((e.clientY - rect.top) / rect.height) * 2 + 1,
1713
+ );
1714
+ this.raycaster.setFromCamera(mouse, this.camera);
1715
+ const target = new THREE.Vector3();
1716
+ const hit = this.raycaster.ray.intersectPlane(this._groundPlane, target);
1717
+ return hit;
1718
+ }
1719
+
1720
+ private _showDropOverlay(): void {
1721
+ if (this._dropOverlay) return;
1722
+ const overlay = document.createElement('div');
1723
+ Object.assign(overlay.style, {
1724
+ position: 'fixed',
1725
+ inset: '0',
1726
+ pointerEvents: 'none',
1727
+ border: '2px dashed #579EFF',
1728
+ background: 'rgba(87, 158, 255, 0.08)',
1729
+ zIndex: '5',
1730
+ });
1731
+ document.body.appendChild(overlay);
1732
+ this._dropOverlay = overlay;
1733
+ }
1734
+
1735
+ private _hideDropOverlay(): void {
1736
+ if (this._dropOverlay) {
1737
+ this._dropOverlay.remove();
1738
+ this._dropOverlay = null;
1739
+ }
1740
+ }
1741
+
1742
+ private _onDragOver = (e: DragEvent): void => {
1743
+ if (!e.dataTransfer?.types.includes('application/x-editor-asset')) return;
1744
+ e.preventDefault();
1745
+ e.dataTransfer.dropEffect = 'copy';
1746
+ this._showDropOverlay();
1747
+ };
1748
+
1749
+ private _onDragLeave = (_e: DragEvent): void => {
1750
+ this._hideDropOverlay();
1751
+ };
1752
+
1753
+ private _onDrop = (e: DragEvent): void => {
1754
+ this._hideDropOverlay();
1755
+ const raw = e.dataTransfer?.getData('application/x-editor-asset');
1756
+ if (!raw) return;
1757
+ e.preventDefault();
1758
+
1759
+ let data: {
1760
+ path: string;
1761
+ kind: string;
1762
+ name: string;
1763
+ online?: {
1764
+ source: string;
1765
+ id: string;
1766
+ name: string;
1767
+ url?: string;
1768
+ format?: string;
1769
+ includes?: { relativePath: string; url: string; size: number }[];
1770
+ };
1771
+ };
1772
+ try {
1773
+ data = JSON.parse(raw);
1774
+ } catch {
1775
+ return;
1776
+ }
1777
+
1778
+ // Find drop position via ground-plane raycast, fallback to origin
1779
+ const worldPos = this._raycastGroundPlane(e);
1780
+ const pos: [number, number, number] = worldPos
1781
+ ? [Math.round(worldPos.x * 100) / 100, 0, Math.round(worldPos.z * 100) / 100]
1782
+ : [0, 0, 0];
1783
+
1784
+ // Online asset: check cache first, otherwise show placeholder and download
1785
+ if (data.online) {
1786
+ const online = data.online;
1787
+ const baseName = online.name.replace(/\.[^.]+$/, '');
1788
+
1789
+ // Already downloaded? Create entity instantly, no placeholder needed.
1790
+ const cachedPath = getDownloadedAssetPath(online.source, online.name);
1791
+ if (cachedPath) {
1792
+ this._store.addEntity(null, {
1793
+ id: crypto.randomUUID(),
1794
+ name: baseName,
1795
+ mesh: { type: 'gltf', src: cachedPath },
1796
+ transform: { position: pos },
1797
+ });
1798
+ return;
1799
+ }
1800
+
1801
+ const placeholderId = crypto.randomUUID();
1802
+
1803
+ // Create a visible placeholder box at the drop position
1804
+ this._store.addEntity(null, {
1805
+ id: placeholderId,
1806
+ name: `${baseName} (downloading...)`,
1807
+ mesh: { type: 'box' },
1808
+ transform: { position: pos, scale: [0.5, 0.5, 0.5] },
1809
+ material: { type: 'standard', color: '#579EFF', opacity: 0.4, transparent: true },
1810
+ });
1811
+
1812
+ const resolveEntity = (localPath: string) => {
1813
+ // Replace placeholder with the real GLTF entity
1814
+ this._store.removeEntity(placeholderId);
1815
+ this._store.addEntity(null, {
1816
+ id: crypto.randomUUID(),
1817
+ name: baseName,
1818
+ mesh: { type: 'gltf', src: `/${localPath}` },
1819
+ transform: { position: pos },
1820
+ });
1821
+ };
1822
+
1823
+ const doDownload = (dlParams: Parameters<typeof downloadOnlineAsset>[0]) => {
1824
+ downloadOnlineAsset(dlParams)
1825
+ .then((result) => {
1826
+ if (result.ok && result.path) {
1827
+ resolveEntity(result.path);
1828
+ } else {
1829
+ // Download failed — update name to indicate error
1830
+ this._store.updateEntity(placeholderId, {
1831
+ name: `${baseName} (download failed)`,
1832
+ });
1833
+ }
1834
+ })
1835
+ .catch(() => {
1836
+ // Browser mode (no Node dev server) throws instead of resolving
1837
+ // { ok: false } — same "download failed" placeholder update as
1838
+ // the resolved-failure branch above, no unhandled rejection.
1839
+ this._store.updateEntity(placeholderId, {
1840
+ name: `${baseName} (download failed)`,
1841
+ });
1842
+ });
1843
+ };
1844
+
1845
+ if (online.url) {
1846
+ const dlParams: Parameters<typeof downloadOnlineAsset>[0] = {
1847
+ source: online.source,
1848
+ id: online.id,
1849
+ name: online.name,
1850
+ url: online.url,
1851
+ format: online.format ?? 'gltf',
1852
+ };
1853
+ if (online.includes) dlParams.includes = online.includes;
1854
+ doDownload(dlParams);
1855
+ } else {
1856
+ // Fetch file options, pick first, then download
1857
+ getOnlineAssetFiles(online.source, online.id).then((files) => {
1858
+ const file = files[0];
1859
+ if (!file) {
1860
+ this._store.updateEntity(placeholderId, {
1861
+ name: `${baseName} (no files available)`,
1862
+ });
1863
+ return;
1864
+ }
1865
+ const params: Parameters<typeof downloadOnlineAsset>[0] = {
1866
+ source: online.source,
1867
+ id: online.id,
1868
+ name: online.name,
1869
+ url: file.url,
1870
+ format: file.format,
1871
+ };
1872
+ if (file.includes) params.includes = file.includes;
1873
+ doDownload(params);
1874
+ });
1875
+ }
1876
+ return;
1877
+ }
1878
+
1879
+ const baseName = data.name.replace(/\.[^.]+$/, '');
1880
+
1881
+ if (data.kind === 'model') {
1882
+ this._store.addEntity(null, {
1883
+ id: crypto.randomUUID(),
1884
+ name: baseName,
1885
+ mesh: { type: 'gltf', src: data.path },
1886
+ transform: { position: pos },
1887
+ });
1888
+ } else if (data.kind === 'image') {
1889
+ this._store.addEntity(null, {
1890
+ id: crypto.randomUUID(),
1891
+ name: baseName,
1892
+ mesh: { type: 'plane', args: [2, 2] },
1893
+ material: { type: 'standard', map: data.path },
1894
+ transform: {
1895
+ position: [pos[0], pos[1] + 1, pos[2]],
1896
+ rotation: [0, 0, 0, 1],
1897
+ },
1898
+ });
1899
+ } else if (data.kind === 'scene') {
1900
+ fetch(resolveUrl(data.path))
1901
+ .then((r) => r.json())
1902
+ .then((doc: { entities?: SceneEntity[] }) => {
1903
+ if (doc.entities) {
1904
+ for (const entity of doc.entities) {
1905
+ const e: SceneEntity = { ...entity, id: crypto.randomUUID() };
1906
+ // Offset root entities to drop position
1907
+ if (!e.transform) e.transform = {};
1908
+ const ep = e.transform.position ?? [0, 0, 0];
1909
+ e.transform.position = [ep[0] + pos[0], ep[1] + pos[1], ep[2] + pos[2]];
1910
+ this._store.addEntity(null, e);
1911
+ }
1912
+ }
1913
+ });
1914
+ } else if (data.kind === 'audio') {
1915
+ const baseName = data.name.replace(/\.[^.]+$/, '');
1916
+ this._store.addEntity(null, {
1917
+ id: crypto.randomUUID(),
1918
+ name: baseName,
1919
+ audio: { src: data.path, spatial: true, volume: 1 },
1920
+ transform: { position: pos },
1921
+ });
1922
+ }
1923
+ };
1924
+ }