summer-engine 3.0.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 (413) 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 +120 -0
  8. package/README.md +22 -7
  9. package/assets/audit/scene_audit.gd +4136 -0
  10. package/assets/seeing/seeing_probe.gd +1873 -0
  11. package/dist/cli/commands/setup.js +6 -19
  12. package/dist/cli/commands/skills.js +57 -32
  13. package/dist/core/api-client.d.ts +9 -1
  14. package/dist/core/api-client.js +21 -4
  15. package/dist/core/capabilities/agent-playbook.js +46 -0
  16. package/dist/core/capabilities/audit/accept.d.ts +55 -0
  17. package/dist/core/capabilities/audit/accept.js +147 -0
  18. package/dist/core/capabilities/audit/args.d.ts +95 -0
  19. package/dist/core/capabilities/audit/args.js +89 -0
  20. package/dist/core/capabilities/audit/audit.d.ts +56 -0
  21. package/dist/core/capabilities/audit/audit.js +287 -0
  22. package/dist/core/capabilities/audit/frame.d.ts +14 -0
  23. package/dist/core/capabilities/audit/frame.js +35 -0
  24. package/dist/core/capabilities/audit/gaps.d.ts +96 -0
  25. package/dist/core/capabilities/audit/gaps.js +257 -0
  26. package/dist/core/capabilities/audit/judge.d.ts +162 -0
  27. package/dist/core/capabilities/audit/judge.js +1134 -0
  28. package/dist/core/capabilities/audit/math.d.ts +110 -0
  29. package/dist/core/capabilities/audit/math.js +263 -0
  30. package/dist/core/capabilities/audit/summary.d.ts +74 -0
  31. package/dist/core/capabilities/audit/summary.js +212 -0
  32. package/dist/core/capabilities/board.d.ts +31 -0
  33. package/dist/core/capabilities/board.js +43 -0
  34. package/dist/core/capabilities/camera-view.d.ts +4 -2
  35. package/dist/core/capabilities/camera-view.js +6 -3
  36. package/dist/core/capabilities/connect-signal.d.ts +85 -0
  37. package/dist/core/capabilities/connect-signal.js +285 -0
  38. package/dist/core/capabilities/engine-fallbacks.d.ts +5 -0
  39. package/dist/core/capabilities/engine-fallbacks.js +5 -0
  40. package/dist/core/capabilities/engine-ops.d.ts +5 -1
  41. package/dist/core/capabilities/engine-ops.js +0 -3
  42. package/dist/core/capabilities/engine-receipt.d.ts +12 -0
  43. package/dist/core/capabilities/engine-receipt.js +26 -0
  44. package/dist/core/capabilities/file-read.d.ts +64 -0
  45. package/dist/core/capabilities/file-read.js +325 -0
  46. package/dist/core/capabilities/game-task-plan.js +4 -2
  47. package/dist/core/capabilities/grep.d.ts +60 -0
  48. package/dist/core/capabilities/grep.js +190 -0
  49. package/dist/core/capabilities/inspect-node.d.ts +56 -0
  50. package/dist/core/capabilities/inspect-node.js +225 -0
  51. package/dist/core/capabilities/inspect-resource.d.ts +61 -0
  52. package/dist/core/capabilities/inspect-resource.js +376 -0
  53. package/dist/core/capabilities/math3d.d.ts +28 -0
  54. package/dist/core/capabilities/math3d.js +88 -0
  55. package/dist/core/capabilities/motion-generation.d.ts +62 -0
  56. package/dist/core/capabilities/motion-generation.js +138 -0
  57. package/dist/core/capabilities/placement-batch.d.ts +102 -0
  58. package/dist/core/capabilities/placement-batch.js +483 -0
  59. package/dist/core/capabilities/placement-math.d.ts +70 -0
  60. package/dist/core/capabilities/placement-math.js +209 -0
  61. package/dist/core/capabilities/placement-script.d.ts +42 -0
  62. package/dist/core/capabilities/placement-script.js +1096 -0
  63. package/dist/core/capabilities/placement.d.ts +485 -0
  64. package/dist/core/capabilities/placement.js +1731 -0
  65. package/dist/core/capabilities/project-context.d.ts +33 -2
  66. package/dist/core/capabilities/project-context.js +87 -4
  67. package/dist/core/capabilities/replace-node.d.ts +91 -0
  68. package/dist/core/capabilities/replace-node.js +423 -0
  69. package/dist/core/capabilities/runtime-control.d.ts +20 -4
  70. package/dist/core/capabilities/runtime-control.js +67 -2
  71. package/dist/core/capabilities/scene-batch.d.ts +63 -0
  72. package/dist/core/capabilities/scene-batch.js +394 -0
  73. package/dist/core/capabilities/scene-readback.d.ts +36 -0
  74. package/dist/core/capabilities/scene-readback.js +54 -0
  75. package/dist/core/capabilities/seeing/args.d.ts +348 -0
  76. package/dist/core/capabilities/seeing/args.js +178 -0
  77. package/dist/core/capabilities/seeing/candidates.d.ts +127 -0
  78. package/dist/core/capabilities/seeing/candidates.js +282 -0
  79. package/dist/core/capabilities/seeing/math.d.ts +134 -0
  80. package/dist/core/capabilities/seeing/math.js +269 -0
  81. package/dist/core/capabilities/seeing/probe.d.ts +62 -0
  82. package/dist/core/capabilities/seeing/probe.js +188 -0
  83. package/dist/core/capabilities/seeing/scoring.d.ts +235 -0
  84. package/dist/core/capabilities/seeing/scoring.js +639 -0
  85. package/dist/core/capabilities/seeing/seeing.d.ts +233 -0
  86. package/dist/core/capabilities/seeing/seeing.js +1341 -0
  87. package/dist/core/capabilities/seeing/shot-store.d.ts +52 -0
  88. package/dist/core/capabilities/seeing/shot-store.js +204 -0
  89. package/dist/core/capabilities/surface-snap-mesh.d.ts +88 -0
  90. package/dist/core/capabilities/surface-snap-mesh.js +425 -0
  91. package/dist/core/capabilities/surface-snap.d.ts +77 -0
  92. package/dist/core/capabilities/surface-snap.js +461 -0
  93. package/dist/core/capabilities/tool-dispatch.js +231 -72
  94. package/dist/core/capabilities/tscn.d.ts +85 -0
  95. package/dist/core/capabilities/tscn.js +391 -0
  96. package/dist/core/capabilities/world-snapshot.d.ts +58 -0
  97. package/dist/core/capabilities/world-snapshot.js +150 -0
  98. package/dist/core/capability-skew.d.ts +5 -5
  99. package/dist/core/capability-skew.js +14 -6
  100. package/dist/core/headless/worker-engine-client.d.ts +1 -1
  101. package/dist/core/headless/worker-engine-client.js +2 -2
  102. package/dist/core/library-read.d.ts +37 -5
  103. package/dist/core/library-read.js +264 -33
  104. package/dist/core/library-search.d.ts +2 -2
  105. package/dist/core/skills-registry.d.ts +9 -1
  106. package/dist/core/skills-registry.js +25 -2
  107. package/dist/installer/agent-config.d.ts +9 -4
  108. package/dist/installer/agent-config.js +194 -392
  109. package/dist/installer/agent-table.d.ts +119 -0
  110. package/dist/installer/agent-table.js +619 -0
  111. package/dist/installer/setup.js +4 -2
  112. package/dist/installer/skill-locations.d.ts +8 -17
  113. package/dist/installer/skill-locations.js +22 -101
  114. package/dist/installer/version-check.d.ts +5 -1
  115. package/dist/installer/version-check.js +38 -28
  116. package/dist/mcp/server.js +9 -3
  117. package/dist/mcp/tools/asset-tools.js +21 -3
  118. package/dist/mcp/tools/audit-tools.d.ts +23 -0
  119. package/dist/mcp/tools/audit-tools.js +60 -0
  120. package/dist/mcp/tools/debug-tools.js +3 -1
  121. package/dist/mcp/tools/file-tools.js +17 -4
  122. package/dist/mcp/tools/generate-tools.js +74 -66
  123. package/dist/mcp/tools/library-tools.js +2 -0
  124. package/dist/mcp/tools/perception-tools.js +6 -19
  125. package/dist/mcp/tools/placement-tools.d.ts +24 -0
  126. package/dist/mcp/tools/placement-tools.js +91 -0
  127. package/dist/mcp/tools/project-tools.js +18 -15
  128. package/dist/mcp/tools/scene-tools.js +71 -83
  129. package/dist/mcp/tools/seeing-tools.d.ts +24 -0
  130. package/dist/mcp/tools/seeing-tools.js +64 -0
  131. package/dist/mcp/tools/spatial-tools.js +17 -9
  132. package/dist/mcp/tools/visual-tools.js +59 -7
  133. package/gemini-extension.json +26 -4
  134. package/integrations/README.md +48 -19
  135. package/integrations/amp/README.md +12 -0
  136. package/integrations/amp/manifest-target.json +3 -0
  137. package/integrations/antigravity/README.md +12 -0
  138. package/integrations/antigravity/manifest-target.json +3 -0
  139. package/integrations/claude-desktop/README.md +12 -0
  140. package/integrations/claude-desktop/manifest-target.json +3 -0
  141. package/integrations/cline/README.md +8 -9
  142. package/integrations/cline-cli/README.md +12 -0
  143. package/integrations/cline-cli/manifest-target.json +3 -0
  144. package/integrations/copilot-jetbrains/README.md +12 -0
  145. package/integrations/copilot-jetbrains/manifest-target.json +3 -0
  146. package/integrations/crush/README.md +12 -0
  147. package/integrations/crush/manifest-target.json +3 -0
  148. package/integrations/github-copilot/README.md +8 -8
  149. package/integrations/goose/README.md +12 -0
  150. package/integrations/goose/manifest-target.json +3 -0
  151. package/integrations/grok-build/README.md +12 -0
  152. package/integrations/grok-build/manifest-target.json +3 -0
  153. package/integrations/hermes/README.md +12 -0
  154. package/integrations/hermes/manifest-target.json +3 -0
  155. package/integrations/junie/README.md +12 -0
  156. package/integrations/junie/manifest-target.json +3 -0
  157. package/integrations/kilo-code/README.md +6 -10
  158. package/integrations/kimi-code/README.md +12 -0
  159. package/integrations/kimi-code/manifest-target.json +3 -0
  160. package/integrations/kiro/README.md +12 -0
  161. package/integrations/kiro/manifest-target.json +3 -0
  162. package/integrations/lm-studio/README.md +8 -8
  163. package/integrations/mistral-vibe/README.md +12 -0
  164. package/integrations/mistral-vibe/manifest-target.json +3 -0
  165. package/integrations/opencode/README.md +9 -12
  166. package/integrations/qoder/README.md +12 -0
  167. package/integrations/qoder/manifest-target.json +3 -0
  168. package/integrations/qwen-code/README.md +12 -0
  169. package/integrations/qwen-code/manifest-target.json +3 -0
  170. package/integrations/roo-code/README.md +8 -7
  171. package/integrations/rovo-dev/README.md +12 -0
  172. package/integrations/rovo-dev/manifest-target.json +3 -0
  173. package/integrations/trae/README.md +12 -0
  174. package/integrations/trae/manifest-target.json +3 -0
  175. package/integrations/visual-studio/README.md +12 -0
  176. package/integrations/visual-studio/manifest-target.json +3 -0
  177. package/integrations/vscode-copilot/README.md +9 -9
  178. package/integrations/warp/README.md +12 -0
  179. package/integrations/warp/manifest-target.json +3 -0
  180. package/integrations/windsurf/README.md +8 -9
  181. package/integrations/zed/README.md +12 -0
  182. package/integrations/zed/manifest-target.json +3 -0
  183. package/library/references/mcp-tools-reference/mcp-tools-reference.md +59 -15
  184. package/library/references/mcp-tools-reference/resource.yaml +1 -1
  185. package/library/references/summer-folder/resource.yaml +1 -1
  186. package/library/references/summer-folder/summer-folder.md +1 -1
  187. package/library/skills/3d-lighting/SKILL.md +1 -1
  188. package/library/skills/README.md +264 -0
  189. package/library/skills/adaptive-music/SKILL.md +1 -1
  190. package/library/skills/agent-playtesting/SKILL.md +1 -1
  191. package/library/skills/ai-characters-in-games/SKILL.md +220 -0
  192. package/library/skills/ai-characters-in-games/resource.yaml +30 -0
  193. package/library/skills/ambient-bed/SKILL.md +1 -1
  194. package/library/skills/animated-loop/SKILL.md +1 -1
  195. package/library/skills/animation-tree/SKILL.md +1 -1
  196. package/library/skills/art-direction/SKILL.md +1 -1
  197. package/library/skills/asset-strategy/SKILL.md +2 -1
  198. package/library/skills/asset-strategy/resource.yaml +2 -1
  199. package/library/skills/audio-direction/SKILL.md +1 -1
  200. package/library/skills/auto-fire-targeting/SKILL.md +1 -1
  201. package/library/skills/brainstorm-game/SKILL.md +1 -1
  202. package/library/skills/brainstorming/SKILL.md +1 -1
  203. package/library/skills/browse-templates/SKILL.md +3 -3
  204. package/library/skills/browse-templates/resource.yaml +1 -1
  205. package/library/skills/camera-rigs/SKILL.md +128 -0
  206. package/library/skills/camera-rigs/resource.yaml +29 -0
  207. package/library/skills/celeste-momentum-platforming/SKILL.md +1 -1
  208. package/library/skills/character-animation-wiring/SKILL.md +1 -1
  209. package/library/skills/character-model/SKILL.md +1 -1
  210. package/library/skills/character-movement/SKILL.md +326 -0
  211. package/library/skills/character-movement/references/default-third-person-controller.gd +201 -0
  212. package/library/skills/character-movement/references/default-third-person-verification-probe.gd +154 -0
  213. package/library/skills/character-movement/references/movement-3d.md +136 -0
  214. package/library/skills/character-movement/references/platformer-feel.md +83 -0
  215. package/library/skills/character-movement/resource.yaml +40 -0
  216. package/library/skills/character-portrait/SKILL.md +1 -1
  217. package/library/skills/cinematic-cutscene/SKILL.md +1 -1
  218. package/library/skills/combat-basics/SKILL.md +159 -0
  219. package/library/skills/combat-basics/references/projectiles-and-aoe.md +100 -0
  220. package/library/skills/combat-basics/resource.yaml +34 -0
  221. package/library/skills/concept-art/SKILL.md +1 -1
  222. package/library/skills/create-asset-sheet/SKILL.md +1 -1
  223. package/library/skills/debug/SKILL.md +1 -1
  224. package/library/skills/debugging-game-feel/SKILL.md +1 -1
  225. package/library/skills/design-level/SKILL.md +1 -1
  226. package/library/skills/design-mechanic/SKILL.md +1 -1
  227. package/library/skills/design-npc/SKILL.md +1 -1
  228. package/library/skills/diagnosing-perf-regressions/SKILL.md +1 -1
  229. package/library/skills/dispatching-parallel-agents/SKILL.md +1 -1
  230. package/library/skills/driving-the-editor-ui/SKILL.md +1 -1
  231. package/library/skills/environment-kit/SKILL.md +1 -1
  232. package/library/skills/export-and-ship/SKILL.md +1 -1
  233. package/library/skills/fabricating-assets/SKILL.md +1 -1
  234. package/library/skills/facial-and-lipsync/SKILL.md +1 -1
  235. package/library/skills/fps-controller/SKILL.md +9 -7
  236. package/library/skills/fps-controller/resource.yaml +4 -1
  237. package/library/skills/game-feel/SKILL.md +1 -1
  238. package/library/skills/gameskill/SKILL.md +1 -1
  239. package/library/skills/gdscript-patterns/SKILL.md +1 -1
  240. package/library/skills/generate-motion/SKILL.md +9 -9
  241. package/library/skills/generate-motion/resource.yaml +2 -1
  242. package/library/skills/godogen-visual-proof-game-generation/SKILL.md +1 -1
  243. package/library/skills/headless-scripting/SKILL.md +1 -1
  244. package/library/skills/in-game-purchases/SKILL.md +254 -0
  245. package/library/skills/in-game-purchases/resource.yaml +29 -0
  246. package/library/skills/instantiate-asset-pack/SKILL.md +1 -1
  247. package/library/skills/investigating-bugs/SKILL.md +1 -1
  248. package/library/skills/lumera-single-image-scene-reconstruction/SKILL.md +1 -1
  249. package/library/skills/make-game/SKILL.md +25 -3
  250. package/library/skills/make-game/references/2d-platformer-polish.md +93 -0
  251. package/library/skills/make-game/references/2d-platformer.md +117 -0
  252. package/library/skills/make-game/references/2d-rpg-content.md +234 -0
  253. package/library/skills/make-game/references/2d-rpg.md +241 -0
  254. package/library/skills/make-game/references/2d-survivors-progression.md +201 -0
  255. package/library/skills/make-game/references/2d-survivors.md +237 -0
  256. package/library/skills/make-game/references/3d-fps-systems.md +216 -0
  257. package/library/skills/make-game/references/3d-fps.md +177 -0
  258. package/library/skills/make-game/references/3d-horror-systems.md +186 -0
  259. package/library/skills/make-game/references/3d-horror.md +201 -0
  260. package/library/skills/make-game/references/3d-third-person-systems.md +220 -0
  261. package/library/skills/make-game/references/3d-third-person.md +215 -0
  262. package/library/skills/make-game/resource.yaml +6 -1
  263. package/library/skills/multiplayer/SKILL.md +150 -0
  264. package/library/skills/multiplayer/resource.yaml +33 -0
  265. package/library/skills/multiplayer-movement/SKILL.md +510 -0
  266. package/library/skills/multiplayer-movement/files.md +501 -0
  267. package/library/skills/multiplayer-movement/resource.yaml +30 -0
  268. package/library/skills/multiplayer-project/SKILL.md +521 -0
  269. package/library/skills/multiplayer-project/files.md +448 -0
  270. package/library/skills/multiplayer-project/resource.yaml +36 -0
  271. package/library/skills/multiplayer-publish/SKILL.md +229 -0
  272. package/library/skills/multiplayer-publish/export-errors.md +89 -0
  273. package/library/skills/multiplayer-publish/resource.yaml +28 -0
  274. package/library/skills/multiplayer-state/SKILL.md +438 -0
  275. package/library/skills/multiplayer-state/files.md +592 -0
  276. package/library/skills/multiplayer-state/resource.yaml +35 -0
  277. package/library/skills/multiplayer-testing/SKILL.md +236 -0
  278. package/library/skills/multiplayer-testing/resource.yaml +39 -0
  279. package/library/skills/music-track/SKILL.md +1 -1
  280. package/library/skills/navigate-summer/SKILL.md +1 -1
  281. package/library/skills/new-project/SKILL.md +1 -1
  282. package/library/skills/organic-model/SKILL.md +1 -1
  283. package/library/skills/pixel-art/SKILL.md +1 -1
  284. package/library/skills/play/SKILL.md +1 -1
  285. package/library/skills/playtesting-a-feature/SKILL.md +1 -1
  286. package/library/skills/procedural-animation/SKILL.md +2 -2
  287. package/library/skills/procedural-animation/resource.yaml +1 -1
  288. package/library/skills/prop-model/SKILL.md +1 -1
  289. package/library/skills/psx-retro-rendering/SKILL.md +1 -1
  290. package/library/skills/realtime-wet-surfaces/SKILL.md +2 -2
  291. package/library/skills/remote-deploy/SKILL.md +1 -1
  292. package/library/skills/retarget/SKILL.md +1 -1
  293. package/library/skills/running-in-the-cloud/SKILL.md +1 -1
  294. package/library/skills/save-load/SKILL.md +156 -0
  295. package/library/skills/save-load/resource.yaml +27 -0
  296. package/library/skills/scene-composition/SKILL.md +1 -1
  297. package/library/skills/scene-hierarchy-design/SKILL.md +1 -1
  298. package/library/skills/scene-scripting/SKILL.md +1 -1
  299. package/library/skills/scene-to-level/SKILL.md +1 -1
  300. package/library/skills/skill-create/SKILL.md +1 -1
  301. package/library/skills/skill-improve/SKILL.md +1 -1
  302. package/library/skills/skill-test/SKILL.md +1 -1
  303. package/library/skills/skintokens-auto-rigging/SKILL.md +1 -1
  304. package/library/skills/skybox-panorama/SKILL.md +1 -1
  305. package/library/skills/sound-effect/SKILL.md +1 -1
  306. package/library/skills/spatial-placement/SKILL.md +134 -3
  307. package/library/skills/spatial-placement/references/kit-placement-tools.md +364 -0
  308. package/library/skills/spatial-placement/resource.yaml +16 -2
  309. package/library/skills/sprite-sheet/SKILL.md +1 -1
  310. package/library/skills/summer-analytics/SKILL.md +147 -0
  311. package/library/skills/summer-analytics/resource.yaml +28 -0
  312. package/library/skills/summer-friends/SKILL.md +226 -0
  313. package/library/skills/summer-friends/resource.yaml +28 -0
  314. package/library/skills/summer-leaderboards/SKILL.md +231 -0
  315. package/library/skills/summer-leaderboards/resource.yaml +30 -0
  316. package/library/skills/summer-match-results/SKILL.md +286 -0
  317. package/library/skills/summer-match-results/resource.yaml +32 -0
  318. package/library/skills/summer-matchmaking/SKILL.md +301 -0
  319. package/library/skills/summer-matchmaking/resource.yaml +32 -0
  320. package/library/skills/summer-parties/SKILL.md +253 -0
  321. package/library/skills/summer-parties/resource.yaml +30 -0
  322. package/library/skills/summer-player-data/SKILL.md +322 -0
  323. package/library/skills/summer-player-data/resource.yaml +30 -0
  324. package/library/skills/summer-store/SKILL.md +271 -0
  325. package/library/skills/summer-store/resource.yaml +29 -0
  326. package/library/skills/summer-world-chat/SKILL.md +195 -0
  327. package/library/skills/summer-world-chat/resource.yaml +30 -0
  328. package/library/skills/summer-world-saves/SKILL.md +284 -0
  329. package/library/skills/summer-world-saves/resource.yaml +29 -0
  330. package/library/skills/text-to-motion/SKILL.md +99 -0
  331. package/library/skills/text-to-motion/prompts-and-controls.md +34 -0
  332. package/library/skills/text-to-motion/resource.yaml +41 -0
  333. package/library/skills/text-to-motion/rigging-for-text-motion.md +54 -0
  334. package/library/skills/tileable-texture/SKILL.md +1 -1
  335. package/library/skills/trailer-shot/SKILL.md +1 -1
  336. package/library/skills/tune-performance/SKILL.md +1 -1
  337. package/library/skills/ui-basics/SKILL.md +10 -1
  338. package/library/skills/ui-graphics/SKILL.md +1 -1
  339. package/library/skills/use-widget-asset/SKILL.md +1 -1
  340. package/library/skills/using-summer/SKILL.md +2 -2
  341. package/library/skills/using-summer/resource.yaml +2 -2
  342. package/library/skills/vehicle-model/SKILL.md +1 -1
  343. package/library/skills/verification-before-completion/SKILL.md +1 -1
  344. package/library/skills/verifying-scenes/SKILL.md +76 -2
  345. package/library/skills/verifying-scenes/resource.yaml +13 -2
  346. package/library/skills/vfx-dissolve/SKILL.md +1 -1
  347. package/library/skills/vfx-fire/SKILL.md +1 -1
  348. package/library/skills/vfx-hit-spark/SKILL.md +1 -1
  349. package/library/skills/vfx-lightning/SKILL.md +1 -1
  350. package/library/skills/vfx-magic-glow/SKILL.md +1 -1
  351. package/library/skills/vfx-muzzle-flash/SKILL.md +1 -1
  352. package/library/skills/vfx-smoke/SKILL.md +1 -1
  353. package/library/skills/vfx-water-ripple/SKILL.md +1 -1
  354. package/library/skills/voice-line/SKILL.md +1 -1
  355. package/library/skills/web-and-touch-input/SKILL.md +38 -0
  356. package/library/skills/web-and-touch-input/resource.yaml +28 -0
  357. package/library/skills/web-compatibility-look/SKILL.md +62 -0
  358. package/library/skills/web-compatibility-look/resource.yaml +30 -0
  359. package/library/skills/world-building-3d/SKILL.md +8 -1
  360. package/library/skills/world-building-3d/references/spatial-tools.md +1 -1
  361. package/library/skills/world-building-3d/resource.yaml +2 -1
  362. package/library/skills/writing-plans/SKILL.md +1 -1
  363. package/library/skills/writing-skills/SKILL.md +1 -1
  364. package/library/templates/3d-lan-multiplayer-starter/resource.yaml +2 -2
  365. package/library/tools/attach-to-surface/resource.yaml +142 -0
  366. package/library/tools/batch/resource.yaml +9 -3
  367. package/library/tools/connect-ports/resource.yaml +120 -0
  368. package/library/tools/connect-signal/resource.yaml +3 -2
  369. package/library/tools/debug-views/resource.yaml +94 -0
  370. package/library/tools/frame-nodes/resource.yaml +104 -0
  371. package/library/tools/frame-shot/resource.yaml +134 -0
  372. package/library/tools/generate-motion/resource.yaml +44 -9
  373. package/library/tools/get-board/resource.yaml +50 -0
  374. package/library/tools/get-project-context/resource.yaml +13 -2
  375. package/library/tools/grep/resource.yaml +77 -0
  376. package/library/tools/inspect-asset/resource.yaml +71 -0
  377. package/library/tools/inspect-node/resource.yaml +9 -2
  378. package/library/tools/inspect-resource/resource.yaml +16 -5
  379. package/library/tools/instantiate-scene/resource.yaml +27 -2
  380. package/library/tools/measure/resource.yaml +96 -0
  381. package/library/tools/place-adjacent/resource.yaml +113 -0
  382. package/library/tools/play/resource.yaml +15 -2
  383. package/library/tools/raycast/resource.yaml +101 -0
  384. package/library/tools/read-file/resource.yaml +36 -2
  385. package/library/tools/read-library/resource.yaml +2 -2
  386. package/library/tools/repeat-along/resource.yaml +129 -0
  387. package/library/tools/replace-node/resource.yaml +11 -5
  388. package/library/tools/scene-audit/resource.yaml +144 -0
  389. package/library/tools/screenshot/resource.yaml +13 -2
  390. package/library/tools/search-assets/resource.yaml +18 -0
  391. package/library/tools/shot-sheet/resource.yaml +110 -0
  392. package/library/tools/snap-to-surface/resource.yaml +4 -1
  393. package/library/tools/world-snapshot/resource.yaml +24 -3
  394. package/library/tools/zoom/resource.yaml +105 -0
  395. package/package.json +3 -3
  396. package/registry/generated/aliases.json +17 -3
  397. package/registry/generated/counts.json +3 -3
  398. package/registry/generated/gemini-extension.json +26 -4
  399. package/registry/generated/index.json +2154 -469
  400. package/registry/generated/marketplace.claude.json +2 -2
  401. package/registry/generated/plugin.claude.json +27 -5
  402. package/registry/generated/plugin.codex.json +28 -6
  403. package/registry/generated/plugin.cursor.json +27 -5
  404. package/registry/generated/plugin.factory.json +27 -5
  405. package/registry/generated/skills-index.md +264 -0
  406. package/registry/generated/skills-registry.json +901 -206
  407. package/registry/generated/templates-registry.json +2 -2
  408. package/library/skills/host-authoritative-state/SKILL.md +0 -332
  409. package/library/skills/host-authoritative-state/resource.yaml +0 -28
  410. package/library/skills/peer-to-peer-multiplayer/SKILL.md +0 -373
  411. package/library/skills/peer-to-peer-multiplayer/resource.yaml +0 -28
  412. package/library/skills/setup-multiplayer/SKILL.md +0 -336
  413. package/library/skills/setup-multiplayer/resource.yaml +0 -28
@@ -0,0 +1,1341 @@
1
+ /**
2
+ * The seeing tools — ONE implementation for both faces (MCP: src/mcp/tools/
3
+ * seeing-tools.ts; CLI: tool-dispatch.ts). Each operation validates its
4
+ * arguments (ToolInputError: nothing sent), talks to the engine only through
5
+ * read-only ops (ScenePreview, ListCameraBookmarks; SaveCameraBookmark when
6
+ * the caller asked to save a pose), and returns either an inline image plus a
7
+ * compact caption, or a structured failure. No silent fallbacks.
8
+ *
9
+ * frameNodes fit a pose to node bounds, render it with the REAL environment
10
+ * shotSheet N bookmarks/poses in one labelled grid (compare_previous too)
11
+ * debugViews one pose as beauty/lighting/unshaded/normals/overdraw/wireframe
12
+ * zoom an exact sub-frustum of a region or of mark N, at full resolution
13
+ * frameShot smart framing: candidates -> thick-sweep visibility -> score
14
+ */
15
+ import { analyzeFrameBase64 } from "../frame-quality.js";
16
+ import { missingEngineOpResult } from "../../capability-skew.js";
17
+ import { ToolInputError } from "../../tool-errors.js";
18
+ import { BOOKMARK_NAME_PATTERN, formatSceneMarks, readSceneMarks } from "../camera-view.js";
19
+ import { resolveCurrentScene } from "../project-context.js";
20
+ import { aabbCenter, add, clamp, deg, directionFromAngles, DIRECTION_PRESETS, exactCrop, fitDistance, letterbox, formatVector3, mergeAabbs, normalize, parseVector3, roundVec, scale, } from "./math.js";
21
+ import { chooseCorridorAxis, corridorBox, corridorFloorY, eyeBand, generateCandidates, SHOT_DEFAULTS, subjectSamples, } from "./candidates.js";
22
+ import { EMPTY_LIMIT, heightAboveGround, pickTop, rankOrder, rejectionCounts, scoreMeasurement, viewProblem, } from "./scoring.js";
23
+ import { makeProbeDir, runProbe } from "./probe.js";
24
+ import { SHOT_MAX_EDGE, ShotStore, ShotStoreError } from "./shot-store.js";
25
+ import { readFile } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+ // ---------------------------------------------------------------------------
28
+ // Shared types
29
+ // ---------------------------------------------------------------------------
30
+ export const VIEW_MODES = ["beauty", "lighting", "unshaded", "normals", "overdraw", "wireframe"];
31
+ export const SEEING_FALLBACK = 'render explicit poses with summer_screenshot target:"scene" framing:"free" (camera_position + camera_look_at), one image per call';
32
+ const DEFAULT_ASPECT = 16 / 9;
33
+ const DEFAULT_SINGLE_EDGE = 1024;
34
+ const DEFAULT_SHEET_EDGE = 1536;
35
+ export const MAX_IMAGE_EDGE = 4096;
36
+ const TILE_GAP = 4;
37
+ // ---------------------------------------------------------------------------
38
+ // Argument validation (strict: these strings reach a scene file and the engine)
39
+ // ---------------------------------------------------------------------------
40
+ const SCENE_PATH_PATTERN = /^res:\/\/[A-Za-z0-9_\-./ ()+,@]{1,480}\.(tscn|scn)$/;
41
+ const NODE_PATH_PATTERN = /^(\.|[A-Za-z0-9_\- ]{1,128}(\/[A-Za-z0-9_\- ]{1,128}){0,31})$/;
42
+ const LABEL_PATTERN = /^[^\u0000-\u001f\u007f"'`\\$]{1,64}$/;
43
+ export function validateScenePath(path) {
44
+ const p = path.trim();
45
+ if (!SCENE_PATH_PATTERN.test(p) || p.split("/").includes("..")) {
46
+ throw new ToolInputError(`scenePath must be a res:// .tscn/.scn path of letters, digits and _ - . / ( ) + , @ or spaces, without ".." (got ${JSON.stringify(path).slice(0, 120)}). Nothing was sent.`);
47
+ }
48
+ return p;
49
+ }
50
+ export function validateNodePath(path, label) {
51
+ const p = path.trim();
52
+ if (!NODE_PATH_PATTERN.test(p) || Buffer.byteLength(p, "utf8") > 256) {
53
+ throw new ToolInputError(`${label} must be a node path relative to the scene root ("House1/Front") of letters, digits, _ - and spaces, at most 256 bytes (got ${JSON.stringify(path).slice(0, 120)}). Nothing was sent.`);
54
+ }
55
+ return p;
56
+ }
57
+ export function validateLabel(label) {
58
+ if (!LABEL_PATTERN.test(label)) {
59
+ throw new ToolInputError(`label must be 1-64 printable characters without quotes, backslashes, backticks or "$" (got ${JSON.stringify(label).slice(0, 80)}). Nothing was sent.`);
60
+ }
61
+ return label;
62
+ }
63
+ export function validateBookmarkName(name, label = "bookmark_name") {
64
+ const n = name.trim();
65
+ if (!BOOKMARK_NAME_PATTERN.test(n)) {
66
+ throw new ToolInputError(`${label} must be 1-64 characters from A-Z a-z 0-9 _ - (got ${JSON.stringify(name).slice(0, 80)}). Nothing was sent.`);
67
+ }
68
+ return n;
69
+ }
70
+ function validateFov(fov, label = "fov") {
71
+ if (fov === undefined)
72
+ return undefined;
73
+ if (!Number.isFinite(fov) || fov < 1 || fov > 179)
74
+ throw new ToolInputError(`${label} must be 1..179 degrees. Nothing was sent.`);
75
+ return fov;
76
+ }
77
+ function validateEdge(edge, fallback) {
78
+ if (edge === undefined)
79
+ return fallback;
80
+ if (!Number.isInteger(edge) || edge < 64 || edge > MAX_IMAGE_EDGE) {
81
+ throw new ToolInputError(`max_size must be an integer from 64 through ${MAX_IMAGE_EDGE} (longest edge in pixels). Nothing was sent.`);
82
+ }
83
+ return edge;
84
+ }
85
+ function validateAspect(aspect) {
86
+ if (aspect === undefined)
87
+ return DEFAULT_ASPECT;
88
+ if (!Number.isFinite(aspect) || aspect < 0.25 || aspect > 4)
89
+ throw new ToolInputError("aspect must be a width/height ratio from 0.25 through 4 (16:9 = 1.7778). Nothing was sent.");
90
+ return aspect;
91
+ }
92
+ function validateOccluders(occ) {
93
+ if (!occ)
94
+ return {};
95
+ const out = {};
96
+ for (const key of ["hard", "soft", "ignore"]) {
97
+ const list = occ[key];
98
+ if (!list)
99
+ continue;
100
+ if (list.length > 32)
101
+ throw new ToolInputError(`occluders.${key} takes at most 32 node paths. Nothing was sent.`);
102
+ out[key] = list.map((p, i) => validateNodePath(p, `occluders.${key}[${i}]`));
103
+ }
104
+ for (const key of ["hard_layers", "soft_layers"]) {
105
+ const v = occ[key];
106
+ if (v === undefined)
107
+ continue;
108
+ if (!Number.isInteger(v) || v < 0 || v > 0xffffffff)
109
+ throw new ToolInputError(`occluders.${key} must be a 32-bit physics layer mask. Nothing was sent.`);
110
+ out[key] = v;
111
+ }
112
+ return out;
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // Helpers
116
+ // ---------------------------------------------------------------------------
117
+ function fail(failure_reason, error, hint, detail) {
118
+ return { ok: false, failure_reason, error, ...(hint ? { hint } : {}), ...(detail ? { detail } : {}) };
119
+ }
120
+ function preflight(client, ops) {
121
+ for (const op of ops) {
122
+ const missing = missingEngineOpResult(client, op, SEEING_FALLBACK);
123
+ if (missing)
124
+ return fail(missing.failure_reason, missing.error, missing.hint, { op, engine_version: missing.engine_version });
125
+ }
126
+ return null;
127
+ }
128
+ async function resolveScene(client, scenePath) {
129
+ if (scenePath)
130
+ return validateScenePath(scenePath);
131
+ const current = resolveCurrentScene(undefined, await client.getSceneState(), undefined);
132
+ if (!current)
133
+ throw new ToolInputError("No scenePath given and no scene is open in the editor. Pass scenePath (\"res://...tscn\"). Nothing was sent.");
134
+ return validateScenePath(current);
135
+ }
136
+ function firstResult(response) {
137
+ if (!response || typeof response !== "object")
138
+ return {};
139
+ const envelope = response;
140
+ const first = Array.isArray(envelope.results) ? envelope.results[0] : undefined;
141
+ return first && typeof first === "object" ? first : envelope;
142
+ }
143
+ async function listBookmarks(client) {
144
+ const response = firstResult(await client.executeOps([{ op: "ListCameraBookmarks" }]));
145
+ if (response.ok === false)
146
+ return fail(String(response.failure_reason ?? "bookmark_list_failed"), String(response.error ?? "ListCameraBookmarks failed"));
147
+ const out = {};
148
+ const raw = (response.bookmarks ?? {});
149
+ for (const [name, entry] of Object.entries(raw)) {
150
+ try {
151
+ out[name] = {
152
+ pose: {
153
+ position: parseVector3(String(entry.position), `bookmark ${name} position`),
154
+ look_at: parseVector3(String(entry.look_at), `bookmark ${name} look_at`),
155
+ fov: typeof entry.fov === "number" ? entry.fov : 60,
156
+ },
157
+ ...(typeof entry.created === "string" ? { created: entry.created } : {}),
158
+ };
159
+ }
160
+ catch {
161
+ // A malformed entry is reported when it is asked for by name.
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+ function poseFromArgs(args, label) {
167
+ const fov = validateFov(args.fov, `${label} fov`);
168
+ const bookmark = args.bookmark_name?.trim();
169
+ if (bookmark) {
170
+ if (args.camera_position || args.camera_look_at) {
171
+ throw new ToolInputError(`${label}: bookmark_name takes its pose from the bookmark; drop camera_position/camera_look_at. Nothing was sent.`);
172
+ }
173
+ return { needsBookmark: validateBookmarkName(bookmark), ...(fov !== undefined ? { fov } : {}) };
174
+ }
175
+ if (!args.camera_position || !args.camera_look_at) {
176
+ throw new ToolInputError(`${label} needs bookmark_name, or BOTH camera_position and camera_look_at as "Vector3(x, y, z)" literals. Nothing was sent.`);
177
+ }
178
+ const position = parseVector3(args.camera_position, `${label} camera_position`);
179
+ const look_at = parseVector3(args.camera_look_at, `${label} camera_look_at`);
180
+ if (position.every((v, i) => Math.abs(v - look_at[i]) < 1e-6))
181
+ throw new ToolInputError(`${label}: camera_position and camera_look_at must differ. Nothing was sent.`);
182
+ return { pose: { position, look_at, fov: fov ?? 60 } };
183
+ }
184
+ async function resolvePoses(client, items) {
185
+ const pending = items.map((item) => poseFromArgs(item.args, item.label));
186
+ let bookmarks = null;
187
+ const out = [];
188
+ for (const p of pending) {
189
+ if ("pose" in p) {
190
+ out.push(p);
191
+ continue;
192
+ }
193
+ if (!bookmarks) {
194
+ const listed = await listBookmarks(client);
195
+ if ("ok" in listed && listed.ok === false)
196
+ return listed;
197
+ bookmarks = listed;
198
+ }
199
+ const hit = bookmarks[p.needsBookmark];
200
+ if (!hit) {
201
+ const names = Object.keys(bookmarks).sort();
202
+ return fail("unknown_bookmark", `No camera bookmark named "${p.needsBookmark}".`, names.length ? `Saved bookmarks: ${names.join(", ")}.` : 'No bookmarks are saved yet — summer_camera_bookmark action:"save", or summer_frame_nodes / summer_frame_shot with bookmark_name.');
203
+ }
204
+ out.push({ pose: { ...hit.pose, ...(p.fov !== undefined ? { fov: p.fov } : {}) }, bookmark: p.needsBookmark, ...(hit.created ? { created: hit.created } : {}) });
205
+ }
206
+ return out;
207
+ }
208
+ function poseLiteral(pose) {
209
+ return `position ${formatVector3(pose.position)}, look_at ${formatVector3(pose.look_at)}, fov ${Math.round(pose.fov * 10) / 10}`;
210
+ }
211
+ function poseRecord(pose) {
212
+ return { position: formatVector3(pose.position), look_at: formatVector3(pose.look_at), fov: Math.round(pose.fov * 100) / 100 };
213
+ }
214
+ function sizeFor(edge, aspect) {
215
+ const even = (n) => Math.max(16, Math.round(n / 2) * 2);
216
+ return aspect >= 1 ? [even(edge), even(edge / aspect)] : [even(edge * aspect), even(edge)];
217
+ }
218
+ /** Same-size tiles in a grid whose longest edge is `maxEdge`. */
219
+ export function layoutGrid(count, aspect, maxEdge, forcedCols) {
220
+ const cols = forcedCols ?? (count <= 3 ? count : count === 4 ? 2 : count <= 9 ? 3 : 4);
221
+ const rows = Math.ceil(count / cols);
222
+ // Width-limited first, then shrink if the grid is taller than wide.
223
+ let tileW = Math.floor((maxEdge - TILE_GAP * (cols - 1)) / cols);
224
+ let tileH = Math.round(tileW / aspect);
225
+ const height = rows * tileH + TILE_GAP * (rows - 1);
226
+ if (height > maxEdge) {
227
+ tileH = Math.floor((maxEdge - TILE_GAP * (rows - 1)) / rows);
228
+ tileW = Math.round(tileH * aspect);
229
+ }
230
+ tileW = Math.max(16, tileW - (tileW % 2));
231
+ tileH = Math.max(16, tileH - (tileH % 2));
232
+ const rects = [];
233
+ for (let i = 0; i < count; i++) {
234
+ const c = i % cols;
235
+ const r = Math.floor(i / cols);
236
+ rects.push([c * (tileW + TILE_GAP), r * (tileH + TILE_GAP), tileW, tileH]);
237
+ }
238
+ return {
239
+ cols,
240
+ rows,
241
+ tile: [tileW, tileH],
242
+ canvas: [cols * tileW + TILE_GAP * (cols - 1), rows * tileH + TILE_GAP * (rows - 1)],
243
+ rects,
244
+ };
245
+ }
246
+ function shotStore(client) {
247
+ try {
248
+ return ShotStore.forProject(typeof client.getProjectRoot === "function" ? client.getProjectRoot() : undefined);
249
+ }
250
+ catch (err) {
251
+ return err instanceof ShotStoreError ? err : new ShotStoreError("no_project", String(err));
252
+ }
253
+ }
254
+ function imageFrom(run) {
255
+ return run.image ? { ...run.image } : null;
256
+ }
257
+ function flatWarning(image) {
258
+ if (!image)
259
+ return null;
260
+ const q = analyzeFrameBase64(image.base64, image.mime);
261
+ return q.analyzable && q.flat ? "WARNING: the returned image is uniformly flat — nothing visible was rendered. Do not judge the scene from it; check the poses and summer_get_console." : null;
262
+ }
263
+ function capCaption(lines, limit = 4800) {
264
+ let text = lines.filter(Boolean).join("\n");
265
+ if (Buffer.byteLength(text, "utf8") > limit)
266
+ text = `${text.slice(0, limit - 40)}\n... (caption truncated)`;
267
+ return text;
268
+ }
269
+ async function storeSlots(store, plans, run) {
270
+ const written = [];
271
+ const notes = [];
272
+ if (!plans.length)
273
+ return { written, notes };
274
+ if (store instanceof ShotStoreError) {
275
+ notes.push(`before/after slots not kept: ${store.message}`);
276
+ return { written, notes };
277
+ }
278
+ const captures = Array.isArray(run.result?.captures) ? run.result.captures : [];
279
+ for (const plan of plans) {
280
+ if (!captures.some((c) => c.i === plan.tile)) {
281
+ notes.push(`slot for "${plan.bookmark}" not updated: the engine produced no capture for it`);
282
+ continue;
283
+ }
284
+ try {
285
+ written.push(await store.writeSlot(plan.bookmark, await readFile(plan.capturePath)));
286
+ }
287
+ catch (err) {
288
+ notes.push(`slot for "${plan.bookmark}" not updated: ${err instanceof Error ? err.message : String(err)}`);
289
+ }
290
+ }
291
+ return { written, notes };
292
+ }
293
+ async function saveCopy(store, name, image) {
294
+ if (!name)
295
+ return {};
296
+ if (store instanceof ShotStoreError)
297
+ return { note: `save_to skipped: ${store.message}` };
298
+ if (!image)
299
+ return { note: "save_to skipped: no image" };
300
+ try {
301
+ return { saved: await store.saveCopy(name, Buffer.from(image.base64, "base64")) };
302
+ }
303
+ catch (err) {
304
+ return { note: `save_to failed: ${err instanceof Error ? err.message : String(err)}` };
305
+ }
306
+ }
307
+ function slotLines(written, notes, saved, saveNote) {
308
+ const lines = [];
309
+ for (const w of written)
310
+ lines.push(`kept as previous for the next compare: ${w.resPath} (${w.width}x${w.height}${w.evicted.length ? `; evicted ${w.evicted.join(", ")}` : ""})`);
311
+ if (saved)
312
+ lines.push(`saved copy: ${saved.resPath} (${saved.width}x${saved.height})`);
313
+ for (const n of notes)
314
+ lines.push(`NOTE: ${n}`);
315
+ if (saveNote)
316
+ lines.push(`NOTE: ${saveNote}`);
317
+ return lines;
318
+ }
319
+ function checkSaveTo(saveTo, edge) {
320
+ if (saveTo === undefined)
321
+ return undefined;
322
+ const name = saveTo.trim();
323
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name))
324
+ throw new ToolInputError("save_to must be 1-64 characters from A-Z a-z 0-9 _ - (a file name under res://.summer/shots/saved/). Nothing was sent.");
325
+ if (edge > SHOT_MAX_EDGE)
326
+ throw new ToolInputError(`save_to stores images up to ${SHOT_MAX_EDGE} px on the longest edge; pass max_size <= ${SHOT_MAX_EDGE} with save_to. Nothing was sent.`);
327
+ return name;
328
+ }
329
+ function probeFailure(run) {
330
+ const result = run.result ?? {};
331
+ const missing = Array.isArray(result.missing) ? result.missing : undefined;
332
+ return fail(run.failureReason ?? "probe_failed", run.error ?? "The engine could not run the seeing pass.", run.failureReason === "node_not_found"
333
+ ? "Pass node paths relative to the scene root (summer_get_scene_tree shows them), e.g. \"House1\" or \"House1/Front\"."
334
+ : run.failureReason === "game_running"
335
+ ? "Offscreen renders are disabled while a game is running; stop it first (summer_stop) or capture the game with summer_screenshot target:\"game\"."
336
+ : undefined, missing ? { missing } : undefined);
337
+ }
338
+ function readSubjects(result) {
339
+ const raw = Array.isArray(result.subjects) ? result.subjects : [];
340
+ return raw.map((s) => {
341
+ const box = s.aabb;
342
+ return {
343
+ path: String(s.path),
344
+ resolved: String(s.resolved),
345
+ has_geometry: s.has_geometry === true,
346
+ visuals: Number(s.visuals ?? 0),
347
+ aabb: { position: box.position, size: box.size },
348
+ };
349
+ });
350
+ }
351
+ function aabbLine(box) {
352
+ const c = aabbCenter(box);
353
+ return `center ${formatVector3(c)}, size ${formatVector3(box.size)}`;
354
+ }
355
+ async function nativeRender(client, input) {
356
+ const op = { op: "ScenePreview", scene_path: input.scenePath, framing: input.framing, size: input.size };
357
+ if (input.pose) {
358
+ op.camera_position = formatVector3(input.pose.position);
359
+ op.camera_look_at = formatVector3(input.pose.look_at);
360
+ op.fov = input.pose.fov;
361
+ }
362
+ else if (input.fov !== undefined) {
363
+ op.fov = input.fov;
364
+ }
365
+ if (input.marks)
366
+ op.marks = true;
367
+ if (input.maxMarks !== undefined)
368
+ op.max_marks = input.maxMarks;
369
+ const r = firstResult(await client.executeOps([op]));
370
+ const base64 = typeof r.image_base64 === "string" ? r.image_base64 : undefined;
371
+ const meta = { ...r };
372
+ delete meta.image_base64;
373
+ if (r.ok === false || !base64) {
374
+ return { ok: false, meta, failureReason: typeof r.failure_reason === "string" ? r.failure_reason : "preview_failed", error: typeof r.error === "string" ? r.error : "ScenePreview returned no image." };
375
+ }
376
+ const expected = input.framing.startsWith("bookmark:") ? input.framing : input.framing;
377
+ if (typeof r.framing === "string" && r.framing !== expected) {
378
+ return { ok: false, meta, failureReason: "framing_unsupported", error: `This engine resolved framing "${input.framing}" to "${r.framing}" (it predates fixed-pose framings), so the frame would not show the requested pose with the real environment.` };
379
+ }
380
+ return {
381
+ ok: true,
382
+ meta,
383
+ image: { base64, mime: typeof r.mime === "string" ? r.mime : "image/jpeg", ...(typeof r.width === "number" ? { width: r.width } : {}), ...(typeof r.height === "number" ? { height: r.height } : {}) },
384
+ };
385
+ }
386
+ export function slotPolicy(options) {
387
+ return options.comparePrevious || options.updatePrevious || options.definesPose ? "replace" : "create_if_missing";
388
+ }
389
+ const BASELINE_KEPT = (bookmark) => `NOTE: "${bookmark}" already has a previous image; it was kept as the compare baseline (pass update_previous:true, or compare_previous:true, to replace it).`;
390
+ /** Keep the native render of a bookmark as its before/after slot when it is
391
+ * clean (no marks), within the stored-shot edge and allowed by the policy.
392
+ * Returns caption notes. */
393
+ export async function rememberBookmarkRender(projectRoot, bookmark, image, marks, policy = "create_if_missing") {
394
+ if (marks)
395
+ return [`NOTE: this render carries numbered marks, so it was not kept as "${bookmark}"'s previous image.`];
396
+ const edge = Math.max(image.width ?? 0, image.height ?? 0);
397
+ if (edge > SHOT_MAX_EDGE)
398
+ return [`NOTE: renders larger than ${SHOT_MAX_EDGE} px are not kept as "${bookmark}"'s previous image (size it at most ${SHOT_MAX_EDGE} px to keep one).`];
399
+ try {
400
+ const store = ShotStore.forProject(projectRoot);
401
+ if (policy === "create_if_missing" && (await store.readSlot(bookmark)))
402
+ return [BASELINE_KEPT(bookmark)];
403
+ const w = await store.writeSlot(bookmark, Buffer.from(image.base64, "base64"));
404
+ return slotLines([w], []);
405
+ }
406
+ catch (err) {
407
+ return [`NOTE: previous image for "${bookmark}" not kept: ${err instanceof Error ? err.message : String(err)}`];
408
+ }
409
+ }
410
+ export async function frameNodes(client, args) {
411
+ if (!args.nodes?.length || args.nodes.length > 16)
412
+ throw new ToolInputError("nodes must list 1-16 node paths. Nothing was sent.");
413
+ const nodes = args.nodes.map((n, i) => validateNodePath(n, `nodes[${i}]`));
414
+ if (args.direction && args.from)
415
+ throw new ToolInputError('Pass either direction (a preset) or from ("Vector3(x, y, z)": direction from the subject toward the camera), not both. Nothing was sent.');
416
+ const fromDir = args.from ? parseVector3(args.from, "from") : DIRECTION_PRESETS[args.direction ?? "iso"];
417
+ if (Math.hypot(...fromDir) < 1e-6)
418
+ throw new ToolInputError("from must be a non-zero direction. Nothing was sent.");
419
+ const fill = args.fill ?? 0.8;
420
+ if (!(fill >= 0.1 && fill <= 1.5))
421
+ throw new ToolInputError("fill must be 0.1..1.5 (share of the frame the nodes span). Nothing was sent.");
422
+ const fov = validateFov(args.fov) ?? 50;
423
+ const aspect = validateAspect(args.aspect);
424
+ const edge = validateEdge(args.max_size, DEFAULT_SINGLE_EDGE);
425
+ const bookmark = args.bookmark_name !== undefined ? validateBookmarkName(args.bookmark_name) : undefined;
426
+ if (args.max_marks !== undefined && (!Number.isInteger(args.max_marks) || args.max_marks < 1 || args.max_marks > 128))
427
+ throw new ToolInputError("max_marks must be 1..128. Nothing was sent.");
428
+ const scenePath = await resolveScene(client, args.scenePath);
429
+ const blocked = preflight(client, bookmark ? ["ScenePreview", "SaveCameraBookmark"] : ["ScenePreview"]);
430
+ if (blocked)
431
+ return blocked;
432
+ const run = await runProbe(client, { scenePath, size: [16, 16], config: { mode: "analyze", subjects: nodes, tasks: [] } });
433
+ await run.dispose();
434
+ if (!run.ok)
435
+ return probeFailure(run);
436
+ const subjects = readSubjects(run.result);
437
+ const empty = subjects.filter((s) => !s.has_geometry).map((s) => s.path);
438
+ const box = mergeAabbs(subjects.map((s) => s.aabb));
439
+ const center = aabbCenter(box);
440
+ const toCamera = normalize(fromDir);
441
+ const dist = fitDistance(box, center, toCamera, fov, aspect, fill);
442
+ const pose = { position: roundVec(add(center, scale(toCamera, dist))), look_at: roundVec(center), fov };
443
+ const size = sizeFor(edge, aspect);
444
+ const lines = [];
445
+ let framing = "free";
446
+ if (bookmark) {
447
+ const saved = firstResult(await client.executeOps([{ op: "SaveCameraBookmark", name: bookmark, position: formatVector3(pose.position), look_at: formatVector3(pose.look_at), fov }]));
448
+ if (saved.ok === false)
449
+ return fail(String(saved.failure_reason ?? "bookmark_save_failed"), String(saved.error ?? "SaveCameraBookmark failed"));
450
+ framing = `bookmark:${bookmark}`;
451
+ lines.push(`saved bookmark "${bookmark}"${saved.overwritten === true ? " (replaced the previous pose)" : ""} — reuse: summer_screenshot target:"scene" framing:"bookmark" bookmark_name:"${bookmark}", summer_shot_sheet, summer_debug_views.`);
452
+ }
453
+ const render = await nativeRender(client, { scenePath, framing, pose: bookmark ? undefined : pose, size, marks: args.marks, maxMarks: args.max_marks });
454
+ if (!render.ok)
455
+ return fail(render.failureReason ?? "preview_failed", render.error ?? "render failed");
456
+ if (bookmark)
457
+ lines.push(...(await rememberBookmarkRender(client.getProjectRoot?.(), bookmark, render.image, args.marks === true, slotPolicy({ definesPose: true }))));
458
+ const marks = readSceneMarks(render.meta);
459
+ // One physics pass for both checks: an explicit `from` is measured exactly
460
+ // as given (plus the nearby alternatives), and marks get an occlusion test.
461
+ const options = args.from ? viewOptions(box, center, toCamera, fov, aspect, fill) : [];
462
+ const markList = marks?.marks.map((m) => ({ id: m.id, path: m.path })) ?? [];
463
+ let viewCheck;
464
+ let occlusion = new Map();
465
+ let occlusionNote = "";
466
+ if (options.length || markList.length) {
467
+ const samples = subjectSamples(box);
468
+ const check = await runProbe(client, {
469
+ scenePath,
470
+ size: [16, 16],
471
+ config: {
472
+ mode: "analyze",
473
+ subjects: nodes,
474
+ tasks: [...(options.length ? ["measure"] : []), ...(markList.length ? ["occlusion"] : [])],
475
+ ...(options.length
476
+ ? {
477
+ candidates: options.map((o) => ({ position: [...o.pose.position], look_at: [...o.pose.look_at], fov: o.pose.fov, samples: samples.map((p) => [...p]), min_clearance: 0, low_angle_rule: false, adjust: false })),
478
+ measure: { aspect, grid_cols: FRAME_CHECK_COLS, grid_rows: Math.max(4, Math.round(FRAME_CHECK_COLS / aspect)), near_lens_radius: 0.3, sweep_radius: 0.15 },
479
+ }
480
+ : {}),
481
+ ...(markList.length ? { occlusion: { position: [...pose.position], marks: markList } } : {}),
482
+ },
483
+ });
484
+ await check.dispose();
485
+ if (!check.ok) {
486
+ const why = check.error ?? check.failureReason ?? "the visibility pass failed";
487
+ if (options.length)
488
+ lines.push(`NOTE: the view check for this explicit from could not run (${why}); the pose was NOT checked for walls between the camera and the nodes.`);
489
+ if (markList.length)
490
+ occlusionNote = `NOTE: the mark occlusion check could not run (${why}); a label may sit over a node hidden behind something else.`;
491
+ }
492
+ else {
493
+ if (options.length)
494
+ viewCheck = judgeViewOptions(options, (check.result.measurements ?? []));
495
+ occlusion = readOcclusion(check.result);
496
+ }
497
+ }
498
+ const env = String(render.meta.environment_used ?? "unknown");
499
+ const header = `Framed ${nodes.join(", ")} in ${scenePath} with the REAL environment (${env}); ${render.image.width ?? size[0]}x${render.image.height ?? size[1]}. ` +
500
+ `Pose: ${poseLiteral(pose)} (from ${args.from ? formatVector3(fromDir) : (args.direction ?? "iso")}, fill ${fill}).`;
501
+ const marksBlock = marks ? formatMarksWithOcclusion(marks, occlusion) : [];
502
+ if (occlusionNote)
503
+ marksBlock.push(occlusionNote);
504
+ const caption = capCaption([
505
+ ...(viewCheck?.problem ? viewCheckLines(viewCheck) : []),
506
+ header,
507
+ viewCheck && !viewCheck.problem ? `view check: clear — ${viewCheck.requestedSummary}.` : "",
508
+ `bounds: ${aabbLine(box)}`,
509
+ empty.length ? `WARNING: no visible geometry under ${empty.join(", ")} — framed a 1 m box at its origin.` : "",
510
+ ...lines,
511
+ ...marksBlock,
512
+ flatWarning(render.image) ?? "",
513
+ "Describe only what is visibly in the image.",
514
+ ]);
515
+ return {
516
+ ok: true,
517
+ image: render.image,
518
+ caption,
519
+ receipt: {
520
+ scenePath,
521
+ nodes,
522
+ pose: poseRecord(pose),
523
+ bounds: { center: formatVector3(center), size: formatVector3(box.size) },
524
+ environment_used: env,
525
+ ...(bookmark ? { bookmark } : {}),
526
+ ...(marks ? { marks: marks.marks.map((m) => ({ ...m, ...(occlusion.get(m.id) ? { visibility: occlusion.get(m.id) } : {}) })) } : {}),
527
+ ...(viewCheck ? { view_check: viewCheckReceipt(viewCheck) } : {}),
528
+ },
529
+ };
530
+ }
531
+ // ---------------------------------------------------------------------------
532
+ // frameNodes: the view check for an explicit `from`
533
+ // ---------------------------------------------------------------------------
534
+ // A coarser grid than smart framing's: enough to see a frame that is mostly back faces.
535
+ const FRAME_CHECK_COLS = 16;
536
+ /**
537
+ * The requested view first, then the nearby alternatives a caller can pass
538
+ * straight back to summer_frame_nodes (`from` + `fov`): the same direction
539
+ * with a wider lens (the camera fits closer, in front of what it stood
540
+ * behind), the direction turned around the nodes, and raised or lowered.
541
+ */
542
+ export function viewOptions(box, center, toCamera, fov, aspect, fill) {
543
+ const base = normalize(toCamera);
544
+ const azimuth = deg(Math.atan2(base[0], base[2]));
545
+ const elevation = deg(Math.asin(clamp(base[1], -1, 1)));
546
+ const raw = [{ tag: "as requested", dir: base, fov, cost: 0 }];
547
+ for (const extra of [15, 30, 45]) {
548
+ const f = Math.min(100, fov + extra);
549
+ if (f > fov + 1)
550
+ raw.push({ tag: `same direction, fov ${Math.round(f)}`, dir: base, fov: f, cost: (f - fov) * 0.5 });
551
+ }
552
+ for (const turn of [15, 30, 45, 60, 90, 120, 150, 180]) {
553
+ for (const sign of turn === 180 ? [1] : [1, -1]) {
554
+ const d = directionFromAngles(azimuth + sign * turn, elevation);
555
+ raw.push({ tag: turn === 180 ? "from the opposite side" : `turned ${turn} deg ${sign > 0 ? "counter-clockwise" : "clockwise"} (seen from above)`, dir: d, fov, cost: turn });
556
+ }
557
+ }
558
+ for (const lift of [15, 30, -15, -30]) {
559
+ const e = clamp(elevation + lift, -60, 85);
560
+ if (Math.abs(e - elevation) < 1)
561
+ continue;
562
+ raw.push({ tag: `${lift > 0 ? "raised" : "lowered"} ${Math.abs(Math.round(e - elevation))} deg`, dir: directionFromAngles(azimuth, e), fov, cost: Math.abs(e - elevation) });
563
+ }
564
+ const out = [];
565
+ for (const r of raw) {
566
+ if (out.some((o) => Math.abs(o.fov - r.fov) < 0.01 && Math.hypot(o.from[0] - r.dir[0], o.from[1] - r.dir[1], o.from[2] - r.dir[2]) < 1e-3))
567
+ continue;
568
+ const d = fitDistance(box, center, r.dir, r.fov, aspect, fill);
569
+ out.push({ tag: r.tag, from: roundVec(r.dir, 4), fov: Math.round(r.fov * 100) / 100, cost: r.cost, pose: { position: roundVec(add(center, scale(r.dir, d))), look_at: roundVec(center), fov: Math.round(r.fov * 100) / 100 } });
570
+ }
571
+ return out;
572
+ }
573
+ function sightSummary(m) {
574
+ const vis = m?.vis ?? "";
575
+ if (!vis.length)
576
+ return "no sight lines measured";
577
+ const count = (c) => [...vis].filter((x) => x === c).length;
578
+ const parts = [`${count("V") + count("T")} of ${vis.length} sight lines to the nodes clear`];
579
+ if (count("T"))
580
+ parts.push(`${count("T")} through transparent surfaces`);
581
+ if (count("F"))
582
+ parts.push(`${count("F")} past soft cover`);
583
+ return parts.join(", ");
584
+ }
585
+ export function judgeViewOptions(options, measurements) {
586
+ const byIndex = new Map(measurements.map((m) => [m.i, m]));
587
+ const requested = byIndex.get(0);
588
+ const problem = requested ? viewProblem(requested) : { reason: "not_measured", detail: "the engine returned no measurement for the requested pose" };
589
+ let alternative;
590
+ if (problem) {
591
+ const valid = options
592
+ .map((o, i) => ({ o, m: byIndex.get(i) }))
593
+ .filter((x, i) => i > 0 && x.m && !viewProblem(x.m))
594
+ .sort((a, b) => a.o.cost - b.o.cost);
595
+ alternative = valid[0]?.o;
596
+ }
597
+ return { problem, requestedSummary: sightSummary(requested), requestedFov: options[0]?.fov ?? 0, checked: measurements.length, ...(alternative ? { alternative } : {}) };
598
+ }
599
+ function viewCheckLines(check) {
600
+ const p = check.problem;
601
+ const lines = [
602
+ `WARNING: this explicit from gives a view no player could have (${p.reason}): ${p.detail}.${p.reason === "behind_surface" || p.reason === "inside_volume" ? " The renderer does not draw the back of a one-sided surface, so this image looks THROUGH it — do not judge the scene from it." : ""}`,
603
+ ];
604
+ const a = check.alternative;
605
+ lines.push(a
606
+ ? `nearest valid pose (${a.tag}): summer_frame_nodes from:"${formatVector3(a.from)}"${a.fov !== check.requestedFov ? ` fov:${a.fov}` : ""} -> ${poseLiteral(a.pose)}.`
607
+ : `no valid pose found among ${check.checked - 1} nearby alternatives (wider lens, turned around the nodes, raised/lowered): try summer_frame_shot shot:"detail" for a measured view.`);
608
+ return lines;
609
+ }
610
+ function viewCheckReceipt(check) {
611
+ return {
612
+ ok: !check.problem,
613
+ ...(check.problem ? { reason: check.problem.reason, detail: check.problem.detail, ...(check.problem.blockers ? { blockers: check.problem.blockers } : {}) } : {}),
614
+ sight_lines: check.requestedSummary,
615
+ ...(check.alternative ? { nearest_valid: { tag: check.alternative.tag, from: formatVector3(check.alternative.from), fov: check.alternative.fov, ...poseRecord(check.alternative.pose) } } : {}),
616
+ };
617
+ }
618
+ export function readOcclusion(result) {
619
+ const out = new Map();
620
+ const raw = result?.occlusion?.marks;
621
+ if (!Array.isArray(raw))
622
+ return out;
623
+ for (const entry of raw) {
624
+ if (!entry || typeof entry !== "object")
625
+ continue;
626
+ const e = entry;
627
+ if (typeof e.id !== "number")
628
+ continue;
629
+ out.set(e.id, {
630
+ id: e.id,
631
+ path: String(e.path ?? ""),
632
+ ...(typeof e.visible === "number" ? { visible: e.visible } : {}),
633
+ ...(typeof e.samples === "number" ? { samples: e.samples } : {}),
634
+ ...(typeof e.blocker === "string" && e.blocker ? { blocker: e.blocker } : {}),
635
+ ...(typeof e.own_back === "number" && e.own_back ? { own_back: e.own_back } : {}),
636
+ ...(e.missing === true ? { missing: true } : {}),
637
+ });
638
+ }
639
+ return out;
640
+ }
641
+ /** "" when the node is mostly visible; else a short phrase for its label. */
642
+ export function markOcclusionNote(v) {
643
+ if (v.missing)
644
+ return "not found in the saved scene";
645
+ const samples = v.samples ?? 5;
646
+ const visible = v.visible ?? samples;
647
+ if (visible === 0) {
648
+ if (v.blocker)
649
+ return `hidden behind ${v.blocker}`;
650
+ if (v.own_back)
651
+ return "hidden: only its back faces face the camera, and they are not drawn";
652
+ return "hidden";
653
+ }
654
+ if (visible * 2 < samples)
655
+ return `partly hidden: ${visible} of ${samples} points visible`;
656
+ return "";
657
+ }
658
+ export function isHiddenMark(v) {
659
+ return !!v && (v.missing === true || (v.visible ?? 1) === 0);
660
+ }
661
+ /** formatSceneMarks plus a "(hidden ...)" note per hidden label and one line
662
+ * saying what the occlusion test found. */
663
+ export function formatMarksWithOcclusion(summary, occlusion) {
664
+ const notes = new Map();
665
+ for (const [id, v] of occlusion) {
666
+ const note = markOcclusionNote(v);
667
+ if (note)
668
+ notes.set(id, note);
669
+ }
670
+ const lines = formatSceneMarks(summary, notes);
671
+ if (occlusion.size) {
672
+ const hidden = summary.marks.filter((m) => isHiddenMark(occlusion.get(m.id))).length;
673
+ lines.push(hidden
674
+ ? `occlusion: ${hidden} of ${summary.marks.length} labelled node(s) are HIDDEN from this camera (5 points each: centre + 4 across the box face it shows; another opaque surface is in front, or only undrawn back faces face the camera). Their labels sit over whatever is in front — do not attribute what you see there to them.`
675
+ : `occlusion: every labelled node is at least partly visible from this camera (5 points each: centre + 4 across the box face it shows).`);
676
+ }
677
+ return lines;
678
+ }
679
+ /**
680
+ * The occlusion test on its own (summer_screenshot marks): one analyze pass.
681
+ * Never throws; a failure becomes a caption note, the screenshot stands.
682
+ */
683
+ export async function checkMarkOcclusion(client, scenePath, position, marks) {
684
+ const list = marks.map((m) => ({ id: m.id, path: m.path }));
685
+ if (!list.length)
686
+ return { occlusion: new Map() };
687
+ try {
688
+ const scene = await resolveScene(client, scenePath);
689
+ const run = await runProbe(client, { scenePath: scene, size: [16, 16], config: { mode: "analyze", subjects: [], tasks: ["occlusion"], occlusion: { position: [...position], marks: list } } });
690
+ await run.dispose();
691
+ if (!run.ok)
692
+ return { occlusion: new Map(), note: `NOTE: the mark occlusion check could not run (${run.error ?? run.failureReason ?? "unknown"}); a label may sit over a node hidden behind something else.` };
693
+ return { occlusion: readOcclusion(run.result) };
694
+ }
695
+ catch (err) {
696
+ return { occlusion: new Map(), note: `NOTE: the mark occlusion check could not run (${err instanceof Error ? err.message : String(err)}); a label may sit over a node hidden behind something else.` };
697
+ }
698
+ }
699
+ function kernelPose(pose, extra = {}) {
700
+ return { position: [...pose.position], look_at: [...pose.look_at], fov: pose.fov, ...extra };
701
+ }
702
+ function slotRenderSize(aspect, tile) {
703
+ const slot = sizeFor(SHOT_MAX_EDGE, aspect);
704
+ return tile[0] >= slot[0] ? tile : slot;
705
+ }
706
+ async function renderTiles(client, scenePath, canvas, tiles, dir) {
707
+ return runProbe(client, { scenePath, size: canvas, config: { mode: "render", canvas, tiles } }, dir);
708
+ }
709
+ function tileMethods(run) {
710
+ const tiles = Array.isArray(run.result?.tiles) ? run.result.tiles : [];
711
+ return tiles.filter((t) => t.kind === "shot").map((t) => `${String(t.view)}=${String(t.method)}`);
712
+ }
713
+ export async function shotSheet(client, args) {
714
+ if (!args.shots?.length || args.shots.length > 12)
715
+ throw new ToolInputError("shots must list 1-12 entries (bookmark_name, or camera_position + camera_look_at). Nothing was sent.");
716
+ const labels = args.shots.map((s, i) => (s.label !== undefined ? validateLabel(s.label) : undefined) ?? s.bookmark_name?.trim() ?? `shot ${i + 1}`);
717
+ const view = args.view ?? "beauty";
718
+ const aspect = validateAspect(args.aspect);
719
+ const edge = validateEdge(args.max_size, DEFAULT_SHEET_EDGE);
720
+ const saveTo = checkSaveTo(args.save_to, edge);
721
+ if (args.compare_previous && !args.shots.some((s) => s.bookmark_name)) {
722
+ throw new ToolInputError("compare_previous compares a bookmark with its previous render; at least one shot needs bookmark_name. Nothing was sent.");
723
+ }
724
+ const scenePath = await resolveScene(client, args.scenePath);
725
+ const blocked = preflight(client, ["ScenePreview", "ListCameraBookmarks"]);
726
+ if (blocked)
727
+ return blocked;
728
+ const poses = await resolvePoses(client, args.shots.map((s, i) => ({ args: s, label: `shots[${i}]` })));
729
+ if (!Array.isArray(poses))
730
+ return poses;
731
+ const store = shotStore(client);
732
+ const cells = [];
733
+ const compareNotes = [];
734
+ for (let i = 0; i < poses.length; i++) {
735
+ const p = poses[i];
736
+ const label = `${i + 1} ${labels[i]}${view !== "beauty" ? ` · ${view}` : ""}`;
737
+ if (args.compare_previous && p.bookmark) {
738
+ const prev = store instanceof ShotStoreError ? null : await store.readSlot(p.bookmark);
739
+ if (prev) {
740
+ // Bookmark timestamps have one-second resolution: a pose saved in the
741
+ // same second as the previous render counts as possibly newer.
742
+ if (p.created && Date.parse(p.created) >= Math.floor(prev.mtimeMs / 1000) * 1000)
743
+ compareNotes.push(`"${p.bookmark}": the previous image may predate the bookmark's current pose (saved ${p.created}); the difference map may compare two different viewpoints.`);
744
+ cells.push({ kind: "prev", shot: i, label: `${i + 1} ${labels[i]} · previous`, prevPath: prev.path });
745
+ cells.push({ kind: "shot", shot: i, label: `${i + 1} ${labels[i]} · now` });
746
+ cells.push({ kind: "diff", shot: i, label: `${i + 1} difference`, prevPath: prev.path });
747
+ }
748
+ else {
749
+ cells.push({ kind: "note", shot: i, label: `${i + 1} ${labels[i]} · previous`, text: "no previous image yet — this render becomes the baseline" });
750
+ cells.push({ kind: "shot", shot: i, label: `${i + 1} ${labels[i]} · now` });
751
+ cells.push({ kind: "note", shot: i, label: `${i + 1} difference`, text: "nothing to compare" });
752
+ }
753
+ }
754
+ else {
755
+ cells.push({ kind: "shot", shot: i, label });
756
+ }
757
+ }
758
+ const comparing = args.compare_previous === true;
759
+ const policy = slotPolicy({ comparePrevious: comparing, updatePrevious: args.update_previous === true });
760
+ // Bookmarks whose baseline this render must NOT replace (they have one and
761
+ // neither compare_previous nor update_previous asked for a new one).
762
+ const keepBaseline = new Set();
763
+ if (policy === "create_if_missing" && !(store instanceof ShotStoreError)) {
764
+ for (const p of poses)
765
+ if (p.bookmark && !keepBaseline.has(p.bookmark) && (await store.readSlot(p.bookmark)))
766
+ keepBaseline.add(p.bookmark);
767
+ }
768
+ const layout = layoutGrid(cells.length, aspect, edge, comparing ? 3 : undefined);
769
+ const run0Dir = await makeProbeDir();
770
+ const tiles = [];
771
+ const slots = [];
772
+ const nowTileOf = new Map();
773
+ const slotted = new Set();
774
+ cells.forEach((cell, index) => {
775
+ const rect = layout.rects[index];
776
+ const pose = poses[cell.shot];
777
+ if (cell.kind === "shot") {
778
+ const spec = { kind: "shot", rect, label: cell.label, view, pose: kernelPose(pose.pose) };
779
+ if (pose.bookmark && view === "beauty" && !(store instanceof ShotStoreError) && !keepBaseline.has(pose.bookmark) && !slotted.has(pose.bookmark)) {
780
+ slotted.add(pose.bookmark);
781
+ spec.render_size = slotRenderSize(aspect, layout.tile);
782
+ spec.capture_path = join(run0Dir, `slot-${index}.jpg`).replace(/\\/g, "/");
783
+ spec.capture_max_edge = SHOT_MAX_EDGE;
784
+ slots.push({ tile: index, bookmark: pose.bookmark, capturePath: spec.capture_path });
785
+ }
786
+ nowTileOf.set(cell.shot, index);
787
+ tiles.push(spec);
788
+ }
789
+ else if (cell.kind === "prev") {
790
+ tiles.push({ kind: "prev", rect, label: cell.label, image_path: cell.prevPath.replace(/\\/g, "/") });
791
+ }
792
+ else if (cell.kind === "diff") {
793
+ tiles.push({ kind: "diff", rect, label: cell.label, image_path: cell.prevPath.replace(/\\/g, "/"), now_tile: -1 });
794
+ }
795
+ else {
796
+ tiles.push({ kind: "note", rect, label: cell.label, text: cell.text });
797
+ }
798
+ });
799
+ cells.forEach((cell, index) => {
800
+ if (cell.kind === "diff")
801
+ tiles[index].now_tile = nowTileOf.get(cell.shot) ?? -1;
802
+ });
803
+ const run = await renderTiles(client, scenePath, layout.canvas, tiles, run0Dir);
804
+ try {
805
+ if (!run.ok)
806
+ return probeFailure(run);
807
+ const image = imageFrom(run);
808
+ const { written, notes } = await storeSlots(store, slots, run);
809
+ const { saved, note } = await saveCopy(store, saveTo, image);
810
+ const diffs = Array.isArray(run.result?.diffs) ? run.result.diffs : [];
811
+ const lines = [
812
+ `Shot sheet: ${poses.length} shot(s) of ${scenePath}, ${view} view${view === "beauty" ? " with the REAL environment and lights" : ""}, ${layout.cols}x${layout.rows} grid of ${layout.tile[0]}x${layout.tile[1]} tiles (${layout.canvas[0]}x${layout.canvas[1]}).`,
813
+ ...poses.map((p, i) => ` ${i + 1} ${labels[i]}: ${poseLiteral(p.pose)}${p.bookmark ? ` [bookmark ${p.bookmark}]` : ""}`),
814
+ ...diffs.map((d) => {
815
+ const cell = cells[Number(d.i)];
816
+ const box = Array.isArray(d.changed_box) ? ` in box u ${d.changed_box[0]}-${d.changed_box[2]}, v ${d.changed_box[1]}-${d.changed_box[3]}` : "";
817
+ return ` difference ${cell ? cell.shot + 1 : "?"}: ${Math.round(Number(d.changed_fraction) * 1000) / 10}% of pixels changed visibly (mean abs ${d.mean_abs})${box}.`;
818
+ }),
819
+ ...compareNotes.map((n) => `NOTE: ${n}`),
820
+ ...slotLines(written, notes, saved, note),
821
+ ...(keepBaseline.size ? [`NOTE: kept the existing previous image (compare baseline) of ${[...keepBaseline].sort().join(", ")}; pass update_previous:true or compare_previous:true to replace it.`] : []),
822
+ flatWarning(image) ?? "",
823
+ "Describe only what is visibly in the image.",
824
+ ];
825
+ return {
826
+ ok: true,
827
+ image,
828
+ caption: capCaption(lines),
829
+ receipt: {
830
+ scenePath,
831
+ view,
832
+ layout: { cols: layout.cols, rows: layout.rows, tile: layout.tile, canvas: layout.canvas },
833
+ shots: poses.map((p, i) => ({ label: labels[i], ...poseRecord(p.pose), ...(p.bookmark ? { bookmark: p.bookmark } : {}) })),
834
+ ...(diffs.length ? { diffs } : {}),
835
+ slots: written.map((w) => w.resPath),
836
+ ...(saved ? { saved: saved.resPath } : {}),
837
+ methods: tileMethods(run),
838
+ },
839
+ };
840
+ }
841
+ finally {
842
+ await run.dispose();
843
+ }
844
+ }
845
+ export const VIEW_NOTES = {
846
+ beauty: "final image: real environment, lights, fog, tonemap",
847
+ lighting: "light only (albedo white): light direction, pools, shadow shape, dead-dark areas",
848
+ unshaded: "albedo/texture only, no light: texture quality, colour balance, value grouping",
849
+ normals: "world-space normals (x red, y green, z blue) from an unshaded override material: seams, flipped or faceted normals; normal maps and alpha cut-outs are NOT applied",
850
+ overdraw: "additive overdraw: bright = many layers drawn on top of each other (foliage cards, decals, glass)",
851
+ wireframe: "triangle density: over/under-tessellation, LOD, floating or duplicated geometry",
852
+ };
853
+ export async function debugViews(client, args) {
854
+ const views = args.views?.length ? args.views : [...VIEW_MODES];
855
+ if (views.length > 8)
856
+ throw new ToolInputError("views takes at most 8 entries. Nothing was sent.");
857
+ const aspect = validateAspect(args.aspect);
858
+ const edge = validateEdge(args.max_size, DEFAULT_SHEET_EDGE);
859
+ const saveTo = checkSaveTo(args.save_to, edge);
860
+ const scenePath = await resolveScene(client, args.scenePath);
861
+ const blocked = preflight(client, args.bookmark_name ? ["ScenePreview", "ListCameraBookmarks"] : ["ScenePreview"]);
862
+ if (blocked)
863
+ return blocked;
864
+ const poses = await resolvePoses(client, [{ args, label: "pose" }]);
865
+ if (!Array.isArray(poses))
866
+ return poses;
867
+ const pose = poses[0];
868
+ const store = shotStore(client);
869
+ const keepBaseline = !!pose.bookmark && slotPolicy({ updatePrevious: args.update_previous === true }) === "create_if_missing" && !(store instanceof ShotStoreError) && !!(await store.readSlot(pose.bookmark));
870
+ const layout = layoutGrid(views.length, aspect, edge);
871
+ const dir = await makeProbeDir();
872
+ const slots = [];
873
+ const tiles = views.map((view, i) => {
874
+ const spec = { kind: "shot", rect: layout.rects[i], label: `${i + 1} ${view}`, view, pose: kernelPose(pose.pose) };
875
+ if (view === "beauty" && pose.bookmark && !(store instanceof ShotStoreError) && !keepBaseline && !slots.length) {
876
+ spec.render_size = slotRenderSize(aspect, layout.tile);
877
+ spec.capture_path = join(dir, `slot-${i}.jpg`).replace(/\\/g, "/");
878
+ spec.capture_max_edge = SHOT_MAX_EDGE;
879
+ slots.push({ tile: i, bookmark: pose.bookmark, capturePath: spec.capture_path });
880
+ }
881
+ return spec;
882
+ });
883
+ const run = await renderTiles(client, scenePath, layout.canvas, tiles, dir);
884
+ try {
885
+ if (!run.ok)
886
+ return probeFailure(run);
887
+ const image = imageFrom(run);
888
+ const { written, notes } = await storeSlots(store, slots, run);
889
+ const { saved, note } = await saveCopy(store, saveTo, image);
890
+ const renderer = String(run.result?.renderer ?? "unknown");
891
+ const lines = [
892
+ `Debug views of ${scenePath} from ${pose.bookmark ? `bookmark "${pose.bookmark}"` : "an explicit pose"} (${poseLiteral(pose.pose)}); renderer ${renderer}; ${layout.cols}x${layout.rows} grid of ${layout.tile[0]}x${layout.tile[1]}.`,
893
+ ...views.map((v, i) => ` ${i + 1} ${v}: ${VIEW_NOTES[v]}`),
894
+ `methods: ${tileMethods(run).join(", ")}`,
895
+ ...slotLines(written, notes, saved, note),
896
+ ...(keepBaseline && pose.bookmark ? [BASELINE_KEPT(pose.bookmark)] : []),
897
+ flatWarning(image) ?? "",
898
+ "Describe only what is visibly in the image.",
899
+ ];
900
+ return {
901
+ ok: true,
902
+ image,
903
+ caption: capCaption(lines),
904
+ receipt: { scenePath, renderer, views, pose: poseRecord(pose.pose), ...(pose.bookmark ? { bookmark: pose.bookmark } : {}), methods: tileMethods(run), slots: written.map((w) => w.resPath), ...(saved ? { saved: saved.resPath } : {}) },
905
+ };
906
+ }
907
+ finally {
908
+ await run.dispose();
909
+ }
910
+ }
911
+ export async function zoom(client, args) {
912
+ if ((args.region === undefined) === (args.mark === undefined)) {
913
+ throw new ToolInputError("Pass exactly one of region ([x, y, w, h] as fractions 0..1 of the frame) or mark (a label number from a marks:true screenshot of the same pose). Nothing was sent.");
914
+ }
915
+ const reference = (args.reference_size ?? [1024, 576]);
916
+ if (reference.length !== 2 || !reference.every((n) => Number.isInteger(n) && n >= 16 && n <= MAX_IMAGE_EDGE))
917
+ throw new ToolInputError("reference_size must be [width, height] in pixels (16..4096). Nothing was sent.");
918
+ const refAspect = reference[0] / reference[1];
919
+ // A region is honoured as given; a mark's box is tight, so it gets a margin.
920
+ const pad = args.pad ?? (args.mark !== undefined ? 0.15 : 0);
921
+ if (!(pad >= 0 && pad <= 1))
922
+ throw new ToolInputError("pad must be 0..1 (fraction of the region added on each side). Nothing was sent.");
923
+ const edge = validateEdge(args.max_size, DEFAULT_SINGLE_EDGE);
924
+ const saveTo = checkSaveTo(args.save_to, edge);
925
+ if (args.region) {
926
+ if (args.region.length !== 4)
927
+ throw new ToolInputError("region must be [x, y, w, h]. Nothing was sent.");
928
+ const [x, y, w, h] = args.region;
929
+ if (![x, y, w, h].every(Number.isFinite) || w <= 0 || h <= 0 || x < 0 || y < 0 || x + w > 1.0001 || y + h > 1.0001) {
930
+ throw new ToolInputError("region must be [x, y, w, h] fractions of the frame (0..1, x+w and y+h at most 1). Nothing was sent.");
931
+ }
932
+ }
933
+ if (args.mark !== undefined && (!Number.isInteger(args.mark) || args.mark < 1 || args.mark > 128))
934
+ throw new ToolInputError("mark must be a label number 1..128. Nothing was sent.");
935
+ const scenePath = await resolveScene(client, args.scenePath);
936
+ const blocked = preflight(client, args.bookmark_name ? ["ScenePreview", "ListCameraBookmarks"] : ["ScenePreview"]);
937
+ if (blocked)
938
+ return blocked;
939
+ const poses = await resolvePoses(client, [{ args, label: "pose" }]);
940
+ if (!Array.isArray(poses))
941
+ return poses;
942
+ const pose = poses[0];
943
+ let rect;
944
+ let markInfo = "";
945
+ let markPath = "";
946
+ if (args.mark !== undefined) {
947
+ const marked = await nativeRender(client, { scenePath, framing: "free", pose: pose.pose, size: reference, marks: true, maxMarks: 128 });
948
+ if (!marked.ok)
949
+ return fail(marked.failureReason ?? "preview_failed", marked.error ?? "marks render failed");
950
+ const summary = readSceneMarks(marked.meta);
951
+ if (!summary)
952
+ return fail("marks_unsupported", "This engine build ignored marks:true, so mark numbers cannot be resolved; pass region instead.");
953
+ const hit = summary.marks.find((m) => m.id === args.mark);
954
+ if (!hit || !hit.screen_rect) {
955
+ return fail("mark_not_found", `No mark ${args.mark} at this pose and reference_size ${reference[0]}x${reference[1]}.`, `Marks here: ${summary.marks.slice(0, 20).map((m) => `${m.id}=${m.path}`).join(", ") || "none"}. Use the SAME pose and size as the marks screenshot.`);
956
+ }
957
+ const r = hit.screen_rect;
958
+ rect = { u0: r.x / reference[0], v0: r.y / reference[1], u1: (r.x + r.w) / reference[0], v1: (r.y + r.h) / reference[1] };
959
+ markInfo = `mark ${hit.id} -> ${hit.path} (${hit.class})`;
960
+ markPath = hit.path;
961
+ }
962
+ else {
963
+ const [x, y, w, h] = args.region;
964
+ rect = { u0: x, v0: y, u1: x + w, v1: y + h };
965
+ }
966
+ const { crop, widenedBecause, clipped } = exactCrop(rect, pad);
967
+ const cw = crop[2] - crop[0];
968
+ const ch = crop[3] - crop[1];
969
+ // The window's own pixel aspect: the render covers exactly this window.
970
+ const regionAspect = (cw / ch) * refAspect;
971
+ const fit = letterbox(regionAspect, edge);
972
+ const zoomX = 1 / cw;
973
+ const zoomY = 1 / ch;
974
+ const zoomText = Math.abs(zoomX - zoomY) / Math.max(zoomX, zoomY) < 0.05 ? `x${zoomX.toFixed(1)}` : `x${zoomX.toFixed(1)} across, x${zoomY.toFixed(1)} down`;
975
+ const view = args.view ?? "beauty";
976
+ const markEntry = args.mark !== undefined && markPath ? [{ id: args.mark, path: markPath }] : [];
977
+ const tiles = [
978
+ {
979
+ kind: "shot",
980
+ rect: fit.rect,
981
+ render_size: [fit.rect[2], fit.rect[3]],
982
+ view,
983
+ pose: kernelPose(pose.pose, { crop, ref_aspect: refAspect }),
984
+ label: `zoom ${zoomText}${markInfo ? ` · mark ${args.mark}` : ""}`,
985
+ },
986
+ ];
987
+ const run = await runProbe(client, {
988
+ scenePath,
989
+ size: fit.canvas,
990
+ config: { mode: "render", canvas: fit.canvas, tiles, ...(markEntry.length ? { occlusion: { position: [...pose.pose.position], marks: markEntry } } : {}) },
991
+ });
992
+ try {
993
+ if (!run.ok)
994
+ return probeFailure(run);
995
+ const image = imageFrom(run);
996
+ const store = saveTo ? shotStore(client) : null;
997
+ const { saved, note } = store ? await saveCopy(store, saveTo, image) : {};
998
+ const r3 = (n) => Math.round(n * 1000) / 1000;
999
+ const occlusion = readOcclusion(run.result);
1000
+ const hidden = markEntry.length ? occlusion.get(args.mark) : undefined;
1001
+ const hiddenNote = hidden ? markOcclusionNote(hidden) : "";
1002
+ const requested = `u ${r3(rect.u0)}-${r3(rect.u1)}, v ${r3(rect.v0)}-${r3(rect.v1)}`;
1003
+ const rendered = `u ${r3(crop[0])}-${r3(crop[2])}, v ${r3(crop[1])}-${r3(crop[3])}`;
1004
+ const lines = [
1005
+ `Zoom ${zoomText} of ${scenePath} (${view}): exactly the region ${rendered} of the ${reference[0]}x${reference[1]} frame, rendered as an exact sub-frustum at ${fit.rect[2]}x${fit.rect[3]}${fit.letterboxed ? ` inside a ${fit.canvas[0]}x${fit.canvas[1]} image (dark bars: the region's aspect is kept, never stretched or widened)` : ""} — real detail, not upscaled pixels.`,
1006
+ widenedBecause.length ? `widened_because: ${widenedBecause.join("; ")} (asked ${requested}${clipped ? "; clipped at the frame edge" : ""}); pass pad:0 for the exact region.` : `region as asked (${requested})${markInfo ? ` from ${markInfo}` : ""}.`,
1007
+ markInfo && widenedBecause.length ? `mark: ${markInfo}` : "",
1008
+ hiddenNote && isHiddenMark(hidden) ? `WARNING: mark ${args.mark}'s node is ${hiddenNote} at this pose — the zoom shows what is in front of it, not the node.` : hiddenNote ? `NOTE: mark ${args.mark}'s node is ${hiddenNote}.` : "",
1009
+ `from ${pose.bookmark ? `bookmark "${pose.bookmark}"` : "pose"}: ${poseLiteral(pose.pose)}`,
1010
+ ...slotLines([], [], saved, note),
1011
+ flatWarning(image) ?? "",
1012
+ "Describe only what is visibly in the image.",
1013
+ ];
1014
+ return {
1015
+ ok: true,
1016
+ image,
1017
+ caption: capCaption(lines),
1018
+ receipt: {
1019
+ scenePath,
1020
+ view,
1021
+ pose: poseRecord(pose.pose),
1022
+ region: [rect.u0, rect.v0, rect.u1, rect.v1].map(r3),
1023
+ crop: crop.map(r3),
1024
+ zoom: Math.round(Math.min(zoomX, zoomY) * 100) / 100,
1025
+ zoom_xy: [Math.round(zoomX * 100) / 100, Math.round(zoomY * 100) / 100],
1026
+ widened_because: widenedBecause,
1027
+ image_size: fit.canvas,
1028
+ region_px: [fit.rect[2], fit.rect[3]],
1029
+ ...(fit.letterboxed ? { letterboxed: true } : {}),
1030
+ ...(markInfo ? { mark: markInfo } : {}),
1031
+ ...(hidden ? { mark_visibility: hidden } : {}),
1032
+ ...(saved ? { saved: saved.resPath } : {}),
1033
+ },
1034
+ };
1035
+ }
1036
+ finally {
1037
+ await run.dispose();
1038
+ }
1039
+ }
1040
+ const GRID_COLS = 24;
1041
+ function readKeyLight(result) {
1042
+ const raw = result.key_light;
1043
+ if (!raw || !Array.isArray(raw.direction) || raw.direction.length < 3)
1044
+ return undefined;
1045
+ const d = raw.direction.map(Number);
1046
+ if (!d.every(Number.isFinite) || Math.hypot(d[0], d[1], d[2]) < 1e-6)
1047
+ return undefined;
1048
+ return { path: String(raw.path ?? "?"), direction: [d[0], d[1], d[2]] };
1049
+ }
1050
+ const fmt2 = (n) => (Math.round(n * 100) / 100).toFixed(2);
1051
+ /**
1052
+ * The real camera height, said explicitly: the absolute y of the camera and
1053
+ * its height above the first solid surface straight below it (the walkable
1054
+ * surface for eye-level and corridor poses; for a high camera that can be a
1055
+ * roof). `short` is for tile labels.
1056
+ */
1057
+ export function cameraHeightText(pose, groundY, short = false) {
1058
+ const y = pose.position[1];
1059
+ const h = heightAboveGround(pose.position, groundY);
1060
+ if (short)
1061
+ return h === undefined ? `camera y ${fmt2(y)}, nothing below` : `camera ${fmt2(h)} m above surface (y ${fmt2(y)})`;
1062
+ if (h === undefined) {
1063
+ return groundY === null
1064
+ ? `camera at y ${fmt2(y)} (absolute); no surface below it (world edge)`
1065
+ : `camera at y ${fmt2(y)} (absolute); height above the surface below not measured`;
1066
+ }
1067
+ return `camera ${fmt2(h)} m above the surface below it (camera y ${fmt2(y)}, surface y ${fmt2(groundY)})`;
1068
+ }
1069
+ export function scanFloorText(floor, subject) {
1070
+ if (floor.floor_source === "walkable") {
1071
+ const votes = floor.floor_hits !== undefined && floor.floor_rays !== undefined ? ` (${floor.floor_hits} of ${floor.floor_rays} downward rays met a walkable surface)` : "";
1072
+ const sunk = subject.position[1] < floor.floor_y - 0.05 ? `; the subject reaches ${fmt2(floor.floor_y - subject.position[1])} m below it (sunk walls or foundations), which is not floor` : "";
1073
+ return `corridor scan seeded on the walkable floor at y ${fmt2(floor.floor_y)}${votes}${sunk}`;
1074
+ }
1075
+ if (floor.floor_source === "lowest_point") {
1076
+ return `NOTE: the corridor scan found no walkable surface inside the subject and seeded from its lowest point (y ${fmt2(floor.floor_y)}); if that is under the floor, frame the corridor with summer_frame_nodes or pass a subject whose floor is walkable.`;
1077
+ }
1078
+ return `corridor scan seeded at y ${fmt2(floor.floor_y)} (${floor.floor_source})`;
1079
+ }
1080
+ function defaultBookmarkName(shot, subject, spawn) {
1081
+ const leaf = (subject?.[0] ?? spawn ?? "scene").split("/").pop() ?? "scene";
1082
+ return `${shot}_${leaf}`.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
1083
+ }
1084
+ export async function frameShot(client, args) {
1085
+ const shot = args.shot;
1086
+ const subject = args.subject?.map((p, i) => validateNodePath(p, `subject[${i}]`));
1087
+ if (subject && subject.length > 8)
1088
+ throw new ToolInputError("subject takes at most 8 node paths. Nothing was sent.");
1089
+ const spawn = args.spawn !== undefined ? validateNodePath(args.spawn, "spawn") : undefined;
1090
+ if (shot === "eye_level" && !spawn)
1091
+ throw new ToolInputError('shot "eye_level" needs spawn: the node the player stands at (its Camera3D child, or origin + eye_height, is the eye). Nothing was sent.');
1092
+ if (shot !== "eye_level" && !subject?.length)
1093
+ throw new ToolInputError(`shot "${shot}" needs subject: 1-8 node paths to frame. Nothing was sent.`);
1094
+ if (args.eye_height !== undefined && !(args.eye_height > 0 && args.eye_height < 20))
1095
+ throw new ToolInputError("eye_height must be 0..20 m. Nothing was sent.");
1096
+ const fov = validateFov(args.fov);
1097
+ const aspect = validateAspect(args.aspect);
1098
+ const occluders = validateOccluders(args.occluders);
1099
+ const maxSoft = args.max_soft_fraction ?? 0.67;
1100
+ if (!(maxSoft >= 0 && maxSoft <= 1))
1101
+ throw new ToolInputError("max_soft_fraction must be 0..1. Nothing was sent.");
1102
+ const saveBookmark = args.save_bookmark ?? true;
1103
+ const bookmark = saveBookmark ? validateBookmarkName(args.bookmark_name ?? defaultBookmarkName(shot, subject, spawn)) : undefined;
1104
+ const render = args.render ?? "sheet";
1105
+ const edge = validateEdge(args.max_size, render === "sheet" ? DEFAULT_SHEET_EDGE : DEFAULT_SINGLE_EDGE);
1106
+ const scenePath = await resolveScene(client, args.scenePath);
1107
+ const blocked = preflight(client, saveBookmark ? ["ScenePreview", "SaveCameraBookmark"] : ["ScenePreview"]);
1108
+ if (blocked)
1109
+ return blocked;
1110
+ const gridRows = Math.max(4, Math.round(GRID_COLS / aspect));
1111
+ const timings = {};
1112
+ const timed = async (key, work) => {
1113
+ const t0 = Date.now();
1114
+ try {
1115
+ return await work();
1116
+ }
1117
+ finally {
1118
+ timings[key] = Date.now() - t0;
1119
+ }
1120
+ };
1121
+ // Pass 1: bounds, spawn, and for corridors the free-run scan.
1122
+ const ignore = [...(occluders.ignore ?? []), ...(spawn ? [spawn] : [])];
1123
+ const occ = { ...occluders, ignore, subject_occludes: shot === "corridor" };
1124
+ const pass1 = await timed("bounds_ms", () => runProbe(client, {
1125
+ scenePath,
1126
+ size: [16, 16],
1127
+ config: {
1128
+ mode: "analyze",
1129
+ subjects: subject ?? [],
1130
+ ...(spawn ? { spawn } : {}),
1131
+ tasks: shot === "corridor" ? ["corridor_scan"] : [],
1132
+ occluders: occ,
1133
+ corridor: { height: args.eye_height ?? 1.6 },
1134
+ },
1135
+ }));
1136
+ await pass1.dispose();
1137
+ if (!pass1.ok)
1138
+ return probeFailure(pass1);
1139
+ const bounds = readSubjects(pass1.result);
1140
+ const subjectBox = bounds.length ? mergeAabbs(bounds.map((b) => b.aabb)) : undefined;
1141
+ const spawnInfo = pass1.result.spawn;
1142
+ const keyLight = readKeyLight(pass1.result);
1143
+ let corridorAxes = [];
1144
+ let scanFloor;
1145
+ if (shot === "corridor") {
1146
+ const scan = pass1.result.corridor_scan;
1147
+ if (scan && typeof scan.floor_y === "number")
1148
+ scanFloor = { floor_y: scan.floor_y, floor_source: String(scan.floor_source ?? "unknown"), ...(typeof scan.floor_hits === "number" ? { floor_hits: scan.floor_hits } : {}), ...(typeof scan.floor_rays === "number" ? { floor_rays: scan.floor_rays } : {}) };
1149
+ corridorAxes = chooseCorridorAxis(scan?.runs ?? [], subjectBox);
1150
+ if (!corridorAxes.length) {
1151
+ return fail("no_corridor_found", `No walkable corridor line was found inside ${subject.join(", ")}: every free run from the scan seeds was shorter than 4 m or narrower than 1 m.${scanFloor?.floor_source === "lowest_point" ? ` No walkable floor was found inside the subject either, so the seeds started from its lowest point (y ${fmt2(scanFloor.floor_y)}).` : ""}`, "Check the subject is the corridor/lane node itself, or frame it with summer_frame_nodes instead.", scanFloor ? { scan_floor: scanFloor } : undefined);
1152
+ }
1153
+ }
1154
+ // Corridors stand on the floor the scan found under its seed; other shots
1155
+ // use the subject's lowest point for the low-angle rule.
1156
+ const groundY = shot === "corridor" && corridorAxes[0] ? corridorFloorY(corridorAxes[0], args.eye_height) : subjectBox ? subjectBox.position[1] : undefined;
1157
+ const candidates = generateCandidates({
1158
+ shot,
1159
+ ...(subjectBox ? { subject: subjectBox } : {}),
1160
+ ...(spawnInfo ? { spawn: spawnInfo } : {}),
1161
+ ...(corridorAxes[0] ? { corridor: corridorAxes[0] } : {}),
1162
+ aspect,
1163
+ ...(fov !== undefined ? { fov } : {}),
1164
+ ...(args.eye_height !== undefined ? { eyeHeight: args.eye_height } : {}),
1165
+ ...(groundY !== undefined ? { groundY } : {}),
1166
+ });
1167
+ // Pass 2: what each candidate would see.
1168
+ const pass2 = await timed("measure_ms", () => runProbe(client, {
1169
+ scenePath,
1170
+ size: [16, 16],
1171
+ config: {
1172
+ mode: "analyze",
1173
+ subjects: subject ?? [],
1174
+ tasks: ["measure"],
1175
+ occluders: occ,
1176
+ candidates: candidates.map((c) => ({
1177
+ position: [...c.position],
1178
+ look_at: [...c.look_at],
1179
+ fov: c.fov,
1180
+ samples: c.samples.map((s) => [...s]),
1181
+ min_clearance: c.min_clearance,
1182
+ low_angle_rule: c.low_angle_rule,
1183
+ // Eye mode: the kernel stands the camera at eye height above the
1184
+ // walkable surface under it, moves it only horizontally, never up.
1185
+ ...(c.eye ? { eye: { min: c.eye.min, max: c.eye.max, ...(c.eye.target !== undefined ? { target: c.eye.target } : {}), stand: [...c.eye.stand] } } : {}),
1186
+ })),
1187
+ measure: { aspect, grid_cols: GRID_COLS, grid_rows: gridRows, subject_occludes: shot === "corridor", near_lens_radius: 0.3, sweep_radius: 0.15 },
1188
+ // A 4x4-pixel-per-cell beauty render per candidate, for the featureless
1189
+ // area and near/far value checks.
1190
+ image_check: { size: [GRID_COLS * 4, gridRows * 4] },
1191
+ },
1192
+ }));
1193
+ await pass2.dispose();
1194
+ if (!pass2.ok)
1195
+ return probeFailure(pass2);
1196
+ const measurements = (pass2.result.measurements ?? []);
1197
+ const scoringSubject = shot === "corridor" ? corridorBox(corridorAxes[0], groundY) : subjectBox;
1198
+ const scored = measurements.map((m) => scoreMeasurement(m, candidates[m.i], {
1199
+ shot,
1200
+ aspect,
1201
+ gridCols: GRID_COLS,
1202
+ gridRows,
1203
+ ...(scoringSubject ? { subject: scoringSubject } : {}),
1204
+ maxHardFraction: 0.34,
1205
+ maxSoftFraction: maxSoft,
1206
+ ...(keyLight ? { keyLight: keyLight.direction } : {}),
1207
+ }));
1208
+ const separation = subjectBox ? Math.max(1, Math.hypot(...subjectBox.size) * 0.12) : 1;
1209
+ // Three different views, not one view three times: ring shots spread
1210
+ // around the subject (one per side while the score allows, then >= 25 deg
1211
+ // apart); eye-level looks spread in yaw; corridors keep their two ends.
1212
+ const ring = shot === "establishing" || shot === "low_angle" || shot === "detail";
1213
+ const top = pickTop(scored, 3, separation, ring && subjectBox ? { around: aabbCenter(subjectBox), minYaw: 25, sides: true } : shot === "eye_level" ? { minYaw: 25 } : undefined);
1214
+ const rejected = rejectionCounts(scored);
1215
+ const occSummary = (pass2.result.occluders ?? {});
1216
+ if (!top.length) {
1217
+ return fail("no_usable_pose", `All ${scored.length} candidate poses for a ${shot} shot were rejected.`, shot === "eye_level" || shot === "corridor"
1218
+ ? "Eye-level and corridor cameras stand 1.5-1.8 m (or eye_height) above the walkable surface under them and are never raised; no_walkable_ground / not_walkable mean no floor within a step of the spawn's or corridor's floor below them. Loosen occluders (occluders.soft / occluders.ignore), raise max_soft_fraction, or pick a different subject/spawn."
1219
+ : "Loosen occluders (move blocking props to occluders.soft or occluders.ignore), raise max_soft_fraction, or pick a different subject/spawn.", { rejected, occluders: occSummary.counts });
1220
+ }
1221
+ const lines = [];
1222
+ const best = top[0];
1223
+ if (bookmark) {
1224
+ const saved = firstResult(await client.executeOps([{ op: "SaveCameraBookmark", name: bookmark, position: formatVector3(best.pose.position), look_at: formatVector3(best.pose.look_at), fov: Math.round(best.pose.fov * 100) / 100 }]));
1225
+ if (saved.ok === false)
1226
+ return fail(String(saved.failure_reason ?? "bookmark_save_failed"), String(saved.error ?? "SaveCameraBookmark failed"));
1227
+ lines.push(`saved best as bookmark "${bookmark}"${saved.overwritten === true ? " (replaced its previous pose)" : ""}.`);
1228
+ }
1229
+ let image = null;
1230
+ let slotText = [];
1231
+ if (render !== "none") {
1232
+ const shown = render === "best" ? [best] : top;
1233
+ const layout = layoutGrid(shown.length, aspect, edge);
1234
+ const store = shotStore(client);
1235
+ const dir = await makeProbeDir();
1236
+ const slots = [];
1237
+ const tiles = shown.map((s, i) => {
1238
+ const spec = { kind: "shot", rect: layout.rects[i], view: "beauty", label: `${i + 1} ${shot} ${s.total.toFixed(2)} · ${cameraHeightText(s.pose, s.groundY, true)}`, pose: kernelPose(s.pose) };
1239
+ if (i === 0 && bookmark && !(store instanceof ShotStoreError)) {
1240
+ spec.render_size = slotRenderSize(aspect, layout.tile);
1241
+ spec.capture_path = join(dir, "slot-0.jpg").replace(/\\/g, "/");
1242
+ spec.capture_max_edge = SHOT_MAX_EDGE;
1243
+ slots.push({ tile: 0, bookmark, capturePath: spec.capture_path });
1244
+ }
1245
+ return spec;
1246
+ });
1247
+ const run = await timed("render_ms", () => renderTiles(client, scenePath, layout.canvas, tiles, dir));
1248
+ try {
1249
+ if (!run.ok)
1250
+ return probeFailure(run);
1251
+ image = imageFrom(run);
1252
+ const { written, notes } = await storeSlots(store, slots, run);
1253
+ slotText = slotLines(written, notes);
1254
+ const flat = flatWarning(image);
1255
+ if (flat)
1256
+ slotText.push(flat);
1257
+ }
1258
+ finally {
1259
+ await run.dispose();
1260
+ }
1261
+ }
1262
+ const counts = (occSummary.counts ?? {});
1263
+ const byRule = (occSummary.by_rule ?? {});
1264
+ const header = [
1265
+ `Smart framing: ${shot} of ${subject?.join(", ") ?? `the view from ${spawn}`} in ${scenePath}. ${scored.length} candidates measured in-engine, ${scored.length - Object.values(rejected).reduce((a, b) => a + b, 0)} usable${Object.keys(rejected).length ? `; rejected ${Object.entries(rejected).map(([k, v]) => `${k} ${v}`).join(", ")}` : ""}.`,
1266
+ subjectBox ? `subject bounds: ${aabbLine(subjectBox)}` : "",
1267
+ shot === "corridor" ? `corridor axis: dir ${formatVector3(corridorAxes[0].dir)}, free ${(corridorAxes[0].usableFwd + corridorAxes[0].usableBack).toFixed(1)} m, width ${corridorAxes[0].width.toFixed(1)} m` : "",
1268
+ spawnInfo
1269
+ ? `eye: ${spawnInfo.camera && args.eye_height === undefined ? `${spawnInfo.camera.path} (its own height kept inside ${eyeBand(undefined).join("-")} m)` : `${args.eye_height ?? 1.6} m`} above the walkable surface under the camera at ${spawnInfo.path}; never raised`
1270
+ : "",
1271
+ shot === "corridor" ? `eye: ${args.eye_height ?? 1.6} m above the walkable surface under each camera (corridor floor y ${fmt2(groundY)}); never raised` : "",
1272
+ shot === "corridor" && scanFloor ? scanFloorText(scanFloor, subjectBox) : "",
1273
+ shot === "establishing" ? `tier rule: a pose showing more than ${Math.round(EMPTY_LIMIT * 100)}% empty ground or world edge ranks below every pose showing less` : "",
1274
+ `occluders: hard ${counts.hard ?? 0}, soft ${counts.soft ?? 0}, subject ${counts.subject ?? 0}, ignored ${counts.ignored ?? 0}, of which transparent (see-through cover) ${counts.translucent ?? 0} (by rule: ${Object.entries(byRule).map(([k, v]) => `${k} ${v}`).join(", ")})`,
1275
+ keyLight
1276
+ ? `key light: ${keyLight.path} travelling ${formatVector3(keyLight.direction)} (light term: side or front-side light scores best, light from straight behind the camera — a flat-lit face — worst)`
1277
+ : "key light: no visible DirectionalLight3D, so no light-direction term",
1278
+ ];
1279
+ const topLines = top.map((s, i) => {
1280
+ const t = s.terms;
1281
+ const termText = Object.entries(t).map(([k, v]) => `${k} ${v}`).join(" ");
1282
+ const adj = s.adjustments?.length ? ` adjusted: ${s.adjustments.map((a) => String(a.kind)).join("+")}` : "";
1283
+ const occl = s.blockers?.soft?.length ? ` framed by ${s.blockers.soft.slice(0, 2).join(", ")}` : "";
1284
+ const through = s.blockers?.through?.length ? ` seen through ${s.blockers.through.slice(0, 2).join(", ")}` : "";
1285
+ const img = s.stats?.flat !== undefined ? ` flat ${s.stats.flat}` : "";
1286
+ const backFaces = s.stats?.back ? ` back-faces ${s.stats.back}` : "";
1287
+ const worldEdge = s.stats?.void ? ` world-edge ${s.stats.void}` : "";
1288
+ const emptyGround = s.stats?.emptyGround ? ` empty-ground ${s.stats.emptyGround}` : "";
1289
+ const tierNote = s.tier ? ` (over ${Math.round(EMPTY_LIMIT * 100)}% empty: ranked below every cleaner pose)` : "";
1290
+ return `${i + 1}. score ${s.total.toFixed(2)} [${s.id}] ${poseLiteral(s.pose)}\n ${cameraHeightText(s.pose, s.groundY)}\n ${termText}; sky ${s.stats?.sky} fg ${s.stats?.foreground} wall-behind ${s.stats?.wallBehind}${img}${backFaces}${worldEdge}${emptyGround}${tierNote}${s.fill !== undefined ? ` fill ${s.fill}` : ""}${adj}${occl}${through}`;
1291
+ });
1292
+ const ranked = scored.filter((x) => !x.rejected).sort(rankOrder);
1293
+ const spawnForward = scored.find((x) => x.id === "eye_spawn_forward");
1294
+ if (spawnForward) {
1295
+ const rank = ranked.indexOf(spawnForward) + 1;
1296
+ header.push(`the spawn's own forward view: ${spawnForward.rejected ? `rejected (${spawnForward.rejected})` : `score ${spawnForward.total.toFixed(2)}, rank ${rank} of ${ranked.length}`} — ${poseLiteral(spawnForward.pose)}`);
1297
+ }
1298
+ header.push(`engine time: ${Object.entries(timings).map(([k, v]) => `${k.replace(/_ms$/, "")} ${(v / 1000).toFixed(1)} s`).join(", ")}`);
1299
+ const caption = capCaption([
1300
+ ...header,
1301
+ "top poses (tile order in the image):",
1302
+ ...topLines,
1303
+ ...lines,
1304
+ ...slotText,
1305
+ render === "none" ? "No image rendered (render:\"none\"). Render with summer_shot_sheet." : "Describe only what is visibly in the image.",
1306
+ ]);
1307
+ return {
1308
+ ok: true,
1309
+ image,
1310
+ caption,
1311
+ receipt: {
1312
+ scenePath,
1313
+ shot,
1314
+ candidates: scored.length,
1315
+ rejected,
1316
+ occluders: { counts, by_rule: byRule, examples: occSummary.examples },
1317
+ top: top.map((s) => {
1318
+ const h = heightAboveGround(s.pose.position, s.groundY);
1319
+ return {
1320
+ id: s.id,
1321
+ score: s.total,
1322
+ terms: s.terms,
1323
+ stats: s.stats,
1324
+ ...poseRecord(s.pose),
1325
+ camera_y: Math.round(s.pose.position[1] * 1000) / 1000,
1326
+ ...(s.groundY !== undefined ? { ground_y: s.groundY } : {}),
1327
+ ...(h !== undefined ? { height_above_ground: Math.round(h * 1000) / 1000 } : {}),
1328
+ ...(s.tier !== undefined ? { tier: s.tier } : {}),
1329
+ ...(s.adjustments ? { adjustments: s.adjustments } : {}),
1330
+ ...(s.blockers ? { blockers: s.blockers } : {}),
1331
+ };
1332
+ }),
1333
+ ...(bookmark ? { bookmark } : {}),
1334
+ ...(keyLight ? { key_light: { path: keyLight.path, direction: formatVector3(keyLight.direction) } } : {}),
1335
+ ...(shot === "corridor" ? { corridor: corridorAxes[0], ...(scanFloor ? { scan_floor: scanFloor } : {}) } : {}),
1336
+ timings,
1337
+ table: scored.map((s) => ({ id: s.id, total: s.total, ...(s.rejected ? { rejected: s.rejected } : {}), terms: s.terms })),
1338
+ },
1339
+ };
1340
+ }
1341
+ export const SHOT_TYPE_DEFAULTS = SHOT_DEFAULTS;