summer-engine 3.1.0 → 3.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 (366) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +27 -5
  3. package/.codex-plugin/plugin.json +28 -6
  4. package/.cursor-plugin/plugin.json +27 -5
  5. package/.factory-plugin/plugin.json +27 -5
  6. package/.opencode/plugins/summer.js +1 -1
  7. package/CHANGELOG.md +102 -0
  8. package/README.md +18 -3
  9. package/assets/audit/scene_audit.gd +4136 -0
  10. package/assets/seeing/seeing_probe.gd +1873 -0
  11. package/dist/cli/commands/skills.js +20 -2
  12. package/dist/core/api-client.d.ts +9 -1
  13. package/dist/core/api-client.js +21 -4
  14. package/dist/core/capabilities/agent-playbook.js +46 -0
  15. package/dist/core/capabilities/audit/accept.d.ts +55 -0
  16. package/dist/core/capabilities/audit/accept.js +147 -0
  17. package/dist/core/capabilities/audit/args.d.ts +95 -0
  18. package/dist/core/capabilities/audit/args.js +89 -0
  19. package/dist/core/capabilities/audit/audit.d.ts +56 -0
  20. package/dist/core/capabilities/audit/audit.js +287 -0
  21. package/dist/core/capabilities/audit/frame.d.ts +14 -0
  22. package/dist/core/capabilities/audit/frame.js +35 -0
  23. package/dist/core/capabilities/audit/gaps.d.ts +96 -0
  24. package/dist/core/capabilities/audit/gaps.js +257 -0
  25. package/dist/core/capabilities/audit/judge.d.ts +162 -0
  26. package/dist/core/capabilities/audit/judge.js +1134 -0
  27. package/dist/core/capabilities/audit/math.d.ts +110 -0
  28. package/dist/core/capabilities/audit/math.js +263 -0
  29. package/dist/core/capabilities/audit/summary.d.ts +74 -0
  30. package/dist/core/capabilities/audit/summary.js +212 -0
  31. package/dist/core/capabilities/board.d.ts +31 -0
  32. package/dist/core/capabilities/board.js +43 -0
  33. package/dist/core/capabilities/camera-view.d.ts +4 -2
  34. package/dist/core/capabilities/camera-view.js +6 -3
  35. package/dist/core/capabilities/connect-signal.d.ts +85 -0
  36. package/dist/core/capabilities/connect-signal.js +285 -0
  37. package/dist/core/capabilities/engine-fallbacks.d.ts +5 -0
  38. package/dist/core/capabilities/engine-fallbacks.js +5 -0
  39. package/dist/core/capabilities/engine-ops.d.ts +5 -1
  40. package/dist/core/capabilities/engine-ops.js +0 -3
  41. package/dist/core/capabilities/engine-receipt.d.ts +12 -0
  42. package/dist/core/capabilities/engine-receipt.js +26 -0
  43. package/dist/core/capabilities/file-read.d.ts +64 -0
  44. package/dist/core/capabilities/file-read.js +325 -0
  45. package/dist/core/capabilities/game-task-plan.js +4 -2
  46. package/dist/core/capabilities/grep.d.ts +60 -0
  47. package/dist/core/capabilities/grep.js +190 -0
  48. package/dist/core/capabilities/inspect-node.d.ts +56 -0
  49. package/dist/core/capabilities/inspect-node.js +225 -0
  50. package/dist/core/capabilities/inspect-resource.d.ts +61 -0
  51. package/dist/core/capabilities/inspect-resource.js +376 -0
  52. package/dist/core/capabilities/math3d.d.ts +28 -0
  53. package/dist/core/capabilities/math3d.js +88 -0
  54. package/dist/core/capabilities/motion-generation.d.ts +62 -0
  55. package/dist/core/capabilities/motion-generation.js +138 -0
  56. package/dist/core/capabilities/placement-batch.d.ts +102 -0
  57. package/dist/core/capabilities/placement-batch.js +483 -0
  58. package/dist/core/capabilities/placement-math.d.ts +70 -0
  59. package/dist/core/capabilities/placement-math.js +209 -0
  60. package/dist/core/capabilities/placement-script.d.ts +42 -0
  61. package/dist/core/capabilities/placement-script.js +1096 -0
  62. package/dist/core/capabilities/placement.d.ts +485 -0
  63. package/dist/core/capabilities/placement.js +1731 -0
  64. package/dist/core/capabilities/project-context.d.ts +33 -2
  65. package/dist/core/capabilities/project-context.js +87 -4
  66. package/dist/core/capabilities/replace-node.d.ts +91 -0
  67. package/dist/core/capabilities/replace-node.js +423 -0
  68. package/dist/core/capabilities/runtime-control.d.ts +18 -2
  69. package/dist/core/capabilities/runtime-control.js +67 -2
  70. package/dist/core/capabilities/scene-batch.d.ts +63 -0
  71. package/dist/core/capabilities/scene-batch.js +394 -0
  72. package/dist/core/capabilities/scene-readback.d.ts +36 -0
  73. package/dist/core/capabilities/scene-readback.js +54 -0
  74. package/dist/core/capabilities/seeing/args.d.ts +348 -0
  75. package/dist/core/capabilities/seeing/args.js +178 -0
  76. package/dist/core/capabilities/seeing/candidates.d.ts +127 -0
  77. package/dist/core/capabilities/seeing/candidates.js +282 -0
  78. package/dist/core/capabilities/seeing/math.d.ts +134 -0
  79. package/dist/core/capabilities/seeing/math.js +269 -0
  80. package/dist/core/capabilities/seeing/probe.d.ts +62 -0
  81. package/dist/core/capabilities/seeing/probe.js +188 -0
  82. package/dist/core/capabilities/seeing/scoring.d.ts +235 -0
  83. package/dist/core/capabilities/seeing/scoring.js +639 -0
  84. package/dist/core/capabilities/seeing/seeing.d.ts +233 -0
  85. package/dist/core/capabilities/seeing/seeing.js +1341 -0
  86. package/dist/core/capabilities/seeing/shot-store.d.ts +52 -0
  87. package/dist/core/capabilities/seeing/shot-store.js +204 -0
  88. package/dist/core/capabilities/surface-snap-mesh.d.ts +88 -0
  89. package/dist/core/capabilities/surface-snap-mesh.js +425 -0
  90. package/dist/core/capabilities/surface-snap.d.ts +77 -0
  91. package/dist/core/capabilities/surface-snap.js +461 -0
  92. package/dist/core/capabilities/tool-dispatch.js +231 -72
  93. package/dist/core/capabilities/tscn.d.ts +85 -0
  94. package/dist/core/capabilities/tscn.js +391 -0
  95. package/dist/core/capabilities/world-snapshot.d.ts +58 -0
  96. package/dist/core/capabilities/world-snapshot.js +150 -0
  97. package/dist/core/capability-skew.d.ts +5 -5
  98. package/dist/core/capability-skew.js +14 -6
  99. package/dist/core/headless/worker-engine-client.d.ts +1 -1
  100. package/dist/core/headless/worker-engine-client.js +2 -2
  101. package/dist/core/library-read.d.ts +37 -5
  102. package/dist/core/library-read.js +264 -33
  103. package/dist/core/library-search.d.ts +2 -2
  104. package/dist/installer/agent-config.js +5 -3
  105. package/dist/installer/agent-table.d.ts +16 -0
  106. package/dist/installer/agent-table.js +52 -17
  107. package/dist/mcp/server.js +9 -3
  108. package/dist/mcp/tools/asset-tools.js +21 -3
  109. package/dist/mcp/tools/audit-tools.d.ts +23 -0
  110. package/dist/mcp/tools/audit-tools.js +60 -0
  111. package/dist/mcp/tools/debug-tools.js +3 -1
  112. package/dist/mcp/tools/file-tools.js +17 -4
  113. package/dist/mcp/tools/generate-tools.js +74 -66
  114. package/dist/mcp/tools/library-tools.js +2 -0
  115. package/dist/mcp/tools/perception-tools.js +6 -19
  116. package/dist/mcp/tools/placement-tools.d.ts +24 -0
  117. package/dist/mcp/tools/placement-tools.js +91 -0
  118. package/dist/mcp/tools/project-tools.js +18 -15
  119. package/dist/mcp/tools/scene-tools.js +71 -83
  120. package/dist/mcp/tools/seeing-tools.d.ts +24 -0
  121. package/dist/mcp/tools/seeing-tools.js +64 -0
  122. package/dist/mcp/tools/spatial-tools.js +17 -9
  123. package/dist/mcp/tools/visual-tools.js +59 -7
  124. package/gemini-extension.json +26 -4
  125. package/integrations/amp/README.md +1 -1
  126. package/integrations/crush/README.md +1 -1
  127. package/integrations/grok-build/README.md +1 -1
  128. package/integrations/hermes/README.md +1 -1
  129. package/integrations/kimi-code/README.md +1 -1
  130. package/integrations/mistral-vibe/README.md +1 -1
  131. package/integrations/opencode/README.md +1 -1
  132. package/integrations/rovo-dev/README.md +1 -1
  133. package/integrations/vscode-copilot/README.md +1 -1
  134. package/integrations/warp/README.md +1 -1
  135. package/integrations/windsurf/README.md +1 -1
  136. package/library/references/mcp-tools-reference/mcp-tools-reference.md +59 -15
  137. package/library/references/mcp-tools-reference/resource.yaml +1 -1
  138. package/library/references/summer-folder/resource.yaml +1 -1
  139. package/library/references/summer-folder/summer-folder.md +1 -1
  140. package/library/skills/3d-lighting/SKILL.md +1 -1
  141. package/library/skills/README.md +36 -14
  142. package/library/skills/adaptive-music/SKILL.md +1 -1
  143. package/library/skills/agent-playtesting/SKILL.md +1 -1
  144. package/library/skills/ai-characters-in-games/SKILL.md +220 -0
  145. package/library/skills/ai-characters-in-games/resource.yaml +30 -0
  146. package/library/skills/ambient-bed/SKILL.md +1 -1
  147. package/library/skills/animated-loop/SKILL.md +1 -1
  148. package/library/skills/animation-tree/SKILL.md +1 -1
  149. package/library/skills/art-direction/SKILL.md +1 -1
  150. package/library/skills/asset-strategy/SKILL.md +2 -1
  151. package/library/skills/asset-strategy/resource.yaml +2 -1
  152. package/library/skills/audio-direction/SKILL.md +1 -1
  153. package/library/skills/auto-fire-targeting/SKILL.md +1 -1
  154. package/library/skills/brainstorm-game/SKILL.md +1 -1
  155. package/library/skills/brainstorming/SKILL.md +1 -1
  156. package/library/skills/browse-templates/SKILL.md +3 -3
  157. package/library/skills/browse-templates/resource.yaml +1 -1
  158. package/library/skills/camera-rigs/SKILL.md +128 -0
  159. package/library/skills/camera-rigs/resource.yaml +29 -0
  160. package/library/skills/celeste-momentum-platforming/SKILL.md +1 -1
  161. package/library/skills/character-animation-wiring/SKILL.md +1 -1
  162. package/library/skills/character-model/SKILL.md +1 -1
  163. package/library/skills/character-movement/SKILL.md +326 -0
  164. package/library/skills/character-movement/references/default-third-person-controller.gd +201 -0
  165. package/library/skills/character-movement/references/default-third-person-verification-probe.gd +154 -0
  166. package/library/skills/character-movement/references/movement-3d.md +136 -0
  167. package/library/skills/character-movement/references/platformer-feel.md +83 -0
  168. package/library/skills/character-movement/resource.yaml +40 -0
  169. package/library/skills/character-portrait/SKILL.md +1 -1
  170. package/library/skills/cinematic-cutscene/SKILL.md +1 -1
  171. package/library/skills/combat-basics/SKILL.md +159 -0
  172. package/library/skills/combat-basics/references/projectiles-and-aoe.md +100 -0
  173. package/library/skills/combat-basics/resource.yaml +34 -0
  174. package/library/skills/concept-art/SKILL.md +1 -1
  175. package/library/skills/create-asset-sheet/SKILL.md +1 -1
  176. package/library/skills/debug/SKILL.md +1 -1
  177. package/library/skills/debugging-game-feel/SKILL.md +1 -1
  178. package/library/skills/design-level/SKILL.md +1 -1
  179. package/library/skills/design-mechanic/SKILL.md +1 -1
  180. package/library/skills/design-npc/SKILL.md +1 -1
  181. package/library/skills/diagnosing-perf-regressions/SKILL.md +1 -1
  182. package/library/skills/dispatching-parallel-agents/SKILL.md +1 -1
  183. package/library/skills/driving-the-editor-ui/SKILL.md +1 -1
  184. package/library/skills/environment-kit/SKILL.md +1 -1
  185. package/library/skills/export-and-ship/SKILL.md +1 -1
  186. package/library/skills/fabricating-assets/SKILL.md +1 -1
  187. package/library/skills/facial-and-lipsync/SKILL.md +1 -1
  188. package/library/skills/fps-controller/SKILL.md +9 -7
  189. package/library/skills/fps-controller/resource.yaml +4 -1
  190. package/library/skills/game-feel/SKILL.md +1 -1
  191. package/library/skills/gameskill/SKILL.md +1 -1
  192. package/library/skills/gdscript-patterns/SKILL.md +1 -1
  193. package/library/skills/generate-motion/SKILL.md +9 -9
  194. package/library/skills/generate-motion/resource.yaml +2 -1
  195. package/library/skills/godogen-visual-proof-game-generation/SKILL.md +1 -1
  196. package/library/skills/headless-scripting/SKILL.md +1 -1
  197. package/library/skills/in-game-purchases/SKILL.md +254 -0
  198. package/library/skills/in-game-purchases/resource.yaml +29 -0
  199. package/library/skills/instantiate-asset-pack/SKILL.md +1 -1
  200. package/library/skills/investigating-bugs/SKILL.md +1 -1
  201. package/library/skills/lumera-single-image-scene-reconstruction/SKILL.md +1 -1
  202. package/library/skills/make-game/SKILL.md +25 -3
  203. package/library/skills/make-game/references/2d-platformer-polish.md +93 -0
  204. package/library/skills/make-game/references/2d-platformer.md +117 -0
  205. package/library/skills/make-game/references/2d-rpg-content.md +234 -0
  206. package/library/skills/make-game/references/2d-rpg.md +241 -0
  207. package/library/skills/make-game/references/2d-survivors-progression.md +201 -0
  208. package/library/skills/make-game/references/2d-survivors.md +237 -0
  209. package/library/skills/make-game/references/3d-fps-systems.md +216 -0
  210. package/library/skills/make-game/references/3d-fps.md +177 -0
  211. package/library/skills/make-game/references/3d-horror-systems.md +186 -0
  212. package/library/skills/make-game/references/3d-horror.md +201 -0
  213. package/library/skills/make-game/references/3d-third-person-systems.md +220 -0
  214. package/library/skills/make-game/references/3d-third-person.md +215 -0
  215. package/library/skills/make-game/resource.yaml +6 -1
  216. package/library/skills/multiplayer/SKILL.md +150 -0
  217. package/library/skills/multiplayer/resource.yaml +33 -0
  218. package/library/skills/multiplayer-movement/SKILL.md +510 -0
  219. package/library/skills/multiplayer-movement/files.md +501 -0
  220. package/library/skills/multiplayer-movement/resource.yaml +30 -0
  221. package/library/skills/multiplayer-project/SKILL.md +521 -0
  222. package/library/skills/multiplayer-project/files.md +448 -0
  223. package/library/skills/multiplayer-project/resource.yaml +36 -0
  224. package/library/skills/multiplayer-publish/SKILL.md +229 -0
  225. package/library/skills/multiplayer-publish/export-errors.md +89 -0
  226. package/library/skills/multiplayer-publish/resource.yaml +28 -0
  227. package/library/skills/multiplayer-state/SKILL.md +438 -0
  228. package/library/skills/multiplayer-state/files.md +592 -0
  229. package/library/skills/multiplayer-state/resource.yaml +35 -0
  230. package/library/skills/multiplayer-testing/SKILL.md +236 -0
  231. package/library/skills/multiplayer-testing/resource.yaml +39 -0
  232. package/library/skills/music-track/SKILL.md +1 -1
  233. package/library/skills/navigate-summer/SKILL.md +1 -1
  234. package/library/skills/new-project/SKILL.md +1 -1
  235. package/library/skills/organic-model/SKILL.md +1 -1
  236. package/library/skills/pixel-art/SKILL.md +1 -1
  237. package/library/skills/play/SKILL.md +1 -1
  238. package/library/skills/playtesting-a-feature/SKILL.md +1 -1
  239. package/library/skills/procedural-animation/SKILL.md +2 -2
  240. package/library/skills/procedural-animation/resource.yaml +1 -1
  241. package/library/skills/prop-model/SKILL.md +1 -1
  242. package/library/skills/psx-retro-rendering/SKILL.md +1 -1
  243. package/library/skills/realtime-wet-surfaces/SKILL.md +2 -2
  244. package/library/skills/remote-deploy/SKILL.md +1 -1
  245. package/library/skills/retarget/SKILL.md +1 -1
  246. package/library/skills/running-in-the-cloud/SKILL.md +1 -1
  247. package/library/skills/save-load/SKILL.md +156 -0
  248. package/library/skills/save-load/resource.yaml +27 -0
  249. package/library/skills/scene-composition/SKILL.md +1 -1
  250. package/library/skills/scene-hierarchy-design/SKILL.md +1 -1
  251. package/library/skills/scene-scripting/SKILL.md +1 -1
  252. package/library/skills/scene-to-level/SKILL.md +1 -1
  253. package/library/skills/skill-create/SKILL.md +1 -1
  254. package/library/skills/skill-improve/SKILL.md +1 -1
  255. package/library/skills/skill-test/SKILL.md +1 -1
  256. package/library/skills/skintokens-auto-rigging/SKILL.md +1 -1
  257. package/library/skills/skybox-panorama/SKILL.md +1 -1
  258. package/library/skills/sound-effect/SKILL.md +1 -1
  259. package/library/skills/spatial-placement/SKILL.md +134 -3
  260. package/library/skills/spatial-placement/references/kit-placement-tools.md +364 -0
  261. package/library/skills/spatial-placement/resource.yaml +16 -2
  262. package/library/skills/sprite-sheet/SKILL.md +1 -1
  263. package/library/skills/summer-analytics/SKILL.md +147 -0
  264. package/library/skills/summer-analytics/resource.yaml +28 -0
  265. package/library/skills/summer-friends/SKILL.md +226 -0
  266. package/library/skills/summer-friends/resource.yaml +28 -0
  267. package/library/skills/summer-leaderboards/SKILL.md +231 -0
  268. package/library/skills/summer-leaderboards/resource.yaml +30 -0
  269. package/library/skills/summer-match-results/SKILL.md +286 -0
  270. package/library/skills/summer-match-results/resource.yaml +32 -0
  271. package/library/skills/summer-matchmaking/SKILL.md +301 -0
  272. package/library/skills/summer-matchmaking/resource.yaml +32 -0
  273. package/library/skills/summer-parties/SKILL.md +253 -0
  274. package/library/skills/summer-parties/resource.yaml +30 -0
  275. package/library/skills/summer-player-data/SKILL.md +322 -0
  276. package/library/skills/summer-player-data/resource.yaml +30 -0
  277. package/library/skills/summer-store/SKILL.md +271 -0
  278. package/library/skills/summer-store/resource.yaml +29 -0
  279. package/library/skills/summer-world-chat/SKILL.md +195 -0
  280. package/library/skills/summer-world-chat/resource.yaml +30 -0
  281. package/library/skills/summer-world-saves/SKILL.md +284 -0
  282. package/library/skills/summer-world-saves/resource.yaml +29 -0
  283. package/library/skills/text-to-motion/SKILL.md +99 -0
  284. package/library/skills/text-to-motion/prompts-and-controls.md +34 -0
  285. package/library/skills/text-to-motion/resource.yaml +41 -0
  286. package/library/skills/text-to-motion/rigging-for-text-motion.md +54 -0
  287. package/library/skills/tileable-texture/SKILL.md +1 -1
  288. package/library/skills/trailer-shot/SKILL.md +1 -1
  289. package/library/skills/tune-performance/SKILL.md +1 -1
  290. package/library/skills/ui-basics/SKILL.md +10 -1
  291. package/library/skills/ui-graphics/SKILL.md +1 -1
  292. package/library/skills/use-widget-asset/SKILL.md +1 -1
  293. package/library/skills/using-summer/SKILL.md +2 -2
  294. package/library/skills/using-summer/resource.yaml +2 -2
  295. package/library/skills/vehicle-model/SKILL.md +1 -1
  296. package/library/skills/verification-before-completion/SKILL.md +1 -1
  297. package/library/skills/verifying-scenes/SKILL.md +76 -2
  298. package/library/skills/verifying-scenes/resource.yaml +13 -2
  299. package/library/skills/vfx-dissolve/SKILL.md +1 -1
  300. package/library/skills/vfx-fire/SKILL.md +1 -1
  301. package/library/skills/vfx-hit-spark/SKILL.md +1 -1
  302. package/library/skills/vfx-lightning/SKILL.md +1 -1
  303. package/library/skills/vfx-magic-glow/SKILL.md +1 -1
  304. package/library/skills/vfx-muzzle-flash/SKILL.md +1 -1
  305. package/library/skills/vfx-smoke/SKILL.md +1 -1
  306. package/library/skills/vfx-water-ripple/SKILL.md +1 -1
  307. package/library/skills/voice-line/SKILL.md +1 -1
  308. package/library/skills/web-and-touch-input/SKILL.md +38 -0
  309. package/library/skills/web-and-touch-input/resource.yaml +28 -0
  310. package/library/skills/web-compatibility-look/SKILL.md +62 -0
  311. package/library/skills/web-compatibility-look/resource.yaml +30 -0
  312. package/library/skills/world-building-3d/SKILL.md +8 -1
  313. package/library/skills/world-building-3d/references/spatial-tools.md +1 -1
  314. package/library/skills/world-building-3d/resource.yaml +2 -1
  315. package/library/skills/writing-plans/SKILL.md +1 -1
  316. package/library/skills/writing-skills/SKILL.md +1 -1
  317. package/library/templates/3d-lan-multiplayer-starter/resource.yaml +2 -2
  318. package/library/tools/attach-to-surface/resource.yaml +142 -0
  319. package/library/tools/batch/resource.yaml +9 -3
  320. package/library/tools/connect-ports/resource.yaml +120 -0
  321. package/library/tools/connect-signal/resource.yaml +3 -2
  322. package/library/tools/debug-views/resource.yaml +94 -0
  323. package/library/tools/frame-nodes/resource.yaml +104 -0
  324. package/library/tools/frame-shot/resource.yaml +134 -0
  325. package/library/tools/generate-motion/resource.yaml +44 -9
  326. package/library/tools/get-board/resource.yaml +50 -0
  327. package/library/tools/get-project-context/resource.yaml +13 -2
  328. package/library/tools/grep/resource.yaml +77 -0
  329. package/library/tools/inspect-asset/resource.yaml +71 -0
  330. package/library/tools/inspect-node/resource.yaml +9 -2
  331. package/library/tools/inspect-resource/resource.yaml +16 -5
  332. package/library/tools/instantiate-scene/resource.yaml +27 -2
  333. package/library/tools/measure/resource.yaml +96 -0
  334. package/library/tools/place-adjacent/resource.yaml +113 -0
  335. package/library/tools/play/resource.yaml +15 -2
  336. package/library/tools/raycast/resource.yaml +101 -0
  337. package/library/tools/read-file/resource.yaml +36 -2
  338. package/library/tools/read-library/resource.yaml +2 -2
  339. package/library/tools/repeat-along/resource.yaml +129 -0
  340. package/library/tools/replace-node/resource.yaml +11 -5
  341. package/library/tools/scene-audit/resource.yaml +144 -0
  342. package/library/tools/screenshot/resource.yaml +13 -2
  343. package/library/tools/search-assets/resource.yaml +18 -0
  344. package/library/tools/shot-sheet/resource.yaml +110 -0
  345. package/library/tools/snap-to-surface/resource.yaml +4 -1
  346. package/library/tools/world-snapshot/resource.yaml +24 -3
  347. package/library/tools/zoom/resource.yaml +105 -0
  348. package/package.json +1 -1
  349. package/registry/generated/aliases.json +17 -3
  350. package/registry/generated/counts.json +3 -3
  351. package/registry/generated/gemini-extension.json +26 -4
  352. package/registry/generated/index.json +2154 -469
  353. package/registry/generated/marketplace.claude.json +2 -2
  354. package/registry/generated/plugin.claude.json +27 -5
  355. package/registry/generated/plugin.codex.json +28 -6
  356. package/registry/generated/plugin.cursor.json +27 -5
  357. package/registry/generated/plugin.factory.json +27 -5
  358. package/registry/generated/skills-index.md +36 -14
  359. package/registry/generated/skills-registry.json +421 -124
  360. package/registry/generated/templates-registry.json +2 -2
  361. package/library/skills/host-authoritative-state/SKILL.md +0 -332
  362. package/library/skills/host-authoritative-state/resource.yaml +0 -28
  363. package/library/skills/peer-to-peer-multiplayer/SKILL.md +0 -373
  364. package/library/skills/peer-to-peer-multiplayer/resource.yaml +0 -28
  365. package/library/skills/setup-multiplayer/SKILL.md +0 -336
  366. package/library/skills/setup-multiplayer/resource.yaml +0 -28
@@ -0,0 +1,1731 @@
1
+ /**
2
+ * Kit-placement tools, shared by both faces: the MCP tools
3
+ * (src/mcp/tools/placement-tools.ts) register these zod shapes and call these
4
+ * runners; `summer tool <slug>` (tool-dispatch.ts) parses with the same
5
+ * schemas and calls the same runners.
6
+ *
7
+ * summer_inspect_asset measure an asset file without adding it to a scene
8
+ * summer_place_adjacent put one node's bounds face against another's
9
+ * summer_attach_to_surface turn a piece's back onto a surface and seat it
10
+ * summer_repeat_along instance copies of a scene along a line
11
+ * summer_connect_ports meet one piece's port with another's
12
+ * summer_raycast cast an arbitrary ray (physics, then visual AABB)
13
+ * summer_measure gap/overlap per axis, or face coplanarity
14
+ *
15
+ * No engine op is new. Reads run the placement probe (placement-script.ts)
16
+ * through RunSceneScript with undo "none" and no checkpoint; mutations are the
17
+ * ordinary SetProp / SnapToSurface / InstantiateScene ops with the usual scene
18
+ * target, undo and final SaveScene. attach_to_surface checks its seat before
19
+ * the SaveScene and, when it refuses, sets the piece's original transform back
20
+ * instead of saving. Every result is compact (< 5 KB) and names its evidence:
21
+ * visual_aabb (visible GeometryInstance3D bounds, the definition
22
+ * AlignDistribute3D uses), physics (collider queries), or mesh_triangles.
23
+ */
24
+ import { z } from "zod";
25
+ import { missingEngineOpResult, resolveSingleOnlyOps } from "../capability-skew.js";
26
+ import { ToolInputError } from "../tool-errors.js";
27
+ import { asRecord } from "../util/json.js";
28
+ import { executeSceneMutation, sceneMutationOps } from "./engine-ops.js";
29
+ import { extractOpError, withOldEngineHint } from "./engine-receipt.js";
30
+ import { SNAP_TO_SURFACE_FALLBACK } from "./engine-fallbacks.js";
31
+ import { buildRunSceneScriptOp } from "./scene-script.js";
32
+ import { buildPlacementScript } from "./placement-script.js";
33
+ import { executePlacementBatch, summarizeTrace } from "./placement-batch.js";
34
+ import { AXIS_NAMES, WORLD_AXES, add, angleDegrees, axisIndex, basisInverse, basisMul, basisMulVec, basisDeterminant, basisScale, dot, isFiniteVec3, length, normalize, parseGodotTransform, rotationAbout, rotationBetween, rotationFromFrames, round, roundVec, sameAxisLine, scale, signedAxisVector, sub, toGodotTransform, toGodotVector3, xformCompose, xformFromArray, xformInverse, xformMulPoint, } from "./placement-math.js";
35
+ /** What a placement tool tells the model when the engine lacks RunSceneScript. */
36
+ export const PLACEMENT_PROBE_FALLBACK = "read bounds with summer_starcast (or summer_inspect_node + summer_inspect_resource) and place with summer_set_prop, then verify with summer_screenshot";
37
+ const SCENE_PATH_LIMIT_BYTES = 512;
38
+ const NODE_PATH_LIMIT_BYTES = 256;
39
+ const FIT_TARGET_BYTES = 4700;
40
+ const utf8Within = (limit) => (value) => Buffer.byteLength(value, "utf8") <= limit;
41
+ // Strict character sets for every string that names a node, port or file.
42
+ // They travel to the editor only as base64 data (placement-script.ts), but a
43
+ // name with quotes, $, backslashes or control characters is never a real kit
44
+ // node, so it is refused at the boundary with a clear message.
45
+ const NODE_PATH_CHARS = /^[\p{L}\p{N}_\- ./]+$/u;
46
+ const PORT_NAME_CHARS = /^[\p{L}\p{N}_\- /]+$/u;
47
+ const RES_PATH_CHARS = /^res:\/\/[\p{L}\p{N}_\- ./]+$/u;
48
+ const SAFE_NODE_PATH_MESSAGE = "node paths may use letters, digits, _, -, spaces, '.' and '/' only";
49
+ const SAFE_RES_PATH_MESSAGE = "res:// paths may use letters, digits, _, -, spaces, '.' and '/' only";
50
+ export function isSafeNodePath(value) {
51
+ return NODE_PATH_CHARS.test(value) && !value.includes("..");
52
+ }
53
+ export function isSafeResPath(value) {
54
+ return RES_PATH_CHARS.test(value) && !value.includes("..");
55
+ }
56
+ const scenePathSchema = z
57
+ .string()
58
+ .trim()
59
+ .min(1)
60
+ .max(SCENE_PATH_LIMIT_BYTES)
61
+ .refine(utf8Within(SCENE_PATH_LIMIT_BYTES), `scenePath must be at most ${SCENE_PATH_LIMIT_BYTES} UTF-8 bytes`)
62
+ .refine((value) => /^res:\/\/.+\.(tscn|scn)$/i.test(value), "scenePath must be an exact res:// .tscn or .scn path")
63
+ .refine(isSafeResPath, SAFE_RES_PATH_MESSAGE)
64
+ .describe("Exact scene to read or change, e.g. 'res://levels/street.tscn'. It must be open in the editor (any tab).");
65
+ const nodePathSchema = (what) => z
66
+ .string()
67
+ .trim()
68
+ .min(1)
69
+ .max(NODE_PATH_LIMIT_BYTES)
70
+ .refine(utf8Within(NODE_PATH_LIMIT_BYTES), `node paths must be at most ${NODE_PATH_LIMIT_BYTES} UTF-8 bytes`)
71
+ .refine(isSafeNodePath, SAFE_NODE_PATH_MESSAGE)
72
+ .describe(`${what}: exact node path relative to the scene root, e.g. './Facade/Wall_01'.`);
73
+ const finite = z.number().finite();
74
+ const vec3 = z.tuple([finite, finite, finite]);
75
+ const nonZeroVec3 = vec3.refine((v) => Math.hypot(v[0], v[1], v[2]) > 1e-6, "vector must be non-zero");
76
+ const signedAxis = z.enum(AXIS_NAMES);
77
+ const worldAxis = z.enum(WORLD_AXES);
78
+ const collisionMask = z
79
+ .number()
80
+ .int()
81
+ .min(0)
82
+ .max(0xffffffff)
83
+ .optional()
84
+ .default(0xffffffff)
85
+ .describe("Godot 3D physics layer mask for physics queries.");
86
+ const ASSET_PATH = /^res:\/\/.+\.(tscn|scn|glb|gltf|res|tres|mesh|obj)$/i;
87
+ const SCENE_ASSET_PATH = /^res:\/\/.+\.(tscn|scn|glb|gltf)$/i;
88
+ const NODE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_\- ]*$/;
89
+ // ---------------------------------------------------------------------------
90
+ // Probe plumbing
91
+ // ---------------------------------------------------------------------------
92
+ function fail(tool, failureReason, error, extra = {}) {
93
+ return { ok: false, tool, failure_reason: failureReason, error, ...extra };
94
+ }
95
+ function firstScriptError(entry) {
96
+ const lists = [entry.parse_errors, entry.errors];
97
+ for (const list of lists) {
98
+ if (!Array.isArray(list) || list.length === 0)
99
+ continue;
100
+ const first = asRecord(list[0]);
101
+ if (first) {
102
+ const message = first.message ?? first.error ?? first.text;
103
+ const line = first.line;
104
+ if (typeof message === "string")
105
+ return typeof line === "number" ? `line ${line}: ${message}` : message;
106
+ }
107
+ if (typeof list[0] === "string")
108
+ return list[0];
109
+ }
110
+ return undefined;
111
+ }
112
+ /**
113
+ * Run the placement probe and return its result dictionary, or a structured
114
+ * failure. Never mutates: undo "none", no checkpoint.
115
+ */
116
+ export async function runPlacementProbe(client, tool, args, seconds = 20) {
117
+ const missing = missingEngineOpResult(client, "RunSceneScript", PLACEMENT_PROBE_FALLBACK);
118
+ if (missing)
119
+ return { ...missing, tool };
120
+ const { op, timeoutMs } = buildRunSceneScriptOp({
121
+ source: buildPlacementScript(args),
122
+ max_seconds: seconds,
123
+ checkpoint: false,
124
+ undo: "none",
125
+ });
126
+ const receipt = await client.executeIdentityBoundOps([op], undefined, timeoutMs);
127
+ const hinted = asRecord(withOldEngineHint(receipt, "RunSceneScript", PLACEMENT_PROBE_FALLBACK));
128
+ if (hinted?.failure_reason === "engine_lacks_op") {
129
+ return fail(tool, "engine_lacks_op", String(hinted.error), { op: "RunSceneScript" });
130
+ }
131
+ const envelope = asRecord(receipt) ?? {};
132
+ const results = Array.isArray(envelope.results) ? envelope.results : [];
133
+ const entry = asRecord(results.find((item) => asRecord(item)?.op === "RunSceneScript") ?? results[0]);
134
+ const envelopeError = extractOpError(receipt);
135
+ if (!entry || entry.ok === false || envelopeError) {
136
+ const reason = typeof entry?.failure_reason === "string" ? entry.failure_reason : "probe_failed";
137
+ const scriptError = entry ? firstScriptError(entry) : undefined;
138
+ return fail(tool, "measurement_script_failed", `The placement probe (RunSceneScript) failed: ${String(entry?.error ?? envelopeError ?? "no result")}`, { engine_failure_reason: reason, ...(scriptError ? { script_error: scriptError.slice(0, 300) } : {}) });
139
+ }
140
+ const result = asRecord(entry.result);
141
+ if (!result) {
142
+ return fail(tool, "measurement_script_failed", "The placement probe ran but returned no result.");
143
+ }
144
+ if (result.ok !== true) {
145
+ return fail(tool, typeof result.failure_reason === "string" ? result.failure_reason : "measurement_failed", typeof result.error === "string" ? result.error : "The placement probe reported a failure.");
146
+ }
147
+ return result;
148
+ }
149
+ function bytes(value) {
150
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
151
+ }
152
+ /** Trim the named lists (in order) until the result fits; declare every trim. */
153
+ export function fitToBudget(result, listKeys, target = FIT_TARGET_BYTES) {
154
+ if (bytes(result) <= target)
155
+ return result;
156
+ const out = { ...result };
157
+ const truncated = {};
158
+ for (const key of listKeys) {
159
+ const original = out[key];
160
+ if (!Array.isArray(original))
161
+ continue;
162
+ const list = [...original];
163
+ while (list.length > 0 && bytes({ ...out, [key]: list, truncated: { ...truncated, [key]: {} } }) > target) {
164
+ list.pop();
165
+ }
166
+ if (list.length < original.length) {
167
+ out[key] = list;
168
+ truncated[key] = { shown: list.length, total: original.length };
169
+ }
170
+ if (bytes({ ...out, truncated }) <= target)
171
+ break;
172
+ }
173
+ out.truncated = truncated;
174
+ return out;
175
+ }
176
+ /** Engine mutation receipt -> null on success, or a compact failure. */
177
+ function mutationFailure(tool, receipt, extra = {}) {
178
+ const error = extractOpError(receipt);
179
+ if (!error)
180
+ return null;
181
+ let message = error;
182
+ let reason = "mutation_failed";
183
+ try {
184
+ const parsed = JSON.parse(error);
185
+ if (typeof parsed.error === "string")
186
+ message = parsed.error;
187
+ if (typeof parsed.failure_reason === "string")
188
+ reason = parsed.failure_reason;
189
+ }
190
+ catch {
191
+ // plain-text failure
192
+ }
193
+ return fail(tool, reason, message.slice(0, 600), extra);
194
+ }
195
+ function opEntry(receipt, op) {
196
+ const results = asRecord(receipt)?.results;
197
+ if (!Array.isArray(results))
198
+ return undefined;
199
+ return asRecord(results.find((item) => asRecord(item)?.op === op)) ?? undefined;
200
+ }
201
+ function parseBounds(record) {
202
+ const r = asRecord(record) ?? {};
203
+ return {
204
+ path: String(r.path ?? ""),
205
+ resolved: String(r.resolved ?? ""),
206
+ geometryCount: typeof r.geometry_count === "number" ? r.geometry_count : 0,
207
+ xform: xformFromArray(r.xform),
208
+ parent: xformFromArray(r.parent_xform),
209
+ position: (Array.isArray(r.position) ? r.position : [0, 0, 0]),
210
+ transformStr: typeof r.transform_str === "string" ? r.transform_str : undefined,
211
+ intervals: Array.isArray(r.intervals) ? r.intervals : undefined,
212
+ reach: typeof r.reach === "number" ? r.reach : undefined,
213
+ };
214
+ }
215
+ function dirsOf(read) {
216
+ return (Array.isArray(read.dirs) ? read.dirs : []);
217
+ }
218
+ /** "./Facade/Wall" and "Facade/Wall" name the same node. */
219
+ function stripDotSlash(path) {
220
+ return path.startsWith("./") ? path.slice(2) || "." : path;
221
+ }
222
+ /** Path b is inside path a (scene-root-relative, "." = root). */
223
+ function isInside(inner, outer) {
224
+ if (outer === ".")
225
+ return inner !== ".";
226
+ return inner.startsWith(`${outer}/`);
227
+ }
228
+ /** World delta -> new parent-local position. */
229
+ function localPositionAfter(bounds, worldDelta) {
230
+ const localDelta = basisMulVec(basisInverse(bounds.parent.basis), worldDelta);
231
+ return add(bounds.position, localDelta);
232
+ }
233
+ // ---------------------------------------------------------------------------
234
+ // summer_inspect_asset
235
+ // ---------------------------------------------------------------------------
236
+ export const inspectAssetArgsSchema = z.object({
237
+ path: z
238
+ .string()
239
+ .trim()
240
+ .min(1)
241
+ .max(SCENE_PATH_LIMIT_BYTES)
242
+ .refine((value) => ASSET_PATH.test(value), "path must be a res:// .tscn, .scn, .glb, .gltf, .res, .tres, .mesh or .obj file")
243
+ .refine(isSafeResPath, SAFE_RES_PATH_MESSAGE)
244
+ .describe("Asset to measure, e.g. 'res://kit/facade/wall_single_01.tscn'. It is loaded and instanced off-scene, never added to an open scene."),
245
+ maxTriangles: z
246
+ .number()
247
+ .int()
248
+ .min(100)
249
+ .max(300000)
250
+ .optional()
251
+ .default(60000)
252
+ .describe("Triangle budget (100-300000) for the face and open-loop analysis (AABBs and counts are always complete). summary.warnings says when the budget cut the analysis and which budget covers the whole mesh."),
253
+ detail: z
254
+ .enum(["summary", "full"])
255
+ .optional()
256
+ .default("summary")
257
+ .describe("summary (default): open_loops lists only port-like loops (radius over 2 cm with a partner loop facing another way, or the one opening of an end piece on its bounding-box face), or none. full: every open loop, including the outline of flat sheets. Every loop has a stable id ('+Y', '+Y#2') that summer_connect_ports accepts."),
258
+ });
259
+ /** Normals closer than this (cosine) are one plane direction. */
260
+ const PAIR_COS = 0.996;
261
+ /** Pairs smaller than this share of the largest pair are left out. */
262
+ const PAIR_MIN_AREA_FRACTION = 0.01;
263
+ /** A normal this close (cosine) to a signed axis is labelled with it. */
264
+ const AXIS_LABEL_COS = 0.9998;
265
+ const PORT_MIN_RADIUS = 0.02;
266
+ const PORT_RADIUS_RATIO = 0.7;
267
+ /** Partner loops must face more than 60 degrees apart. */
268
+ const PORT_PARTNER_COS = 0.5;
269
+ const SUMMARY_PLANE_COUNT = 6;
270
+ // ---- Stable open-loop ids --------------------------------------------------
271
+ //
272
+ // Ordered by radius, the loops of a gutter outlet could list internal seam
273
+ // loops tilted 30 degrees before its +Y socket, and joining "index 0" tilted
274
+ // the outlet. Ids come from where a loop faces and where it sits, which a
275
+ // radius cannot reorder.
276
+ /** Order of the facing groups in the stable order. */
277
+ const LOOP_FACING_ORDER = ["+X", "-X", "+Y", "-Y", "+Z", "-Z", "~X", "~Y", "~Z"];
278
+ /** Positions are compared in whole millimetres, radii in tenths of one. */
279
+ const LOOP_POSITION_STEP = 0.001;
280
+ const LOOP_RADIUS_STEP = 0.0001;
281
+ /** A stable open-loop id: "+Y", "+Y#2" (the second loop facing +Y), "~Z". */
282
+ export const LOOP_ID_PATTERN = /^([+\-~])([xyz])(?:#([1-9][0-9]{0,2}))?$/i;
283
+ function loopFacing(loop) {
284
+ const d = loop.direction;
285
+ let axis = 0;
286
+ if (Math.abs(d[1]) > Math.abs(d[axis]))
287
+ axis = 1;
288
+ if (Math.abs(d[2]) > Math.abs(d[axis]))
289
+ axis = 2;
290
+ const name = "XYZ"[axis];
291
+ if (loop.direction_ambiguous || !(Math.abs(d[axis]) > 0))
292
+ return { facing: `~${name}`, axis, sign: 1 };
293
+ const sign = d[axis] >= 0 ? 1 : -1;
294
+ return { facing: `${sign > 0 ? "+" : "-"}${name}`, axis, sign };
295
+ }
296
+ function quantized(value, step) {
297
+ const q = Math.round(value / step);
298
+ return Object.is(q, -0) ? 0 : q;
299
+ }
300
+ /**
301
+ * The stable order and ids of a piece's open loops. Sort key: the signed piece
302
+ * axis the outward direction is nearest (+X, -X, +Y, -Y, +Z, -Z, then the
303
+ * undecided ~X, ~Y, ~Z); then the outermost along that axis first; then centre
304
+ * x, y, z; radius (largest first) and vertex count only break exact ties. All
305
+ * in the piece's own frame, so the ids are the same in summer_inspect_asset
306
+ * and summer_connect_ports and do not change with the piece's pose. The first
307
+ * loop facing +Y is "+Y", the next "+Y#2". Exported for tests.
308
+ */
309
+ export function orderLoops(loops) {
310
+ const keyed = loops.map((loop) => {
311
+ const { facing, axis, sign } = loopFacing(loop);
312
+ const c = loop.center;
313
+ const key = [
314
+ LOOP_FACING_ORDER.indexOf(facing),
315
+ -quantized(sign * c[axis], LOOP_POSITION_STEP),
316
+ quantized(c[0], LOOP_POSITION_STEP),
317
+ quantized(c[1], LOOP_POSITION_STEP),
318
+ quantized(c[2], LOOP_POSITION_STEP),
319
+ -quantized(loop.radius, LOOP_RADIUS_STEP),
320
+ -(loop.vertices ?? 0),
321
+ loop.mesh ?? 0,
322
+ ];
323
+ return { loop, facing, key };
324
+ });
325
+ keyed.sort((a, b) => {
326
+ for (let k = 0; k < a.key.length; k++) {
327
+ if (a.key[k] !== b.key[k])
328
+ return a.key[k] - b.key[k];
329
+ }
330
+ return 0;
331
+ });
332
+ const seen = new Map();
333
+ return keyed.map(({ loop, facing }, index) => {
334
+ const n = (seen.get(facing) ?? 0) + 1;
335
+ seen.set(facing, n);
336
+ return { ...loop, index, id: n === 1 ? facing : `${facing}#${n}`, facing };
337
+ });
338
+ }
339
+ /** "+y", "+Y" and "+Y#1" all name the loop "+Y"; numbers are loop indices;
340
+ * any other string is a Marker3D (or Node3D) name. */
341
+ export function parsePortSelector(port) {
342
+ if (typeof port === "number")
343
+ return { kind: "loop_index", index: port };
344
+ const match = LOOP_ID_PATTERN.exec(port.trim());
345
+ if (!match)
346
+ return { kind: "marker", name: port };
347
+ const facing = `${match[1]}${match[2].toUpperCase()}`;
348
+ const n = match[3] ? Number(match[3]) : 1;
349
+ return { kind: "loop_id", id: n === 1 ? facing : `${facing}#${n}` };
350
+ }
351
+ function findLoop(loops, selector) {
352
+ if (selector.kind === "loop_index")
353
+ return loops[selector.index];
354
+ if (selector.kind === "loop_id")
355
+ return loops.find((loop) => loop.id === selector.id);
356
+ return undefined;
357
+ }
358
+ /** A loop as the model reads it (rounded; id first). */
359
+ function loopOut(loop) {
360
+ return {
361
+ id: loop.id,
362
+ index: loop.index,
363
+ ...(loop.mesh !== undefined ? { mesh: loop.mesh } : {}),
364
+ center: roundVec(loop.center),
365
+ direction: roundVec(loop.direction, 4),
366
+ radius: round(loop.radius),
367
+ ...(loop.vertices !== undefined ? { vertices: loop.vertices } : {}),
368
+ ...(loop.max_dev !== undefined ? { max_dev: round(loop.max_dev) } : {}),
369
+ ...(loop.direction_ambiguous ? { direction_ambiguous: true } : {}),
370
+ };
371
+ }
372
+ function axisLabel(normal) {
373
+ for (const axis of AXIS_NAMES) {
374
+ if (dot(normal, signedAxisVector(axis)) >= AXIS_LABEL_COS)
375
+ return axis;
376
+ }
377
+ return undefined;
378
+ }
379
+ function planeOut(plane) {
380
+ const label = axisLabel(plane.normal);
381
+ return {
382
+ ...(label ? { axis: label } : {}),
383
+ normal: roundVec(plane.normal, 4),
384
+ offset: round(plane.offset),
385
+ area: round(plane.area, 4),
386
+ ...(typeof plane.cull_back === "number" ? { one_sided: plane.cull_back >= 0.5 } : {}),
387
+ };
388
+ }
389
+ /**
390
+ * The two largest pairs of opposite planes, for reading facing at a glance:
391
+ * per plane direction the largest plane and the largest one facing the other
392
+ * way (null for a single sheet), ranked by their combined area; pairs under 1%
393
+ * of the largest pair's area are left out. Exported for tests.
394
+ */
395
+ export function planePairs(planes, limit = 2) {
396
+ const groups = [];
397
+ for (const plane of [...planes].sort((a, b) => b.area - a.area)) {
398
+ const group = groups.find((g) => Math.abs(dot(g.larger.normal, plane.normal)) >= PAIR_COS);
399
+ if (!group) {
400
+ groups.push({ larger: plane });
401
+ }
402
+ else if (!group.opposite && dot(group.larger.normal, plane.normal) < 0) {
403
+ group.opposite = plane;
404
+ }
405
+ }
406
+ const area = (g) => g.larger.area + (g.opposite?.area ?? 0);
407
+ const ranked = groups.sort((a, b) => area(b) - area(a));
408
+ const floor = ranked.length ? area(ranked[0]) * PAIR_MIN_AREA_FRACTION : 0;
409
+ return ranked
410
+ .filter((g) => area(g) >= floor)
411
+ .slice(0, limit)
412
+ .map((g) => ({
413
+ larger: planeOut(g.larger),
414
+ opposite: g.opposite ? planeOut(g.opposite) : null,
415
+ ...(g.opposite ? { separation: round(g.larger.offset + g.opposite.offset) } : {}),
416
+ }));
417
+ }
418
+ /**
419
+ * Loops that look like pipe or duct ends: radius over 2 cm, a decided
420
+ * direction, and at least one partner loop of similar radius facing more than
421
+ * 60 degrees away (the other end of a straight run or a bend). The outline of
422
+ * a flat sheet and its holes all face one way and never qualify. Exported for
423
+ * tests.
424
+ */
425
+ export function portLikeLoops(loops, aabb) {
426
+ const candidates = loops.filter((loop) => loop.radius > PORT_MIN_RADIUS && !loop.direction_ambiguous);
427
+ return candidates.filter((loop) => candidates.some((other) => other !== loop &&
428
+ Math.min(other.radius, loop.radius) / Math.max(other.radius, loop.radius) >= PORT_RADIUS_RATIO &&
429
+ dot(normalize(other.direction), normalize(loop.direction)) < PORT_PARTNER_COS) || (aabb !== undefined && isEndSocket(loop, candidates, aabb)));
430
+ }
431
+ /** Within about 10 degrees of a piece axis. */
432
+ const PORT_END_COS = 0.985;
433
+ /** Which bounding-box face a loop opens on, if any: its direction within about
434
+ * 10 degrees of a piece axis and its centre on the box face that way (1 cm,
435
+ * or 2% of the depth). */
436
+ function endFace(loop, aabb) {
437
+ const center = loop.center;
438
+ if (!isFiniteVec3(center))
439
+ return undefined;
440
+ const d = normalize(loop.direction);
441
+ let axis = 0;
442
+ if (Math.abs(d[1]) > Math.abs(d[axis]))
443
+ axis = 1;
444
+ if (Math.abs(d[2]) > Math.abs(d[axis]))
445
+ axis = 2;
446
+ if (Math.abs(d[axis]) < PORT_END_COS)
447
+ return undefined;
448
+ const sign = d[axis] > 0 ? 1 : -1;
449
+ const depth = aabb.max[axis] - aabb.min[axis];
450
+ const tolerance = Math.max(0.01, depth * 0.02);
451
+ const face = sign > 0 ? aabb.max[axis] : aabb.min[axis];
452
+ return Math.abs(center[axis] - face) <= tolerance ? { axis, sign, tolerance } : undefined;
453
+ }
454
+ /**
455
+ * The one opening of an end piece (a gutter outlet's socket, a funnel's
456
+ * mouth, a cap's collar) has no partner loop, so the partner rule misses it.
457
+ * It counts when it opens on the bounding-box face it points through, the
458
+ * piece reaches at least two radii behind it (a hole in a thin sheet does
459
+ * not), and no other loop opens on that face except ones concentric with it
460
+ * (the inner wall of the same opening).
461
+ */
462
+ function isEndSocket(loop, candidates, aabb) {
463
+ const face = endFace(loop, aabb);
464
+ if (!face)
465
+ return false;
466
+ const depth = aabb.max[face.axis] - aabb.min[face.axis];
467
+ if (depth < 2 * loop.radius)
468
+ return false;
469
+ const center = loop.center;
470
+ return !candidates.some((other) => {
471
+ if (other === loop)
472
+ return false;
473
+ const otherFace = endFace(other, aabb);
474
+ if (!otherFace || otherFace.axis !== face.axis || otherFace.sign !== face.sign)
475
+ return false;
476
+ return length(sub(other.center, center)) > face.tolerance;
477
+ });
478
+ }
479
+ function inspectSummary(probe, planes, portLike) {
480
+ const analysis = asRecord(probe.analysis) ?? {};
481
+ const triangles = typeof probe.triangles === "number" ? probe.triangles : 0;
482
+ const warnings = [];
483
+ if (analysis.truncated === true) {
484
+ const analyzed = typeof analysis.triangles_analyzed === "number" ? analysis.triangles_analyzed : 0;
485
+ warnings.push(`the triangle budget cut the analysis (${analyzed} of ${triangles} triangles): planes and open loops are incomplete; pass maxTriangles ${Math.min(300000, Math.max(triangles, 100))} for the whole mesh`);
486
+ }
487
+ return {
488
+ aabb: probe.aabb ?? null,
489
+ origin: probe.origin ?? null,
490
+ plane_pairs: planePairs(planes),
491
+ port_like_loops: portLike.map((loop) => loop.id),
492
+ triangles,
493
+ mesh_count: probe.mesh_count ?? 0,
494
+ anchor_count: probe.anchor_count ?? 0,
495
+ collision_count: probe.collision_count ?? 0,
496
+ ...(warnings.length ? { warnings } : {}),
497
+ };
498
+ }
499
+ export async function inspectAsset(client, args) {
500
+ const tool = "summer_inspect_asset";
501
+ const probe = await runPlacementProbe(client, tool, {
502
+ cmd: "inspect_asset",
503
+ path: args.path,
504
+ max_triangles: args.maxTriangles,
505
+ }, 30);
506
+ if (!probe.ok)
507
+ return probe;
508
+ const planes = (Array.isArray(probe.planes) ? probe.planes : []);
509
+ const loops = orderLoops((Array.isArray(probe.open_loops) ? probe.open_loops : []));
510
+ const box = asRecord(probe.aabb);
511
+ const aabb = box && isFiniteVec3(box.min) && isFiniteVec3(box.max) ? { min: box.min, max: box.max } : undefined;
512
+ const portLike = portLikeLoops(loops, aabb);
513
+ const { ok: _ok, planes: _planes, open_loops: _loops, ...rest } = probe;
514
+ const listed = (args.detail === "full" ? loops : portLike).map(loopOut);
515
+ const result = {
516
+ ok: true,
517
+ tool,
518
+ detail: args.detail,
519
+ summary: inspectSummary(probe, planes, portLike),
520
+ ...rest,
521
+ planes: planes.slice(0, SUMMARY_PLANE_COUNT).map((plane) => ({ ...plane, ...(typeof plane.cull_back === "number" ? { one_sided: plane.cull_back >= 0.5 } : {}) })),
522
+ open_loops: listed,
523
+ ...(args.detail === "full" ? {} : { open_loops_listed: "port_like", open_loops_omitted: loops.length - listed.length }),
524
+ evidence: "mesh_triangles",
525
+ };
526
+ return fitToBudget(result, ["meshes", "collision", "anchors", "open_loops", "planes"]);
527
+ }
528
+ // ---------------------------------------------------------------------------
529
+ // summer_measure
530
+ // ---------------------------------------------------------------------------
531
+ export const measureArgsSchema = z.object({
532
+ scenePath: scenePathSchema,
533
+ mode: z
534
+ .enum(["pair", "plane"])
535
+ .optional()
536
+ .default("pair")
537
+ .describe("pair: gap or overlap per axis between nodes a and b. plane: whether one face of every node in `nodes` lies on one plane."),
538
+ a: nodePathSchema("pair mode, first node").optional(),
539
+ b: nodePathSchema("pair mode, second node").optional(),
540
+ axis: worldAxis.optional().describe("pair mode: report only this axis (x, y or z of the chosen space)."),
541
+ nodes: z
542
+ .array(nodePathSchema("plane mode node"))
543
+ .min(2)
544
+ .max(32)
545
+ .optional()
546
+ .describe("plane mode: 2-32 nodes whose face is checked, e.g. every module of one facade line."),
547
+ face: signedAxis
548
+ .optional()
549
+ .describe("plane mode: which face, e.g. '+z' for the face pointing along +z (a facade front that looks toward +z)."),
550
+ space: z
551
+ .enum(["world", "local"])
552
+ .optional()
553
+ .default("world")
554
+ .describe("world: world axes. local: the axes of a (pair) or nodes[0] (plane), for rotated facades."),
555
+ tolerance: z
556
+ .number()
557
+ .min(0)
558
+ .max(1)
559
+ .optional()
560
+ .default(0.005)
561
+ .describe("Distance in scene units treated as touching (pair) or coplanar (plane). Default 5 mm."),
562
+ });
563
+ export async function measure(client, args) {
564
+ const tool = "summer_measure";
565
+ if (args.mode === "plane") {
566
+ if (!args.nodes || !args.face) {
567
+ throw new ToolInputError("plane mode needs nodes (2-32 paths) and face (e.g. '+z').");
568
+ }
569
+ return measurePlane(client, tool, args, args.nodes, args.face);
570
+ }
571
+ if (!args.a || !args.b)
572
+ throw new ToolInputError("pair mode needs a and b.");
573
+ if (args.a === args.b)
574
+ throw new ToolInputError("a and b must be different nodes.");
575
+ const read = await runPlacementProbe(client, tool, {
576
+ cmd: "bounds",
577
+ scene_path: args.scenePath,
578
+ space_node: args.space === "local" ? args.a : "",
579
+ nodes: [{ path: args.a }, { path: args.b }],
580
+ });
581
+ if (!read.ok)
582
+ return read;
583
+ const nodes = read.nodes.map(parseBounds);
584
+ const [a, b] = [nodes[0], nodes[1]];
585
+ for (const node of [a, b]) {
586
+ if (!node.intervals) {
587
+ return fail(tool, "no_visual_bounds", `${node.path} has no visible GeometryInstance3D bounds to measure.`);
588
+ }
589
+ }
590
+ const names = WORLD_AXES;
591
+ const axes = {};
592
+ const seps = [];
593
+ for (let k = 0; k < 3; k++) {
594
+ const [amin, amax] = a.intervals[k];
595
+ const [bmin, bmax] = b.intervals[k];
596
+ const sep = Math.max(bmin - amax, amin - bmax);
597
+ seps.push(sep);
598
+ if (args.axis && names[k] !== args.axis)
599
+ continue;
600
+ axes[names[k]] = {
601
+ gap: round(sep),
602
+ relation: sep > args.tolerance ? "gap" : sep < -args.tolerance ? "overlap" : "touching",
603
+ a: [round(amin), round(amax)],
604
+ b: [round(bmin), round(bmax)],
605
+ delta_min: round(bmin - amin),
606
+ delta_max: round(bmax - amax),
607
+ delta_center: round((bmin + bmax) / 2 - (amin + amax) / 2),
608
+ };
609
+ }
610
+ return {
611
+ ok: true,
612
+ tool,
613
+ mode: "pair",
614
+ evidence: "visual_aabb",
615
+ space: read.space,
616
+ ...(read.space === "local" ? { dirs: dirsOf(read).map((v) => roundVec(v, 4)) } : {}),
617
+ a: a.resolved,
618
+ b: b.resolved,
619
+ tolerance: args.tolerance,
620
+ axes,
621
+ boxes_overlap: seps.every((sep) => sep < -args.tolerance),
622
+ note: "gap > 0 is clearance, gap < 0 is overlap depth; delta_* are b minus a. Bounds are visible-mesh AABBs (projected corners in local space), not triangle contact.",
623
+ };
624
+ }
625
+ async function measurePlane(client, tool, args, nodes, face) {
626
+ if (new Set(nodes).size !== nodes.length)
627
+ throw new ToolInputError("nodes must not contain duplicates.");
628
+ const read = await runPlacementProbe(client, tool, {
629
+ cmd: "bounds",
630
+ scene_path: args.scenePath,
631
+ space_node: args.space === "local" ? nodes[0] : "",
632
+ nodes: nodes.map((path) => ({ path })),
633
+ });
634
+ if (!read.ok)
635
+ return read;
636
+ const k = axisIndex(face[1]);
637
+ const positive = face[0] === "+";
638
+ const parsed = read.nodes.map(parseBounds);
639
+ const measured = parsed
640
+ .filter((node) => node.intervals)
641
+ .map((node) => ({ path: node.resolved, coord: positive ? node.intervals[k][1] : node.intervals[k][0] }));
642
+ const missing = parsed.filter((node) => !node.intervals).map((node) => node.resolved);
643
+ if (measured.length < 2) {
644
+ return fail(tool, "no_visual_bounds", "Fewer than two of the nodes have visible geometry to measure.", { no_geometry: missing });
645
+ }
646
+ const sorted = measured.map((entry) => entry.coord).sort((x, y) => x - y);
647
+ const mid = sorted.length / 2;
648
+ const median = sorted.length % 2 ? sorted[Math.floor(mid)] : (sorted[mid - 1] + sorted[mid]) / 2;
649
+ const spread = sorted[sorted.length - 1] - sorted[0];
650
+ const rows = measured
651
+ .map((entry) => {
652
+ const deviation = entry.coord - median;
653
+ const outward = positive ? deviation : -deviation;
654
+ return {
655
+ path: entry.path,
656
+ face: round(entry.coord),
657
+ deviation: round(deviation),
658
+ ...(Math.abs(deviation) > args.tolerance ? { off_plane: outward > 0 ? "proud" : "recessed" } : {}),
659
+ };
660
+ })
661
+ .sort((x, y) => Math.abs(y.deviation) - Math.abs(x.deviation));
662
+ const result = {
663
+ ok: true,
664
+ tool,
665
+ mode: "plane",
666
+ evidence: "visual_aabb",
667
+ face,
668
+ space: read.space,
669
+ ...(read.space === "local" ? { dirs: dirsOf(read).map((v) => roundVec(v, 4)) } : {}),
670
+ tolerance: args.tolerance,
671
+ coplanar: spread <= args.tolerance,
672
+ plane: round(median),
673
+ spread: round(spread),
674
+ off_plane_count: rows.filter((row) => "off_plane" in row).length,
675
+ nodes: rows,
676
+ ...(missing.length ? { no_geometry: missing } : {}),
677
+ note: "face is the node's extreme visible-mesh coordinate along the face axis; proud = sticks out past the median plane in the face's direction.",
678
+ };
679
+ return fitToBudget(result, ["nodes"]);
680
+ }
681
+ // ---------------------------------------------------------------------------
682
+ // summer_raycast
683
+ // ---------------------------------------------------------------------------
684
+ export const raycastArgsSchema = z.object({
685
+ scenePath: scenePathSchema,
686
+ origin: vec3.describe("Ray start in scene (world) space [x, y, z]. Nothing needs to be placed there."),
687
+ direction: nonZeroVec3.describe("Ray direction [x, y, z]; normalized for you."),
688
+ maxDistance: z.number().positive().max(10000).optional().default(100).describe("Maximum ray length in scene units."),
689
+ collisionMask,
690
+ collideWithAreas: z.boolean().optional().default(false).describe("Also hit Area3D volumes (physics only)."),
691
+ evidence: z
692
+ .enum(["auto", "physics", "visual_aabb"])
693
+ .optional()
694
+ .default("auto")
695
+ .describe("auto: physics first, visual AABB fallback when physics finds nothing (declared). physics or visual_aabb: that channel only."),
696
+ exclude: z
697
+ .array(nodePathSchema("node to ignore"))
698
+ .max(16)
699
+ .optional()
700
+ .describe("Nodes (with their descendants) the ray ignores, e.g. the piece you are about to move."),
701
+ });
702
+ function parseHit(value) {
703
+ const r = asRecord(value);
704
+ if (!r)
705
+ return undefined;
706
+ return {
707
+ path: String(r.path ?? ""),
708
+ point: r.point,
709
+ normal: r.normal,
710
+ distance: Number(r.distance),
711
+ };
712
+ }
713
+ function hitOut(hit) {
714
+ return { path: hit.path, point: roundVec(hit.point), normal: roundVec(hit.normal, 4), distance: round(hit.distance) };
715
+ }
716
+ /** physics first; the visual AABB hit only when physics has none (declared). */
717
+ function chooseHit(read, evidence) {
718
+ const physics = parseHit(read.physics);
719
+ const visual = parseHit(read.visual);
720
+ const warnings = [];
721
+ if (typeof read.visual_origin_inside === "number" && read.visual_origin_inside > 0) {
722
+ warnings.push(`ray_origin_inside_${read.visual_origin_inside}_visual_bounds_ignored`);
723
+ }
724
+ if (read.visual_truncated === true)
725
+ warnings.push("visual_scan_truncated_at_20000_nodes");
726
+ if (evidence === "visual_aabb")
727
+ return { hit: visual, evidence: visual ? "visual_aabb" : undefined, warnings };
728
+ if (physics) {
729
+ const nearerVisual = evidence === "auto" && visual && visual.distance < physics.distance - 0.01 ? visual : undefined;
730
+ return { hit: physics, evidence: "physics", nearerVisual, warnings };
731
+ }
732
+ if (evidence === "physics")
733
+ return { warnings };
734
+ if (visual) {
735
+ const why = read.physics_available === true ? "physics_ray_hit_nothing" : `physics_unavailable_${String(read.physics_unavailable_reason || "unknown")}`;
736
+ return { hit: visual, evidence: "visual_aabb", fallback: why, warnings };
737
+ }
738
+ return { warnings };
739
+ }
740
+ export async function raycast(client, args) {
741
+ const tool = "summer_raycast";
742
+ const read = await runPlacementProbe(client, tool, {
743
+ cmd: "raycast",
744
+ scene_path: args.scenePath,
745
+ origin: args.origin,
746
+ direction: args.direction,
747
+ max_distance: args.maxDistance,
748
+ collision_mask: args.collisionMask,
749
+ collide_with_areas: args.collideWithAreas,
750
+ evidence: args.evidence,
751
+ exclude: args.exclude ?? [],
752
+ });
753
+ if (!read.ok)
754
+ return read;
755
+ if (args.evidence === "physics" && read.physics_available !== true) {
756
+ return fail(tool, "physics_unavailable", `Physics ray unavailable: ${String(read.physics_unavailable_reason ?? "unknown")}. The scene must be the active editor tab for physics queries; use evidence 'auto' or 'visual_aabb', or open the scene with summer_open_scene.`);
757
+ }
758
+ const chosen = chooseHit(read, args.evidence);
759
+ return {
760
+ ok: true,
761
+ tool,
762
+ hit: Boolean(chosen.hit),
763
+ ...(chosen.hit ? { evidence: chosen.evidence, ...hitOut(chosen.hit) } : {}),
764
+ ...(chosen.fallback ? { fallback: true, fallback_reason: chosen.fallback } : {}),
765
+ ...(chosen.nearerVisual ? { nearer_visual_only: hitOut(chosen.nearerVisual) } : {}),
766
+ origin: roundVec(args.origin),
767
+ direction: roundVec(normalize(args.direction), 4),
768
+ physics_available: read.physics_available === true,
769
+ ...(read.physics_available !== true ? { physics_unavailable_reason: read.physics_unavailable_reason } : {}),
770
+ warnings: chosen.warnings,
771
+ };
772
+ }
773
+ // ---------------------------------------------------------------------------
774
+ // summer_place_adjacent
775
+ // ---------------------------------------------------------------------------
776
+ const alignMode = z.enum(["min", "center", "max", "none"]);
777
+ export const placeAdjacentArgsSchema = z.object({
778
+ scenePath: scenePathSchema,
779
+ subject: nodePathSchema("Node to move"),
780
+ reference: nodePathSchema("Node that stays put"),
781
+ axis: worldAxis.describe("Axis along which the two pieces meet (x, y or z of the chosen space)."),
782
+ side: z
783
+ .enum(["min", "max"])
784
+ .describe("max: subject goes on the reference's +axis side (its min face against the reference's max face). min: the -axis side."),
785
+ gap: z
786
+ .number()
787
+ .finite()
788
+ .min(-100)
789
+ .max(1000)
790
+ .optional()
791
+ .default(0)
792
+ .describe("Distance between the two faces along axis. 0 = flush; negative = overlap (inset)."),
793
+ alignOtherAxes: z
794
+ .union([
795
+ alignMode,
796
+ z.object({ x: alignMode.optional(), y: alignMode.optional(), z: alignMode.optional() }),
797
+ ])
798
+ .optional()
799
+ .default("none")
800
+ .describe("How to line up the other two axes: min, center, max or none (keep). One mode for both, or per axis, e.g. {y: 'min', z: 'max'} for same base height and one front plane."),
801
+ space: z
802
+ .enum(["world", "local"])
803
+ .optional()
804
+ .default("world")
805
+ .describe("world: world axes. local: the reference's own axes, for a rotated facade."),
806
+ });
807
+ function alignFor(args, axis) {
808
+ const spec = args.alignOtherAxes;
809
+ if (typeof spec === "string")
810
+ return spec;
811
+ return spec[axis] ?? "none";
812
+ }
813
+ /** Per-axis translation that puts subject against reference. Exported for tests. */
814
+ export function adjacentDeltas(subject, reference, axis, side, gap, align) {
815
+ const main = axisIndex(axis);
816
+ const out = [0, 0, 0];
817
+ for (let k = 0; k < 3; k++) {
818
+ const [smin, smax] = subject[k];
819
+ const [rmin, rmax] = reference[k];
820
+ if (k === main) {
821
+ out[k] = side === "max" ? rmax + gap - smin : rmin - gap - smax;
822
+ continue;
823
+ }
824
+ const mode = align(WORLD_AXES[k]);
825
+ out[k] =
826
+ mode === "min" ? rmin - smin : mode === "max" ? rmax - smax : mode === "center" ? (rmin + rmax - smin - smax) / 2 : 0;
827
+ }
828
+ return out;
829
+ }
830
+ export async function placeAdjacent(client, args) {
831
+ const tool = "summer_place_adjacent";
832
+ if (args.subject === args.reference)
833
+ throw new ToolInputError("subject and reference must be different nodes.");
834
+ const typed = args.alignOtherAxes;
835
+ if (typeof typed === "object" && typed[args.axis] !== undefined) {
836
+ throw new ToolInputError(`alignOtherAxes.${args.axis} is the placement axis itself; set gap and side instead.`);
837
+ }
838
+ const readArgs = {
839
+ cmd: "bounds",
840
+ scene_path: args.scenePath,
841
+ space_node: args.space === "local" ? args.reference : "",
842
+ nodes: [{ path: args.subject }, { path: args.reference, exclude: [args.subject] }],
843
+ };
844
+ const read = await runPlacementProbe(client, tool, readArgs);
845
+ if (!read.ok)
846
+ return read;
847
+ const [subject, reference] = read.nodes.map(parseBounds);
848
+ if (isInside(reference.resolved, subject.resolved)) {
849
+ return fail(tool, "reference_inside_subject", "The reference is inside the subject, so moving the subject would move the reference too.");
850
+ }
851
+ if (!subject.intervals)
852
+ return fail(tool, "subject_has_no_visual_bounds", `${args.subject} has no visible GeometryInstance3D bounds.`);
853
+ if (!reference.intervals)
854
+ return fail(tool, "reference_has_no_visual_bounds", `${args.reference} has no visible GeometryInstance3D bounds (outside the subject).`);
855
+ const dirs = dirsOf(read);
856
+ const deltas = adjacentDeltas(subject.intervals, reference.intervals, args.axis, args.side, args.gap, (axis) => alignFor(args, axis));
857
+ const worldDelta = dirs.reduce((acc, dir, k) => add(acc, scale(dir, deltas[k])), [0, 0, 0]);
858
+ const excludes = Array.isArray(read.excluded) && read.excluded.length > 0;
859
+ const base = {
860
+ tool,
861
+ evidence: "visual_aabb",
862
+ space: read.space,
863
+ subject: subject.resolved,
864
+ reference: reference.resolved,
865
+ axis: args.axis,
866
+ side: args.side,
867
+ gap: args.gap,
868
+ ...(excludes ? { reference_excludes_subject: true } : {}),
869
+ };
870
+ if (length(worldDelta) < 1e-6) {
871
+ return { ok: true, ...base, moved: false, moved_by: [0, 0, 0], saved: false, note: "Already in place; nothing was changed." };
872
+ }
873
+ const position = localPositionAfter(subject, worldDelta);
874
+ const receipt = await executeSceneMutation(client, args.scenePath, [
875
+ { op: "SetProp", path: args.subject, key: "position", value: toGodotVector3(position) },
876
+ ]);
877
+ const failed = mutationFailure(tool, receipt);
878
+ if (failed)
879
+ return failed;
880
+ // Verify from a fresh read: the achieved gap and alignment residuals.
881
+ const after = await runPlacementProbe(client, tool, readArgs);
882
+ const result = {
883
+ ok: true,
884
+ ...base,
885
+ moved: true,
886
+ moved_by: roundVec(worldDelta),
887
+ position: roundVec(position),
888
+ saved: true,
889
+ };
890
+ if (!after.ok) {
891
+ result.verify = { ok: false, error: after.error };
892
+ return result;
893
+ }
894
+ const [s2, r2] = after.nodes.map(parseBounds);
895
+ if (s2.intervals && r2.intervals) {
896
+ const main = axisIndex(args.axis);
897
+ const achievedGap = args.side === "max" ? s2.intervals[main][0] - r2.intervals[main][1] : r2.intervals[main][0] - s2.intervals[main][1];
898
+ const residuals = {};
899
+ const remaining = adjacentDeltas(s2.intervals, r2.intervals, args.axis, args.side, args.gap, (axis) => alignFor(args, axis));
900
+ WORLD_AXES.forEach((name, k) => {
901
+ if (k !== main && alignFor(args, name) !== "none")
902
+ residuals[name] = round(remaining[k]);
903
+ });
904
+ result.verify = { gap: round(achievedGap), residuals };
905
+ }
906
+ return result;
907
+ }
908
+ // ---------------------------------------------------------------------------
909
+ // summer_attach_to_surface
910
+ // ---------------------------------------------------------------------------
911
+ export const attachToSurfaceArgsSchema = z.object({
912
+ scenePath: scenePathSchema,
913
+ subject: nodePathSchema("Piece to mount"),
914
+ surface: nodePathSchema("Surface node (wall, ceiling, floor). Without ray, the ray runs from the subject's origin to the nearest point of this node's bounds; with ray, hits on other nodes are skipped. The seat must land on this node: a seat on any other node is refused and the piece is put back").optional(),
915
+ ray: z
916
+ .object({
917
+ origin: vec3.describe("Ray start [x, y, z] in scene space, e.g. a point in front of the wall."),
918
+ direction: nonZeroVec3.describe("Ray direction [x, y, z], e.g. toward the wall."),
919
+ })
920
+ .optional()
921
+ .describe("Explicit ray that finds the surface plane and its normal. Pass this or surface (or both)."),
922
+ placeAt: z
923
+ .enum(["current", "hit"])
924
+ .optional()
925
+ .default("current")
926
+ .describe("current (default): the piece keeps its height and its place along the surface and only moves along the surface normal, so its measured back face ends at standoff from the surface. hit: it also slides along the surface so the centre of its back face lands on the ray hit point (its height then follows the ray)."),
927
+ maxMove: z
928
+ .number()
929
+ .positive()
930
+ .max(100)
931
+ .optional()
932
+ .default(2)
933
+ .describe("Refuse, changing nothing, when the piece would end farther than this from where it starts (scene units). Place the piece near its mount first; raise this only for a deliberate long move."),
934
+ backAxis: signedAxis
935
+ .optional()
936
+ .default("-z")
937
+ .describe("The subject's LOCAL axis that must face into the surface, e.g. '-z' for a piece whose back is -Z. Measure it first with summer_inspect_asset."),
938
+ upAxis: signedAxis
939
+ .optional()
940
+ .default("+y")
941
+ .describe("The subject's LOCAL axis kept closest to worldUp. Must not be on the same line as backAxis."),
942
+ worldUp: nonZeroVec3.optional().default([0, 1, 0]).describe("World direction the upAxis should follow (projected onto the surface)."),
943
+ standoff: z.number().min(0).max(10).optional().default(0).describe("Gap between the subject's back face and the surface, in scene units."),
944
+ maxDistance: z.number().positive().max(1000).optional().default(20).describe("Maximum ray length when looking for the surface."),
945
+ collisionMask,
946
+ });
947
+ /** The piece is turned and put this far in front of its planned seat before
948
+ * SnapToSurface sweeps it in. */
949
+ export const ATTACH_PRESEAT_CLEARANCE = 0.05;
950
+ /** How far past the planned seat the sweep may go (a collider set back from
951
+ * the visible back face). */
952
+ export const ATTACH_SEAT_REACH = 0.25;
953
+ /** A seat this far in front of the planned plane stopped on something else. */
954
+ export const ATTACH_PLAN_TOLERANCE = 0.03;
955
+ /** Extents of a node's visible bounds along its own local axes, relative to
956
+ * its origin: lo/hi per local x, y, z. Exported for tests. */
957
+ export function localExtents(intervals, dirs, origin) {
958
+ const lo = [0, 0, 0];
959
+ const hi = [0, 0, 0];
960
+ for (let k = 0; k < 3; k++) {
961
+ const at = dot(origin, dirs[k]);
962
+ lo[k] = intervals[k][0] - at;
963
+ hi[k] = intervals[k][1] - at;
964
+ }
965
+ return { lo, hi };
966
+ }
967
+ /**
968
+ * Where the subject's origin must go so its back face (the extreme of its
969
+ * visible bounds along backAxis) sits standoff in front of the surface plane
970
+ * through hitPoint. `current` keeps the origin's place along the surface (and
971
+ * so its height on a wall); `hit` puts the centre of the back face on the hit
972
+ * point. `rotation` maps local unit axes to their new world directions.
973
+ * Exported for tests.
974
+ */
975
+ export function seatOrigin(plan) {
976
+ const n = normalize(plan.normal);
977
+ const k = axisIndex(plan.backAxis[1]);
978
+ const positive = plan.backAxis[0] === "+";
979
+ const { lo, hi } = plan.extents;
980
+ // Distance from the origin to the back face, measured along the back axis.
981
+ const backOffset = positive ? hi[k] : -lo[k];
982
+ // The back face's centre relative to the origin, turned into the mount.
983
+ const centre = [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2];
984
+ centre[k] = positive ? hi[k] : lo[k];
985
+ const offset = basisMulVec(plan.rotation, centre);
986
+ if (plan.placeAt === "current") {
987
+ const depth = plan.standoff + backOffset - dot(sub(plan.origin, plan.hitPoint), n);
988
+ const origin = add(plan.origin, scale(n, depth));
989
+ return { origin, backOffset, backCentre: add(origin, offset) };
990
+ }
991
+ const backCentre = add(plan.hitPoint, scale(n, plan.standoff));
992
+ return { origin: sub(backCentre, offset), backOffset, backCentre };
993
+ }
994
+ /** Same node, or one inside the other ("./" ignored; the engine cuts paths
995
+ * over 160 characters to 157 + "..."). */
996
+ function pathRelation(support, intended) {
997
+ const a = stripDotSlash(support);
998
+ const b = stripDotSlash(intended);
999
+ if (a.endsWith("...")) {
1000
+ const prefix = a.slice(0, -3);
1001
+ return b.startsWith(prefix) || prefix.startsWith(b) ? "inside" : "other";
1002
+ }
1003
+ if (a === b)
1004
+ return "same";
1005
+ if (isInside(a, b))
1006
+ return "inside";
1007
+ if (isInside(b, a))
1008
+ return "ancestor";
1009
+ return "other";
1010
+ }
1011
+ /** The mounting rotation: local back -> -normal, local up -> world up projected
1012
+ * onto the surface plane. Exported for tests. */
1013
+ export function mountingRotation(backAxis, upAxis, normal, worldUp, currentUp) {
1014
+ const n = normalize(normal);
1015
+ const project = (v) => sub(v, scale(n, dot(v, n)));
1016
+ let up = project(worldUp);
1017
+ let usedCurrentUp = false;
1018
+ if (length(up) < 1e-3 && currentUp) {
1019
+ up = project(currentUp);
1020
+ usedCurrentUp = true;
1021
+ }
1022
+ if (length(up) < 1e-3)
1023
+ return null;
1024
+ up = normalize(up);
1025
+ const rotation = rotationFromFrames(signedAxisVector(backAxis), signedAxisVector(upAxis), scale(n, -1), up);
1026
+ return { rotation, up, usedCurrentUp };
1027
+ }
1028
+ function scaledBasis(rotation, s) {
1029
+ return [scale(rotation[0], s[0]), scale(rotation[1], s[1]), scale(rotation[2], s[2])];
1030
+ }
1031
+ function compactSnap(entry) {
1032
+ if (!entry)
1033
+ return {};
1034
+ const after = asRecord(entry.after);
1035
+ const keep = {};
1036
+ for (const key of ["ok", "evidence", "supportPath", "finalGap", "gapErrorBound", "initiallyOverlapping", "backoffDistance", "hitTravel", "failure_reason", "failureReason", "error"]) {
1037
+ if (entry[key] !== undefined)
1038
+ keep[key] = entry[key];
1039
+ }
1040
+ if (after && Array.isArray(after.origin))
1041
+ keep.origin = after.origin;
1042
+ if (Array.isArray(entry.warnings) && entry.warnings.length)
1043
+ keep.warnings = entry.warnings.slice(0, 6);
1044
+ return keep;
1045
+ }
1046
+ /** What blocks the subject where it stands (the placement probe's blockers
1047
+ * command), compacted; undefined when the probe itself failed. */
1048
+ async function readBlockers(client, tool, args, direction, distance) {
1049
+ const read = await runPlacementProbe(client, tool, {
1050
+ cmd: "blockers",
1051
+ scene_path: args.scenePath,
1052
+ path: args.subject,
1053
+ direction: roundVec(direction, 6),
1054
+ max_distance: distance,
1055
+ collision_mask: args.collisionMask,
1056
+ });
1057
+ if (!read.ok)
1058
+ return undefined;
1059
+ const overlaps = (Array.isArray(read.overlaps) ? read.overlaps : []).map(String);
1060
+ const contact = asRecord(read.first_contact);
1061
+ return {
1062
+ evidence: read.evidence,
1063
+ overlapping: overlaps.length > 0,
1064
+ overlaps: overlaps.slice(0, 8),
1065
+ first_contact: contact ? { path: String(contact.path), distance: round(Number(contact.distance)) } : null,
1066
+ };
1067
+ }
1068
+ /** One concrete next step for a failed seat, naming what blocked it. */
1069
+ export function seatNextStep(reason, blockers, sweep) {
1070
+ const overlaps = Array.isArray(blockers?.overlaps) ? blockers.overlaps : [];
1071
+ const contact = asRecord(blockers?.first_contact);
1072
+ const blocker = typeof contact?.path === "string" ? contact.path : overlaps[0];
1073
+ switch (reason) {
1074
+ case "overlap_recovery_exceeded":
1075
+ case "aligned_overlap_recovery_exceeded":
1076
+ return `In front of the surface the piece already overlaps ${overlaps.length ? overlaps.join(", ") : "something"}, and backing away from the surface does not clear it (it is probably sunk into the floor or a neighbour). Move it clear of ${blocker ?? "that node"} (for example lift it a few cm) and re-run.`;
1077
+ case "gap_exceeds_hit_travel":
1078
+ return `${blocker ?? "Something"} sits between the piece and the surface, closer than the standoff. Move the piece along the surface clear of it, lower standoff, or name ${blocker ?? "it"} as surface if that is the intended mount.`;
1079
+ case "surface_not_found":
1080
+ return `The seat sweep (${round(sweep)} along -normal from just in front of the planned seat) touched nothing: the surface has no collider there, or the piece's collider sits farther behind its visible back. Compare summer_inspect_asset collision with aabb, and check the spot with summer_raycast.`;
1081
+ case "subject_not_ready":
1082
+ return "Make the scene the active editor tab (summer_open_scene) and re-run: the seat needs the scene's physics.";
1083
+ default:
1084
+ return "Read seat.error and fix the cause, then re-run.";
1085
+ }
1086
+ }
1087
+ export async function attachToSurface(client, args) {
1088
+ const tool = "summer_attach_to_surface";
1089
+ if (!args.surface && !args.ray)
1090
+ throw new ToolInputError("Pass surface (a node path) or ray ({origin, direction}).");
1091
+ if (sameAxisLine(args.backAxis, args.upAxis))
1092
+ throw new ToolInputError("backAxis and upAxis must be different axes.");
1093
+ if (args.surface && args.surface === args.subject)
1094
+ throw new ToolInputError("surface must not be the subject.");
1095
+ const missingSnap = missingEngineOpResult(client, "SnapToSurface", SNAP_TO_SURFACE_FALLBACK);
1096
+ if (missingSnap)
1097
+ return { ...missingSnap, tool };
1098
+ const ray = args.ray
1099
+ ? { origin: args.ray.origin, direction: args.ray.direction }
1100
+ : { origin: [0, 0, 0], direction: [0, 0, -1], from_subject: args.subject };
1101
+ const read = await runPlacementProbe(client, tool, {
1102
+ cmd: "multi",
1103
+ scene_path: args.scenePath,
1104
+ steps: [
1105
+ // The subject's bounds along its OWN axes: where its back face is.
1106
+ { cmd: "bounds", space_node: args.subject, nodes: [{ path: args.subject }] },
1107
+ {
1108
+ cmd: "raycast",
1109
+ ...ray,
1110
+ surface: args.surface ?? "",
1111
+ exclude: [args.subject],
1112
+ max_distance: args.maxDistance,
1113
+ collision_mask: args.collisionMask,
1114
+ evidence: "auto",
1115
+ },
1116
+ ],
1117
+ });
1118
+ if (!read.ok)
1119
+ return read;
1120
+ const [boundsStep, rayStep] = read.steps;
1121
+ const subject = parseBounds(boundsStep.nodes[0]);
1122
+ if (!subject.intervals) {
1123
+ return fail(tool, "subject_has_no_visual_bounds", `${args.subject} has no visible geometry to seat.`);
1124
+ }
1125
+ if (basisDeterminant(subject.xform.basis) < 0) {
1126
+ return fail(tool, "mirrored_subject", `${args.subject} is mirrored (negative scale here or on a parent); turning it with a rotation would un-mirror it. Place it with summer_set_prop instead.`);
1127
+ }
1128
+ const chosen = chooseHit(rayStep, "auto");
1129
+ if (!chosen.hit) {
1130
+ return fail(tool, "surface_not_hit", args.surface ? `The ray did not hit ${args.surface} within ${args.maxDistance}.` : `The ray hit nothing within ${args.maxDistance}.`, {
1131
+ origin: roundVec(rayStep.origin),
1132
+ direction: roundVec(rayStep.direction, 4),
1133
+ });
1134
+ }
1135
+ const hit = chosen.hit;
1136
+ const warnings = [...chosen.warnings];
1137
+ if (chosen.fallback)
1138
+ warnings.push(`surface_found_by_visual_aabb (${chosen.fallback}): the normal is an AABB face normal`);
1139
+ if (chosen.nearerVisual)
1140
+ warnings.push(`mesh_only_geometry_nearer_than_hit: ${chosen.nearerVisual.path}`);
1141
+ let n = normalize(hit.normal);
1142
+ // The piece mounts on the side the ray came from, also when the ray hit a
1143
+ // back face whose normal points away from it.
1144
+ if (Array.isArray(rayStep.direction) && dot(n, rayStep.direction) > 0) {
1145
+ n = scale(n, -1);
1146
+ warnings.push("hit_normal_pointed_away_from_the_ray_flipped");
1147
+ }
1148
+ const currentUp = basisMulVec(subject.xform.basis, signedAxisVector(args.upAxis));
1149
+ const mount = mountingRotation(args.backAxis, args.upAxis, n, args.worldUp, currentUp);
1150
+ if (!mount) {
1151
+ return fail(tool, "up_axis_degenerate", "worldUp and the subject's current up are both parallel to the surface normal; pass a worldUp that lies along the surface.");
1152
+ }
1153
+ if (mount.usedCurrentUp)
1154
+ warnings.push("world_up_parallel_to_normal_kept_current_up");
1155
+ const start = subject.xform.origin;
1156
+ const extents = localExtents(subject.intervals, dirsOf(boundsStep), start);
1157
+ const plan = seatOrigin({
1158
+ origin: start,
1159
+ extents,
1160
+ backAxis: args.backAxis,
1161
+ rotation: mount.rotation,
1162
+ hitPoint: hit.point,
1163
+ normal: n,
1164
+ standoff: args.standoff,
1165
+ placeAt: args.placeAt,
1166
+ });
1167
+ const surfaceHit = { evidence: chosen.evidence, ...hitOut(hit) };
1168
+ const base = {
1169
+ tool,
1170
+ subject: subject.resolved,
1171
+ placed_at: args.placeAt,
1172
+ surface_hit: surfaceHit,
1173
+ orientation: {
1174
+ backAxis: args.backAxis,
1175
+ upAxis: args.upAxis,
1176
+ world_back: roundVec(scale(n, -1), 4),
1177
+ world_up: roundVec(mount.up, 4),
1178
+ },
1179
+ back_face_offset: round(plan.backOffset),
1180
+ standoff: args.standoff,
1181
+ };
1182
+ // How far along the surface the hit is from the piece's back face: in
1183
+ // "current" mode the piece stays where it is along the surface, so a hit
1184
+ // metres to the side means the ray found the surface somewhere else.
1185
+ const toHit = sub(hit.point, plan.backCentre);
1186
+ const alongSurface = length(sub(toHit, scale(n, dot(toHit, n))));
1187
+ if (alongSurface > args.maxMove) {
1188
+ return fail(tool, "hit_far_from_piece", `The ray hit ${hit.path} ${round(alongSurface)} along the surface from the piece, more than maxMove ${args.maxMove}. placeAt "current" keeps the piece where it is along the surface, so it would not go to the hit. Nothing was changed.`, {
1189
+ ...base,
1190
+ mutationApplied: false,
1191
+ saved: false,
1192
+ next_step: "Aim the ray at the piece, pass placeAt \"hit\" to move it to the hit point, or place it near the hit first (summer_set_prop position).",
1193
+ });
1194
+ }
1195
+ const plannedMove = length(sub(plan.origin, start));
1196
+ if (plannedMove > args.maxMove) {
1197
+ return fail(tool, "move_exceeds_max_move", `The piece would move ${round(plannedMove)} from where it starts, more than maxMove ${args.maxMove}. Nothing was changed.`, {
1198
+ ...base,
1199
+ planned_origin: roundVec(plan.origin),
1200
+ mutationApplied: false,
1201
+ saved: false,
1202
+ next_step: args.placeAt === "hit"
1203
+ ? "Aim the ray at the spot next to the piece, place the piece near its mount first (summer_set_prop position), or raise maxMove for a deliberate long move."
1204
+ : "Place the piece near its mount first (summer_set_prop position, or summer_instantiate_scene with position), or raise maxMove for a deliberate long move.",
1205
+ });
1206
+ }
1207
+ // Turn the piece and put its back face just in front of the planned seat,
1208
+ // then let SnapToSurface sweep it in over a short, bounded distance.
1209
+ const basis = scaledBasis(mount.rotation, basisScale(subject.xform.basis));
1210
+ const preseat = { basis, origin: add(plan.origin, scale(n, ATTACH_PRESEAT_CLEARANCE)) };
1211
+ const local = xformCompose(xformInverse(subject.parent), preseat);
1212
+ const sweep = round(args.standoff + ATTACH_PRESEAT_CLEARANCE + ATTACH_SEAT_REACH, 4);
1213
+ const options = { scenePath: args.scenePath };
1214
+ const restore = async () => {
1215
+ const original = subject.transformStr && parseGodotTransform(subject.transformStr)
1216
+ ? subject.transformStr
1217
+ : toGodotTransform(xformCompose(xformInverse(subject.parent), subject.xform));
1218
+ const receipt = await client.executeIdentityBoundOps([{ op: "SetProp", path: args.subject, key: "transform", value: original }], options);
1219
+ return !extractOpError(receipt);
1220
+ };
1221
+ const refuse = async (reason, message, extra) => {
1222
+ const restored = await restore();
1223
+ return fail(tool, reason, message, {
1224
+ ...base,
1225
+ ...extra,
1226
+ restored,
1227
+ mutationApplied: !restored,
1228
+ saved: false,
1229
+ note: restored
1230
+ ? "The piece was put back where it started; the scene was not saved."
1231
+ : "Putting the piece back FAILED: it is turned and moved in the editor, the scene is not saved. Undo it (summer_batch [{op:\"Undo\"}]) or set its transform.",
1232
+ });
1233
+ };
1234
+ const receipt = await client.executeIdentityBoundOps([
1235
+ { op: "SetProp", path: args.subject, key: "transform", value: toGodotTransform(local) },
1236
+ {
1237
+ op: "SnapToSurface",
1238
+ subject_path: args.subject,
1239
+ direction: roundVec(scale(n, -1), 6),
1240
+ max_distance: sweep,
1241
+ gap: args.standoff,
1242
+ align_up: false,
1243
+ },
1244
+ ], options);
1245
+ const hinted = asRecord(withOldEngineHint(receipt, "SnapToSurface", SNAP_TO_SURFACE_FALLBACK));
1246
+ const setProp = opEntry(receipt, "SetProp");
1247
+ const snap = compactSnap(opEntry(receipt, "SnapToSurface"));
1248
+ const seatError = extractOpError(hinted ?? receipt);
1249
+ if (seatError) {
1250
+ if (setProp?.ok !== true) {
1251
+ return mutationFailure(tool, hinted ?? receipt, { ...base, seat: snap, mutationApplied: false, saved: false });
1252
+ }
1253
+ if (hinted?.failure_reason === "engine_lacks_op") {
1254
+ return refuse("engine_lacks_op", String(hinted.error), { op: "SnapToSurface", seat: snap });
1255
+ }
1256
+ const reason = String(snap.failure_reason ?? snap.failureReason ?? "seat_failed");
1257
+ // Name the obstacle while the piece still stands where the seat started.
1258
+ const blockers = await readBlockers(client, tool, args, scale(n, -1), sweep);
1259
+ return refuse(reason, `The seat failed: ${String(snap.error ?? seatError).slice(0, 300)}`, {
1260
+ seat: snap,
1261
+ ...(blockers ? { blockers } : {}),
1262
+ next_step: seatNextStep(reason, blockers, sweep),
1263
+ });
1264
+ }
1265
+ // The seat applied. Check WHERE before saving: on the intended surface, at
1266
+ // the planned plane, within maxMove.
1267
+ const supportPath = typeof snap.supportPath === "string" && snap.supportPath.length > 0 ? stripDotSlash(snap.supportPath) : undefined;
1268
+ const intended = stripDotSlash(args.surface ?? hit.path);
1269
+ const afterOrigin = isFiniteVec3(snap.origin) ? snap.origin : undefined;
1270
+ // > 0: stopped in front of the planned seat; < 0: went deeper.
1271
+ const deviation = afterOrigin ? dot(sub(afterOrigin, plan.origin), n) : undefined;
1272
+ const relation = supportPath ? pathRelation(supportPath, intended) : undefined;
1273
+ const inFront = deviation !== undefined && deviation > ATTACH_PLAN_TOLERANCE;
1274
+ const seatFacts = {
1275
+ seat: snap,
1276
+ seated_on: supportPath ?? null,
1277
+ intended_surface: intended,
1278
+ ...(deviation !== undefined ? { back_face_gap: round(args.standoff + deviation), in_front_of_plan: round(deviation) } : {}),
1279
+ };
1280
+ const otherNode = relation === "other" || (relation === "ancestor" && inFront);
1281
+ if (otherNode && (args.surface || deviation === undefined || inFront)) {
1282
+ return refuse("seated_on_other_node", `The seat stopped on ${supportPath}, not on ${intended}${deviation !== undefined ? `, ${round(deviation)} in front of the planned seat` : ""}.`, {
1283
+ ...seatFacts,
1284
+ next_step: `Move the piece along the surface clear of ${supportPath} (summer_measure pair with it shows the overlap), or name ${supportPath} as surface if it is the intended mount, then re-run.`,
1285
+ });
1286
+ }
1287
+ if (!supportPath && inFront) {
1288
+ return refuse("seated_in_front_of_surface", `The seat stopped ${round(deviation)} in front of the planned seat and the engine did not name the support.`, {
1289
+ ...seatFacts,
1290
+ next_step: "Check what is between the piece and the surface with summer_starcast (directionSpace local) or summer_test_placement, move the piece clear, then re-run.",
1291
+ });
1292
+ }
1293
+ const finalMove = afterOrigin ? length(sub(afterOrigin, start)) : undefined;
1294
+ if (finalMove !== undefined && finalMove > args.maxMove + sweep) {
1295
+ return refuse("move_exceeds_max_move", `The seat moved the piece ${round(finalMove)} from where it started, more than maxMove ${args.maxMove}.`, {
1296
+ ...seatFacts,
1297
+ next_step: "Place the piece near its mount first, or raise maxMove for a deliberate long move.",
1298
+ });
1299
+ }
1300
+ if (otherNode)
1301
+ warnings.push(`seated_on_coplanar_${supportPath}`);
1302
+ if (!supportPath)
1303
+ warnings.push("seat_support_not_named_by_engine");
1304
+ if (inFront && relation !== undefined && relation !== "other") {
1305
+ warnings.push(`surface_not_flat_under_piece: seated ${round(deviation)} in front of the ray hit plane`);
1306
+ }
1307
+ if (deviation !== undefined && deviation < -ATTACH_PLAN_TOLERANCE) {
1308
+ warnings.push(`visible_back_${round(-deviation)}_into_surface: the collider sits behind the visible back face; add that to standoff to keep the mesh flush`);
1309
+ }
1310
+ if (!afterOrigin)
1311
+ warnings.push("seat_origin_missing_plan_not_checked");
1312
+ const saveReceipt = await client.executeIdentityBoundOps([{ op: "SaveScene" }], options);
1313
+ const saveError = extractOpError(saveReceipt);
1314
+ if (saveError) {
1315
+ return fail(tool, "save_failed", `The piece is seated in the editor but SaveScene failed: ${saveError.slice(0, 300)}`, {
1316
+ ...base,
1317
+ ...seatFacts,
1318
+ mutationApplied: true,
1319
+ saved: false,
1320
+ note: "Fix the cause, then call summer_save_scene.",
1321
+ });
1322
+ }
1323
+ return fitToBudget({
1324
+ ok: true,
1325
+ ...base,
1326
+ seated_on: supportPath ?? null,
1327
+ final_gap: snap.finalGap ?? null,
1328
+ ...(deviation !== undefined ? { back_face_gap: round(args.standoff + deviation) } : {}),
1329
+ ...(afterOrigin ? { moved_by: roundVec(sub(afterOrigin, start)) } : {}),
1330
+ seat: snap,
1331
+ saved: true,
1332
+ warnings,
1333
+ }, ["warnings"]);
1334
+ }
1335
+ // ---------------------------------------------------------------------------
1336
+ // summer_repeat_along
1337
+ // ---------------------------------------------------------------------------
1338
+ export const repeatAlongArgsSchema = z.object({
1339
+ scenePath: scenePathSchema,
1340
+ template: z
1341
+ .string()
1342
+ .trim()
1343
+ .min(1)
1344
+ .max(SCENE_PATH_LIMIT_BYTES)
1345
+ .refine((value) => SCENE_ASSET_PATH.test(value), "template must be a res:// .tscn, .scn, .glb or .gltf scene")
1346
+ .refine(isSafeResPath, SAFE_RES_PATH_MESSAGE)
1347
+ .describe("Scene to instance for every copy, e.g. 'res://kit/pipes/wall_clamp_a.tscn'."),
1348
+ parent: nodePathSchema("Parent for the copies").describe("Parent for the copies; start/end/direction are in this parent's local space (same as position)."),
1349
+ start: vec3.describe("First copy's position [x, y, z] in the parent's space."),
1350
+ end: vec3.optional().describe("Line end. With spacing: as many copies as fit; with count: count copies spread from start to end."),
1351
+ direction: nonZeroVec3.optional().describe("Instead of end: direction of the row; needs count and spacing."),
1352
+ count: z.number().int().min(1).max(64).optional().describe("Number of copies (1-64)."),
1353
+ spacing: z.number().positive().max(1000).optional().describe("Distance between neighbouring copies' origins."),
1354
+ align: z
1355
+ .enum(["start", "center", "end"])
1356
+ .optional()
1357
+ .default("start")
1358
+ .describe("With end + spacing: where the leftover length goes. start = first copy at start; center = leftover split; end = last copy at end."),
1359
+ rotationDegrees: vec3.optional().describe("rotation_degrees for every copy [x, y, z]."),
1360
+ scale: vec3.optional().describe("scale for every copy [x, y, z]."),
1361
+ namePrefix: z
1362
+ .string()
1363
+ .trim()
1364
+ .min(1)
1365
+ .max(64)
1366
+ .refine((value) => NODE_NAME.test(value), "namePrefix may use letters, digits, _, - and spaces")
1367
+ .optional()
1368
+ .describe("Copies are named <namePrefix>_<n> (default: the template file name)."),
1369
+ });
1370
+ export const REPEAT_MAX_COPIES = 64;
1371
+ /** Copy positions for a repeat_along call. Exported for tests. */
1372
+ export function repeatPositions(args) {
1373
+ const start = args.start;
1374
+ if (args.end) {
1375
+ if (args.direction)
1376
+ throw new ToolInputError("Pass end or direction, not both.");
1377
+ if (args.count !== undefined && args.spacing !== undefined) {
1378
+ throw new ToolInputError("With end, pass spacing or count, not both.");
1379
+ }
1380
+ const span = sub(args.end, start);
1381
+ const total = length(span);
1382
+ if (args.count === 1) {
1383
+ const at = args.align === "end" ? args.end : args.align === "center" ? scale(add(start, args.end), 0.5) : start;
1384
+ return { positions: [at], spacing: 0, direction: total > 1e-9 ? normalize(span) : [0, 0, 0] };
1385
+ }
1386
+ if (total < 1e-9)
1387
+ throw new ToolInputError("start and end are the same point; pass count 1 or a longer line.");
1388
+ const dir = normalize(span);
1389
+ if (args.spacing !== undefined) {
1390
+ const n = Math.floor(total / args.spacing + 1e-9) + 1;
1391
+ if (n > REPEAT_MAX_COPIES) {
1392
+ throw new ToolInputError(`That line holds ${n} copies at spacing ${args.spacing}; the limit is ${REPEAT_MAX_COPIES} per call. Split the line.`);
1393
+ }
1394
+ const leftover = total - (n - 1) * args.spacing;
1395
+ const offset = args.align === "end" ? leftover : args.align === "center" ? leftover / 2 : 0;
1396
+ const positions = Array.from({ length: n }, (_, i) => add(start, scale(dir, offset + i * args.spacing)));
1397
+ return { positions, spacing: args.spacing, direction: dir };
1398
+ }
1399
+ if (args.count !== undefined) {
1400
+ const spacing = total / (args.count - 1);
1401
+ const positions = Array.from({ length: args.count }, (_, i) => add(start, scale(dir, i * spacing)));
1402
+ return { positions, spacing, direction: dir };
1403
+ }
1404
+ throw new ToolInputError("With end, pass spacing or count.");
1405
+ }
1406
+ if (args.direction) {
1407
+ if (args.count === undefined)
1408
+ throw new ToolInputError("With direction, pass count.");
1409
+ if (args.count > 1 && args.spacing === undefined)
1410
+ throw new ToolInputError("With direction and count > 1, pass spacing.");
1411
+ const dir = normalize(args.direction);
1412
+ const spacing = args.spacing ?? 0;
1413
+ const positions = Array.from({ length: args.count }, (_, i) => add(start, scale(dir, i * spacing)));
1414
+ return { positions, spacing, direction: dir };
1415
+ }
1416
+ if (args.count === 1)
1417
+ return { positions: [start], spacing: 0, direction: [0, 0, 0] };
1418
+ throw new ToolInputError("Pass end (with spacing or count) or direction (with count and spacing).");
1419
+ }
1420
+ function templateName(template) {
1421
+ const base = template.split("/").pop() ?? "Copy";
1422
+ const stem = base.replace(/\.[^.]+$/, "").replace(/[^A-Za-z0-9_\- ]/g, "_");
1423
+ return /^[A-Za-z0-9_]/.test(stem) ? stem : `Copy_${stem}`;
1424
+ }
1425
+ export async function repeatAlong(client, args) {
1426
+ const tool = "summer_repeat_along";
1427
+ const { positions, spacing, direction } = repeatPositions(args);
1428
+ const prefix = args.namePrefix ?? templateName(args.template);
1429
+ const ops = positions.map((position, i) => ({
1430
+ op: "InstantiateScene",
1431
+ parent: args.parent,
1432
+ scene: args.template,
1433
+ name: `${prefix}_${i + 1}`,
1434
+ ...(args.rotationDegrees ? { rotation_degrees: args.rotationDegrees } : {}),
1435
+ ...(args.scale ? { scale: args.scale } : {}),
1436
+ position,
1437
+ }));
1438
+ const trace = await executePlacementBatch((chunk) => client.executeIdentityBoundOps(chunk, { groupUndo: true, scenePath: args.scenePath }), sceneMutationOps(ops), resolveSingleOnlyOps(client), ops.length);
1439
+ const summary = summarizeTrace(trace, ops);
1440
+ return fitToBudget({
1441
+ ...summary,
1442
+ ok: summary.ok === true,
1443
+ tool,
1444
+ template: args.template,
1445
+ parent: args.parent,
1446
+ count: positions.length,
1447
+ spacing: round(spacing),
1448
+ direction: roundVec(direction, 4),
1449
+ first: roundVec(positions[0]),
1450
+ last: roundVec(positions[positions.length - 1]),
1451
+ }, ["created", "renamed", "failures"]);
1452
+ }
1453
+ // ---------------------------------------------------------------------------
1454
+ // summer_connect_ports
1455
+ // ---------------------------------------------------------------------------
1456
+ /** A join may tilt the subject's up axis this many degrees unless allowed. */
1457
+ export const DEFAULT_MAX_TILT_DEGREES = 5;
1458
+ /** Float noise in acos near 1, so maxTiltDegrees 0 still accepts a pure turn. */
1459
+ const TILT_EPSILON_DEGREES = 0.001;
1460
+ const portSchema = (what) => z
1461
+ .union([
1462
+ z
1463
+ .string()
1464
+ .trim()
1465
+ .min(1)
1466
+ .max(128)
1467
+ .refine((value) => LOOP_ID_PATTERN.test(value) || (PORT_NAME_CHARS.test(value) && !value.includes("..")), "a port is a loop id such as '+Y' or '-Z#2', or a node name using letters, digits, _, -, spaces and '/' only"),
1468
+ z.number().int().min(0).max(255),
1469
+ ])
1470
+ .describe(`${what}: an open-loop id from summer_inspect_asset on that node's scene, e.g. '+Y' (the outermost open loop facing +Y in the piece's own axes) or '+Y#2' (the next one facing +Y); or a Marker3D (or any Node3D) name under the node, whose -Z axis points out of the port; or an open-loop index. Ids and indices follow the stable order (facing, then position), never the radius.`);
1471
+ export const connectPortsArgsSchema = z.object({
1472
+ scenePath: scenePathSchema,
1473
+ subject: nodePathSchema("Piece to move"),
1474
+ subjectPort: portSchema("Port on the subject"),
1475
+ target: nodePathSchema("Piece that stays put"),
1476
+ targetPort: portSchema("Port on the target"),
1477
+ gap: z.number().min(-1).max(10).optional().default(0).describe("Distance between the two ports along the target port's direction. 0 = touching."),
1478
+ rollDegrees: z
1479
+ .number()
1480
+ .min(-360)
1481
+ .max(360)
1482
+ .optional()
1483
+ .default(0)
1484
+ .describe("Extra turn of the subject, in degrees, about the joined port axis, applied after the ports are lined up. Axis: the target port's direction reversed, i.e. pointing from the joint into the target (receipt roll_axis). Sign: right-hand rule about that axis; positive turns counter-clockwise when you look back along the axis from inside the target toward the subject. Zero: the shortest turn that makes the subject's port face the target's from the subject's CURRENT orientation, so the same value gives different results from different start poses. Read other_ports in the receipt; once the ports are joined, a second call turns by exactly rollDegrees about the joint (e.g. 180 flips a bend's free end to the other side)."),
1485
+ maxTiltDegrees: z
1486
+ .number()
1487
+ .min(0)
1488
+ .max(180)
1489
+ .optional()
1490
+ .default(DEFAULT_MAX_TILT_DEGREES)
1491
+ .describe("Refuse the join, changing nothing, when it would tilt the subject's up axis (its local +Y) more than this many degrees from where it points now (default 5). Turning about the up axis is never tilt. The refusal names the predicted tilt and the subject ports that would join within the limit."),
1492
+ allowTilt: z
1493
+ .boolean()
1494
+ .optional()
1495
+ .default(false)
1496
+ .describe("true: join even when the subject tilts more than maxTiltDegrees (a bend laid on its side, a sloped run). The receipt still reports tilt_degrees."),
1497
+ maxTriangles: z.number().int().min(100).max(300000).optional().default(60000).describe("Triangle budget (100-300000) per node when ports are open-loop ids or indices."),
1498
+ });
1499
+ /** At most this many of the subject's other ports go in the receipt. */
1500
+ const OTHER_PORTS_SHOWN = 8;
1501
+ /** At most this many ports are listed when a port is not found or a join is refused. */
1502
+ const PORT_CHOICES_SHOWN = 8;
1503
+ function portOut(port) {
1504
+ return {
1505
+ kind: port.kind,
1506
+ ...(port.id !== undefined ? { id: port.id } : {}),
1507
+ ...(port.name !== undefined ? { name: port.name } : {}),
1508
+ ...(port.index !== undefined ? { index: port.index } : {}),
1509
+ position: roundVec(port.position),
1510
+ direction: roundVec(port.direction, 4),
1511
+ ...(port.radius !== undefined ? { radius: round(port.radius) } : {}),
1512
+ ...(port.direction_ambiguous ? { direction_ambiguous: true } : {}),
1513
+ };
1514
+ }
1515
+ /** An ordered loop in scene space, through its node's scene transform. */
1516
+ function loopPort(loop, nodeXf) {
1517
+ return {
1518
+ kind: "open_loop",
1519
+ id: loop.id,
1520
+ index: loop.index,
1521
+ position: xformMulPoint(nodeXf, loop.center),
1522
+ direction: normalize(basisMulVec(nodeXf.basis, loop.direction)),
1523
+ radius: loop.radius,
1524
+ ...(loop.direction_ambiguous ? { direction_ambiguous: true } : {}),
1525
+ };
1526
+ }
1527
+ /**
1528
+ * The probe's answer for one port -> the port in scene space. A marker comes
1529
+ * resolved; an open-loop port comes as every loop of the node in its own
1530
+ * frame, which is ordered here (orderLoops) and picked by id or index, so the
1531
+ * same id names the same loop in every pose. With withOthers, the node's other
1532
+ * loops come along in scene space.
1533
+ */
1534
+ export function resolveProbePort(raw, selector, label, node, withOthers) {
1535
+ const record = asRecord(raw) ?? {};
1536
+ if (record.kind !== "open_loops")
1537
+ return { port: record };
1538
+ const tool = "summer_connect_ports";
1539
+ const loops = orderLoops((Array.isArray(record.loops) ? record.loops : []));
1540
+ const loop = findLoop(loops, selector);
1541
+ if (!loop) {
1542
+ const asked = selector.kind === "loop_index" ? `open-loop index ${selector.index}` : selector.kind === "loop_id" ? `open loop ${selector.id}` : `port ${selector.name}`;
1543
+ return {
1544
+ failure: fail(tool, "port_not_found", `${asked} not found on the ${label} ${node} (${loops.length} open loop(s)). Pass an id from ${label}_ports, e.g. '${loops[0]?.id ?? "+Y"}', or a Marker3D name.`, {
1545
+ [`${label}_ports`]: loops.slice(0, PORT_CHOICES_SHOWN).map((l) => ({ id: l.id, index: l.index, center: roundVec(l.center), direction: roundVec(l.direction, 4), radius: round(l.radius) })),
1546
+ ...(loops.length > PORT_CHOICES_SHOWN ? { [`${label}_ports_total`]: loops.length } : {}),
1547
+ }),
1548
+ };
1549
+ }
1550
+ const nodeXf = xformFromArray(record.node_xform);
1551
+ const port = loopPort(loop, nodeXf);
1552
+ if (record.analysis_truncated === true)
1553
+ port.analysis_truncated = true;
1554
+ if (withOthers) {
1555
+ port.others = loops.filter((other) => other !== loop).map((other) => loopPort(other, nodeXf));
1556
+ port.others_total = loops.length - 1;
1557
+ }
1558
+ return { port };
1559
+ }
1560
+ /** Value the probe gets for a port: a marker name, or a request for loops. */
1561
+ function probePortArg(selector) {
1562
+ return selector.kind === "marker" ? selector.name : { loops: true };
1563
+ }
1564
+ /** New scene-space transform that joins the subject port to the target port.
1565
+ * `joint` is where the subject port lands; a subject point p moves to
1566
+ * rotation * (p - subject port) + joint. */
1567
+ export function connectTransform(subject, sp, tp, gap, rollDegrees) {
1568
+ const ds = normalize(sp.direction);
1569
+ const dt = normalize(tp.direction);
1570
+ const want = scale(dt, -1);
1571
+ const fallback = normalize(basisMulVec(subject.basis, [0, 1, 0]));
1572
+ let rotation = rotationBetween(ds, want, fallback);
1573
+ if (Math.abs(rollDegrees) > 1e-9)
1574
+ rotation = basisMul(rotationAbout(want, (rollDegrees * Math.PI) / 180), rotation);
1575
+ const joint = add(tp.position, scale(dt, gap));
1576
+ const origin = add(basisMulVec(rotation, sub(subject.origin, sp.position)), joint);
1577
+ return { xform: { basis: basisMul(rotation, subject.basis), origin }, rotation, joint, rollAxis: want };
1578
+ }
1579
+ /** How far a scene-space rotation turns the subject's up axis (its local +Y),
1580
+ * in degrees. A turn about the up axis is 0. Exported for tests. */
1581
+ export function tiltDegrees(subject, rotation) {
1582
+ const up = normalize(basisMulVec(subject.basis, [0, 1, 0]));
1583
+ return angleDegrees(up, basisMulVec(rotation, up));
1584
+ }
1585
+ /** Where the subject's other ports end up: measured after the move when the
1586
+ * verify read has them, else predicted from the move itself. */
1587
+ function otherPortsOut(before, after, move) {
1588
+ const measured = after?.others;
1589
+ const source = measured ?? before.others ?? [];
1590
+ const ports = source.slice(0, OTHER_PORTS_SHOWN).map((port) => {
1591
+ if (measured)
1592
+ return portOut(port);
1593
+ return portOut({
1594
+ ...port,
1595
+ position: add(basisMulVec(move.rotation, sub(port.position, before.position)), move.joint),
1596
+ direction: basisMulVec(move.rotation, port.direction),
1597
+ });
1598
+ });
1599
+ const total = (measured ? after?.others_total : before.others_total) ?? source.length;
1600
+ return { ports, total, predicted: !measured };
1601
+ }
1602
+ /** The subject's other ports that would join the target within the tilt
1603
+ * limit, least tilt first. */
1604
+ function portsWithinTilt(subjectXf, sp, tp, args) {
1605
+ return (sp.others ?? [])
1606
+ .map((port) => ({ port, tilt: tiltDegrees(subjectXf, connectTransform(subjectXf, port, tp, args.gap, args.rollDegrees).rotation) }))
1607
+ .filter((choice) => choice.tilt <= args.maxTiltDegrees + TILT_EPSILON_DEGREES)
1608
+ .sort((a, b) => a.tilt - b.tilt || (a.port.index ?? 0) - (b.port.index ?? 0))
1609
+ .slice(0, PORT_CHOICES_SHOWN)
1610
+ .map(({ port, tilt }) => ({
1611
+ ...(port.id !== undefined ? { id: port.id } : {}),
1612
+ ...(port.name !== undefined ? { name: port.name } : {}),
1613
+ ...(port.index !== undefined ? { index: port.index } : {}),
1614
+ tilt_degrees: round(tilt, 2),
1615
+ direction: roundVec(port.direction, 4),
1616
+ }));
1617
+ }
1618
+ function rotationAngleDegrees(r) {
1619
+ const trace = r[0][0] + r[1][1] + r[2][2];
1620
+ return (Math.acos(Math.max(-1, Math.min(1, (trace - 1) / 2))) * 180) / Math.PI;
1621
+ }
1622
+ export async function connectPorts(client, args) {
1623
+ const tool = "summer_connect_ports";
1624
+ if (args.subject === args.target)
1625
+ throw new ToolInputError("subject and target must be different nodes.");
1626
+ const subjectSel = parsePortSelector(args.subjectPort);
1627
+ const targetSel = parsePortSelector(args.targetPort);
1628
+ const readArgs = {
1629
+ cmd: "ports",
1630
+ scene_path: args.scenePath,
1631
+ subject: args.subject,
1632
+ subject_port: probePortArg(subjectSel),
1633
+ target: args.target,
1634
+ target_port: probePortArg(targetSel),
1635
+ max_triangles: args.maxTriangles,
1636
+ other_ports: true,
1637
+ };
1638
+ const read = await runPlacementProbe(client, tool, readArgs);
1639
+ if (!read.ok)
1640
+ return read;
1641
+ const spRead = resolveProbePort(read.subject_port, subjectSel, "subject", args.subject, true);
1642
+ if ("failure" in spRead)
1643
+ return spRead.failure;
1644
+ const tpRead = resolveProbePort(read.target_port, targetSel, "target", args.target, false);
1645
+ if ("failure" in tpRead)
1646
+ return tpRead.failure;
1647
+ const sp = spRead.port;
1648
+ const tp = tpRead.port;
1649
+ const subjectXf = xformFromArray(read.xform);
1650
+ const parentXf = xformFromArray(read.parent_xform);
1651
+ const { xform, rotation, joint, rollAxis } = connectTransform(subjectXf, sp, tp, args.gap, args.rollDegrees);
1652
+ const tilt = tiltDegrees(subjectXf, rotation);
1653
+ if (tilt > args.maxTiltDegrees + TILT_EPSILON_DEGREES && !args.allowTilt) {
1654
+ const within = portsWithinTilt(subjectXf, sp, tp, args);
1655
+ return fail(tool, "tilt_exceeds_limit", `Joining these ports would tilt ${args.subject}'s up axis by ${round(tilt, 1)} degrees (limit ${args.maxTiltDegrees}); nothing was changed. ` +
1656
+ (within.length > 0
1657
+ ? `Subject ports that join within the limit: ${within.map((c) => String(c.id ?? c.name ?? c.index)).join(", ")}. `
1658
+ : "No other subject port joins within the limit. ") +
1659
+ "If the tilt is intended (a bend on its side, a sloped run), pass allowTilt true.", {
1660
+ tilt_degrees: round(tilt, 2),
1661
+ max_tilt_degrees: args.maxTiltDegrees,
1662
+ subject_port: portOut(sp),
1663
+ target_port: portOut(tp),
1664
+ ports_within_limit: within,
1665
+ saved: false,
1666
+ });
1667
+ }
1668
+ const local = xformCompose(xformInverse(parentXf), xform);
1669
+ const warnings = [];
1670
+ for (const [label, port] of [["subject", sp], ["target", tp]]) {
1671
+ if (port.direction_ambiguous)
1672
+ warnings.push(`${label}_port_direction_ambiguous`);
1673
+ if (port.analysis_truncated)
1674
+ warnings.push(`${label}_port_analysis_truncated`);
1675
+ }
1676
+ if (tilt > args.maxTiltDegrees + TILT_EPSILON_DEGREES)
1677
+ warnings.push("tilt_allowed");
1678
+ const receipt = await executeSceneMutation(client, args.scenePath, [
1679
+ { op: "SetProp", path: args.subject, key: "transform", value: toGodotTransform(local) },
1680
+ ]);
1681
+ const failed = mutationFailure(tool, receipt);
1682
+ if (failed)
1683
+ return failed;
1684
+ const result = {
1685
+ ok: true,
1686
+ tool,
1687
+ evidence: sp.kind === "marker" && tp.kind === "marker" ? "markers" : "mesh_triangles",
1688
+ subject_port: portOut(sp),
1689
+ target_port: portOut(tp),
1690
+ roll_axis: roundVec(rollAxis, 4),
1691
+ roll_degrees: args.rollDegrees,
1692
+ rotated_degrees: round(rotationAngleDegrees(rotation), 2),
1693
+ tilt_degrees: round(tilt, 2),
1694
+ max_tilt_degrees: args.maxTiltDegrees,
1695
+ moved_by: roundVec(sub(xform.origin, subjectXf.origin)),
1696
+ saved: true,
1697
+ warnings,
1698
+ };
1699
+ const after = await runPlacementProbe(client, tool, readArgs);
1700
+ const sp2Read = after.ok ? resolveProbePort(after.subject_port, subjectSel, "subject", args.subject, true) : undefined;
1701
+ const tp2Read = after.ok ? resolveProbePort(after.target_port, targetSel, "target", args.target, false) : undefined;
1702
+ const sp2 = sp2Read && "port" in sp2Read ? sp2Read.port : undefined;
1703
+ const tp2 = tp2Read && "port" in tp2Read ? tp2Read.port : undefined;
1704
+ const others = otherPortsOut(sp, sp2, { rotation, joint });
1705
+ // Every other end of the subject, where it is now: a bend's free end tells
1706
+ // you at once whether it points where the run continues.
1707
+ result.other_ports = others.ports;
1708
+ if (others.total > others.ports.length)
1709
+ result.other_ports_total = others.total;
1710
+ if (others.predicted && others.ports.length > 0)
1711
+ result.other_ports_predicted = true;
1712
+ if (!after.ok || !sp2 || !tp2) {
1713
+ result.verify = { ok: false, error: after.ok ? "the verify read could not resolve the ports" : after.error };
1714
+ return fitToBudget(result, ["other_ports", "warnings"]);
1715
+ }
1716
+ const meet = add(tp2.position, scale(normalize(tp2.direction), args.gap));
1717
+ let measuredTilt;
1718
+ try {
1719
+ const afterXf = xformFromArray(after.xform);
1720
+ measuredTilt = angleDegrees(basisMulVec(subjectXf.basis, [0, 1, 0]), basisMulVec(afterXf.basis, [0, 1, 0]));
1721
+ }
1722
+ catch {
1723
+ measuredTilt = undefined;
1724
+ }
1725
+ result.verify = {
1726
+ distance: round(length(sub(sp2.position, meet)), 4),
1727
+ angle_degrees: round(angleDegrees(sp2.direction, scale(tp2.direction, -1)), 2),
1728
+ ...(measuredTilt !== undefined ? { tilt_degrees: round(measuredTilt, 2) } : {}),
1729
+ };
1730
+ return fitToBudget(result, ["other_ports", "warnings"]);
1731
+ }