@vgai/editor 0.2.0 → 0.4.0-canary.20260715.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 (430) hide show
  1. package/README.md +24 -0
  2. package/dist/assets/axe-C7YBtOuM.js +30 -0
  3. package/dist/assets/browserAll-DDtGGE48.js +1 -0
  4. package/dist/assets/create-runtime-DbTlBlKG.js +1 -0
  5. package/dist/assets/{game-BI111o5f.js → game-BE0r7DMI.js} +3 -3
  6. package/dist/assets/game-BdTHR2_X.js +1 -0
  7. package/dist/assets/{game-pt_1lQLv.js → game-C3iQGSqo.js} +1 -1
  8. package/dist/assets/game-CcHfYxjP.js +1 -0
  9. package/dist/assets/game-Cqzj_lpD.js +1 -0
  10. package/dist/assets/game-DSZdeCHu.js +1 -0
  11. package/dist/assets/game-Dch9sAPc.js +1 -0
  12. package/dist/assets/{game-xsOho_wg.js → game-SAFn90pA.js} +3 -3
  13. package/dist/assets/game-loop-DMIgSMV-.js +1 -0
  14. package/dist/assets/{game-source-CvidN7AB.js → game-source-Dk83w26t.js} +1 -1
  15. package/dist/assets/{index-Bp5cecD6.js → index-4idIsHHJ.js} +2 -2
  16. package/dist/assets/{index-DL7RcQ0n.js → index-C-LhOTBN.js} +1 -1
  17. package/dist/assets/{index-Cs8xwv3e.js → index-CDkxceqE.js} +1 -1
  18. package/dist/assets/index-CQEfi217.js +15412 -0
  19. package/dist/assets/index-Dvi-V0NE.css +1 -0
  20. package/dist/assets/ingest-siblings-s0P5I1Ub.js +1 -0
  21. package/dist/assets/mount-manifest-DXMOlF79.js +1 -0
  22. package/dist/assets/react-18-C9eHn8x1.js +1 -0
  23. package/dist/assets/webworkerAll-Fn0e-ouF.js +1 -0
  24. package/dist/assets/world-documents-GM0a9Kv7.js +1 -0
  25. package/dist/index.html +15 -13
  26. package/dist/ingest/racing-game/vgai.game.json +5 -4
  27. package/dist/ingest/simcity/vgai.game.json +5 -4
  28. package/dist/ingest/tanks/vgai.game.json +5 -4
  29. package/dist/project-scripts.bundle.js +3917 -0
  30. package/dist/scenes/untitled.vscn.json +2 -0
  31. package/dist-server/packaged.mjs +7697 -3435
  32. package/package.json +31 -4
  33. package/scripts/probe-reporter.ts +349 -0
  34. package/scripts/run-probe.ts +2260 -0
  35. package/server/asset-catalog-audit.ts +268 -0
  36. package/server/asset-catalog-v2.ts +146 -0
  37. package/server/asset-library-routes.ts +276 -58
  38. package/server/canonical-path.ts +1 -1
  39. package/server/dev.ts +148 -44
  40. package/server/editor-server.ts +888 -61
  41. package/server/editor-sse.ts +115 -4
  42. package/server/harness-chat-service.ts +796 -0
  43. package/server/local-asset-catalog.ts +157 -22
  44. package/server/packaged.ts +51 -21
  45. package/server/process-shutdown.ts +136 -0
  46. package/server/prod.ts +9 -6
  47. package/server/project-hmr-files.ts +41 -0
  48. package/server/project-operations.ts +343 -0
  49. package/server/project-optimize-deps-entries.ts +4 -4
  50. package/server/project-output-writer.ts +156 -0
  51. package/server/project-validation.ts +140 -0
  52. package/server/project-view.ts +8 -50
  53. package/server/server-utils.ts +75 -10
  54. package/server/session-registry.ts +12 -0
  55. package/src/EditorContext.tsx +107 -3
  56. package/src/action-registry.ts +115 -17
  57. package/src/adapter-resolver.ts +213 -478
  58. package/src/asset-events.ts +64 -1
  59. package/src/asset-preview.ts +906 -0
  60. package/src/asset-selection.ts +121 -0
  61. package/src/asset-workflow/asset-capabilities.ts +259 -0
  62. package/src/asset-workflow/asset-editor-errors.ts +46 -0
  63. package/src/asset-workflow/asset-import-pipeline.ts +176 -0
  64. package/src/asset-workflow/asset-library-query.ts +107 -0
  65. package/src/asset-workflow/asset-types.ts +192 -0
  66. package/src/asset-workflow/asset-workflow-quality.ts +65 -0
  67. package/src/asset-workflow/import-contract.ts +113 -0
  68. package/src/asset-workflow/model-conversion.ts +126 -0
  69. package/src/asset-workflow/model-inspection.ts +185 -0
  70. package/src/asset-workflow/project-asset-commands.ts +23 -0
  71. package/src/asset-workflow/project-asset-health.ts +153 -0
  72. package/src/asset-workflow/project-asset-operations.ts +119 -0
  73. package/src/asset-workflow/thumbnail-system.ts +172 -0
  74. package/src/authoring/active-systems.ts +15 -0
  75. package/src/authoring/boundary-authoring-adapter.ts +16 -5
  76. package/src/authoring/composite-authoring-adapter.ts +305 -124
  77. package/src/authoring/design-time-layers.ts +300 -117
  78. package/src/authoring/edit-mode-authoring.ts +278 -293
  79. package/src/authoring/embed-only-adapter.ts +4 -2
  80. package/src/authoring/first-party-hierarchy-menu.ts +7 -2
  81. package/src/authoring/first-party-inspector/AddSectionMenu.tsx +5 -12
  82. package/src/authoring/first-party-inspector/EnvironmentSection.tsx +53 -126
  83. package/src/authoring/first-party-inspector/HeaderSection.tsx +54 -65
  84. package/src/authoring/first-party-inspector/is-first-party-selection.ts +30 -11
  85. package/src/authoring/first-party-inspector/register.tsx +13 -2
  86. package/src/authoring/ingest-authoring-adapter.ts +142 -45
  87. package/src/authoring/mount-failure-report.ts +5 -5
  88. package/src/authoring/provenance.ts +41 -0
  89. package/src/authoring/react-dom-authoring-adapter.ts +92 -92
  90. package/src/authoring/react-inspector-section.tsx +489 -51
  91. package/src/authoring/react-world-authoring-adapter.ts +541 -306
  92. package/src/authoring/story-overlay.ts +1 -1
  93. package/src/authoring/story-types.ts +11 -0
  94. package/src/authoring/vgai-scene-authoring-adapter.ts +54 -25
  95. package/src/authoring/viewport-tool-context.ts +78 -0
  96. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  97. package/src/authoring/world-hidden-viewport.ts +186 -0
  98. package/src/authoring/world-pan-state.ts +33 -17
  99. package/src/authoring/world-session-state.ts +1 -1
  100. package/src/authoring/world2d-authoring-adapter.ts +109 -52
  101. package/src/availability-tick.ts +56 -0
  102. package/src/browser-play-entry.ts +5 -5
  103. package/src/browser-project-scripts.ts +1 -0
  104. package/src/build-identity.ts +15 -0
  105. package/src/command-listener.ts +893 -19
  106. package/src/components/AlignToolbar.tsx +74 -63
  107. package/src/components/AppRoot.tsx +193 -26
  108. package/src/components/ApplicationMenus.tsx +272 -0
  109. package/src/components/AssetBrowser.tsx +1450 -293
  110. package/src/components/AssetEditorShell.tsx +141 -0
  111. package/src/components/AssetImportDetails.tsx +218 -0
  112. package/src/components/AssetLibraryPanel.tsx +14 -0
  113. package/src/components/CameraInfo.tsx +11 -20
  114. package/src/components/CenterDocuments.tsx +585 -0
  115. package/src/components/CommandPalette.tsx +75 -30
  116. package/src/components/ConsolePanel.css +55 -0
  117. package/src/components/ConsolePanel.tsx +57 -112
  118. package/src/components/DebugConsolePanel.tsx +461 -0
  119. package/src/components/DebuggerPanel.tsx +165 -0
  120. package/src/components/DefaultEditorLayout.tsx +167 -231
  121. package/src/components/GameHierarchy.tsx +873 -290
  122. package/src/components/GamePerformanceOverlay.tsx +46 -0
  123. package/src/components/HarnessChatPanel.tsx +2352 -0
  124. package/src/components/HistoryPanel.tsx +226 -0
  125. package/src/components/Inspector.tsx +153 -99
  126. package/src/components/InspectorToolSection.tsx +6 -4
  127. package/src/components/MountFailureBanner.tsx +31 -48
  128. package/src/components/NetworkSection.tsx +25 -14
  129. package/src/components/OnlineAssetBrowser.tsx +794 -343
  130. package/src/components/OverlayReportBanner.tsx +38 -64
  131. package/src/components/PerformancePanel.tsx +206 -0
  132. package/src/components/PlayBar.tsx +93 -263
  133. package/src/components/PrefabSaveDialog.tsx +55 -127
  134. package/src/components/ProjectHeader.css +60 -0
  135. package/src/components/ProjectHeader.tsx +170 -105
  136. package/src/components/ProjectScreen.tsx +246 -184
  137. package/src/components/ReactCanvasControls.tsx +185 -0
  138. package/src/components/ResolutionPicker.tsx +85 -0
  139. package/src/components/RootActivateDialog.tsx +39 -68
  140. package/src/components/SaveDialog.tsx +55 -127
  141. package/src/components/SaveStatus.tsx +11 -29
  142. package/src/components/StartupLoadingScreen.tsx +96 -0
  143. package/src/components/StateWatchPanel.tsx +302 -0
  144. package/src/components/StatsOverlay.tsx +12 -5
  145. package/src/components/StatusBar.tsx +64 -0
  146. package/src/components/ThemeManagerDialog.css +89 -0
  147. package/src/components/ThemeManagerDialog.tsx +371 -0
  148. package/src/components/ToolHost.tsx +26 -15
  149. package/src/components/Toolbar.tsx +117 -214
  150. package/src/components/VgaiVoxelLogo.tsx +89 -0
  151. package/src/components/ViewportOverlay.tsx +97 -172
  152. package/src/components/ViewportPanel.tsx +130 -31
  153. package/src/components/WorkspaceDock.tsx +891 -0
  154. package/src/components/WorldSelectionOverlay.tsx +51 -63
  155. package/src/components/WorldTextEditor.tsx +9 -5
  156. package/src/components/asset-documents.tsx +390 -0
  157. package/src/components/asset-editor-persistence.ts +44 -0
  158. package/src/components/asset-selection-section.tsx +338 -0
  159. package/src/components/asset-viewers/AudioViewer.tsx +73 -21
  160. package/src/components/asset-viewers/GenericJsonViewer.tsx +26 -11
  161. package/src/components/asset-viewers/ImageViewer.tsx +43 -11
  162. package/src/components/asset-viewers/InputMapViewer.tsx +512 -134
  163. package/src/components/asset-viewers/MaterialAssetEditor.tsx +540 -0
  164. package/src/components/asset-viewers/ModelViewer.tsx +805 -121
  165. package/src/components/asset-viewers/OnlineAssetDetail.tsx +177 -60
  166. package/src/components/asset-viewers/SourceAssetViewer.tsx +121 -0
  167. package/src/components/asset-workflow.css +506 -0
  168. package/src/components/build-documents.tsx +696 -0
  169. package/src/components/core-utilities.tsx +75 -0
  170. package/src/components/data-documents.tsx +364 -0
  171. package/src/components/{DataPanel.tsx → data-editor.tsx} +138 -288
  172. package/src/components/debugger-panel-state.ts +57 -0
  173. package/src/components/inspector-widgets/AlignmentGrid.tsx +8 -14
  174. package/src/components/inspector-widgets/BorderEditor.tsx +41 -33
  175. package/src/components/inspector-widgets/ColorPicker.tsx +39 -39
  176. package/src/components/inspector-widgets/FontPicker.tsx +13 -21
  177. package/src/components/inspector-widgets/GradientEditor.tsx +11 -19
  178. package/src/components/inspector-widgets/ScrubbableInput.tsx +6 -3
  179. package/src/components/inspector-widgets/ShadowEditor.tsx +20 -33
  180. package/src/components/inspector-widgets/shared.tsx +90 -49
  181. package/src/components/inspectors/AnimationSection.tsx +39 -40
  182. package/src/components/inspectors/AudioSection.tsx +28 -39
  183. package/src/components/inspectors/BoneAttachmentSection.tsx +19 -24
  184. package/src/components/inspectors/CameraSection.tsx +14 -23
  185. package/src/components/inspectors/ComponentsSection.tsx +39 -43
  186. package/src/components/inspectors/JointsSection.tsx +34 -43
  187. package/src/components/inspectors/LightSection.tsx +90 -35
  188. package/src/components/inspectors/MaterialSection.tsx +137 -174
  189. package/src/components/inspectors/MeshSection.tsx +9 -17
  190. package/src/components/inspectors/NavigationSection.tsx +8 -15
  191. package/src/components/inspectors/ParticleSection.tsx +91 -76
  192. package/src/components/inspectors/PhysicsSection.tsx +42 -46
  193. package/src/components/inspectors/PostProcessingSection.tsx +28 -36
  194. package/src/components/inspectors/RenderingSection.tsx +12 -20
  195. package/src/components/inspectors/ShadowSection.tsx +7 -8
  196. package/src/components/inspectors/SplineSection.tsx +31 -42
  197. package/src/components/inspectors/TransformSection.tsx +14 -22
  198. package/src/components/patterns/Dialog.tsx +95 -0
  199. package/src/components/patterns/Fields.tsx +44 -0
  200. package/src/components/patterns/List.tsx +25 -0
  201. package/src/components/patterns/StateSurface.tsx +40 -0
  202. package/src/components/patterns/Surfaces.tsx +115 -0
  203. package/src/components/patterns/Tabs.tsx +80 -0
  204. package/src/components/patterns/Toolbar.tsx +51 -0
  205. package/src/components/patterns/Tree.tsx +72 -0
  206. package/src/components/primitives/Button.tsx +46 -0
  207. package/src/components/primitives/ColorInput.tsx +11 -47
  208. package/src/components/primitives/EditorIcon.tsx +99 -0
  209. package/src/components/primitives/FormControls.tsx +71 -0
  210. package/src/components/primitives/JsonInput.tsx +15 -2
  211. package/src/components/primitives/Layout.tsx +100 -0
  212. package/src/components/primitives/Menu.tsx +105 -0
  213. package/src/components/primitives/NumberInput.tsx +8 -35
  214. package/src/components/primitives/Panel.tsx +49 -44
  215. package/src/components/primitives/SectionHeader.tsx +10 -28
  216. package/src/components/primitives/Text.tsx +46 -0
  217. package/src/components/primitives/Tooltip.tsx +20 -12
  218. package/src/components/primitives/Vec3Input.tsx +3 -0
  219. package/src/components/primitives/banner-tones.ts +33 -0
  220. package/src/components/primitives/editor-icons.ts +102 -0
  221. package/src/components/primitives/panel-header-styles.ts +30 -0
  222. package/src/components/project-operation-documents.tsx +226 -0
  223. package/src/components/status-contributions.tsx +175 -0
  224. package/src/components/story-documents-register.ts +15 -0
  225. package/src/components/story-documents.tsx +1185 -0
  226. package/src/components/tool-documents.tsx +110 -0
  227. package/src/components/workspace-dock.css +293 -0
  228. package/src/components/workspace-history.ts +33 -0
  229. package/src/components/world-documents.tsx +145 -0
  230. package/src/components/xstate-documents.tsx +209 -0
  231. package/src/composite-screenshot.ts +248 -0
  232. package/src/design-system/index.ts +70 -0
  233. package/src/design-system-stories/Foundation.stories.tsx +183 -0
  234. package/src/design-system-stories/Patterns.stories.tsx +251 -0
  235. package/src/design-system-stories/Workspace.stories.tsx +538 -0
  236. package/src/doctor/report.ts +57 -13
  237. package/src/editor-api.ts +295 -31
  238. package/src/editor-compatibility.ts +200 -0
  239. package/src/editor-console.ts +35 -17
  240. package/src/editor-hotkeys.ts +85 -33
  241. package/src/editor-store.ts +538 -233
  242. package/src/editor-viewport.ts +160 -34
  243. package/src/entity-factory.ts +14 -3
  244. package/src/gizmo-registry.ts +21 -5
  245. package/src/harness-chat-editor-context.ts +37 -0
  246. package/src/harness-chat-indicator.ts +68 -0
  247. package/src/harness-chat-layout.ts +53 -0
  248. package/src/harness-chat-lifecycle.ts +170 -0
  249. package/src/harness-chat-reconnect.ts +85 -0
  250. package/src/harness-chat-types.ts +409 -0
  251. package/src/hierarchy-drop.ts +50 -0
  252. package/src/hierarchy-expansion-state.ts +100 -0
  253. package/src/hierarchy-menu-registry.ts +1 -1
  254. package/src/hierarchy-projection.ts +76 -0
  255. package/src/history/editor-session.ts +21 -0
  256. package/src/history/history-commands.ts +147 -0
  257. package/src/history/history-service.ts +904 -0
  258. package/src/history/json-history-resource.ts +165 -0
  259. package/src/history/persistence-coordinator.ts +35 -0
  260. package/src/history/project-file-history.ts +266 -0
  261. package/src/history/project-root-history-backends.ts +53 -0
  262. package/src/history/resource-registry.ts +209 -0
  263. package/src/history/snapshot-store.ts +108 -0
  264. package/src/history/source-history-backend.ts +374 -0
  265. package/src/history/types.ts +100 -0
  266. package/src/hmr-registration-group.ts +67 -0
  267. package/src/hmr-stable-react-context.ts +23 -0
  268. package/src/hosted-example.ts +12 -2
  269. package/src/hotkeys.ts +89 -18
  270. package/src/ingest/discovery-react.ts +9 -10
  271. package/src/ingest/discovery.ts +4 -4
  272. package/src/ingest/discovery2d.ts +5 -5
  273. package/src/ingest/games/bloom-composer/vgai.game.json +3 -3
  274. package/src/ingest/games/embed-bundle/vgai.game.json +3 -3
  275. package/src/ingest/games/games-fps/vgai.game.json +3 -3
  276. package/src/ingest/games/iframe-reachable/vgai.game.json +3 -3
  277. package/src/ingest/games/interactive-cubes/vgai.game.json +3 -3
  278. package/src/ingest/games/mismatched-three/vgai.game.json +3 -3
  279. package/src/ingest/games/own-three-bundled/vgai.game.json +3 -3
  280. package/src/ingest/games/pointerlock-maze/vgai.game.json +7 -4
  281. package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +3 -3
  282. package/src/ingest/games-2d/bunnymark/vgai.game.json +3 -3
  283. package/src/ingest/games-2d/flappy/vgai.game.json +3 -3
  284. package/src/ingest/games-2d/flappy-pixi/vgai.game.json +3 -3
  285. package/src/ingest/games-2d/puzzling-potions/vgai.game.json +3 -3
  286. package/src/ingest/games-2d/tilemap-camera/vgai.game.json +3 -3
  287. package/src/ingest/games-react/react-rpg/vgai.game.json +3 -3
  288. package/src/ingest/types.ts +1 -1
  289. package/src/ingest-mode.ts +47 -52
  290. package/src/ingest-siblings.ts +23 -33
  291. package/src/initial-scene.ts +1 -2
  292. package/src/main.tsx +16 -8
  293. package/src/manifest-project.ts +10 -12
  294. package/src/model-thumbnail.ts +261 -26
  295. package/src/module-mode.ts +44 -50
  296. package/src/play-log-events.ts +26 -0
  297. package/src/play-mode.ts +495 -401
  298. package/src/play-scene-adoption.ts +23 -0
  299. package/src/playground/main.ts +68 -24
  300. package/src/playwright-shim.ts +473 -0
  301. package/src/project-manager.ts +90 -10
  302. package/src/project-operations.ts +112 -0
  303. package/src/project-story-discovery.ts +25 -0
  304. package/src/project-tool-discovery.ts +43 -0
  305. package/src/scene-serializer.ts +0 -6
  306. package/src/scene-sync.ts +7 -2
  307. package/src/script-loader.ts +3 -3
  308. package/src/skeleton-helper.ts +57 -0
  309. package/src/storage/seed.ts +68 -8
  310. package/src/stories/StoryPreviewMount.tsx +265 -0
  311. package/src/stories/action-stories.fixture.tsx +44 -0
  312. package/src/stories/button-stories.fixture.tsx +50 -0
  313. package/src/stories/compose-project-stories.ts +182 -0
  314. package/src/stories/loader-stories.fixture.tsx +53 -0
  315. package/src/stories/story-discovery.ts +130 -0
  316. package/src/stories/story-registry.ts +146 -0
  317. package/src/theme-css.ts +1049 -0
  318. package/src/theme-library.ts +238 -0
  319. package/src/theme-preference.ts +130 -0
  320. package/src/theme.ts +527 -0
  321. package/src/tool-loader.ts +80 -24
  322. package/src/ui-source/inspect.ts +27 -4
  323. package/src/ui-source/source-write-backend.ts +83 -22
  324. package/src/widgets/index.ts +9 -18
  325. package/src/workspace-aux-commands.ts +11 -0
  326. package/src/workspace-dock-controller.ts +64 -0
  327. package/src/workspace-document-ids.ts +5 -0
  328. package/src/workspace-document-registry.ts +367 -0
  329. package/src/workspace-state-persistence.ts +455 -0
  330. package/src/workspace-status-registry.ts +110 -0
  331. package/src/workspace-utility-commands.ts +49 -0
  332. package/src/workspace-utility-registry.ts +161 -0
  333. package/src/world-document-routing.ts +16 -0
  334. package/src/xstate-inspect/XStateMachineInspector.tsx +544 -0
  335. package/src/xstate-inspect/character-animation-machine.fixture.ts +74 -0
  336. package/src/xstate-inspect/use-live-actor-state.ts +39 -0
  337. package/src/xstate-inspect/xstate-graph.ts +265 -0
  338. package/src/xstate-inspect/xstate-layout.ts +76 -0
  339. package/src/z-index.ts +26 -0
  340. package/template/.claude/skills/editor/SKILL.md +129 -90
  341. package/template/AGENTS.md +1110 -0
  342. package/template/CLAUDE.md +1 -512
  343. package/template/IDIOMS.md +50 -0
  344. package/template/check-idioms.ts +706 -0
  345. package/template/manifest-entry-modules-plugin.ts +53 -0
  346. package/template/package.json +13 -4
  347. package/template/public/scenes/default.scn2d.json +6 -0
  348. package/template/public/scenes/default.vscn.json +7 -1
  349. package/template/public/scenes/untitled.vscn.json +10 -0
  350. package/template/scripts/playtest.ts +40 -0
  351. package/template/server/loopback.ts +75 -0
  352. package/template/server/rooms/arena-room.ts +69 -1
  353. package/template/server/rooms/game-room.ts +68 -1
  354. package/template/src/data/README.md +9 -4
  355. package/template/src/data/data-ref.ts +35 -0
  356. package/template/src/data/tuning.schema.ts +1 -1
  357. package/template/src/main.ts +41 -62
  358. package/template/src/manifest-entry-modules.d.ts +3 -0
  359. package/template/src/operations/README.md +88 -0
  360. package/template/src/runtime/pixi-adapter.ts +24 -0
  361. package/template/src/scripts/components/data-spinner.ts +43 -2
  362. package/template/src/scripts/components/spin-lap-math.ts +27 -0
  363. package/template/src/tools/README.md +5 -3
  364. package/template/src/tools/example.tool.tsx +13 -7
  365. package/template/src/ui/Button.stories.tsx +66 -0
  366. package/template/src/ui/RebindPanel.tsx +2 -6
  367. package/template/src/ui/game.tsx +17 -0
  368. package/template/tests/acceptance/example.spec.ts +64 -0
  369. package/template/tests/logic/example.test.ts +31 -0
  370. package/template/tests/logic/room.test.ts +100 -0
  371. package/template/tests/tsconfig.json +30 -0
  372. package/template/validate-manifest.ts +5 -5
  373. package/template/validate-scenes.ts +50 -18
  374. package/template/vgai.game.json +20 -13
  375. package/template/vite.config.ts +77 -16
  376. package/vite-plugin-ui-oid.ts +261 -17
  377. package/dist/assets/CCDIKSolver-Dz-ysxZF.js +0 -1
  378. package/dist/assets/SelectionBox-BXsv_6aH.js +0 -1
  379. package/dist/assets/browser-project-scripts-DOru4L1x.js +0 -2
  380. package/dist/assets/browserAll-CEkWYlFk.js +0 -1
  381. package/dist/assets/create-runtime-DYIWr3va.js +0 -1
  382. package/dist/assets/data-asset-Bb9sIM7i.js +0 -5
  383. package/dist/assets/game-8SDZPPgH.js +0 -1
  384. package/dist/assets/game-CevorxyV.js +0 -1
  385. package/dist/assets/game-Di7XlywD.js +0 -1
  386. package/dist/assets/game-DjnCaw8R.js +0 -1
  387. package/dist/assets/game-Q9A0aXSV.js +0 -1
  388. package/dist/assets/index-CPVV6pjw.js +0 -1
  389. package/dist/assets/index-CpOnYyeD.js +0 -213
  390. package/dist/assets/index-Dr8H_6Rl.js +0 -1
  391. package/dist/assets/index-_bCaipUk.js +0 -13650
  392. package/dist/assets/ingest-siblings-Dz8eEqrV.js +0 -1
  393. package/dist/assets/mount-manifest-Bhk0ohsl.js +0 -1
  394. package/dist/assets/scene-query-BivAHhJu.js +0 -1
  395. package/dist/assets/webworkerAll-BLrH0F6F.js +0 -1
  396. package/dist/assets/xstate-animation-binding-1NGLmw6y.js +0 -1
  397. package/dist/assets/xstate.esm-BgUD33nl.js +0 -3
  398. package/src/authoring/scene-ui-layer.ts +0 -485
  399. package/src/authoring/ui-authoring-adapter.ts +0 -325
  400. package/src/authoring/ui-hierarchy-menu.ts +0 -40
  401. package/src/authoring/ui-inspector-sections.tsx +0 -156
  402. package/src/authoring/undo-timeline.ts +0 -145
  403. package/src/components/BottomPanel.tsx +0 -326
  404. package/src/components/BuildPanel.tsx +0 -455
  405. package/src/components/FolderTree.tsx +0 -179
  406. package/src/components/RightPanel.tsx +0 -166
  407. package/src/components/WorldContextMenu.tsx +0 -257
  408. package/src/components/asset-viewers/AssetViewer.tsx +0 -38
  409. package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +0 -129
  410. package/src/scene-ui-mode/active-scene-ui.ts +0 -58
  411. package/src/scene-ui-mode/scene-ui-palette-panel.tsx +0 -152
  412. package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +0 -29
  413. package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +0 -74
  414. package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +0 -49
  415. package/src/scene-ui-mode/ui-prefab-io.ts +0 -76
  416. package/src/ui-editor/inspector.tsx +0 -563
  417. package/src/ui-editor/node-ops.ts +0 -266
  418. package/src/ui-editor/overlay.tsx +0 -380
  419. package/src/ui-editor/palette.tsx +0 -114
  420. package/src/ui-editor/react-store.ts +0 -78
  421. package/src/ui-editor/timeline-panel.tsx +0 -443
  422. package/src/ui-editor/ui-edit-store.ts +0 -552
  423. package/src/ui-editor/ui-prefab.ts +0 -95
  424. package/template/project.json +0 -9
  425. package/template/src/scripts/main.ts +0 -94
  426. package/template/src/ui/DefaultHud.tsx +0 -40
  427. package/template/src/ui/game-state.tsx +0 -37
  428. package/template/src/ui/mount-default-hud.tsx +0 -19
  429. package/template/src/ui/mount-ui.ts +0 -15
  430. package/vite-plugin-react-world-provider.ts +0 -81
@@ -11,10 +11,12 @@
11
11
  * - Undo / redo
12
12
  * - Prefab resolution
13
13
  * - Asset path rename
14
+ * - Referenced material asset changes
14
15
  */
15
16
 
16
17
  import type { PersistenceProvider } from '@engine/adapter';
17
18
  import { applyAssetRename } from '@engine/scene/asset-paths';
19
+ import { parseSceneFile } from '@engine/scene/parse';
18
20
  import {
19
21
  mergePrefabInstance,
20
22
  type PrefabFile,
@@ -30,9 +32,19 @@ import {
30
32
  getActiveAuthoring,
31
33
  hasAuthoringOverride,
32
34
  } from './authoring/active-adapter';
33
- import { notifyUndoableOp, registerUndoAdapter } from './authoring/undo-timeline';
34
- import { loadEditorState, saveEditorState, saveFile, saveThumbnail } from './editor-api';
35
+ import {
36
+ loadEditorState,
37
+ readProjectTextFile,
38
+ saveEditorState,
39
+ saveFile,
40
+ saveThumbnail,
41
+ } from './editor-api';
42
+ import { editorConsole } from './editor-console';
35
43
  import { createEntityObject } from './entity-factory';
44
+ import type { HistoryService } from './history/history-service';
45
+ import { getProjectFileHistory } from './history/project-file-history';
46
+ import { sha256Hex } from './history/snapshot-store';
47
+ import type { ResourceDescriptor, ResourceDriver } from './history/types';
36
48
  import { diffScene } from './scene-diff';
37
49
  import type { SceneMetadata } from './scene-serializer';
38
50
  import { cleanDocument, serializeScene } from './scene-serializer';
@@ -40,6 +52,7 @@ import {
40
52
  applyEnvironment,
41
53
  applyShadingOverride,
42
54
  buildPreview,
55
+ clearMaterialRefCache,
43
56
  registerParticles,
44
57
  unregisterParticles,
45
58
  updateEntityPreview,
@@ -55,16 +68,10 @@ export type PivotMode = 'active-element' | 'median-point' | 'individual-origins'
55
68
  export type ShadingMode = 'solid' | 'wireframe' | 'unlit';
56
69
 
57
70
  /**
58
- * Which persistence regime the CURRENT play session's edits are under —
59
- * a pure readout of the decision `play-mode.ts` already makes to pick a
60
- * provider (docs/MULTI-WORLD-DESIGN.md §1.F / MASTER-ARCHITECTURE-REVIEW §7.4
61
- * gap 4): `'ephemeral'` for a single-world session (edits discarded on stop,
62
- * `createEphemeralPersistence`), `'overlay'` for a multi-world (≥2) session
63
- * (composite of per-world overlay-persisted adapters, edits survive the
64
- * session). `null` while not playing. Never derive this independently of the
65
- * real provider decision — see `play-mode.ts`'s `enterPlayMode`, which sets it
66
- * from the SAME `_isMultiWorldSession` boolean that selects
67
- * `installMultiWorldAuthoring` vs `installPlayAuthoring`.
71
+ * Which persistence regime the current play session exposes. D19 makes play
72
+ * edits ephemeral for every world count; `overlay` remains in the type while
73
+ * older/external session surfaces migrate, but native play no longer selects
74
+ * it by cardinality. `null` while not playing.
68
75
  */
69
76
  export type PlayEditRegime = 'ephemeral' | 'overlay' | null;
70
77
 
@@ -90,15 +97,6 @@ export interface OnlineAssetInfo {
90
97
  sourceUrl?: string;
91
98
  }
92
99
 
93
- export interface AssetTab {
94
- key: string;
95
- label: string;
96
- assetPath: string;
97
- kind: AssetKind;
98
- /** Present when this tab represents an online (not-yet-downloaded) asset. */
99
- online?: OnlineAssetInfo;
100
- }
101
-
102
100
  export interface HelperVisibility {
103
101
  lights: boolean;
104
102
  cameras: boolean;
@@ -106,6 +104,7 @@ export interface HelperVisibility {
106
104
  audio: boolean;
107
105
  splines: boolean;
108
106
  navmesh: boolean;
107
+ skeletons: boolean;
109
108
  }
110
109
 
111
110
  export type ViewportAction =
@@ -119,24 +118,38 @@ export type ViewportAction =
119
118
  fov?: number;
120
119
  };
121
120
 
122
- const MAX_UNDO = 50;
123
121
  /** Cooldown (ms) to prevent tight feedback loops between auto-save and external updates. */
124
122
  const SYNC_COOLDOWN_MS = 500;
123
+ const sceneEncoder = new TextEncoder();
124
+ const sceneDecoder = new TextDecoder();
125
125
 
126
126
  function deepClone<T>(obj: T): T {
127
127
  return JSON.parse(JSON.stringify(obj)) as T;
128
128
  }
129
129
 
130
- /** Ensure every entity has a UUID and a children array. Mutates in place. */
131
- export function assignMissingIds(entities: SceneEntity[]): void {
130
+ /**
131
+ * Ensure every entity has a UUID and a children array. Mutates in place.
132
+ * Returns `true` iff at least one entity was missing an `id` and got one
133
+ * assigned here — callers that persist to disk (`loadDocument`) use this to
134
+ * know the freshly-assigned ids need writing back, not just holding in
135
+ * memory (see that call site's comment for why: an id assigned here but
136
+ * never persisted is exactly the `apply-diff`/`vgai status` id-mismatch
137
+ * trap).
138
+ */
139
+ export function assignMissingIds(entities: SceneEntity[]): boolean {
140
+ let assignedAny = false;
132
141
  const walk = (list: SceneEntity[]) => {
133
142
  for (const e of list) {
134
- if (!e.id) e.id = crypto.randomUUID();
143
+ if (!e.id) {
144
+ e.id = crypto.randomUUID();
145
+ assignedAny = true;
146
+ }
135
147
  if (!e.children) e.children = [];
136
148
  walk(e.children);
137
149
  }
138
150
  };
139
151
  walk(entities);
152
+ return assignedAny;
140
153
  }
141
154
 
142
155
  /** Walk entity Object3Ds in the scene graph and call fn on each. */
@@ -163,17 +176,6 @@ function walkEntities(entities: SceneEntity[], fn: (e: SceneEntity) => void): vo
163
176
  }
164
177
 
165
178
  export class EditorStore {
166
- /**
167
- * Register with the editor-global undo TIMELINE (T3.2 slice 2) once, for the
168
- * lifetime of this store — first-party never "disposes" mid-session, so unlike
169
- * ingest/UI sessions there is no matching `unregisterUndoAdapter` call. Safe for
170
- * headless test instances too: the timeline only ever calls INTO
171
- * `this.undo()`/`this.redo()` when notified, never the reverse.
172
- */
173
- constructor() {
174
- registerUndoAdapter(this);
175
- }
176
-
177
179
  // --- Scene graph references (set via bindScene) ---
178
180
  private _scene: THREE.Scene | null = null;
179
181
  private _editorScene: THREE.Scene | null = null; // stored while game scene is active
@@ -209,6 +211,7 @@ export class EditorStore {
209
211
  audio: true,
210
212
  splines: true,
211
213
  navmesh: true,
214
+ skeletons: false,
212
215
  };
213
216
  private _showStats = false;
214
217
  private _shadingMode: ShadingMode = 'solid';
@@ -229,16 +232,21 @@ export class EditorStore {
229
232
  * (undo/redo), and applying the echo would clobber the newer state.
230
233
  */
231
234
  private _lastSavedContent: string | null = null;
235
+ /** #131 — consecutive saves skipped because the on-disk file changed under
236
+ * the editor and could not be adopted (see `_resolveSaveConflict`). */
237
+ private _saveConflictSkips = 0;
232
238
  /** True when the most recent auto-save / manual save attempt failed. */
233
239
  private _lastSaveFailed = false;
234
240
  /** True while a coalesced interactive edit gesture is in progress. */
235
241
  private _interactiveActive = false;
236
242
  private _navMeshBaked = false;
237
243
  private _savePath: string | null = null;
238
- private _undoStack: string[] = [];
239
- private _redoStack: string[] = [];
240
- private _activeTabKey = '__inspector__';
241
- private _assetTabs: AssetTab[] = [];
244
+ private _history: HistoryService | null = null;
245
+ private _historyDescriptor: ResourceDescriptor | null = null;
246
+ private _unregisterHistoryDriver: (() => void) | null = null;
247
+ private _pendingHistoryEdit: { label: string; beforeBytes: Uint8Array } | null = null;
248
+ private _historyGestureActive = false;
249
+ private _historyRevision = 0;
242
250
  private _documentType: 'scene' | 'prefab' = 'scene';
243
251
  private _playState: 'stopped' | 'playing' | 'paused' = 'stopped';
244
252
  /** See {@link PlayEditRegime} — set by play-mode.ts on play-enter, cleared on play-exit. */
@@ -270,6 +278,8 @@ export class EditorStore {
270
278
  * `applyAssetMove`.
271
279
  */
272
280
  private _pendingExternalUpdate: SceneFile | null = null;
281
+ /** A referenced material changed while play owned the live scene. */
282
+ private _pendingMaterialRefresh = false;
273
283
  private _pendingAssetMove: { oldPath: string; newPath: string } | null = null;
274
284
 
275
285
  // --- Bind to scene graph (call once at editor init) ---
@@ -299,11 +309,174 @@ export class EditorStore {
299
309
 
300
310
  getSnapshot = (): number => this._version;
301
311
 
312
+ get projectHistory(): HistoryService | null {
313
+ return this._history;
314
+ }
315
+
316
+ async saveMaterialAsReference(id: string, path: string, content: string): Promise<boolean> {
317
+ return this._writeFileAndMutateDocument(
318
+ path,
319
+ content,
320
+ { label: `Save ${path.split('/').pop() ?? 'Material'}`, kind: 'material' },
321
+ (entity) => {
322
+ if (entity.id !== id) return false;
323
+ delete entity.material;
324
+ entity.materialRef = path;
325
+ return true;
326
+ },
327
+ );
328
+ }
329
+
330
+ async savePrefabAndConvertInstance(id: string, path: string, content: string): Promise<boolean> {
331
+ return this._writeFileAndMutateDocument(
332
+ path,
333
+ content,
334
+ { label: `Save ${path.split('/').pop() ?? 'Prefab'}`, kind: 'prefab' },
335
+ (entity) => {
336
+ if (entity.id !== id) return false;
337
+ const replacement: SceneEntity = {
338
+ id: entity.id,
339
+ name: entity.name,
340
+ prefab: `/${path}`,
341
+ ...(entity.transform ? { transform: entity.transform } : {}),
342
+ };
343
+ for (const key of Object.keys(entity) as (keyof SceneEntity)[]) delete entity[key];
344
+ Object.assign(entity, replacement);
345
+ return true;
346
+ },
347
+ );
348
+ }
349
+
350
+ private async _writeFileAndMutateDocument(
351
+ path: string,
352
+ content: string,
353
+ options: { label: string; kind: 'material' | 'prefab' },
354
+ mutate: (entity: SceneEntity) => boolean,
355
+ ): Promise<boolean> {
356
+ const history = this._history;
357
+ const descriptor = this._historyDescriptor;
358
+ if (!history || !descriptor || this._playState !== 'stopped') return false;
359
+ this._finishHistoryEdit();
360
+ const document = structuredClone(this.getDocument());
361
+ let changed = false;
362
+ walkEntities(document.entities, (entity) => {
363
+ if (!changed) changed = mutate(entity);
364
+ });
365
+ if (!changed) return false;
366
+ const documentBytes = sceneEncoder.encode(JSON.stringify(cleanDocument(document), null, 2));
367
+ return getProjectFileHistory(history).writeWithResources(
368
+ path,
369
+ content,
370
+ { ...options, contentType: 'application/json' },
371
+ [{ resource: descriptor.key, bytes: documentBytes, contentType: 'application/json' }],
372
+ );
373
+ }
374
+
302
375
  private _notify(): void {
376
+ if (!this._interactiveActive && !this._historyGestureActive) this._finishHistoryEdit();
303
377
  this._version++;
304
378
  for (const fn of this._listeners) fn();
305
379
  }
306
380
 
381
+ attachHistory(history: HistoryService): void {
382
+ if (this._history === history) return;
383
+ if (this._history) throw new Error('EditorStore is already attached to project history.');
384
+ this._history = history;
385
+ this._bindDocumentHistory(
386
+ this._documentType,
387
+ this._savePath ??
388
+ (this._documentType === 'prefab'
389
+ ? 'prefabs/__untitled__.prefab.json'
390
+ : 'scenes/__untitled__.vscn.json'),
391
+ );
392
+ }
393
+
394
+ private _bindDocumentHistory(kind: 'scene' | 'prefab', location: string): void {
395
+ const history = this._history;
396
+ if (!history) return;
397
+ const projectLocation = location.replace(/^\/+/, '');
398
+ const normalizedLocation = `project://${projectLocation}`;
399
+ if (
400
+ this._historyDescriptor?.kind === kind &&
401
+ this._historyDescriptor.location === normalizedLocation
402
+ ) {
403
+ return;
404
+ }
405
+ if (this._historyDescriptor) history.expireResources([this._historyDescriptor.key]);
406
+ this._unregisterHistoryDriver?.();
407
+ this._historyDescriptor = history.registry.registerProject(kind, projectLocation);
408
+ const driver: ResourceDriver = {
409
+ descriptor: this._historyDescriptor,
410
+ capture: async () => {
411
+ const bytes = this._captureDocumentBytes();
412
+ return {
413
+ revision: this._historyRevision,
414
+ contentType: 'application/json',
415
+ bytes,
416
+ sha256: await sha256Hex(bytes),
417
+ };
418
+ },
419
+ preflight: async (expected) => {
420
+ const actualSha256 = await sha256Hex(this._captureDocumentBytes());
421
+ return actualSha256 === expected.sha256
422
+ ? { ok: true }
423
+ : {
424
+ ok: false,
425
+ reason: 'content-conflict',
426
+ actualRevision: this._historyRevision,
427
+ actualSha256,
428
+ };
429
+ },
430
+ restore: async (snapshot) => {
431
+ const doc = JSON.parse(
432
+ sceneDecoder.decode(history.snapshots.read(snapshot).bytes),
433
+ ) as SceneFile;
434
+ this._pendingHistoryEdit = null;
435
+ this._loadFromDoc(doc);
436
+ this._historyRevision++;
437
+ this._isDirty = true;
438
+ this._docEpoch++;
439
+ this._navMeshBaked = false;
440
+ this._scheduleAutoSave();
441
+ for (const id of [...this._selectedEntityIds]) {
442
+ if (!this._objectMap.has(id)) this._selectedEntityIds.delete(id);
443
+ }
444
+ this._notify();
445
+ },
446
+ estimateBytes: (snapshot) => snapshot.byteLength,
447
+ };
448
+ this._unregisterHistoryDriver = history.registerDriver(driver);
449
+ }
450
+
451
+ private _captureDocumentBytes(): Uint8Array {
452
+ return sceneEncoder.encode(JSON.stringify(this.getDocument(), null, 2));
453
+ }
454
+
455
+ private _finishHistoryEdit(): void {
456
+ const pending = this._pendingHistoryEdit;
457
+ const history = this._history;
458
+ const descriptor = this._historyDescriptor;
459
+ if (!pending || !history || !descriptor) return;
460
+ this._pendingHistoryEdit = null;
461
+ const afterBytes = this._captureDocumentBytes();
462
+ this._historyRevision++;
463
+ void history
464
+ .recordAppliedTransaction(
465
+ { label: pending.label, resources: [descriptor.key], scope: 'project' },
466
+ [
467
+ {
468
+ resource: descriptor.key,
469
+ beforeBytes: pending.beforeBytes,
470
+ afterBytes,
471
+ contentType: 'application/json',
472
+ },
473
+ ],
474
+ )
475
+ .catch(() => {
476
+ // The structured error is exposed by HistoryService.getSnapshot().
477
+ });
478
+ }
479
+
307
480
  // --- Viewport action dispatch ---
308
481
  // Panels call these methods; ViewportPanel subscribes and executes them.
309
482
 
@@ -420,27 +593,16 @@ export class EditorStore {
420
593
  return this._sceneMeta.environment;
421
594
  }
422
595
 
423
- /** The scene's UI roots (the `ui` field) — read by UI-edit mode (K5). */
424
- getSceneUI(): import('@engine/scene/schema').UIRoot[] {
425
- return this._sceneMeta.ui ?? [];
426
- }
427
- /** Replace the scene's UI roots (UI-edit commit), mark dirty + autosave (C9). */
428
- setSceneUI(roots: import('@engine/scene/schema').UIRoot[]): void {
429
- this._sceneMeta.ui = roots;
430
- this._isDirty = true;
431
- this._docEpoch++;
432
- this._autoSave();
433
- this._notify();
434
- }
435
-
436
596
  /** Scene name. */
437
597
  get sceneName(): string {
438
598
  return this._sceneMeta.name;
439
599
  }
440
600
 
441
601
  setSceneName(name: string): void {
602
+ if (name === this._sceneMeta.name) return;
603
+ this._beginHistoryEdit('Rename Scene');
442
604
  this._sceneMeta.name = name;
443
- // No undo — name changes are lightweight, same as old behavior
605
+ this._notify();
444
606
  }
445
607
 
446
608
  // --- Selection ---
@@ -493,10 +655,10 @@ export class EditorStore {
493
655
  return 'saved';
494
656
  }
495
657
  get canUndo(): boolean {
496
- return this._undoStack.length > 0;
658
+ return this._history?.getSnapshot().canUndo ?? false;
497
659
  }
498
660
  get canRedo(): boolean {
499
- return this._redoStack.length > 0;
661
+ return this._history?.getSnapshot().canRedo ?? false;
500
662
  }
501
663
  get showGrid(): boolean {
502
664
  return this._showGrid;
@@ -507,6 +669,21 @@ export class EditorStore {
507
669
  get helperVisibility(): Readonly<HelperVisibility> {
508
670
  return this._helperVisibility;
509
671
  }
672
+ isSkeletonVisible(id: string): boolean {
673
+ return Boolean(getUserData(this._objectMap.get(id), 'skeletonVisible'));
674
+ }
675
+
676
+ setSkeletonVisible(id: string, visible: boolean): void {
677
+ const object = this._objectMap.get(id);
678
+ if (!object) return;
679
+ setUserData(object, 'skeletonVisible', visible);
680
+ object.traverse((child) => {
681
+ if (getUserData(child, 'editorHelperType') !== 'skeletons') return;
682
+ setUserData(child, 'skeletonEnabled', visible);
683
+ child.visible = visible || this._helperVisibility.skeletons;
684
+ });
685
+ this._notify();
686
+ }
510
687
  get showStats(): boolean {
511
688
  return this._showStats;
512
689
  }
@@ -521,6 +698,12 @@ export class EditorStore {
521
698
  }
522
699
  setSavePath(path: string | null): void {
523
700
  this._savePath = path;
701
+ // #131: the disk state of the NEW path is unknown — a stale baseline from
702
+ // the previous path would false-flag a save-as over an existing file as a
703
+ // conflict. Null keeps the guard inert until the first write establishes
704
+ // a fresh baseline (save-as intentionally overwrites its target).
705
+ this._lastSavedContent = null;
706
+ this._saveConflictSkips = 0;
524
707
  this._notify();
525
708
  }
526
709
  get documentType(): 'scene' | 'prefab' {
@@ -548,7 +731,7 @@ export class EditorStore {
548
731
  * Swap the store's active scene to the game scene. Editor scene is preserved.
549
732
  *
550
733
  * Also snapshots `_sceneMeta` (env/name/ui — see `getDocument`/`updateEnvironment`/
551
- * `setSceneName`/`setSceneUI`). Those setters keep mutating the SAME live
734
+ * `setSceneName`). Those setters keep mutating the SAME live
552
735
  * `_sceneMeta` object during play so the environment inspector / scene name
553
736
  * field keep working and `getDocument()` stays consistent while playing —
554
737
  * but any such edit must be discarded when play stops (probe: F5 in the
@@ -676,6 +859,10 @@ export class EditorStore {
676
859
  this._pendingAssetMove = null;
677
860
  this.applyAssetMove(oldPath, newPath);
678
861
  }
862
+ if (this._pendingMaterialRefresh) {
863
+ this._pendingMaterialRefresh = false;
864
+ this.refreshMaterialAssets();
865
+ }
679
866
  }
680
867
 
681
868
  // --- Viewport tab ---
@@ -709,13 +896,6 @@ export class EditorStore {
709
896
  this._notify();
710
897
  }
711
898
 
712
- get activeTabKey(): string {
713
- return this._activeTabKey;
714
- }
715
- get assetTabs(): readonly AssetTab[] {
716
- return this._assetTabs;
717
- }
718
-
719
899
  /** True if any entities have a navigation role. */
720
900
  hasNavigationEntities(): boolean {
721
901
  if (!this._scene) return false;
@@ -782,20 +962,21 @@ export class EditorStore {
782
962
  return result;
783
963
  }
784
964
 
785
- // --- Undo stack ---
965
+ // --- Project history ---
786
966
 
787
- /** Public: push a snapshot before a drag begins (called by viewport). */
967
+ /** Capture the document before a transform drag begins (called by viewport). */
788
968
  pushUndoSnapshot(): void {
789
- this._pushUndo();
969
+ this._beginHistoryEdit('Transform Selection');
970
+ if (this._history) this._historyGestureActive = true;
790
971
  }
791
972
 
792
- private _pushUndo(): void {
973
+ private _beginHistoryEdit(label = 'Edit Scene'): void {
793
974
  if (!this._scene) return;
794
975
  // Play-mode guard (T1.6): while playing/paused, `_scene`/`_objectMap` point
795
976
  // at the LIVE game scene (see enterPlayScene) — a fresh object graph loaded
796
977
  // from a snapshot of the document, whose entity descriptors are runtime-only
797
978
  // copies discarded on exitPlayScene. Pushing a snapshot here would serialize
798
- // that runtime state onto the REAL undo stack: a later edit's snapshot could
979
+ // that runtime state into project history: a later edit's snapshot could
799
980
  // capture an intermediate runtime pose, and a post-stop Ctrl+Z would then
800
981
  // restore that runtime pose into the restored editor scene. Marking dirty /
801
982
  // scheduling autosave would similarly persist runtime state over the on-disk
@@ -803,20 +984,19 @@ export class EditorStore {
803
984
  // the caller to the live game-scene descriptor/Object3D) still takes effect
804
985
  // for the running game — it's just never recorded in editor history or saved.
805
986
  if (this._playState !== 'stopped') return;
987
+ this._history?.assertCanRecordAppliedChange();
806
988
  // During an interactive gesture (a NumberInput scrub or color-picker drag),
807
989
  // beginInteractiveEdit() already captured ONE pre-gesture snapshot. Skip the
808
990
  // per-event snapshot so the whole gesture collapses to a single undo step,
809
991
  // but still mark dirty + schedule autosave so the live value persists.
810
992
  if (!this._interactiveActive) {
811
993
  const doc = serializeScene(this._scene, this._sceneMeta);
812
- this._undoStack.push(JSON.stringify(doc));
813
- if (this._undoStack.length > MAX_UNDO) this._undoStack.shift();
814
- this._redoStack.length = 0;
815
- // T3.2 slice 2: tag this op on the editor-global undo timeline too (only
816
- // when a snapshot was actually pushed — the interactive-gesture branch
817
- // above collapses a whole drag into the ONE snapshot already notified by
818
- // beginInteractiveEdit's own _pushUndo call).
819
- notifyUndoableOp(this);
994
+ if (this._history) {
995
+ this._pendingHistoryEdit = {
996
+ label,
997
+ beforeBytes: sceneEncoder.encode(JSON.stringify(cleanDocument(doc), null, 2)),
998
+ };
999
+ }
820
1000
  }
821
1001
  this._isDirty = true;
822
1002
  this._docEpoch++;
@@ -831,15 +1011,16 @@ export class EditorStore {
831
1011
  * onChange. Pair with endInteractiveEdit(). Mirrors the viewport gizmo's live
832
1012
  * drag (pushUndoSnapshot at start, no-undo live updates, commit at end).
833
1013
  */
834
- beginInteractiveEdit(): void {
1014
+ beginInteractiveEdit(label = 'Edit Properties'): void {
835
1015
  if (this._interactiveActive) return;
836
- this._pushUndo();
1016
+ this._beginHistoryEdit(label);
837
1017
  this._interactiveActive = true;
838
1018
  }
839
1019
 
840
1020
  /** End a coalesced interactive edit gesture. */
841
1021
  endInteractiveEdit(): void {
842
1022
  this._interactiveActive = false;
1023
+ this._finishHistoryEdit();
843
1024
  }
844
1025
 
845
1026
  private _scheduleAutoSave(): void {
@@ -883,7 +1064,7 @@ export class EditorStore {
883
1064
  private _autoSave(): void {
884
1065
  this._autoSaveTimer = null;
885
1066
  // Play-mode guard (T1.6, belt-and-braces): `_scene` is the live game scene
886
- // while playing/paused (see enterPlayScene). `_pushUndo`/`commitLiveTransform`
1067
+ // while playing/paused (see enterPlayScene). `_beginHistoryEdit`/`commitLiveTransform`
887
1068
  // already skip scheduling autosave during play, and play-mode.ts installs an
888
1069
  // ephemeral persistence provider (isDirty() always false) via
889
1070
  // `setActiveAuthoring` for the duration — so this check is now a backstop
@@ -913,6 +1094,10 @@ export class EditorStore {
913
1094
  void provider.save().catch((err) => {
914
1095
  this._lastSaveFailed = true;
915
1096
  console.warn('[EditorStore] Auto-save threw — changes are NOT saved to disk.', err);
1097
+ // W4: the failure must also land in the EDITOR console — the utility
1098
+ // policy reveals Console on saveState → 'failed', so the entry the
1099
+ // user sees there is real.
1100
+ editorConsole.error('Auto-save failed — changes are NOT saved to disk.', 'editor');
916
1101
  this._notify();
917
1102
  });
918
1103
  }
@@ -969,6 +1154,38 @@ export class EditorStore {
969
1154
  if (!this._savePath || !this._scene) return false;
970
1155
  const content = JSON.stringify(this.getDocument(), null, 2);
971
1156
  const epoch = this._docEpoch;
1157
+
1158
+ // #131 — unobserved-external-change guard. If the bytes on disk no longer
1159
+ // match what this editor last wrote (or loaded — `_captureDiskBaseline`),
1160
+ // someone else (an agent, another tool) edited the file while our watch
1161
+ // channel missed it (drvfs inotify gaps, poll latency). Blind-writing the
1162
+ // in-memory doc would permanently destroy that edit — the exact data-loss
1163
+ // path found in blind run #4's investigation. Adopt the disk version
1164
+ // instead: worst case that reverts the human's last ~300ms gesture, while
1165
+ // blind-writing loses ALL of the external writer's work. Best-effort by
1166
+ // design: no baseline yet (null) or an unreadable disk falls through to
1167
+ // the normal write.
1168
+ if (this._lastSavedContent !== null) {
1169
+ let disk: string | null = null;
1170
+ try {
1171
+ disk = await readProjectTextFile(this._savePath);
1172
+ } catch {
1173
+ disk = null;
1174
+ }
1175
+ if (disk !== null && disk !== this._lastSavedContent && disk !== content) {
1176
+ const outcome = this._resolveSaveConflict(disk);
1177
+ if (outcome === 'adopted') return false; // store now matches disk; nothing to write
1178
+ if (outcome === 'retry') {
1179
+ this._scheduleAutoSave();
1180
+ return false;
1181
+ }
1182
+ // 'overwrite': disk content is unadoptable (unparseable / non-scene
1183
+ // document / retries exhausted) — proceed to the loud write below so
1184
+ // dirty changes are never stranded off-disk forever.
1185
+ }
1186
+ }
1187
+ this._saveConflictSkips = 0;
1188
+
972
1189
  this._ignoreExternalUntil = Date.now() + SYNC_COOLDOWN_MS;
973
1190
  this._lastSavedContent = content;
974
1191
  try {
@@ -984,31 +1201,121 @@ export class EditorStore {
984
1201
  this._saveEditorStateIfDue();
985
1202
  } else {
986
1203
  console.warn('[EditorStore] Save failed — changes are NOT saved to disk.');
1204
+ // W4: mirror into the editor Console — the utility policy reveals it
1205
+ // on saveState → 'failed', so the entry there must be real. Identical
1206
+ // consecutive failures collapse via count.
1207
+ editorConsole.error(
1208
+ `Save failed — changes are NOT saved to disk (${this._savePath}).`,
1209
+ 'editor',
1210
+ );
987
1211
  }
988
1212
  return ok;
989
1213
  } catch (err) {
990
1214
  this._lastSaveFailed = true;
991
1215
  this._notify();
992
1216
  console.warn('[EditorStore] Save threw — changes are NOT saved to disk.', err);
1217
+ editorConsole.error(
1218
+ `Save failed — changes are NOT saved to disk (${this._savePath}).`,
1219
+ 'editor',
1220
+ );
993
1221
  return false;
994
1222
  }
995
1223
  }
996
1224
 
1225
+ /**
1226
+ * #131 — decide what `saveNow()` does about a file that changed on disk
1227
+ * since our last known write. Returns:
1228
+ * - `'adopted'` — the disk version was applied into the store (or was a
1229
+ * semantic no-op, e.g. an external formatter run); the doc is clean and
1230
+ * nothing should be written.
1231
+ * - `'retry'` — could not adopt right now (sync circuit breaker, a torn
1232
+ * mid-write read, a non-scene document); the caller reschedules autosave
1233
+ * and skips this write.
1234
+ * - `'overwrite'`— adoption failed repeatedly (`_saveConflictSkips` > 3);
1235
+ * write anyway, LOUDLY, so dirty changes are never stranded forever
1236
+ * (an invalid file on disk gets restored to validity by the write).
1237
+ */
1238
+ private _resolveSaveConflict(disk: string): 'adopted' | 'retry' | 'overwrite' {
1239
+ if (this._documentType === 'scene') {
1240
+ let incoming: SceneFile | null = null;
1241
+ try {
1242
+ incoming = parseSceneFile(JSON.parse(disk));
1243
+ } catch {
1244
+ incoming = null; // torn mid-write read or genuinely invalid JSON
1245
+ }
1246
+ if (incoming) {
1247
+ // Our own save cooldown must not block adopting a conflict we just
1248
+ // detected ourselves.
1249
+ this._ignoreExternalUntil = 0;
1250
+ const applied = this.applyExternalUpdate(incoming, disk);
1251
+ if (applied) {
1252
+ this._lastSavedContent = disk;
1253
+ this._isDirty = false;
1254
+ this._saveConflictSkips = 0;
1255
+ const msg =
1256
+ `${this._savePath} changed on disk while the editor held unsaved changes — ` +
1257
+ 'adopted the disk version; re-apply your last edit if it disappeared';
1258
+ editorConsole.warn(msg, 'scene');
1259
+ console.warn(`[EditorStore] ${msg}`);
1260
+ this._notify();
1261
+ return 'adopted';
1262
+ }
1263
+ // Dropped by the sync circuit breaker — its own 5s window bounds the
1264
+ // retries; do NOT count these toward the overwrite escalation (a sync
1265
+ // storm is exactly when a forced write would ping-pong).
1266
+ return 'retry';
1267
+ }
1268
+ }
1269
+ this._saveConflictSkips++;
1270
+ if (this._saveConflictSkips <= 3) {
1271
+ console.warn(
1272
+ `[EditorStore] ${this._savePath} changed on disk but could not be adopted ` +
1273
+ `(unparseable or non-scene document) — retrying (${this._saveConflictSkips}/3) before overwriting.`,
1274
+ );
1275
+ return 'retry';
1276
+ }
1277
+ const msg =
1278
+ `${this._savePath} changed on disk but the disk version could not be adopted — ` +
1279
+ 'OVERWRITING it with the editor version to avoid stranding unsaved changes';
1280
+ editorConsole.error(msg, 'scene');
1281
+ console.warn(`[EditorStore] ${msg}`);
1282
+ return 'overwrite';
1283
+ }
1284
+
1285
+ /**
1286
+ * #131 — baseline `_lastSavedContent` with the exact bytes on disk right
1287
+ * after a document load, so the save-conflict guard is armed from t=0 (an
1288
+ * agent's first external edit typically lands before the human's first
1289
+ * save). Fire-and-forget: a failed read just leaves the guard inert (null
1290
+ * baseline), which is the pre-#131 behavior. The `_savePath` / null checks
1291
+ * make a late resolution harmless if the user switched documents or a save
1292
+ * already established a fresher baseline. Known millisecond-scale race: an
1293
+ * external edit landing between the load and this read becomes the
1294
+ * baseline, so the next save would not flag it — the SSE/poll watch channel
1295
+ * covers that window.
1296
+ */
1297
+ private async _captureDiskBaseline(path: string): Promise<void> {
1298
+ try {
1299
+ const raw = await readProjectTextFile(path);
1300
+ if (raw !== null && this._savePath === path && this._lastSavedContent === null) {
1301
+ this._lastSavedContent = raw;
1302
+ }
1303
+ } catch {
1304
+ /* mocked/absent backend or unreadable file — guard stays inert */
1305
+ }
1306
+ }
1307
+
997
1308
  private static readonly THUMBNAIL_INTERVAL_MS = 60_000;
998
1309
  private static readonly THUMBNAIL_SIZE = 256;
999
1310
 
1000
1311
  /**
1001
- * Render the bound camera's current view to a PNG data URL at `size`x`size`
1002
- * (game content only — editor-layer grid/helpers/gizmos excluded, same as
1003
- * the thumbnail path). Shared by the throttled autosave thumbnail
1004
- * (`_captureThumbnail`) and the on-demand `captureViewportImage` (backs the
1005
- * `capture-viewport` relay command / `editor.viewport.screenshot.capture`'s
1006
- * fresh-capture mode) — same rendering technique (offscreen render target +
1007
- * `readRenderTargetPixels`), so it needs no `preserveDrawingBuffer` on the
1008
- * live canvas's WebGL context. Returns null if no renderer/scene/camera is
1009
- * bound yet.
1312
+ * Render the bound camera's current view to a PNG data URL at `size`x`size`.
1313
+ * Autosave thumbnails request game content only; an explicit SDK/editor
1314
+ * viewport capture requests every camera layer so grid/helpers/gizmos are
1315
+ * represented honestly. Both use the same offscreen render-target technique,
1316
+ * so neither needs `preserveDrawingBuffer` on the live WebGL canvas.
1010
1317
  */
1011
- private _renderViewportImage(size: number): string | null {
1318
+ private _renderViewportImage(size: number, includeEditorLayers: boolean): string | null {
1012
1319
  if (!this._renderer || !this._scene || !this._camera) return null;
1013
1320
 
1014
1321
  const renderSize = size * 2; // render at 2x then downscale for crisp result
@@ -1017,11 +1324,14 @@ export class EditorStore {
1017
1324
  const prevTarget = renderer.getRenderTarget();
1018
1325
  const prevPixelRatio = renderer.getPixelRatio();
1019
1326
 
1020
- // Render without editor-layer objects (grid, helpers, gizmos)
1021
1327
  const camera = this._camera as THREE.PerspectiveCamera;
1022
1328
  const prevLayers = camera.layers.mask;
1023
- camera.layers.disableAll();
1024
- camera.layers.enable(0); // game content only
1329
+ if (includeEditorLayers) {
1330
+ camera.layers.enableAll();
1331
+ } else {
1332
+ camera.layers.disableAll();
1333
+ camera.layers.enable(0); // clean project thumbnail: game content only
1334
+ }
1025
1335
 
1026
1336
  renderer.setPixelRatio(1);
1027
1337
  renderer.setRenderTarget(renderTarget);
@@ -1063,7 +1373,7 @@ export class EditorStore {
1063
1373
  const now = Date.now();
1064
1374
  if (now - this._lastThumbnailTime < EditorStore.THUMBNAIL_INTERVAL_MS) return;
1065
1375
  this._lastThumbnailTime = now;
1066
- const dataUrl = this._renderViewportImage(EditorStore.THUMBNAIL_SIZE);
1376
+ const dataUrl = this._renderViewportImage(EditorStore.THUMBNAIL_SIZE, false);
1067
1377
  if (dataUrl) saveThumbnail(dataUrl);
1068
1378
  }
1069
1379
 
@@ -1074,7 +1384,7 @@ export class EditorStore {
1074
1384
  * `ViewportPanel` has mounted).
1075
1385
  */
1076
1386
  captureViewportImage(size = EditorStore.THUMBNAIL_SIZE): string | null {
1077
- return this._renderViewportImage(size);
1387
+ return this._renderViewportImage(size, true);
1078
1388
  }
1079
1389
 
1080
1390
  // --- Editor state persistence ---
@@ -1174,7 +1484,7 @@ export class EditorStore {
1174
1484
  const obj = this._objectMap.get(id);
1175
1485
  const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1176
1486
  if (!entity || !obj) return;
1177
- this._pushUndo();
1487
+ this._beginHistoryEdit(`Edit ${entity.name}`);
1178
1488
  Object.assign(entity, partial);
1179
1489
  this._rebuildEntity(id);
1180
1490
  this._notify();
@@ -1189,7 +1499,7 @@ export class EditorStore {
1189
1499
  const obj = this._objectMap.get(id);
1190
1500
  const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1191
1501
  if (!entity || !obj) return;
1192
- this._pushUndo();
1502
+ this._beginHistoryEdit(`Transform ${entity.name}`);
1193
1503
  entity.transform = { position: pos, rotation: rot, scale: scl };
1194
1504
  obj.position.set(pos[0], pos[1], pos[2]);
1195
1505
  obj.quaternion.set(rot[0], rot[1], rot[2], rot[3]);
@@ -1242,6 +1552,7 @@ export class EditorStore {
1242
1552
  this._navMeshBaked = false;
1243
1553
  this._scheduleAutoSave();
1244
1554
  }
1555
+ this._historyGestureActive = false;
1245
1556
  this._notify();
1246
1557
  }
1247
1558
 
@@ -1250,7 +1561,7 @@ export class EditorStore {
1250
1561
  if (!this._scene) return;
1251
1562
  if (!entity.id) entity.id = crypto.randomUUID();
1252
1563
  assignMissingIds(entity.children ?? []);
1253
- this._pushUndo();
1564
+ this._beginHistoryEdit(`Add ${entity.name}`);
1254
1565
 
1255
1566
  const obj = createEntityObject(entity);
1256
1567
  obj.visible = entity.visible !== false;
@@ -1305,7 +1616,9 @@ export class EditorStore {
1305
1616
  if (this._blockedDuringPlay('delete entity')) return;
1306
1617
  const obj = this._objectMap.get(id);
1307
1618
  if (!obj) return;
1308
- this._pushUndo();
1619
+ this._beginHistoryEdit(
1620
+ `Delete ${(getUserData(obj, 'entity') as SceneEntity | undefined)?.name ?? 'Entity'}`,
1621
+ );
1309
1622
  this._removeObjectFromScene(obj);
1310
1623
  this._selectedEntityIds.delete(id);
1311
1624
  this._notify();
@@ -1341,7 +1654,7 @@ export class EditorStore {
1341
1654
  reassign(cloneEntity);
1342
1655
  cloneEntity.name = `${entity.name} (copy)`;
1343
1656
 
1344
- this._pushUndo();
1657
+ this._beginHistoryEdit(`Duplicate ${entity.name}`);
1345
1658
 
1346
1659
  // Create object and insert after original
1347
1660
  const cloneObj = createEntityObject(cloneEntity);
@@ -1396,7 +1709,9 @@ export class EditorStore {
1396
1709
  const targetParent = parentId ? this._objectMap.get(parentId) : this._scene;
1397
1710
  if (!targetParent) return;
1398
1711
 
1399
- this._pushUndo();
1712
+ this._beginHistoryEdit(
1713
+ `Move ${dragIds.length === 1 ? 'Entity' : `${dragIds.length} Entities`}`,
1714
+ );
1400
1715
 
1401
1716
  // Collect objects and remove from old locations
1402
1717
  const objects: THREE.Object3D[] = [];
@@ -1431,7 +1746,7 @@ export class EditorStore {
1431
1746
  const obj = this._objectMap.get(id);
1432
1747
  const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1433
1748
  if (!entity || !obj) return;
1434
- this._pushUndo();
1749
+ this._beginHistoryEdit(`Rename ${entity.name}`);
1435
1750
  entity.name = name;
1436
1751
  obj.name = name;
1437
1752
  this._notify();
@@ -1442,7 +1757,7 @@ export class EditorStore {
1442
1757
  const obj = this._objectMap.get(id);
1443
1758
  const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1444
1759
  if (!entity || !obj) return;
1445
- this._pushUndo();
1760
+ this._beginHistoryEdit(`${entity.visible === false ? 'Show' : 'Hide'} ${entity.name}`);
1446
1761
  if (entity.visible === false) {
1447
1762
  delete entity.visible;
1448
1763
  obj.visible = true;
@@ -1458,7 +1773,7 @@ export class EditorStore {
1458
1773
  const obj = this._objectMap.get(id);
1459
1774
  const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1460
1775
  if (!entity) return;
1461
- this._pushUndo();
1776
+ this._beginHistoryEdit(`${entity.locked ? 'Unlock' : 'Lock'} ${entity.name}`);
1462
1777
  if (entity.locked) {
1463
1778
  delete entity.locked;
1464
1779
  } else {
@@ -1488,7 +1803,7 @@ export class EditorStore {
1488
1803
  // --- Bulk delete ---
1489
1804
  deleteEntities(ids: string[]): void {
1490
1805
  if (this._blockedDuringPlay('delete entities')) return;
1491
- this._pushUndo();
1806
+ this._beginHistoryEdit(`Delete ${ids.length} ${ids.length === 1 ? 'Entity' : 'Entities'}`);
1492
1807
  for (const id of ids) {
1493
1808
  const obj = this._objectMap.get(id);
1494
1809
  if (!obj) continue;
@@ -1533,7 +1848,7 @@ export class EditorStore {
1533
1848
  const obj = this._objectMap.get(id);
1534
1849
  const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1535
1850
  if (!entity) return;
1536
- this._pushUndo();
1851
+ this._beginHistoryEdit(`Set ${entity.name} Pivot`);
1537
1852
  const isZero = pivot[0] === 0 && pivot[1] === 0 && pivot[2] === 0;
1538
1853
  if (isZero) {
1539
1854
  delete entity.pivot;
@@ -1591,7 +1906,7 @@ export class EditorStore {
1591
1906
  if (this._blockedDuringPlay('cut entities')) return;
1592
1907
  this.copyEntities();
1593
1908
  const ids = [...this._selectedEntityIds];
1594
- this._pushUndo();
1909
+ this._beginHistoryEdit(`Cut ${ids.length} ${ids.length === 1 ? 'Entity' : 'Entities'}`);
1595
1910
  for (const id of ids) {
1596
1911
  const obj = this._objectMap.get(id);
1597
1912
  if (!obj) continue;
@@ -1604,7 +1919,9 @@ export class EditorStore {
1604
1919
  pasteEntities(): void {
1605
1920
  if (this._blockedDuringPlay('paste')) return;
1606
1921
  if (this._clipboard.length === 0 || !this._scene) return;
1607
- this._pushUndo();
1922
+ this._beginHistoryEdit(
1923
+ `Paste ${this._clipboard.length} ${this._clipboard.length === 1 ? 'Entity' : 'Entities'}`,
1924
+ );
1608
1925
  const newIds: string[] = [];
1609
1926
  for (const template of this._clipboard) {
1610
1927
  const clone = deepClone(template);
@@ -1636,7 +1953,7 @@ export class EditorStore {
1636
1953
  const trimmed = tag.trim();
1637
1954
  if (!trimmed) return;
1638
1955
  if (entity.tags?.includes(trimmed)) return;
1639
- this._pushUndo();
1956
+ this._beginHistoryEdit(`Add Tag to ${entity.name}`);
1640
1957
  if (!entity.tags) entity.tags = [];
1641
1958
  entity.tags.push(trimmed);
1642
1959
  this._notify();
@@ -1647,7 +1964,7 @@ export class EditorStore {
1647
1964
  if (!entity?.tags) return;
1648
1965
  const idx = entity.tags.indexOf(tag);
1649
1966
  if (idx < 0) return;
1650
- this._pushUndo();
1967
+ this._beginHistoryEdit(`Remove Tag from ${entity.name}`);
1651
1968
  entity.tags.splice(idx, 1);
1652
1969
  if (entity.tags.length === 0) delete entity.tags;
1653
1970
  this._notify();
@@ -1667,7 +1984,7 @@ export class EditorStore {
1667
1984
  // --- Batch mutations (multi-entity editing) ---
1668
1985
  /** Push undo once, then mutate each entity by id and rebuild. */
1669
1986
  batchMutate(ids: string[], mutator: (entity: SceneEntity) => void): void {
1670
- this._pushUndo();
1987
+ this._beginHistoryEdit(`Edit ${ids.length} ${ids.length === 1 ? 'Entity' : 'Entities'}`);
1671
1988
  for (const id of ids) {
1672
1989
  const obj = this._objectMap.get(id);
1673
1990
  const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
@@ -1681,7 +1998,7 @@ export class EditorStore {
1681
1998
 
1682
1999
  // --- Environment ---
1683
2000
  updateEnvironment(partial: Partial<SceneEnvironment>): void {
1684
- this._pushUndo();
2001
+ this._beginHistoryEdit('Edit Environment');
1685
2002
  this._sceneMeta.environment = { ...this._sceneMeta.environment, ...partial };
1686
2003
  this._applyEnvironment();
1687
2004
  this._notify();
@@ -1689,14 +2006,59 @@ export class EditorStore {
1689
2006
 
1690
2007
  /** Replace the entire environment object (for removing keys). */
1691
2008
  setEnvironment(env: SceneEnvironment): void {
1692
- this._pushUndo();
2009
+ this._beginHistoryEdit('Replace Environment');
1693
2010
  this._sceneMeta.environment = env;
1694
2011
  this._applyEnvironment();
1695
2012
  this._notify();
1696
2013
  }
1697
2014
 
2015
+ /**
2016
+ * Re-apply the AUTHORED environment to the live scene and bump
2017
+ * `composerVersion` — the restore half of the world-hidden eye's
2018
+ * environment suppression (`world-hidden-viewport.ts`'s
2019
+ * `suppressWorldEnvironment`). Truth is `_sceneMeta.environment`; nothing
2020
+ * is snapshotted at suppress time. No undo push (nothing authored changed)
2021
+ * and no notify — the caller (`editor-viewport.ts`'s `syncFromStore`) runs
2022
+ * INSIDE a notify, and `ViewportPanel`'s subscriber checks
2023
+ * `composerVersion` right after `syncFromStore` returns, so the composer
2024
+ * rebuild this triggers is picked up in the same tick.
2025
+ */
2026
+ reapplyEnvironment(): void {
2027
+ this._applyEnvironment();
2028
+ }
2029
+
2030
+ /** Rebuild the stopped authoring scene after a referenced `.mat.json`
2031
+ * changes. This is view maintenance only: descriptors/history/dirty state
2032
+ * stay untouched. */
2033
+ refreshMaterialAssets(): void {
2034
+ clearMaterialRefCache();
2035
+ if (!this._scene) return;
2036
+ if (this._playState !== 'stopped') {
2037
+ this._pendingMaterialRefresh = true;
2038
+ return;
2039
+ }
2040
+ this._pendingMaterialRefresh = false;
2041
+ this._rebuildScene(this.getDocument());
2042
+ this._notify();
2043
+ }
2044
+
1698
2045
  // --- External update (live sync from disk) ---
1699
2046
 
2047
+ /**
2048
+ * Whether `rawContent` (exact bytes from a file-watcher event) is exactly
2049
+ * what this editor itself last wrote to `_savePath` — i.e. an autosave
2050
+ * echo, not a genuine external edit. Callers that want to gate on "did
2051
+ * something ELSE change this file" (e.g. the dirty-notice check in
2052
+ * `DefaultEditorLayout`'s `onSceneChanged`, run-3 friction #1) use this to
2053
+ * tell the two apart before deciding whether to notify — `applyExternalUpdate`
2054
+ * performs the same comparison internally but only AFTER already deciding
2055
+ * to apply, which is too late for a caller that wants to skip notifying on
2056
+ * a plain echo.
2057
+ */
2058
+ isOwnEchoContent(rawContent: string): boolean {
2059
+ return rawContent === this._lastSavedContent;
2060
+ }
2061
+
1700
2062
  /**
1701
2063
  * Apply an external SceneFile update (e.g. from a file watcher) without
1702
2064
  * pushing undo or triggering auto-save. Diffs against current state and
@@ -1707,9 +2069,16 @@ export class EditorStore {
1707
2069
  * what we last wrote is our own save reflected back, and is skipped even
1708
2070
  * if it arrives after `_ignoreExternalUntil` — the document may have moved
1709
2071
  * on (undo/redo) and applying the echo would clobber the newer state.
2072
+ *
2073
+ * Returns whether the store now semantically matches `incoming`: `true`
2074
+ * when the diff was applied OR was a no-op (own echo / zero ops); `false`
2075
+ * when the update was deferred (play mode) or dropped (sync cooldown,
2076
+ * circuit breaker, no scene). `saveNow()`'s unobserved-external-change
2077
+ * guard (#131) relies on this to know whether adopting the disk version
2078
+ * actually happened before it marks the document clean.
1710
2079
  */
1711
- applyExternalUpdate(incoming: SceneFile, rawContent?: string): void {
1712
- if (rawContent !== undefined && rawContent === this._lastSavedContent) return;
2080
+ applyExternalUpdate(incoming: SceneFile, rawContent?: string): boolean {
2081
+ if (rawContent !== undefined && rawContent === this._lastSavedContent) return true;
1713
2082
  // Play-mode / ingest guard: `_scene` is the LIVE game/ingested scene while
1714
2083
  // playing (see enterPlayScene) — diffing and patching it here would rebuild
1715
2084
  // the RUNNING scene out from under the game/ingested adapter, and the disk
@@ -1718,18 +2087,18 @@ export class EditorStore {
1718
2087
  // latest incoming doc and apply it once play/ingest stops (see setPlayState).
1719
2088
  if (this._playState !== 'stopped') {
1720
2089
  this._pendingExternalUpdate = incoming;
1721
- return;
2090
+ return false;
1722
2091
  }
1723
- if (!this._scene) return;
1724
- if (Date.now() < this._ignoreExternalUntil) return;
2092
+ if (!this._scene) return false;
2093
+ if (Date.now() < this._ignoreExternalUntil) return false;
1725
2094
  assignMissingIds(incoming.entities);
1726
2095
  const current = this.getDocument();
1727
2096
  const diff = diffScene(current, incoming);
1728
2097
 
1729
2098
  // No-op polls must not count toward the circuit breaker (ED6) — only a
1730
2099
  // poll that actually changes something is a real sync cycle.
1731
- if (diff.ops.length === 0 && !diff.environmentChanged && !diff.metaChanged) return;
1732
- if (this._isSyncLooping()) return;
2100
+ if (diff.ops.length === 0 && !diff.environmentChanged && !diff.metaChanged) return true;
2101
+ if (this._isSyncLooping()) return false;
1733
2102
 
1734
2103
  this._suppressAutoSaveUntil = Date.now() + SYNC_COOLDOWN_MS;
1735
2104
 
@@ -1856,49 +2225,6 @@ export class EditorStore {
1856
2225
  }
1857
2226
 
1858
2227
  this._notify();
1859
- }
1860
-
1861
- // --- Undo / Redo ---
1862
- undo(): boolean {
1863
- // `false` tells the undo timeline "couldn't act — put the entry back"
1864
- // (see UndoAdapter in authoring/undo-timeline.ts).
1865
- if (this._blockedDuringPlay('undo')) return false;
1866
- if (this._undoStack.length === 0 || !this._scene) return false;
1867
- const currentDoc = serializeScene(this._scene, this._sceneMeta);
1868
- this._redoStack.push(JSON.stringify(currentDoc));
1869
- const doc = JSON.parse(this._undoStack.pop()!) as SceneFile;
1870
- this._loadFromDoc(doc);
1871
- // The restored state is a document mutation like any other: mark dirty and
1872
- // schedule autosave. Without this, an undo after a completed autosave left
1873
- // the store claiming "saved" while the pre-undo state stayed on disk.
1874
- this._isDirty = true;
1875
- this._docEpoch++;
1876
- this._navMeshBaked = false;
1877
- this._scheduleAutoSave();
1878
- // Re-validate selection
1879
- for (const id of [...this._selectedEntityIds]) {
1880
- if (!this._objectMap.has(id)) this._selectedEntityIds.delete(id);
1881
- }
1882
- this._notify();
1883
- return true;
1884
- }
1885
-
1886
- redo(): boolean {
1887
- if (this._blockedDuringPlay('redo')) return false;
1888
- if (this._redoStack.length === 0 || !this._scene) return false;
1889
- const currentDoc = serializeScene(this._scene, this._sceneMeta);
1890
- this._undoStack.push(JSON.stringify(currentDoc));
1891
- const doc = JSON.parse(this._redoStack.pop()!) as SceneFile;
1892
- this._loadFromDoc(doc);
1893
- // Same as undo(): the restored state must be persisted.
1894
- this._isDirty = true;
1895
- this._docEpoch++;
1896
- this._navMeshBaked = false;
1897
- this._scheduleAutoSave();
1898
- for (const id of [...this._selectedEntityIds]) {
1899
- if (!this._objectMap.has(id)) this._selectedEntityIds.delete(id);
1900
- }
1901
- this._notify();
1902
2228
  return true;
1903
2229
  }
1904
2230
 
@@ -1914,22 +2240,48 @@ export class EditorStore {
1914
2240
 
1915
2241
  loadDocument(doc: SceneFile, savePath?: string): void {
1916
2242
  if (this._blockedDuringPlay('load document')) return;
1917
- assignMissingIds(doc.entities);
2243
+ const idsWereAssigned = assignMissingIds(doc.entities);
1918
2244
  this._sceneMeta = { version: doc.version, name: doc.name };
1919
2245
  if (doc.environment) this._sceneMeta.environment = doc.environment;
1920
- if (doc.ui) this._sceneMeta.ui = doc.ui; // preserve scene-graph UI (A1/C9)
1921
2246
  this._documentType = 'scene';
1922
2247
  this._selectedEntityIds.clear();
1923
- this._undoStack.length = 0;
1924
- this._redoStack.length = 0;
2248
+ this._pendingHistoryEdit = null;
2249
+ this._historyGestureActive = false;
1925
2250
  this._isDirty = false;
1926
2251
  this._savePath = savePath ?? null;
2252
+ // #131: reset the save-conflict baseline (a previous document's bytes
2253
+ // must never be compared against this document's disk file), then
2254
+ // re-arm it with this file's exact on-disk bytes.
2255
+ this._lastSavedContent = null;
2256
+ this._saveConflictSkips = 0;
2257
+ if (savePath) void this._captureDiskBaseline(savePath);
2258
+ this._bindDocumentHistory('scene', savePath ?? 'scenes/__untitled__.vscn.json');
1927
2259
 
1928
2260
  if (this._scene) {
1929
2261
  this._rebuildScene(doc);
1930
2262
  }
1931
2263
  this._notify();
1932
2264
 
2265
+ // The `apply-diff`/`vgai status` id-persistence trap: an entity loaded
2266
+ // from a file with no (or partial) `id` fields gets one assigned above,
2267
+ // IN MEMORY ONLY — `assignMissingIds` never touches disk. `vgai status`
2268
+ // reports these freshly-assigned ids (they're the only ids this running
2269
+ // session has), but until this block existed nothing ever wrote them
2270
+ // back to `savePath`, so the exact id `status` just handed out could
2271
+ // never resolve against the file `apply-diff` reads (it matches strictly
2272
+ // on the FILE's persisted id). Marking the document dirty and going
2273
+ // through the ordinary autosave path here closes the loop the same way
2274
+ // any other edit persists: the file catches up to the ids this session
2275
+ // is already handing out, automatically, ~300ms after load, with no
2276
+ // user/agent action required. Gated on `savePath` for the same reason
2277
+ // the disk-baseline capture above is: a path-less doc (e.g. the no-
2278
+ // project fallback scene) is explicitly never written until the user
2279
+ // gives it a real save target.
2280
+ if (idsWereAssigned && savePath) {
2281
+ this._isDirty = true;
2282
+ this._scheduleAutoSave();
2283
+ }
2284
+
1933
2285
  // Capture a thumbnail after assets have had time to load
1934
2286
  if (savePath) {
1935
2287
  setTimeout(() => this._captureThumbnail(), 2000);
@@ -1965,7 +2317,7 @@ export class EditorStore {
1965
2317
  const obj = this._objectMap.get(id);
1966
2318
  const entity = getUserData(obj, 'entity') as SceneEntity | undefined;
1967
2319
  if (!entity) return;
1968
- this._pushUndo();
2320
+ this._beginHistoryEdit(`Convert ${entity.name} to Prefab`);
1969
2321
  const keepName = entity.name;
1970
2322
  const keepId = entity.id;
1971
2323
  const keepTransform = entity.transform;
@@ -1997,7 +2349,7 @@ export class EditorStore {
1997
2349
  console.error(`EditorStore: cannot unpack invalid prefab instance "${entity.name}":`, err);
1998
2350
  return;
1999
2351
  }
2000
- this._pushUndo();
2352
+ this._beginHistoryEdit(`Unpack ${entity.name}`);
2001
2353
  for (const key of Object.keys(entity) as (keyof SceneEntity)[]) {
2002
2354
  delete entity[key];
2003
2355
  }
@@ -2022,10 +2374,17 @@ export class EditorStore {
2022
2374
  };
2023
2375
  this._documentType = 'prefab';
2024
2376
  this._selectedEntityIds.clear();
2025
- this._undoStack.length = 0;
2026
- this._redoStack.length = 0;
2377
+ this._pendingHistoryEdit = null;
2378
+ this._historyGestureActive = false;
2027
2379
  this._isDirty = false;
2028
2380
  this._savePath = savePath ?? null;
2381
+ // #131: same baseline reset+re-arm as loadDocument. Prefab conflicts are
2382
+ // not adoptable (applyExternalUpdate speaks SceneFile), but the baseline
2383
+ // still gates the loud retry/overwrite path instead of a silent clobber.
2384
+ this._lastSavedContent = null;
2385
+ this._saveConflictSkips = 0;
2386
+ if (savePath) void this._captureDiskBaseline(savePath);
2387
+ this._bindDocumentHistory('prefab', savePath ?? 'prefabs/__untitled__.prefab.json');
2029
2388
 
2030
2389
  if (this._scene) {
2031
2390
  this._rebuildScene(doc);
@@ -2057,76 +2416,23 @@ export class EditorStore {
2057
2416
  return;
2058
2417
  }
2059
2418
  if (!this._scene) return;
2060
- this._pushUndo();
2419
+ this._beginHistoryEdit('Update Asset References');
2061
2420
  // Serialize, rename, rebuild
2062
2421
  const doc = serializeScene(this._scene, this._sceneMeta);
2063
2422
  const renamed = applyAssetRename(doc, oldPath, newPath);
2064
2423
  this._sceneMeta = { version: renamed.version, name: renamed.name };
2065
2424
  if (renamed.environment) this._sceneMeta.environment = renamed.environment;
2066
- if (renamed.ui) this._sceneMeta.ui = renamed.ui; // preserve scene-graph UI (T1.5)
2067
2425
  this._rebuildScene(renamed);
2068
2426
  this._notify();
2069
2427
  }
2070
2428
 
2071
- // --- Asset tabs ---
2072
-
2073
- setActiveTab(key: string): void {
2074
- this._activeTabKey = key;
2075
- this._notify();
2076
- }
2077
-
2078
- openAssetTab(assetPath: string, kind: AssetKind): void {
2079
- const existing = this._assetTabs.find((t) => t.key === assetPath);
2080
- if (existing) {
2081
- this._activeTabKey = existing.key;
2082
- this._notify();
2083
- return;
2084
- }
2085
- const label = assetPath.split('/').pop() ?? assetPath;
2086
- const tab: AssetTab = { key: assetPath, label, assetPath, kind };
2087
- this._assetTabs.push(tab);
2088
- this._activeTabKey = tab.key;
2089
- this._notify();
2090
- }
2091
-
2092
- openOnlineAssetTab(online: OnlineAssetInfo): void {
2093
- const key = `online:${online.source}:${online.id}`;
2094
- const existing = this._assetTabs.find((t) => t.key === key);
2095
- if (existing) {
2096
- this._activeTabKey = existing.key;
2097
- this._notify();
2098
- return;
2099
- }
2100
- const kind: AssetKind =
2101
- online.type === 'model'
2102
- ? 'model'
2103
- : online.type === 'animation' || online.type === 'source'
2104
- ? 'source'
2105
- : 'image';
2106
- const tab: AssetTab = { key, label: online.name, assetPath: '', kind, online };
2107
- this._assetTabs.push(tab);
2108
- this._activeTabKey = tab.key;
2109
- this._notify();
2110
- }
2111
-
2112
- /** Update an online asset tab to point at a local path after download. */
2113
- resolveOnlineAssetTab(onlineKey: string, localPath: string): void {
2114
- const tab = this._assetTabs.find((t) => t.key === onlineKey);
2115
- if (!tab) return;
2116
- tab.assetPath = localPath;
2117
- delete tab.online;
2118
- this._notify();
2119
- }
2120
-
2121
- closeAssetTab(key: string): void {
2122
- const idx = this._assetTabs.findIndex((t) => t.key === key);
2123
- if (idx < 0) return;
2124
- this._assetTabs.splice(idx, 1);
2125
- if (this._activeTabKey === key) {
2126
- this._activeTabKey = '__inspector__';
2127
- }
2128
- this._notify();
2129
- }
2429
+ // (The former right-rail asset-tab model — `AssetTab`, `openAssetTab`,
2430
+ // `openEntityAssetTab`, `openOnlineAssetTab`, `resolveOnlineAssetTab`,
2431
+ // `closeAssetTab`, `setActiveTab` — was deleted in W2 of the workspace-
2432
+ // shell program: asset viewers open as CENTER workspace documents via
2433
+ // `components/asset-documents.tsx` + `workspace-document-registry.ts`
2434
+ // (docs/workspace-shell/W0-INVENTORY.md rows R2–R7), and the Inspector is
2435
+ // never displaced from the right rail again.)
2130
2436
 
2131
2437
  // --- Internal helpers ---
2132
2438
 
@@ -2196,7 +2502,6 @@ export class EditorStore {
2196
2502
  private _loadFromDoc(doc: SceneFile): void {
2197
2503
  this._sceneMeta = { version: doc.version, name: doc.name };
2198
2504
  if (doc.environment) this._sceneMeta.environment = doc.environment;
2199
- if (doc.ui) this._sceneMeta.ui = doc.ui; // preserve scene-graph UI through undo/redo (T1.5)
2200
2505
  this._rebuildScene(doc);
2201
2506
  }
2202
2507