@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,1438 @@
1
+ /**
2
+ * world-overlay-gestures — spec 27 §4 B2/B3 pure gesture math
3
+ * (docs/unified-world-editor/27-visual-react-editing.md), factored out of
4
+ * `WorldSelectionOverlay.tsx` so the handle/spacing-band geometry and the
5
+ * per-gesture patch math are directly unit-testable without mounting React
6
+ * (same "exported pure fn" convention `WorldSelectionOverlay.tsx` already
7
+ * uses for `resolveClickSelection`/`resolveEmptySpaceSelection`).
8
+ *
9
+ * DRIFT FLAG (per this task's executor brief): the spec cites an external
10
+ * `visual-edit/overlay.tsx` (`RESIZE_HANDLES`, rotate cursors, `spacingZone`)
11
+ * that does not exist in this repo. The handle table and the padding/margin
12
+ * band geometry below are derived FRESH from the spec text (§4 B2/B3, §5's
13
+ * "lose nothing" rows :459-467) plus the in-repo SE-handle precedent
14
+ * `ui-editor/overlay.tsx` (`GRID = 8` snap, `Handle` ~10×10 white/accent box,
15
+ * `startResize`, the dashed gap-measure label pattern) — noted again in this
16
+ * repo's PR message per the brief's instruction.
17
+ *
18
+ * Rule zero (spec §0): pure geometry/math only — no `UIEditStore`/
19
+ * `SceneEntity`/`THREE.`/`elementFromPoint`. The two adapter-routing helpers
20
+ * (`boxEditForId`/`structureCapableForId`) talk ONLY to the `AuthoringAdapter`
21
+ * contract, cloning `WorldSelectionOverlay.tsx`'s own `rectForId` composite→
22
+ * owner-child routing pattern (`rects`/`boxEdit`/`structure` are deliberately
23
+ * NOT merged onto `CompositeAuthoringAdapter` — only `inspector`/`hierarchy`
24
+ * are).
25
+ */
26
+ import type {
27
+ AuthoringAdapter,
28
+ BoxEditProvider,
29
+ ColorSampleProvider,
30
+ DOMRectLike,
31
+ StructureProvider,
32
+ TextProvider,
33
+ } from '@engine/adapter';
34
+ import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
35
+ import { numericStyleValue } from '../authoring/react-world-authoring-adapter';
36
+ import { isWorldHidden } from '../authoring/world-session-state';
37
+ import { computeSnapTargets } from '../ui-source/inspect';
38
+
39
+ /** 8px snap grid — same constant `ui-editor/overlay.tsx`'s `GRID` uses (K3). */
40
+ export const GRID = 8;
41
+
42
+ /** Minimum resized dimension (px) — mirrors `ui-editor/overlay.tsx`'s own
43
+ * `Math.max(8, snap(...))` resize clamp. */
44
+ export const MIN_SIZE = 8;
45
+
46
+ export type HandlePos = 'nw' | 'n' | 'ne' | 'w' | 'e' | 'sw' | 's' | 'se';
47
+
48
+ /** The 8 resize handles (spec:317, §5 :465 "8 resize handles"), each with the
49
+ * cursor a figma-grade editor uses for that axis: corner handles get the
50
+ * diagonal (nwse/nesw) cursor, edge handles the straight (ns/ew) cursor. */
51
+ export const RESIZE_HANDLES: ReadonlyArray<{ pos: HandlePos; cursor: string }> = [
52
+ { pos: 'nw', cursor: 'nwse-resize' },
53
+ { pos: 'n', cursor: 'ns-resize' },
54
+ { pos: 'ne', cursor: 'nesw-resize' },
55
+ { pos: 'w', cursor: 'ew-resize' },
56
+ { pos: 'e', cursor: 'ew-resize' },
57
+ { pos: 'sw', cursor: 'nesw-resize' },
58
+ { pos: 's', cursor: 'ns-resize' },
59
+ { pos: 'se', cursor: 'nwse-resize' },
60
+ ];
61
+
62
+ export interface RectLike {
63
+ x: number;
64
+ y: number;
65
+ width: number;
66
+ height: number;
67
+ }
68
+
69
+ /** Where a handle sits on `rect` — the handle's CENTER point (the caller
70
+ * offsets by half the handle's rendered size to place its box). */
71
+ export function handlePosition(rect: RectLike, pos: HandlePos): { x: number; y: number } {
72
+ const midX = rect.x + rect.width / 2;
73
+ const midY = rect.y + rect.height / 2;
74
+ switch (pos) {
75
+ case 'nw':
76
+ return { x: rect.x, y: rect.y };
77
+ case 'n':
78
+ return { x: midX, y: rect.y };
79
+ case 'ne':
80
+ return { x: rect.x + rect.width, y: rect.y };
81
+ case 'w':
82
+ return { x: rect.x, y: midY };
83
+ case 'e':
84
+ return { x: rect.x + rect.width, y: midY };
85
+ case 'sw':
86
+ return { x: rect.x, y: rect.y + rect.height };
87
+ case 's':
88
+ return { x: midX, y: rect.y + rect.height };
89
+ case 'se':
90
+ return { x: rect.x + rect.width, y: rect.y + rect.height };
91
+ }
92
+ }
93
+
94
+ /** `v => e.altKey ? Math.round(v) : Math.round(v/GRID)*GRID` (spec:319 "8px
95
+ * grid snap, Alt = free") — shared by resize/move gesture math below. */
96
+ export function snapValue(v: number, free: boolean): number {
97
+ return free ? Math.round(v) : Math.round(v / GRID) * GRID;
98
+ }
99
+
100
+ /**
101
+ * Per-handle resize patch math (spec:321-322): east-side handles grow width
102
+ * by `+dx`; west-side handles shrink width by `dx` AND (only for an
103
+ * absolutely/fixed-positioned node) move `x` by `dx` too (there is no `left`
104
+ * to adjust on a static/relative node — non-absolute nodes resize SIZE only,
105
+ * spec:322). South/north are the `height`/`y` analogue on the vertical axis.
106
+ * A corner handle (e.g. `se`) touches both axes at once. Width/height are
107
+ * clamped to {@link MIN_SIZE}.
108
+ *
109
+ * D1 (spec §6) — `context`, when supplied and `free` is false, additionally
110
+ * edge-snaps the DRAGGED edge (only — the anchored opposite edge never moves,
111
+ * unlike the move gesture where either edge may align) to the owner's own
112
+ * `contextRects(id)` padding-box/sibling edges, within `threshold`, taking
113
+ * priority over the plain 8px grid snap already applied above — same
114
+ * "edge-align wins over grid" precedence {@link computeMovePatch} uses. The
115
+ * VISUAL guide this same decision drives is the separate, pure
116
+ * {@link computeResizeSnapGuides} (this function's own return shape — a
117
+ * style-prop patch — stays exactly what the 8 pre-D1 tests above expect).
118
+ */
119
+ /** EAST-handle width math (grid-snap, then optional edge-snap of the moving
120
+ * RIGHT edge) — factored out of {@link computeResizePatch} purely to keep
121
+ * ITS cognitive complexity down; behavior unchanged from the pre-D1 inline
122
+ * version. Also returns the matched snap candidate (`null` if none), so
123
+ * {@link computeResizeSnapGuides} can reuse this SAME computation for its
124
+ * guide geometry instead of re-deriving it (no drift between the patch the
125
+ * gesture commits and the guide it shows while committing it). */
126
+ function resizeEastWidth(
127
+ dx: number,
128
+ orig: RectLike,
129
+ free: boolean,
130
+ xCandidates: readonly SnapEdgeCandidate[],
131
+ threshold: number,
132
+ ): { value: number; match: SnapEdgeCandidate | null } {
133
+ let width = Math.max(MIN_SIZE, snapValue(orig.width + dx, free));
134
+ let match: SnapEdgeCandidate | null = null;
135
+ if (xCandidates.length) {
136
+ match = edgeSnapPoint(orig.x + width, xCandidates, threshold);
137
+ if (match) width = Math.max(MIN_SIZE, match.edge - orig.x);
138
+ }
139
+ return { value: width, match };
140
+ }
141
+
142
+ /** WEST-handle width math: the WEST handle drags the left edge while the
143
+ * EAST edge stays anchored. Snap+clamp the width first, then derive x FROM
144
+ * the clamped width so the east edge is pinned: `x = eastEdge - width`.
145
+ * Deriving x independently (`orig.x + dx`) breaks under the MIN clamp —
146
+ * D3: over-dragging past the east edge pinned width at MIN but let x keep
147
+ * following the cursor, so the anchored east edge teleported and the
148
+ * resize became a move. This formula also preserves the
149
+ * `x + width == eastEdge` invariant for a non-multiple-of-8 drag, which
150
+ * the old independent per-axis snap could violate. Factored out of
151
+ * {@link computeResizePatch} for the same complexity/reuse reasons as
152
+ * {@link resizeEastWidth}. */
153
+ function resizeWestWidth(
154
+ dx: number,
155
+ orig: RectLike,
156
+ free: boolean,
157
+ xCandidates: readonly SnapEdgeCandidate[],
158
+ threshold: number,
159
+ ): { value: number; eastEdge: number; match: SnapEdgeCandidate | null } {
160
+ const eastEdge = orig.x + orig.width;
161
+ let width = Math.max(MIN_SIZE, snapValue(orig.width - dx, free));
162
+ let match: SnapEdgeCandidate | null = null;
163
+ if (xCandidates.length) {
164
+ match = edgeSnapPoint(eastEdge - width, xCandidates, threshold);
165
+ if (match) width = Math.max(MIN_SIZE, eastEdge - match.edge);
166
+ }
167
+ return { value: width, eastEdge, match };
168
+ }
169
+
170
+ /** SOUTH-handle height math — the vertical analogue of
171
+ * {@link resizeEastWidth}. */
172
+ function resizeSouthHeight(
173
+ dy: number,
174
+ orig: RectLike,
175
+ free: boolean,
176
+ yCandidates: readonly SnapEdgeCandidate[],
177
+ threshold: number,
178
+ ): { value: number; match: SnapEdgeCandidate | null } {
179
+ let height = Math.max(MIN_SIZE, snapValue(orig.height + dy, free));
180
+ let match: SnapEdgeCandidate | null = null;
181
+ if (yCandidates.length) {
182
+ match = edgeSnapPoint(orig.y + height, yCandidates, threshold);
183
+ if (match) height = Math.max(MIN_SIZE, match.edge - orig.y);
184
+ }
185
+ return { value: height, match };
186
+ }
187
+
188
+ /** NORTH-handle height math — the vertical analogue of
189
+ * {@link resizeWestWidth} (the SOUTH edge is the anchor). */
190
+ function resizeNorthHeight(
191
+ dy: number,
192
+ orig: RectLike,
193
+ free: boolean,
194
+ yCandidates: readonly SnapEdgeCandidate[],
195
+ threshold: number,
196
+ ): { value: number; southEdge: number; match: SnapEdgeCandidate | null } {
197
+ const southEdge = orig.y + orig.height;
198
+ let height = Math.max(MIN_SIZE, snapValue(orig.height - dy, free));
199
+ let match: SnapEdgeCandidate | null = null;
200
+ if (yCandidates.length) {
201
+ match = edgeSnapPoint(southEdge - height, yCandidates, threshold);
202
+ if (match) height = Math.max(MIN_SIZE, southEdge - match.edge);
203
+ }
204
+ return { value: height, southEdge, match };
205
+ }
206
+
207
+ /** The x-axis half of {@link computeResizePatch} — `width` (+`x` if
208
+ * `isPositioned` and the WEST handle moved) for whichever of `pos`'s x-side
209
+ * (east/west/neither) applies. Factored out purely to keep
210
+ * `computeResizePatch` itself under the complexity ceiling. */
211
+ function resizeXPatch(
212
+ pos: HandlePos,
213
+ dx: number,
214
+ orig: RectLike,
215
+ isPositioned: boolean,
216
+ free: boolean,
217
+ xCandidates: readonly SnapEdgeCandidate[],
218
+ threshold: number,
219
+ ): Record<string, number> {
220
+ if (pos === 'e' || pos === 'ne' || pos === 'se') {
221
+ return { width: resizeEastWidth(dx, orig, free, xCandidates, threshold).value };
222
+ }
223
+ if (pos === 'w' || pos === 'nw' || pos === 'sw') {
224
+ const { value: width, eastEdge } = resizeWestWidth(dx, orig, free, xCandidates, threshold);
225
+ return isPositioned ? { width, x: eastEdge - width } : { width };
226
+ }
227
+ return {};
228
+ }
229
+
230
+ /** The y-axis analogue of {@link resizeXPatch} (south/north). */
231
+ function resizeYPatch(
232
+ pos: HandlePos,
233
+ dy: number,
234
+ orig: RectLike,
235
+ isPositioned: boolean,
236
+ free: boolean,
237
+ yCandidates: readonly SnapEdgeCandidate[],
238
+ threshold: number,
239
+ ): Record<string, number> {
240
+ if (pos === 's' || pos === 'sw' || pos === 'se') {
241
+ return { height: resizeSouthHeight(dy, orig, free, yCandidates, threshold).value };
242
+ }
243
+ if (pos === 'n' || pos === 'nw' || pos === 'ne') {
244
+ const { value: height, southEdge } = resizeNorthHeight(dy, orig, free, yCandidates, threshold);
245
+ return isPositioned ? { height, y: southEdge - height } : { height };
246
+ }
247
+ return {};
248
+ }
249
+
250
+ export function computeResizePatch(
251
+ pos: HandlePos,
252
+ dx: number,
253
+ dy: number,
254
+ orig: RectLike,
255
+ isPositioned: boolean,
256
+ free: boolean,
257
+ context?: MoveSnapContext,
258
+ threshold = EDGE_SNAP_THRESHOLD_PX,
259
+ ): Record<string, number> {
260
+ const targets = !free && context ? snapTargetsForContext(context) : null;
261
+ const xCandidates = targets ? xEdgeCandidates(targets) : [];
262
+ const yCandidates = targets ? yEdgeCandidates(targets) : [];
263
+ return {
264
+ ...resizeXPatch(pos, dx, orig, isPositioned, free, xCandidates, threshold),
265
+ ...resizeYPatch(pos, dy, orig, isPositioned, free, yCandidates, threshold),
266
+ };
267
+ }
268
+
269
+ /** Snap-target context for a move gesture — the owner's own
270
+ * `rects.contextRects(id)` shape (padding box + sibling rects), reduced to
271
+ * just the two fields the edge-snap below consumes. Reused, unchanged, by
272
+ * the D1 resize-snap path below (same shape, same source — a single
273
+ * `contextRectsForId` call at gesture start covers both). */
274
+ export interface MoveSnapContext {
275
+ paddingBox?: DOMRectLike;
276
+ siblings?: readonly DOMRectLike[];
277
+ /** D1 (spec §6) — the immediate parent's own rect, unused by the
278
+ * edge-snap math above but carried through so a single
279
+ * `contextRectsForId(adapter, id)` call also feeds the D1.c
280
+ * parent-container outline (`WorldSelectionOverlay.tsx`), without a
281
+ * narrower return type silently dropping the field the underlying
282
+ * `RectProvider.contextRects` already returns. */
283
+ parent?: DOMRectLike;
284
+ }
285
+
286
+ /** ±4px — same threshold both the B2 move/resize edge-snap MATH and the D1
287
+ * snap-guide VISIBILITY below use, so a guide is shown if and only if the
288
+ * gesture it describes actually engaged (spec:321 "within ±4px prefer
289
+ * edge-alignment ... over the grid"; spec §6 D1 "snapping engages within
290
+ * threshold"). */
291
+ export const EDGE_SNAP_THRESHOLD_PX = 4;
292
+
293
+ /** One candidate target edge for edge-snapping: its coordinate (x for a
294
+ * vertical/left-right edge, y for a horizontal/top-bottom edge) paired with
295
+ * the SOURCE rect it came from (the parent padding box or a sibling) — the
296
+ * source rect is what a D1 snap guide spans across (see
297
+ * {@link computeMoveSnapGuides}/{@link computeResizeSnapGuides}). */
298
+ interface SnapEdgeCandidate {
299
+ edge: number;
300
+ rect: RectLike;
301
+ }
302
+
303
+ /** `context`'s padding-box + sibling rects, reduced through
304
+ * `inspect.ts:computeSnapTargets` (drops zero-area siblings) — the shared
305
+ * first step both the x/y candidate builders below start from. */
306
+ function snapTargetsForContext(context: MoveSnapContext): {
307
+ paddingBox: RectLike;
308
+ siblingRects: RectLike[];
309
+ } {
310
+ const zeroPad = { top: 0, right: 0, bottom: 0, left: 0 };
311
+ return computeSnapTargets(
312
+ context.paddingBox ?? { x: 0, y: 0, width: 0, height: 0 },
313
+ zeroPad,
314
+ context.siblings ?? [],
315
+ );
316
+ }
317
+
318
+ /** The x-axis (left/right) snap candidates — the parent padding box's own
319
+ * left/right edges plus every sibling's left/right edges. */
320
+ function xEdgeCandidates(targets: {
321
+ paddingBox: RectLike;
322
+ siblingRects: RectLike[];
323
+ }): SnapEdgeCandidate[] {
324
+ const out: SnapEdgeCandidate[] = [
325
+ { edge: targets.paddingBox.x, rect: targets.paddingBox },
326
+ { edge: targets.paddingBox.x + targets.paddingBox.width, rect: targets.paddingBox },
327
+ ];
328
+ for (const r of targets.siblingRects) {
329
+ out.push({ edge: r.x, rect: r });
330
+ out.push({ edge: r.x + r.width, rect: r });
331
+ }
332
+ return out;
333
+ }
334
+
335
+ /** The y-axis (top/bottom) analogue of {@link xEdgeCandidates}. */
336
+ function yEdgeCandidates(targets: {
337
+ paddingBox: RectLike;
338
+ siblingRects: RectLike[];
339
+ }): SnapEdgeCandidate[] {
340
+ const out: SnapEdgeCandidate[] = [
341
+ { edge: targets.paddingBox.y, rect: targets.paddingBox },
342
+ { edge: targets.paddingBox.y + targets.paddingBox.height, rect: targets.paddingBox },
343
+ ];
344
+ for (const r of targets.siblingRects) {
345
+ out.push({ edge: r.y, rect: r });
346
+ out.push({ edge: r.y + r.height, rect: r });
347
+ }
348
+ return out;
349
+ }
350
+
351
+ /** Snap `value` (the candidate LEFT/TOP edge of a `size`-wide/tall box) to the
352
+ * nearest of `candidates` within `threshold` px, preferring alignment of
353
+ * either the box's leading OR trailing edge to a target edge (spec:321
354
+ * "within ±4px prefer edge-alignment ... over the grid") — the MOVE-gesture
355
+ * case, where either edge of the moving box may be the one that aligns.
356
+ * Returns `value` unchanged (and `match: null`) when nothing is within
357
+ * threshold. */
358
+ function edgeSnapBox(
359
+ value: number,
360
+ size: number,
361
+ candidates: readonly SnapEdgeCandidate[],
362
+ threshold: number,
363
+ ): { value: number; match: SnapEdgeCandidate | null } {
364
+ let best = value;
365
+ let bestDelta = threshold;
366
+ let match: SnapEdgeCandidate | null = null;
367
+ for (const c of candidates) {
368
+ const dLeading = Math.abs(value - c.edge);
369
+ if (dLeading <= bestDelta) {
370
+ bestDelta = dLeading;
371
+ best = c.edge;
372
+ match = c;
373
+ }
374
+ const dTrailing = Math.abs(value + size - c.edge);
375
+ if (dTrailing <= bestDelta) {
376
+ bestDelta = dTrailing;
377
+ best = c.edge - size;
378
+ match = c;
379
+ }
380
+ }
381
+ return { value: best, match };
382
+ }
383
+
384
+ /** Snap a SINGLE moving edge coordinate (e.g. a resize handle's dragged
385
+ * border) to the nearest of `candidates` within `threshold` — the RESIZE-
386
+ * gesture case, where only the one edge under the handle can align (the
387
+ * opposite edge is the anchor and never moves). `null` when nothing is
388
+ * within threshold. */
389
+ function edgeSnapPoint(
390
+ value: number,
391
+ candidates: readonly SnapEdgeCandidate[],
392
+ threshold: number,
393
+ ): SnapEdgeCandidate | null {
394
+ let best: SnapEdgeCandidate | null = null;
395
+ let bestDelta = threshold;
396
+ for (const c of candidates) {
397
+ const d = Math.abs(value - c.edge);
398
+ if (d <= bestDelta) {
399
+ bestDelta = d;
400
+ best = c;
401
+ }
402
+ }
403
+ return best;
404
+ }
405
+
406
+ /**
407
+ * Move-gesture patch (spec:322 "move a positioned element writes left/top"):
408
+ * grid-snaps `orig.x + dx`/`orig.y + dy` (Alt = free, matching resize), then —
409
+ * unless Alt is held — prefers ±4px edge-alignment to the owner's own
410
+ * `contextRects(id)` padding-box/sibling edges over the grid (spec:321's snap
411
+ * MATH, `inspect.ts:computeSnapTargets` fed by `rects.contextRects`). The
412
+ * VISUAL guide rendering this same snap decision drives is
413
+ * {@link computeMoveSnapGuides} (D1, spec §6) — a separate pure function
414
+ * (not this one's return shape) so this function's existing `{x,y}` contract
415
+ * stays byte-for-byte stable for every caller/test that predates D1.
416
+ */
417
+ export function computeMovePatch(
418
+ dx: number,
419
+ dy: number,
420
+ orig: RectLike,
421
+ free: boolean,
422
+ context?: MoveSnapContext,
423
+ ): { x: number; y: number } {
424
+ let x = snapValue(orig.x + dx, free);
425
+ let y = snapValue(orig.y + dy, free);
426
+ if (!free && context) {
427
+ const targets = snapTargetsForContext(context);
428
+ x = edgeSnapBox(x, orig.width, xEdgeCandidates(targets), EDGE_SNAP_THRESHOLD_PX).value;
429
+ y = edgeSnapBox(y, orig.height, yEdgeCandidates(targets), EDGE_SNAP_THRESHOLD_PX).value;
430
+ }
431
+ return { x, y };
432
+ }
433
+
434
+ // --- D1 — snap/alignment guides (spec §6 D1, §5 :461 "snap/alignment
435
+ // guides") ------------------------------------------------------------
436
+
437
+ /** A single active D1 alignment guide: a straight line at coordinate `at`
438
+ * along the snapped axis (an x-coordinate for a `'vertical'` guide, a
439
+ * y-coordinate for a `'horizontal'` one — the guide LINE's own orientation,
440
+ * matching {@link computeMeasureLines}'s `orientation` convention), spanning
441
+ * `start`..`end` on the PERPENDICULAR axis (the union of the moving/
442
+ * resizing element's own extent and the target rect's extent, so the guide
443
+ * visibly touches both). Rendered ONLY while the underlying gesture's own
444
+ * edge-snap actually engaged (spec:465 "guides appear only within the snap
445
+ * threshold; disappear when not snapping") — {@link computeMoveSnapGuides}/
446
+ * {@link computeResizeSnapGuides} return `[]` whenever nothing snapped. */
447
+ export interface SnapGuide {
448
+ orientation: 'vertical' | 'horizontal';
449
+ at: number;
450
+ start: number;
451
+ end: number;
452
+ }
453
+
454
+ /** Build the 0, 1, or 2 {@link SnapGuide}s for a matched x/y edge-snap pair —
455
+ * shared tail of {@link computeMoveSnapGuides}/{@link computeResizeSnapGuides}
456
+ * (both resolve their own x/y matches differently — move via
457
+ * {@link edgeSnapBox}, resize via {@link edgeSnapPoint} on a single dragged
458
+ * edge — but converge on the same "matched candidate -> guide spanning the
459
+ * moved rect ∪ target rect" geometry once a match exists). */
460
+ function guidesFromMatches(
461
+ movedRect: RectLike,
462
+ xMatch: SnapEdgeCandidate | null,
463
+ yMatch: SnapEdgeCandidate | null,
464
+ ): SnapGuide[] {
465
+ const guides: SnapGuide[] = [];
466
+ if (xMatch) {
467
+ guides.push({
468
+ orientation: 'vertical',
469
+ at: xMatch.edge,
470
+ start: Math.min(movedRect.y, xMatch.rect.y),
471
+ end: Math.max(movedRect.y + movedRect.height, xMatch.rect.y + xMatch.rect.height),
472
+ });
473
+ }
474
+ if (yMatch) {
475
+ guides.push({
476
+ orientation: 'horizontal',
477
+ at: yMatch.edge,
478
+ start: Math.min(movedRect.x, yMatch.rect.x),
479
+ end: Math.max(movedRect.x + movedRect.width, yMatch.rect.x + yMatch.rect.width),
480
+ });
481
+ }
482
+ return guides;
483
+ }
484
+
485
+ /**
486
+ * D1 (spec §6) — the alignment guides a LIVE move gesture's own edge-snap
487
+ * (the same math {@link computeMovePatch} applies to the committed patch)
488
+ * currently engages, or `[]` when Alt is held, there's no context, or
489
+ * nothing is within `threshold`. Deliberately a SEPARATE pure function from
490
+ * `computeMovePatch` (not a richer return shape on it) so that function's
491
+ * `{x,y}` contract — asserted by name in tests that predate D1 — never
492
+ * changes shape.
493
+ */
494
+ export function computeMoveSnapGuides(
495
+ dx: number,
496
+ dy: number,
497
+ orig: RectLike,
498
+ free: boolean,
499
+ context: MoveSnapContext | undefined,
500
+ threshold = EDGE_SNAP_THRESHOLD_PX,
501
+ ): SnapGuide[] {
502
+ if (free || !context) return [];
503
+ const x = snapValue(orig.x + dx, false);
504
+ const y = snapValue(orig.y + dy, false);
505
+ const targets = snapTargetsForContext(context);
506
+ const xMatch = edgeSnapBox(x, orig.width, xEdgeCandidates(targets), threshold).match;
507
+ const yMatch = edgeSnapBox(y, orig.height, yEdgeCandidates(targets), threshold).match;
508
+ const movedRect: RectLike = { x, y, width: orig.width, height: orig.height };
509
+ return guidesFromMatches(movedRect, xMatch, yMatch);
510
+ }
511
+
512
+ /**
513
+ * D1 (spec §6) — the alignment guide(s) a LIVE resize gesture's own
514
+ * per-handle edge-snap (the same math {@link computeResizePatch} applies)
515
+ * currently engages. Only the axis/axes the handle actually drags produce a
516
+ * candidate match (an `'e'` handle only ever checks x; a corner checks
517
+ * both) — mirrors `computeResizePatch`'s own `east`/`west`/`south`/`north`
518
+ * gating. `[]` under the same bypass conditions `computeMoveSnapGuides` uses.
519
+ */
520
+ export function computeResizeSnapGuides(
521
+ pos: HandlePos,
522
+ dx: number,
523
+ dy: number,
524
+ orig: RectLike,
525
+ free: boolean,
526
+ context: MoveSnapContext | undefined,
527
+ threshold = EDGE_SNAP_THRESHOLD_PX,
528
+ ): SnapGuide[] {
529
+ if (free || !context) return [];
530
+ const east = pos === 'e' || pos === 'ne' || pos === 'se';
531
+ const west = pos === 'w' || pos === 'nw' || pos === 'sw';
532
+ const south = pos === 's' || pos === 'sw' || pos === 'se';
533
+ const north = pos === 'n' || pos === 'nw' || pos === 'ne';
534
+ const targets = snapTargetsForContext(context);
535
+ const xCandidates = xEdgeCandidates(targets);
536
+ const yCandidates = yEdgeCandidates(targets);
537
+
538
+ // Reuses the SAME per-handle helpers `computeResizePatch` calls (only
539
+ // `.match`, discarding the recomputed `.value`) — the guide can never
540
+ // drift from the patch it's describing.
541
+ let xMatch: SnapEdgeCandidate | null = null;
542
+ if (east) xMatch = resizeEastWidth(dx, orig, false, xCandidates, threshold).match;
543
+ else if (west) xMatch = resizeWestWidth(dx, orig, false, xCandidates, threshold).match;
544
+
545
+ let yMatch: SnapEdgeCandidate | null = null;
546
+ if (south) yMatch = resizeSouthHeight(dy, orig, false, yCandidates, threshold).match;
547
+ else if (north) yMatch = resizeNorthHeight(dy, orig, false, yCandidates, threshold).match;
548
+
549
+ return guidesFromMatches(orig, xMatch, yMatch);
550
+ }
551
+
552
+ /**
553
+ * Rotate-gesture patch (spec:322 "rotate handle writes `transform`"): the
554
+ * angle between the gesture's START cursor position and its CURRENT position,
555
+ * both measured from the selection's own center — `atan2(cursor−center) −
556
+ * atan2(start−center)` in degrees, snapped to 15° unless Alt (spec:322). Both
557
+ * `center`/`start`/`current` must be in the SAME coordinate frame (host-local,
558
+ * matching the rect the handle itself is drawn from) — the caller's job.
559
+ */
560
+ export function computeRotatePatch(
561
+ center: { x: number; y: number },
562
+ start: { x: number; y: number },
563
+ current: { x: number; y: number },
564
+ free: boolean,
565
+ ): { rotate: number } {
566
+ const toDeg = (rad: number): number => (rad * 180) / Math.PI;
567
+ const startAngle = toDeg(Math.atan2(start.y - center.y, start.x - center.x));
568
+ const curAngle = toDeg(Math.atan2(current.y - center.y, current.x - center.x));
569
+ const raw = curAngle - startAngle;
570
+ const deg = free ? raw : Math.round(raw / 15) * 15;
571
+ return { rotate: deg };
572
+ }
573
+
574
+ // --- Adapter-routing helpers (composite→owner-child routing, shared by
575
+ // `rectForId` and every capability-specific wrapper below) -----------
576
+
577
+ /** The child adapter that owns `id` — the composite's owning child's adapter
578
+ * for a `CompositeAuthoringAdapter`, or `adapter` itself for a bare adapter.
579
+ * `null` when a composite has no owning child for `id`. Not exported: every
580
+ * outside caller wants one of the two capability-specific wrappers below. */
581
+ function ownerAdapterFor(adapter: AuthoringAdapter, id: string): AuthoringAdapter | null {
582
+ if (adapter instanceof CompositeAuthoringAdapter) {
583
+ const worldId = adapter.ownerOf(id);
584
+ if (!worldId) return null;
585
+ return adapter.childAdapters().find((c) => c.worldId === worldId)?.adapter ?? null;
586
+ }
587
+ return adapter;
588
+ }
589
+
590
+ /**
591
+ * Resolve a node's rect through EITHER a bare adapter's own `rects`
592
+ * provider, OR — when the active adapter is a `CompositeAuthoringAdapter` —
593
+ * its OWNING child's `rects` provider. `rects` is deliberately NOT merged
594
+ * onto the composite (same stance as `pickable`/`boxEdit`/`text` —
595
+ * `composite-authoring-adapter.test.ts`'s T0 suite), so a composite caller
596
+ * must route through `ownerOf()` + `childAdapters()` itself — the SAME
597
+ * routing `ownerAdapterFor` above already does, reused here. `null` when the
598
+ * id has no owner, the owner has no `rects`, or the node is unmounted/
599
+ * offscreen. (D4 — moved here from `WorldSelectionOverlay.tsx`, which
600
+ * re-exports it, so `editor-hotkeys.ts`'s arrow-nudge action can use it
601
+ * without pulling in that component's whole dependency graph.)
602
+ */
603
+ export function rectForId(adapter: AuthoringAdapter, id: string): DOMRectLike | null {
604
+ return ownerAdapterFor(adapter, id)?.rects?.rect(id) ?? null;
605
+ }
606
+
607
+ /** `id`'s owning `BoxEditProvider`, or `null` when the id has no owner or its
608
+ * owner has no `boxEdit` (`boxEdit` is deliberately NOT merged onto the
609
+ * composite, same stance as `rects`/`pickable` — see `rectForId`'s doc
610
+ * comment above). Gates whether resize/move/rotate handles render for the
611
+ * current selection (spec:317). */
612
+ export function boxEditForId(adapter: AuthoringAdapter, id: string): BoxEditProvider | null {
613
+ return ownerAdapterFor(adapter, id)?.boxEdit ?? null;
614
+ }
615
+
616
+ /** True when `id`'s owning adapter exposes `structure` (reorder/reparent/
617
+ * wrap/unwrap/duplicate/delete — spec:308-309's structural-gesture gate;
618
+ * those gestures themselves are built in Phase D, this predicate is the
619
+ * capability check other phases' handles gate on). Deliberately checks the
620
+ * OWNING CHILD, not the composite's own always-present `structure` (which
621
+ * merely forwards to whichever owner the id resolves to and would report
622
+ * `true` even for an id whose real owner has none). */
623
+ export function structureCapableForId(adapter: AuthoringAdapter, id: string): boolean {
624
+ return !!ownerAdapterFor(adapter, id)?.structure;
625
+ }
626
+
627
+ /** `id`'s owning `rects.contextRects(id)` (parent/siblings/padding-box), or
628
+ * `undefined` when the id has no owner or the owner has no `rects`/
629
+ * `contextRects`. Feeds {@link computeMovePatch}'s edge-snap. */
630
+ export function contextRectsForId(
631
+ adapter: AuthoringAdapter,
632
+ id: string,
633
+ ): MoveSnapContext | undefined {
634
+ return ownerAdapterFor(adapter, id)?.rects?.contextRects?.(id);
635
+ }
636
+
637
+ /** `id`'s owning `StructureProvider`, or `null` when the id has no owner or
638
+ * its owner has no `structure` — the D3.a canvas context-menu's actual
639
+ * action target (Duplicate/Wrap/Unwrap/Delete/Insert-child all route through
640
+ * the OBJECT this returns, never a concrete adapter cast — rule zero). Not
641
+ * the same thing as {@link structureCapableForId} (a boolean gate) — a
642
+ * caller that already knows it wants the provider itself uses this instead
643
+ * of gate-then-reach-in-again. */
644
+ export function structureForId(adapter: AuthoringAdapter, id: string): StructureProvider | null {
645
+ return ownerAdapterFor(adapter, id)?.structure ?? null;
646
+ }
647
+
648
+ /** `id`'s owning `TextProvider`, or `null` when the id has no owner or its
649
+ * owner has no `text` (`text` is deliberately NOT merged/forwarded onto the
650
+ * composite — unlike `structure`, it follows the same "leave it to the
651
+ * owner-lookup caller" stance as `rects`/`boxEdit`, see
652
+ * `WorldSelectionOverlay.rectForId`'s doc comment for the precedent this
653
+ * mirrors). Gates the D3.b inline text editor. */
654
+ export function textForId(adapter: AuthoringAdapter, id: string): TextProvider | null {
655
+ return ownerAdapterFor(adapter, id)?.text ?? null;
656
+ }
657
+
658
+ /** `id`'s owning `ColorSampleProvider`, or `null` when the id has no owner or
659
+ * its owner has no `colorSample` (not merged onto the composite either —
660
+ * same NOT-merged group as `rects`/`boxEdit`/`text`). Feeds the D3.d
661
+ * eyedropper fallback swatch. */
662
+ export function colorSampleForId(
663
+ adapter: AuthoringAdapter,
664
+ id: string,
665
+ ): ColorSampleProvider | null {
666
+ return ownerAdapterFor(adapter, id)?.colorSample ?? null;
667
+ }
668
+
669
+ /** Every empty-container hint reachable from `adapter`'s own `rects`
670
+ * provider — or, for a composite, from each VISIBLE child's own `rects`
671
+ * provider (D3.c). Mirrors `WorldSelectionOverlay.collectMarqueeCandidates`'s
672
+ * identical per-child-iteration/session-hidden-skip shape (that helper
673
+ * lives in the sibling file rather than here purely because it was written
674
+ * first — both walk the SAME composite-children/hidden-world contract). */
675
+ export function emptyContainerHintsFor(
676
+ adapter: AuthoringAdapter,
677
+ ): Array<{ id: string; rect: DOMRectLike; displayName: string }> {
678
+ const children: ReadonlyArray<{ worldId: string; adapter: AuthoringAdapter }> =
679
+ adapter instanceof CompositeAuthoringAdapter
680
+ ? adapter.childAdapters()
681
+ : [{ worldId: '', adapter }];
682
+ const out: Array<{ id: string; rect: DOMRectLike; displayName: string }> = [];
683
+ for (const { worldId, adapter: child } of children) {
684
+ if (!child.rects?.emptyContainers) continue;
685
+ if (worldId && isWorldHidden(worldId)) continue;
686
+ out.push(...child.rects.emptyContainers());
687
+ }
688
+ return out;
689
+ }
690
+
691
+ // --- B3 — box-model spacing bands (spec §4 B3, §5 :460/:465-466) ------
692
+
693
+ export type SpacingSide =
694
+ | 'paddingTop'
695
+ | 'paddingRight'
696
+ | 'paddingBottom'
697
+ | 'paddingLeft'
698
+ | 'marginTop'
699
+ | 'marginRight'
700
+ | 'marginBottom'
701
+ | 'marginLeft';
702
+
703
+ export interface SpacingValues {
704
+ paddingTop: number;
705
+ paddingRight: number;
706
+ paddingBottom: number;
707
+ paddingLeft: number;
708
+ marginTop: number;
709
+ marginRight: number;
710
+ marginBottom: number;
711
+ marginLeft: number;
712
+ }
713
+
714
+ /** Read the 8 spacing values off `adapter.inspector` (routed/merged on the
715
+ * composite, unlike `rects`/`boxEdit` — no owner lookup needed here) via the
716
+ * reserved `style.<prop>` inspector paths, parsed with the shared
717
+ * `numericStyleValue` (defensive: the declared `PropertyDescriptor.type` for
718
+ * each of these IS `'number'` on both react adapters today, so `get` already
719
+ * returns a parsed number — but a raw computed-style STRING like `"12px"`
720
+ * parses identically, so this stays correct even if that declared type ever
721
+ * changes, per this task's own note). Unresolved values default to `0`. */
722
+ export function readSpacingValues(adapter: AuthoringAdapter, id: string): SpacingValues {
723
+ const read = (prop: SpacingSide): number => {
724
+ const raw = adapter.inspector?.get(id, `style.${prop}`);
725
+ return numericStyleValue(raw as string | number | undefined) ?? 0;
726
+ };
727
+ return {
728
+ paddingTop: read('paddingTop'),
729
+ paddingRight: read('paddingRight'),
730
+ paddingBottom: read('paddingBottom'),
731
+ paddingLeft: read('paddingLeft'),
732
+ marginTop: read('marginTop'),
733
+ marginRight: read('marginRight'),
734
+ marginBottom: read('marginBottom'),
735
+ marginLeft: read('marginLeft'),
736
+ };
737
+ }
738
+
739
+ export interface SpacingBand {
740
+ side: SpacingSide;
741
+ kind: 'padding' | 'margin';
742
+ x: number;
743
+ y: number;
744
+ width: number;
745
+ height: number;
746
+ cursor: string;
747
+ /** Label anchor — centered in the band's thickness. */
748
+ labelX: number;
749
+ labelY: number;
750
+ /** The side's REAL value (may be 0 even though the band itself is drawn at
751
+ * {@link ZERO_STRIP_PX} thickness so a zero side stays grabbable). */
752
+ value: number;
753
+ isZero: boolean;
754
+ }
755
+
756
+ /** A zero-value padding/margin side still renders a thin grab strip (spec
757
+ * B3 "zero-value side → still render a ~4px grab strip so zero padding is
758
+ * draggable") — this is that strip's thickness. */
759
+ export const ZERO_STRIP_PX = 4;
760
+
761
+ /**
762
+ * Padding (inner) + margin (outer) band geometry for a selection `rect` given
763
+ * its 8 parsed spacing values (spec:332-335). Padding bands sit INSIDE `rect`
764
+ * (spec:460's "box-model spacing bands"); margin bands sit OUTSIDE it. Uses
765
+ * `rect` + the raw values directly — NOT `rects.contextRects(id).paddingBox`
766
+ * (that reflects only the BORDER box per its own doc comment in the react
767
+ * adapters, i.e. ignores padding entirely — using it here would draw the
768
+ * padding band at the wrong thickness whenever the node also has a border).
769
+ * Pure; no DOM/adapter access.
770
+ */
771
+ export function computeSpacingBands(rect: RectLike, values: SpacingValues): SpacingBand[] {
772
+ const thickness = (v: number): number => (v > 0 ? v : ZERO_STRIP_PX);
773
+ const pt = thickness(values.paddingTop);
774
+ const pr = thickness(values.paddingRight);
775
+ const pb = thickness(values.paddingBottom);
776
+ const pl = thickness(values.paddingLeft);
777
+ const mt = thickness(values.marginTop);
778
+ const mr = thickness(values.marginRight);
779
+ const mb = thickness(values.marginBottom);
780
+ const ml = thickness(values.marginLeft);
781
+
782
+ return [
783
+ {
784
+ side: 'paddingTop',
785
+ kind: 'padding',
786
+ x: rect.x,
787
+ y: rect.y,
788
+ width: rect.width,
789
+ height: pt,
790
+ cursor: 'ns-resize',
791
+ labelX: rect.x + rect.width / 2,
792
+ labelY: rect.y + pt / 2,
793
+ value: values.paddingTop,
794
+ isZero: values.paddingTop === 0,
795
+ },
796
+ {
797
+ side: 'paddingBottom',
798
+ kind: 'padding',
799
+ x: rect.x,
800
+ y: rect.y + rect.height - pb,
801
+ width: rect.width,
802
+ height: pb,
803
+ cursor: 'ns-resize',
804
+ labelX: rect.x + rect.width / 2,
805
+ labelY: rect.y + rect.height - pb / 2,
806
+ value: values.paddingBottom,
807
+ isZero: values.paddingBottom === 0,
808
+ },
809
+ {
810
+ side: 'paddingLeft',
811
+ kind: 'padding',
812
+ x: rect.x,
813
+ y: rect.y,
814
+ width: pl,
815
+ height: rect.height,
816
+ cursor: 'ew-resize',
817
+ labelX: rect.x + pl / 2,
818
+ labelY: rect.y + rect.height / 2,
819
+ value: values.paddingLeft,
820
+ isZero: values.paddingLeft === 0,
821
+ },
822
+ {
823
+ side: 'paddingRight',
824
+ kind: 'padding',
825
+ x: rect.x + rect.width - pr,
826
+ y: rect.y,
827
+ width: pr,
828
+ height: rect.height,
829
+ cursor: 'ew-resize',
830
+ labelX: rect.x + rect.width - pr / 2,
831
+ labelY: rect.y + rect.height / 2,
832
+ value: values.paddingRight,
833
+ isZero: values.paddingRight === 0,
834
+ },
835
+ {
836
+ side: 'marginTop',
837
+ kind: 'margin',
838
+ x: rect.x,
839
+ y: rect.y - mt,
840
+ width: rect.width,
841
+ height: mt,
842
+ cursor: 'ns-resize',
843
+ labelX: rect.x + rect.width / 2,
844
+ labelY: rect.y - mt / 2,
845
+ value: values.marginTop,
846
+ isZero: values.marginTop === 0,
847
+ },
848
+ {
849
+ side: 'marginBottom',
850
+ kind: 'margin',
851
+ x: rect.x,
852
+ y: rect.y + rect.height,
853
+ width: rect.width,
854
+ height: mb,
855
+ cursor: 'ns-resize',
856
+ labelX: rect.x + rect.width / 2,
857
+ labelY: rect.y + rect.height + mb / 2,
858
+ value: values.marginBottom,
859
+ isZero: values.marginBottom === 0,
860
+ },
861
+ {
862
+ side: 'marginLeft',
863
+ kind: 'margin',
864
+ x: rect.x - ml,
865
+ y: rect.y,
866
+ width: ml,
867
+ height: rect.height,
868
+ cursor: 'ew-resize',
869
+ labelX: rect.x - ml / 2,
870
+ labelY: rect.y + rect.height / 2,
871
+ value: values.marginLeft,
872
+ isZero: values.marginLeft === 0,
873
+ },
874
+ {
875
+ side: 'marginRight',
876
+ kind: 'margin',
877
+ x: rect.x + rect.width,
878
+ y: rect.y,
879
+ width: mr,
880
+ height: rect.height,
881
+ cursor: 'ew-resize',
882
+ labelX: rect.x + rect.width + mr / 2,
883
+ labelY: rect.y + rect.height / 2,
884
+ value: values.marginRight,
885
+ isZero: values.marginRight === 0,
886
+ },
887
+ ];
888
+ }
889
+
890
+ /** Which raw delta axis (`dx`/`dy`) a given band drags along, and its sign —
891
+ * e.g. dragging the TOP padding band DOWN (`dy > 0`) grows `paddingTop`;
892
+ * dragging the TOP margin band UP (`dy < 0`, i.e. `-dy > 0`) grows
893
+ * `marginTop` (margin bands sit OUTSIDE the box, so their "grow" direction
894
+ * is the opposite screen direction from the same-named padding band). */
895
+ function axisDeltaForSide(side: SpacingSide, dx: number, dy: number): number {
896
+ switch (side) {
897
+ case 'paddingTop':
898
+ return dy;
899
+ case 'paddingBottom':
900
+ return -dy;
901
+ case 'paddingLeft':
902
+ return dx;
903
+ case 'paddingRight':
904
+ return -dx;
905
+ case 'marginTop':
906
+ return -dy;
907
+ case 'marginBottom':
908
+ return dy;
909
+ case 'marginLeft':
910
+ return -dx;
911
+ case 'marginRight':
912
+ return dx;
913
+ }
914
+ }
915
+
916
+ /**
917
+ * Spacing-band drag → `boxEdit` patch (spec:333-335): the delta along the
918
+ * band's own axis (see {@link axisDeltaForSide}) is added to the side's
919
+ * original value and clamped to ≥0. Deliberately whole-PIXEL (`Math.round`),
920
+ * NOT snapped to the 8px {@link GRID} B2's resize/move gestures use — a real
921
+ * padding/margin is routinely NOT a multiple of 8 (this task's own e2e fixture
922
+ * authors `padding: 12`), so grid-quantizing every spacing drag would make
923
+ * that starting value permanently unreachable by any drag distance (e.g.
924
+ * dragging `+8` from `12` would jump to `16` or `24`, never landing on the
925
+ * expected `20`). Whole-px rounding still keeps the value tidy without that
926
+ * unreachability trap.
927
+ */
928
+ export function computeSpacingPatch(
929
+ side: SpacingSide,
930
+ dx: number,
931
+ dy: number,
932
+ origValue: number,
933
+ ): Record<string, number> {
934
+ const delta = axisDeltaForSide(side, dx, dy);
935
+ const next = Math.max(0, Math.round(origValue + delta));
936
+ return { [side]: next };
937
+ }
938
+
939
+ // --- D2 — marquee polish, drag-ghost, reorder clone-preview (spec §6 D2,
940
+ // §5 :461-462/:466) ---------------------------------------------------
941
+
942
+ /** A drag-ghost's geometry: the dragged element's OWN original rect, offset
943
+ * by the pointer's delta since the gesture started (spec:399 "a semi-
944
+ * transparent follow-cursor ghost ... offset by the drag delta"). Shared by
945
+ * the B2 move gesture (`applyGesturePatch`'s `'move'` branch) and the D2.b
946
+ * reorder-preview drag below — both are "dragging a selected element on the
947
+ * canvas" per the spec's own D2 framing, just with a different commit path
948
+ * at drop (a `boxEdit` patch vs. a `structure.reorder`). Pure; no DOM/adapter
949
+ * access. */
950
+ export function computeDragGhost(origRect: RectLike, dx: number, dy: number): RectLike {
951
+ return { x: origRect.x + dx, y: origRect.y + dy, width: origRect.width, height: origRect.height };
952
+ }
953
+
954
+ /** One sibling candidate for the D2.b reorder gap math: an id (the
955
+ * `structure.reorder(id, beforeSiblingId)` argument this sibling would BE,
956
+ * if the pointer lands before it) paired with its current on-screen rect.
957
+ * Deliberately excludes the DRAGGED node itself — the caller builds this
958
+ * list from the dragged id's ordered siblings, filtering the dragged id out
959
+ * (its own rect is busy being dragged, not a valid gap boundary). */
960
+ export interface SiblingRectEntry {
961
+ id: string;
962
+ rect: RectLike;
963
+ }
964
+
965
+ /** The live reorder-preview gap a pointer position resolves to among a set of
966
+ * sibling rects (spec:398-400 "live insertion preview ... updating as the
967
+ * pointer moves across sibling boundaries"). */
968
+ export interface ReorderGap {
969
+ /** The exact `structure.reorder(id, beforeSiblingId)` second argument:
970
+ * the sibling to insert BEFORE, or `null` to insert at the END (spec's
971
+ * own contract doc comment: "null = move to the end"). */
972
+ beforeSiblingId: string | null;
973
+ /** The gap's index among `siblings` in AXIS order — `0` is "before the
974
+ * first sibling", `siblings.length` is "after the last" (same case
975
+ * `beforeSiblingId: null` reports). Exposed mainly for tests: it's a
976
+ * denser signal than re-deriving position from `beforeSiblingId` alone
977
+ * when two siblings tie on id. */
978
+ index: number;
979
+ /** A thin marker rect at the gap boundary — the live insertion-preview
980
+ * line/marker to render. Spans the cross-axis extent of the flanking
981
+ * sibling(s); its thickness is {@link REORDER_MARKER_THICKNESS_PX}. */
982
+ marker: RectLike;
983
+ }
984
+
985
+ /** Thickness (px) of the D2.b live insertion-preview marker line. */
986
+ export const REORDER_MARKER_THICKNESS_PX = 3;
987
+
988
+ /** A sibling rect's center coordinate along one axis — the sort/compare key
989
+ * {@link computeReorderGap} uses throughout. */
990
+ function centerOn(r: RectLike, axis: 'x' | 'y'): number {
991
+ return axis === 'x' ? r.x + r.width / 2 : r.y + r.height / 2;
992
+ }
993
+
994
+ /** The gap boundary coordinate along `axis`, given `ordered` siblings (sorted
995
+ * along that axis already) and the gap `index` among them: the leading edge
996
+ * of the first sibling (index 0), the trailing edge of the last (index ===
997
+ * length), or the midpoint between the flanking pair's facing edges
998
+ * otherwise. Factored out of {@link computeReorderGap} purely to keep ITS
999
+ * cognitive complexity down. */
1000
+ function reorderGapBoundary(
1001
+ ordered: readonly SiblingRectEntry[],
1002
+ index: number,
1003
+ horizontal: boolean,
1004
+ ): number {
1005
+ if (index === 0) {
1006
+ const first = ordered[0]!.rect;
1007
+ return horizontal ? first.x : first.y;
1008
+ }
1009
+ if (index === ordered.length) {
1010
+ const last = ordered[ordered.length - 1]!.rect;
1011
+ return horizontal ? last.x + last.width : last.y + last.height;
1012
+ }
1013
+ const prev = ordered[index - 1]!.rect;
1014
+ const next = ordered[index]!.rect;
1015
+ return horizontal ? (prev.x + prev.width + next.x) / 2 : (prev.y + prev.height + next.y) / 2;
1016
+ }
1017
+
1018
+ /** The D2.b insertion-preview marker rect: a thin {@link REORDER_MARKER_THICKNESS_PX}
1019
+ * line at `boundary` along `axis`, spanning the CROSS-axis extent of every
1020
+ * `ordered` sibling (so it reads as a full-width/height insertion line, not
1021
+ * just as wide as its immediate neighbors). Factored out of
1022
+ * {@link computeReorderGap} purely to keep ITS cognitive complexity down. */
1023
+ function reorderMarkerRect(
1024
+ ordered: readonly SiblingRectEntry[],
1025
+ boundary: number,
1026
+ horizontal: boolean,
1027
+ ): RectLike {
1028
+ const crossMin = Math.min(...ordered.map((s) => (horizontal ? s.rect.y : s.rect.x)));
1029
+ const crossMax = Math.max(
1030
+ ...ordered.map((s) => (horizontal ? s.rect.y + s.rect.height : s.rect.x + s.rect.width)),
1031
+ );
1032
+ const half = REORDER_MARKER_THICKNESS_PX / 2;
1033
+ return horizontal
1034
+ ? {
1035
+ x: boundary - half,
1036
+ y: crossMin,
1037
+ width: REORDER_MARKER_THICKNESS_PX,
1038
+ height: crossMax - crossMin,
1039
+ }
1040
+ : {
1041
+ x: crossMin,
1042
+ y: boundary - half,
1043
+ width: crossMax - crossMin,
1044
+ height: REORDER_MARKER_THICKNESS_PX,
1045
+ };
1046
+ }
1047
+
1048
+ /**
1049
+ * D2.b (spec:397-401) — resolve `pointer` to a live insertion gap among
1050
+ * `siblings` (the dragged node's OWN siblings, already excluding it — see
1051
+ * {@link SiblingRectEntry}'s doc comment). `null` only when `siblings` is
1052
+ * empty (nothing to compute a gap among — the caller's own "≥3 siblings"
1053
+ * capability gate should make this unreachable in practice, but this stays
1054
+ * honest about the degenerate input rather than fabricating a gap).
1055
+ *
1056
+ * Axis detection is PURE GEOMETRY, no CSS/flex-direction read needed: whichever
1057
+ * dimension the siblings' centers spread across MORE is the list's primary
1058
+ * axis (a horizontal flex-row spreads in x, a vertical stack spreads in y).
1059
+ * Siblings are then re-sorted along that axis (defensive — callers should
1060
+ * already pass DOM order, but the gap math only means something in spatial
1061
+ * order) and the pointer's coordinate along the SAME axis is compared against
1062
+ * each sibling's center to find how many siblings it has "passed" — that
1063
+ * count IS the gap index, and `beforeSiblingId` is whichever sibling sits at
1064
+ * that index (`null` past the last one).
1065
+ */
1066
+ export function computeReorderGap(
1067
+ siblings: readonly SiblingRectEntry[],
1068
+ pointer: { x: number; y: number },
1069
+ ): ReorderGap | null {
1070
+ if (siblings.length === 0) return null;
1071
+
1072
+ const xSpread =
1073
+ Math.max(...siblings.map((s) => centerOn(s.rect, 'x'))) -
1074
+ Math.min(...siblings.map((s) => centerOn(s.rect, 'x')));
1075
+ const ySpread =
1076
+ Math.max(...siblings.map((s) => centerOn(s.rect, 'y'))) -
1077
+ Math.min(...siblings.map((s) => centerOn(s.rect, 'y')));
1078
+ const horizontal = xSpread >= ySpread;
1079
+ const axis: 'x' | 'y' = horizontal ? 'x' : 'y';
1080
+
1081
+ const ordered = [...siblings].sort((a, b) => centerOn(a.rect, axis) - centerOn(b.rect, axis));
1082
+
1083
+ const pointerKey = horizontal ? pointer.x : pointer.y;
1084
+ let index = 0;
1085
+ while (index < ordered.length && centerOn(ordered[index]!.rect, axis) < pointerKey) index++;
1086
+ const beforeSiblingId = index < ordered.length ? ordered[index]!.id : null;
1087
+
1088
+ const boundary = reorderGapBoundary(ordered, index, horizontal);
1089
+ const marker = reorderMarkerRect(ordered, boundary, horizontal);
1090
+
1091
+ return { beforeSiblingId, index, marker };
1092
+ }
1093
+
1094
+ /**
1095
+ * `id`'s ordered sibling ids, INCLUDING `id` itself (the caller filters it
1096
+ * back out when building {@link SiblingRectEntry}s) — read off `adapter`'s
1097
+ * OWN `hierarchy` (merged/routed on a `CompositeAuthoringAdapter`, same
1098
+ * stance `labelForId`/`WorldSelectionOverlay.tsx` already document: no owner
1099
+ * lookup needed for `hierarchy`, unlike `rects`/`boxEdit`/`structure`). `null`
1100
+ * when `id` is unknown, or when it claims a `parentId` whose node can't be
1101
+ * resolved. A parentless (top-level) id's siblings are the OWNER'S OWN
1102
+ * `hierarchy.roots()` — for a composite, `roots()` returns synthetic
1103
+ * `world:<id>` GROUP nodes, so a top-level react-world node's real siblings
1104
+ * only resolve correctly through the group node's `childIds`, which is why
1105
+ * this reads `node.parentId` (already rewritten to the group id by the
1106
+ * composite for a child's own top-level root, per
1107
+ * `composite-authoring-adapter.ts`'s `node()`) rather than calling
1108
+ * `roots()` directly for every id.
1109
+ */
1110
+ export function siblingIdsForId(adapter: AuthoringAdapter, id: string): string[] | null {
1111
+ const node = adapter.hierarchy.node(id);
1112
+ if (!node) return null;
1113
+ if (node.parentId) {
1114
+ const parent = adapter.hierarchy.node(node.parentId);
1115
+ return parent ? parent.childIds : null;
1116
+ }
1117
+ return adapter.hierarchy.roots().map((n) => n.id);
1118
+ }
1119
+
1120
+ // --- D1.a — sibling-distance measure lines (spec §6 D1, §5 :461
1121
+ // "sibling-distance measure lines") -----------------------------------
1122
+
1123
+ /** One figma-style "hold-and-measure" gap line between two rects: a straight
1124
+ * segment from `(x1,y1)` to `(x2,y2)` (always axis-aligned — either
1125
+ * `y1 === y2`, a `'horizontal'` line measuring the x-axis gap, or
1126
+ * `x1 === x2`, a `'vertical'` line measuring the y-axis gap), the gap's
1127
+ * rounded px `distance`, and a `label` anchor at the segment's midpoint. */
1128
+ export interface MeasureLine {
1129
+ orientation: 'horizontal' | 'vertical';
1130
+ x1: number;
1131
+ y1: number;
1132
+ x2: number;
1133
+ y2: number;
1134
+ distance: number;
1135
+ labelX: number;
1136
+ labelY: number;
1137
+ }
1138
+
1139
+ /**
1140
+ * D1.a (spec §6 "sibling-distance measure lines ... between selection and
1141
+ * hover"): the gap line(s) between `a` (the selection) and `b` (the hover
1142
+ * target). A `'horizontal'` line (gap along x) is produced when the two
1143
+ * rects DON'T overlap on the x-axis (one is fully left/right of the other);
1144
+ * a `'vertical'` line (gap along y) when they don't overlap on the y-axis.
1145
+ * Both can be produced at once (the rects are diagonal from each other —
1146
+ * neither axis overlaps); neither is produced when the rects overlap on
1147
+ * BOTH axes (no meaningful "gap" to measure — honest empty result, never a
1148
+ * fabricated negative/zero line, per this task's own anti-fabrication rule).
1149
+ *
1150
+ * The line's position on its PERPENDICULAR axis is the midpoint of the two
1151
+ * rects' overlapping range on that axis when they DO overlap there (the
1152
+ * common figma case — a sibling directly beside or below the selection);
1153
+ * when they don't overlap on that axis either (the diagonal case), it falls
1154
+ * back to the midpoint between the two rects' own centers on that axis, so
1155
+ * the line still reads as "between" them rather than snapping to an
1156
+ * arbitrary edge. Pure geometry; `a`/`b` are interchangeable (order doesn't
1157
+ * change which lines are produced, only which distance/endpoint is which
1158
+ * rect's edge — commutative).
1159
+ */
1160
+ export function computeMeasureLines(a: RectLike, b: RectLike): MeasureLine[] {
1161
+ const aLeft = a.x;
1162
+ const aRight = a.x + a.width;
1163
+ const aTop = a.y;
1164
+ const aBottom = a.y + a.height;
1165
+ const bLeft = b.x;
1166
+ const bRight = b.x + b.width;
1167
+ const bTop = b.y;
1168
+ const bBottom = b.y + b.height;
1169
+
1170
+ const lines: MeasureLine[] = [];
1171
+
1172
+ // Horizontal gap (x-axis separation) — only when the rects don't overlap
1173
+ // on x.
1174
+ let gapX: { x1: number; x2: number } | null = null;
1175
+ if (aRight <= bLeft) gapX = { x1: aRight, x2: bLeft };
1176
+ else if (bRight <= aLeft) gapX = { x1: bRight, x2: aLeft };
1177
+ if (gapX) {
1178
+ const overlapTop = Math.max(aTop, bTop);
1179
+ const overlapBottom = Math.min(aBottom, bBottom);
1180
+ const y =
1181
+ overlapBottom > overlapTop
1182
+ ? (overlapTop + overlapBottom) / 2
1183
+ : ((aTop + aBottom) / 2 + (bTop + bBottom) / 2) / 2;
1184
+ lines.push({
1185
+ orientation: 'horizontal',
1186
+ x1: gapX.x1,
1187
+ y1: y,
1188
+ x2: gapX.x2,
1189
+ y2: y,
1190
+ distance: Math.round(gapX.x2 - gapX.x1),
1191
+ labelX: (gapX.x1 + gapX.x2) / 2,
1192
+ labelY: y,
1193
+ });
1194
+ }
1195
+
1196
+ // Vertical gap (y-axis separation) — only when the rects don't overlap
1197
+ // on y.
1198
+ let gapY: { y1: number; y2: number } | null = null;
1199
+ if (aBottom <= bTop) gapY = { y1: aBottom, y2: bTop };
1200
+ else if (bBottom <= aTop) gapY = { y1: bBottom, y2: aTop };
1201
+ if (gapY) {
1202
+ const overlapLeft = Math.max(aLeft, bLeft);
1203
+ const overlapRight = Math.min(aRight, bRight);
1204
+ const x =
1205
+ overlapRight > overlapLeft
1206
+ ? (overlapLeft + overlapRight) / 2
1207
+ : ((aLeft + aRight) / 2 + (bLeft + bRight) / 2) / 2;
1208
+ lines.push({
1209
+ orientation: 'vertical',
1210
+ x1: x,
1211
+ y1: gapY.y1,
1212
+ x2: x,
1213
+ y2: gapY.y2,
1214
+ distance: Math.round(gapY.y2 - gapY.y1),
1215
+ labelX: x,
1216
+ labelY: (gapY.y1 + gapY.y2) / 2,
1217
+ });
1218
+ }
1219
+
1220
+ return lines;
1221
+ }
1222
+
1223
+ // --- D1.c — position/parent-layout badge (spec §6 D1, §5 :461
1224
+ // "position/parent-layout badges") --------------------------------
1225
+
1226
+ /** The D1.c badge's two independent segments — each `null` when the
1227
+ * underlying value couldn't be resolved (no `inspector`, no parent, an
1228
+ * unreadable/absent style value), per this task's own honesty rule: "if a
1229
+ * value can't be resolved, show nothing for it, never a fabricated
1230
+ * default." Never both `null` AND rendered — the caller's own gate. */
1231
+ export interface PositionBadgeInfo {
1232
+ /** `id`'s own `style.position` value (e.g. `'absolute'`), or `null`. */
1233
+ position: string | null;
1234
+ /** `id`'s PARENT's layout, summarized: `'flex row'`/`'flex col'` (from
1235
+ * `display: flex`/`inline-flex` + `flexDirection`), `'grid'` (from
1236
+ * `display: grid`/`inline-grid`), or the parent's raw `display` value for
1237
+ * anything else (e.g. `'block'`) — `null` when there's no parent, no
1238
+ * `inspector`, or the parent's `display` itself can't be read. */
1239
+ parentLayout: string | null;
1240
+ }
1241
+
1242
+ /** Read a possibly-unset inspector value as a non-empty string, or `null` —
1243
+ * the shared "honest unresolved" coercion both segments below use (an
1244
+ * inspector `get` may return `undefined`, `null`, or even a non-string for
1245
+ * an adapter that doesn't model the path at all). */
1246
+ function readStyleString(
1247
+ inspector: { get(id: string, path: string): unknown } | undefined,
1248
+ id: string,
1249
+ path: string,
1250
+ ): string | null {
1251
+ const raw = inspector?.get(id, path);
1252
+ return typeof raw === 'string' && raw.length > 0 ? raw : null;
1253
+ }
1254
+
1255
+ /**
1256
+ * D1.c (spec §6 "position/parent-layout badges") — `id`'s own position mode
1257
+ * plus its PARENT's summarized layout, read entirely through
1258
+ * `adapter.inspector.get`/`adapter.hierarchy.node` (both merged/routed on a
1259
+ * `CompositeAuthoringAdapter` already — `labelForId`/`siblingIdsForId`'s own
1260
+ * doc comments — so, unlike `rects`/`boxEdit`, no owner-child routing helper
1261
+ * is needed here; a bare adapter and a composite call this identically).
1262
+ * Each segment resolves independently — a missing/unreadable parent
1263
+ * `display` never blanks out an otherwise-resolved `position`, and vice
1264
+ * versa (spec's own "never a fabricated default" instruction).
1265
+ */
1266
+ export function resolvePositionBadge(adapter: AuthoringAdapter, id: string): PositionBadgeInfo {
1267
+ const position = readStyleString(adapter.inspector, id, 'style.position');
1268
+
1269
+ let parentLayout: string | null = null;
1270
+ const parentId = adapter.hierarchy.node(id)?.parentId ?? null;
1271
+ if (parentId) {
1272
+ const display = readStyleString(adapter.inspector, parentId, 'style.display');
1273
+ if (display === 'flex' || display === 'inline-flex') {
1274
+ const direction =
1275
+ readStyleString(adapter.inspector, parentId, 'style.flexDirection') ?? 'row';
1276
+ parentLayout = `flex ${direction.startsWith('column') ? 'col' : 'row'}`;
1277
+ } else if (display === 'grid' || display === 'inline-grid') {
1278
+ parentLayout = 'grid';
1279
+ } else if (display) {
1280
+ parentLayout = display;
1281
+ }
1282
+ }
1283
+
1284
+ return { position, parentLayout };
1285
+ }
1286
+
1287
+ // --- D4 — multi-select align/distribute toolbar (spec §6 D4, §5 :462
1288
+ // "alignment/distribute toolbar") -------------------------------------
1289
+
1290
+ /** One selected node's id paired with its current on-screen rect — reused
1291
+ * (not re-declared) from the D2.b reorder-gap math above: both are just
1292
+ * "an id + the rect it currently occupies", the same shape `AlignToolbar`
1293
+ * needs for the align/distribute math below. */
1294
+ export type AlignEntry = SiblingRectEntry;
1295
+
1296
+ export type AlignOp = 'left' | 'hcenter' | 'right' | 'top' | 'vcenter' | 'bottom';
1297
+ export type DistributeAxis = 'horizontal' | 'vertical';
1298
+
1299
+ /** One node's align/distribute RESULT: the absolute host-relative `x` and/or
1300
+ * `y` its rect should move to (only the axis the op touches is present —
1301
+ * align-left only ever sets `x`, never `y`), matching `boxEdit.apply`'s own
1302
+ * ABSOLUTE-coordinate contract (spec:322/B2 — the same convention
1303
+ * `computeMovePatch` already returns, NOT a delta). */
1304
+ export interface AlignTarget {
1305
+ id: string;
1306
+ x?: number;
1307
+ y?: number;
1308
+ }
1309
+
1310
+ /**
1311
+ * D4.b (spec §6 D4 "a multi-select align/distribute toolbar ... align
1312
+ * left/hcenter/right/top/vcenter/bottom (≥2 selected)"): every entry's target
1313
+ * position for `op`, computed from the SELECTION's own union bounding box
1314
+ * (spec's own figma-parity framing — "align left" moves every selected
1315
+ * node's left edge to the leftmost selected node's left edge, which IS the
1316
+ * union bbox's left edge; center/right/top/vcenter/bottom are the analogous
1317
+ * bbox edges/midpoints). `[]` for fewer than 2 entries — the toolbar's own
1318
+ * capability gate (this function stays honest about the degenerate input
1319
+ * rather than fabricating a single-node "alignment"). Pure; no adapter/DOM
1320
+ * access — the caller (`AlignToolbar.tsx`) is the one that routes each
1321
+ * target through `boxEditForId`.
1322
+ */
1323
+ export function computeAlignTargets(entries: readonly AlignEntry[], op: AlignOp): AlignTarget[] {
1324
+ if (entries.length < 2) return [];
1325
+ const rects = entries.map((e) => e.rect);
1326
+ const minX = Math.min(...rects.map((r) => r.x));
1327
+ const maxRight = Math.max(...rects.map((r) => r.x + r.width));
1328
+ const minY = Math.min(...rects.map((r) => r.y));
1329
+ const maxBottom = Math.max(...rects.map((r) => r.y + r.height));
1330
+ const centerX = (minX + maxRight) / 2;
1331
+ const centerY = (minY + maxBottom) / 2;
1332
+ const out: AlignTarget[] = [];
1333
+ for (const { id, rect } of entries) {
1334
+ switch (op) {
1335
+ case 'left':
1336
+ out.push({ id, x: minX });
1337
+ break;
1338
+ case 'hcenter':
1339
+ out.push({ id, x: centerX - rect.width / 2 });
1340
+ break;
1341
+ case 'right':
1342
+ out.push({ id, x: maxRight - rect.width });
1343
+ break;
1344
+ case 'top':
1345
+ out.push({ id, y: minY });
1346
+ break;
1347
+ case 'vcenter':
1348
+ out.push({ id, y: centerY - rect.height / 2 });
1349
+ break;
1350
+ case 'bottom':
1351
+ out.push({ id, y: maxBottom - rect.height });
1352
+ break;
1353
+ }
1354
+ }
1355
+ return out;
1356
+ }
1357
+
1358
+ /**
1359
+ * D4.b (spec §6 D4 "distribute horizontally/vertically (≥3 selected)"):
1360
+ * equalize the GAP between consecutive entries along `axis`, keeping the
1361
+ * first (lowest-coordinate) and last (highest-coordinate) entries fixed at
1362
+ * their own current position — the standard figma distribute algorithm.
1363
+ * `[]` for fewer than 3 entries (spec's own "(≥3 selected)" gate — with only
1364
+ * 2 entries there is exactly one gap, nothing to "equalize" against, so an
1365
+ * honest empty result beats fabricating a no-op target). Pure; entries are
1366
+ * re-sorted internally by their CURRENT position along `axis` (the caller
1367
+ * doesn't need to pre-sort).
1368
+ */
1369
+ export function computeDistributeTargets(
1370
+ entries: readonly AlignEntry[],
1371
+ axis: DistributeAxis,
1372
+ ): AlignTarget[] {
1373
+ if (entries.length < 3) return [];
1374
+ const horizontal = axis === 'horizontal';
1375
+ const sorted = [...entries].sort((a, b) =>
1376
+ horizontal ? a.rect.x - b.rect.x : a.rect.y - b.rect.y,
1377
+ );
1378
+ const first = sorted[0]!;
1379
+ const last = sorted[sorted.length - 1]!;
1380
+ const totalSpan = horizontal
1381
+ ? last.rect.x + last.rect.width - first.rect.x
1382
+ : last.rect.y + last.rect.height - first.rect.y;
1383
+ const totalSize = sorted.reduce((s, e) => s + (horizontal ? e.rect.width : e.rect.height), 0);
1384
+ const gap = (totalSpan - totalSize) / (sorted.length - 1);
1385
+ let cursor = horizontal ? first.rect.x : first.rect.y;
1386
+ const out: AlignTarget[] = [];
1387
+ for (const e of sorted) {
1388
+ out.push(horizontal ? { id: e.id, x: cursor } : { id: e.id, y: cursor });
1389
+ cursor += (horizontal ? e.rect.width : e.rect.height) + gap;
1390
+ }
1391
+ return out;
1392
+ }
1393
+
1394
+ /** The union bounding box of `rects` (spec's own "the selection's own union
1395
+ * bounding box" framing — see {@link computeAlignTargets}'s doc comment) —
1396
+ * used by `AlignToolbar` purely to POSITION itself above the selection;
1397
+ * `null` for an empty input (never a fabricated zero-rect). */
1398
+ export function unionRect(rects: readonly RectLike[]): RectLike | null {
1399
+ if (rects.length === 0) return null;
1400
+ const x = Math.min(...rects.map((r) => r.x));
1401
+ const y = Math.min(...rects.map((r) => r.y));
1402
+ const right = Math.max(...rects.map((r) => r.x + r.width));
1403
+ const bottom = Math.max(...rects.map((r) => r.y + r.height));
1404
+ return { x, y, width: right - x, height: bottom - y };
1405
+ }
1406
+
1407
+ // --- D4.c — arrow-nudge (spec §6 D4 "arrow-nudge (↑↓←→, Shift=×10) ...
1408
+ // writes margins/offsets") --------------------------------------------
1409
+
1410
+ /**
1411
+ * D4.c — the `boxEdit` patch one arrow-key nudge writes: for an
1412
+ * absolutely/fixed-positioned node, an ABSOLUTE `{x,y}` (the same convention
1413
+ * {@link computeMovePatch} already returns, matching B1's "x/y for
1414
+ * absolutely-positioned nodes → left/top"), offset by `(dx,dy)` from `rect`'s
1415
+ * current position. For anything else — the normal-document-flow case B1
1416
+ * also covers ("margin / padding → per-side style writes") — nudges
1417
+ * `marginLeft`/`marginTop` by the same delta instead (there is no `left`/
1418
+ * `top` to move on a non-positioned node — the only spatial lever B1 leaves
1419
+ * it is its own margin). Only the axis/axes actually nudged (`dx`/`dy`
1420
+ * non-zero) appear in the returned patch. Pure; no adapter/DOM access.
1421
+ */
1422
+ export function computeArrowNudgePatch(
1423
+ dx: number,
1424
+ dy: number,
1425
+ isPositioned: boolean,
1426
+ rect: RectLike,
1427
+ margin: { left: number; top: number },
1428
+ ): Record<string, number> {
1429
+ const patch: Record<string, number> = {};
1430
+ if (isPositioned) {
1431
+ if (dx !== 0) patch['x'] = rect.x + dx;
1432
+ if (dy !== 0) patch['y'] = rect.y + dy;
1433
+ return patch;
1434
+ }
1435
+ if (dx !== 0) patch['marginLeft'] = margin.left + dx;
1436
+ if (dy !== 0) patch['marginTop'] = margin.top + dy;
1437
+ return patch;
1438
+ }