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,1873 @@
1
+ @tool
2
+ extends Node
3
+ ## Summer seeing kernel.
4
+ ##
5
+ ## The MCP seeing tools (summer_frame_nodes, summer_shot_sheet,
6
+ ## summer_debug_views, summer_zoom, summer_frame_shot) write a throwaway
7
+ ## wrapper scene into a per-call OS temp directory: the wrapper instances the
8
+ ## target scene as "Subject" and carries this script as a built-in @tool
9
+ ## script. The wrapper is rendered with the engine's ScenePreview op, which
10
+ ## instantiates it under an offscreen SubViewport with its own World3D, so this
11
+ ## node runs inside a private COPY of the scene. Nothing here touches the edited
12
+ ## scene, the undo history or any project file. The only files written are in
13
+ ## the wrapper's own temp directory: result.json and, when asked, tile
14
+ ## captures.
15
+ ##
16
+ ## Arguments arrive as DATA only: config.json next to the wrapper scene, found
17
+ ## through this built-in script's own resource path and parsed as JSON. No
18
+ ## caller text is ever spliced into this source.
19
+ ##
20
+ ## Modes (config "mode"):
21
+ ## "analyze": world bounds of subject nodes, spawn poses, the key light, and
22
+ ## (when asked) a physics pass built from the VISIBLE geometry: corridor
23
+ ## scans, per-candidate measurements (thick sweep visibility, back-face and
24
+ ## closed-shell lens checks, low-angle correction, a ray grid through the
25
+ ## frame) and a mark occlusion test. The preview's own 3D render is
26
+ ## disabled; only result.json matters.
27
+ ## Back faces: every physics face collides on both sides, and a short
28
+ ## front-faces-only ray at each hit tells which side the ray met. A back
29
+ ## face of a surface without cull_disabled is NOT drawn, so it never hides
30
+ ## anything in the image, but a camera on that side is behind or inside the
31
+ ## surface and would look through it.
32
+ ## "render": a labelled grid of tiles. Each "shot" tile is a SubViewport with
33
+ ## its own Camera3D at an explicit pose sharing the preview world, so the
34
+ ## scene's REAL WorldEnvironment and lights apply. Views are native
35
+ ## Viewport.debug_draw modes, except "normals", which renders a second
36
+ ## private copy with an unshaded world-normal override material. "prev" and
37
+ ## "diff" tiles compare a shot against a previous JPEG. ScenePreview reads
38
+ ## the composed canvas back as the returned image.
39
+
40
+ const SUBJECT_NAME := "Subject"
41
+
42
+ const CLASS_HARD := 0
43
+ const CLASS_SOFT := 1
44
+ const CLASS_SUBJECT := 2
45
+ const LAYER_HARD := 1
46
+ const LAYER_SOFT := 2
47
+ const LAYER_SUBJECT := 4
48
+ # Transparent surfaces (alpha blend, alpha scissor, alpha hash, depth
49
+ # pre-pass, or a GeometryInstance3D fade): their own layer, whatever their
50
+ # class, so the solid sweeps never treat a foliage card or a glass pane as a
51
+ # wall. They count as see-through cover at partial weight.
52
+ const LAYER_SEE := 8
53
+ const CLASS_NAMES := ["hard", "soft", "subject"]
54
+ # Per-surface material flags. A surface without FLAG_DOUBLE is drawn from its
55
+ # front side only: seen from behind, the renderer culls it and the image shows
56
+ # whatever lies beyond, so a camera behind a one-sided wall looks THROUGH it.
57
+ const FLAG_DOUBLE := 1
58
+ const FLAG_SEE := 2
59
+ # A pose whose lens sits inside a closed shell: at least this many of the six
60
+ # axis rays meet a back face first.
61
+ const INSIDE_BACK_RAYS := 4
62
+ # Eye mode: the surface under the camera is walkable when it lies at most this
63
+ # far above / below the floor under the stand point (a kerb, a few steps, a
64
+ # slope), so a car roof or a wall cap is never the ground an eye stands on.
65
+ const EYE_STEP_UP := 0.75
66
+ const EYE_STEP_DOWN := 2.0
67
+ # Eye mode: the solid surfaces a camera can stand over (soft props and foliage
68
+ # never count as ground; water and glass floors do).
69
+ const EYE_GROUND_MASK := LAYER_HARD | LAYER_SUBJECT | LAYER_SEE
70
+
71
+ const DEFAULT_SOFT_PATTERN := "(tree|bush|shrub|grass|weed|moss|foliage|leaf|leaves|plant|flower|vine|ivy|hedge|pebble|gravel|rubble|litter|puddle|decal|fence|rail|lamp|lantern|light|pole|post|sign|wire|cable|pipe|crate|barrel|bench|prop|clutter|debris|trash|bin|bollard|hydrant|planter|pot|chair|table|awning|banner|flag)"
72
+ const DEFAULT_HARD_PATTERN := "(wall|building|house|facade|terrain|ground|floor|road|street|pavement|sidewalk|cliff|rock|mountain|roof|tower|bridge|stair|pier|corner|crown|cornice|base|dado|block)"
73
+
74
+ var _cfg: Dictionary = {}
75
+ var _out_dir := ""
76
+ var _result: Dictionary = {}
77
+ var _subject: Node = null
78
+ var _scene_root_name := ""
79
+ var _captures: Array = []
80
+ var _diffs: Array = []
81
+ var _draws := 0
82
+
83
+ # Physics pass state (analyze mode only).
84
+ var _space := RID()
85
+ var _state: PhysicsDirectSpaceState3D = null
86
+ var _bodies: Array = []
87
+ var _shapes: Array = []
88
+ var _body_geom: Dictionary = {}
89
+ var _geoms: Array = []
90
+ var _sphere_shapes: Dictionary = {}
91
+
92
+ # Image check (analyze "measure" with config image_check): one small beauty
93
+ # render per candidate, read back after the draw, reduced to a per-cell
94
+ # luminance and texture code aligned with the ray grid.
95
+ var _checks: Array = []
96
+ var _measurements: Array = []
97
+
98
+ var _soft_re: RegEx = null
99
+ var _hard_re: RegEx = null
100
+ var _alpha_re: RegEx = null
101
+ var _render_mode_re: RegEx = null
102
+ var _material_flag_cache: Dictionary = {}
103
+
104
+ # Render mode state.
105
+ var _normals_world: World3D = null
106
+
107
+
108
+ func _ready() -> void:
109
+ _result = {"ok": false, "stage": "ready", "warnings": [], "errors": []}
110
+ # Built-in script path: "<dir>/wrapper.tscn::GDScript_seeing".
111
+ var script_path := String((get_script() as Script).resource_path)
112
+ var wrapper_path := script_path.get_slice("::", 0)
113
+ _out_dir = wrapper_path.get_base_dir()
114
+ if wrapper_path == "" or not wrapper_path.ends_with("wrapper.tscn"):
115
+ push_error("Summer seeing kernel: cannot locate its wrapper scene (script path '%s')." % script_path)
116
+ return
117
+ var config_path := _out_dir.path_join("config.json")
118
+ var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(config_path))
119
+ if typeof(parsed) != TYPE_DICTIONARY:
120
+ _fail("config_unreadable", "Could not read the seeing config at " + config_path)
121
+ return
122
+ _cfg = parsed
123
+ _result["renderer"] = RenderingServer.get_current_rendering_method()
124
+ _subject = get_parent().get_node_or_null(SUBJECT_NAME)
125
+ if _subject == null:
126
+ _fail("no_subject", "The wrapper scene has no Subject instance (the target scene failed to instantiate).")
127
+ return
128
+ var packed: PackedScene = load(String(_cfg.get("scene_path", ""))) as PackedScene
129
+ if packed != null and packed.get_state().get_node_count() > 0:
130
+ _scene_root_name = String(packed.get_state().get_node_name(0))
131
+ _result["scene_root_name"] = _scene_root_name
132
+ _result["stage"] = "configured"
133
+ var mode := String(_cfg.get("mode", ""))
134
+ if mode == "render":
135
+ _setup_render()
136
+ if _cfg.has("occlusion"):
137
+ # Deferred like the analyze pass; ScenePreview flushes it before the
138
+ # first draw, so result.json carries it when the image is read.
139
+ call_deferred("_render_occlusion")
140
+ elif mode == "analyze":
141
+ get_viewport().disable_3d = true
142
+ _setup_image_checks()
143
+ _write_result()
144
+ # Deferred so CSG roots and @tool scripts that settle with call_deferred
145
+ # have run before the geometry is read. ScenePreview iterates the main
146
+ # loop after instantiating, which flushes this call.
147
+ call_deferred("_analyze")
148
+ else:
149
+ _fail("bad_mode", "Unknown seeing mode '" + mode + "'.")
150
+
151
+
152
+ func _exit_tree() -> void:
153
+ if RenderingServer.frame_post_draw.is_connected(_on_post_draw):
154
+ RenderingServer.frame_post_draw.disconnect(_on_post_draw)
155
+ _free_physics()
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Result plumbing
160
+ # ---------------------------------------------------------------------------
161
+
162
+ func _fail(reason: String, message: String) -> void:
163
+ _result["ok"] = false
164
+ _result["failure_reason"] = reason
165
+ (_result["errors"] as Array).append(message)
166
+ _write_result()
167
+
168
+
169
+ func _warn(message: String) -> void:
170
+ var warnings: Array = _result["warnings"]
171
+ if warnings.size() < 24 and not warnings.has(message):
172
+ warnings.append(message)
173
+
174
+
175
+ func _write_result() -> void:
176
+ var f := FileAccess.open(_out_dir.path_join("result.json"), FileAccess.WRITE)
177
+ if f == null:
178
+ return
179
+ f.store_string(JSON.stringify(_result))
180
+ f.close()
181
+
182
+
183
+ static func _vec(value: Variant, fallback := Vector3.ZERO) -> Vector3:
184
+ if value is Array and (value as Array).size() >= 3:
185
+ var a: Array = value
186
+ return Vector3(float(a[0]), float(a[1]), float(a[2]))
187
+ return fallback
188
+
189
+
190
+ static func _arr(v: Vector3, digits := 3) -> Array:
191
+ var step := pow(10.0, -digits)
192
+ return [snappedf(v.x, step), snappedf(v.y, step), snappedf(v.z, step)]
193
+
194
+
195
+ static func _aabb_dict(box: AABB) -> Dictionary:
196
+ return {"position": _arr(box.position), "size": _arr(box.size)}
197
+
198
+
199
+ # Lenient path resolution, the same spellings ScenePreview accepts: a path
200
+ # relative to the scene root, one that includes the root's name, the root
201
+ # itself ("." or its name), or a bare unique name searched recursively.
202
+ func _resolve(path: String) -> Node:
203
+ var p := path.strip_edges()
204
+ if p == "" or p == "." or p == _scene_root_name:
205
+ return _subject
206
+ var node := _subject.get_node_or_null(NodePath(p))
207
+ if node == null and _scene_root_name != "" and p.begins_with(_scene_root_name + "/"):
208
+ node = _subject.get_node_or_null(NodePath(p.substr(_scene_root_name.length() + 1)))
209
+ if node == null and not p.contains("/"):
210
+ node = _subject.find_child(p, true, false)
211
+ return node
212
+
213
+
214
+ func _rel(node: Node) -> String:
215
+ if node == _subject:
216
+ return "."
217
+ return String(_subject.get_path_to(node))
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Geometry
222
+ # ---------------------------------------------------------------------------
223
+
224
+ func _hidden(node: Node) -> bool:
225
+ return node is Node3D and not (node as Node3D).is_visible_in_tree()
226
+
227
+
228
+ # Every visible mesh under `root` as {node, mesh, xform}. MultiMesh instances
229
+ # and GridMap cells expand to one entry each (capped); CSG contributes its
230
+ # root mesh only.
231
+ func _geometry_items(root: Node, multimesh_cap: int) -> Array:
232
+ var items: Array = []
233
+ var stack: Array = [root]
234
+ while not stack.is_empty():
235
+ var node: Node = stack.pop_back()
236
+ if _hidden(node):
237
+ continue
238
+ var descend := true
239
+ if node is MeshInstance3D:
240
+ var mi := node as MeshInstance3D
241
+ if mi.mesh != null:
242
+ items.append({"node": mi, "mesh": mi.mesh, "xform": mi.global_transform})
243
+ elif node is MultiMeshInstance3D:
244
+ var mm: MultiMesh = (node as MultiMeshInstance3D).multimesh
245
+ if mm != null and mm.mesh != null and mm.transform_format == MultiMesh.TRANSFORM_3D:
246
+ var count := mm.instance_count
247
+ if mm.visible_instance_count >= 0:
248
+ count = mini(count, mm.visible_instance_count)
249
+ if count > multimesh_cap:
250
+ _warn("MultiMesh %s has %d instances; only the first %d were used." % [_rel(node), count, multimesh_cap])
251
+ count = multimesh_cap
252
+ var base := (node as Node3D).global_transform
253
+ for i in count:
254
+ items.append({"node": node, "mesh": mm.mesh, "xform": base * mm.get_instance_transform(i)})
255
+ elif node is CSGShape3D:
256
+ var csg := node as CSGShape3D
257
+ if csg.is_root_shape():
258
+ var meshes: Array = csg.get_meshes()
259
+ if meshes.size() >= 2 and meshes[1] is Mesh:
260
+ items.append({"node": csg, "mesh": meshes[1], "xform": csg.global_transform * Transform3D(meshes[0])})
261
+ descend = false
262
+ elif node is GridMap:
263
+ var gm := node as GridMap
264
+ var pairs: Array = gm.get_meshes()
265
+ var i := 0
266
+ while i + 1 < pairs.size():
267
+ if pairs[i + 1] is Mesh:
268
+ items.append({"node": gm, "mesh": pairs[i + 1], "xform": gm.global_transform * Transform3D(pairs[i])})
269
+ i += 2
270
+ if descend:
271
+ for child in node.get_children():
272
+ stack.push_back(child)
273
+ return items
274
+
275
+
276
+ # Merged world bounds of the visible geometry under `root` (Label3D/Sprite3D
277
+ # included, lights/particles/probes excluded).
278
+ func _bounds(root: Node) -> Dictionary:
279
+ var box := AABB()
280
+ var has := false
281
+ var count := 0
282
+ for item in _geometry_items(root, 4096):
283
+ var mesh: Mesh = item["mesh"]
284
+ var b: AABB = (item["xform"] as Transform3D) * mesh.get_aabb()
285
+ box = b if not has else box.merge(b)
286
+ has = true
287
+ count += 1
288
+ var stack: Array = [root]
289
+ while not stack.is_empty():
290
+ var node: Node = stack.pop_back()
291
+ if _hidden(node):
292
+ continue
293
+ if node is SpriteBase3D or node is Label3D:
294
+ var vi := node as VisualInstance3D
295
+ var b2: AABB = vi.global_transform * vi.get_aabb()
296
+ if b2.size != Vector3.ZERO:
297
+ box = b2 if not has else box.merge(b2)
298
+ has = true
299
+ count += 1
300
+ for child in node.get_children():
301
+ stack.push_back(child)
302
+ if not has and root is Node3D:
303
+ box = AABB((root as Node3D).global_position - Vector3(0.5, 0.5, 0.5), Vector3.ONE)
304
+ return {"has": has, "aabb": box, "count": count}
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Analyze
309
+ # ---------------------------------------------------------------------------
310
+
311
+ func _analyze() -> void:
312
+ var started := Time.get_ticks_msec()
313
+ _result["stage"] = "analyze"
314
+ var subjects: Array = []
315
+ var subject_nodes: Array = []
316
+ var merged := AABB()
317
+ var missing: Array = []
318
+ for raw in (_cfg.get("subjects", []) as Array):
319
+ var path := String(raw)
320
+ var node := _resolve(path)
321
+ if node == null:
322
+ missing.append(path)
323
+ continue
324
+ subject_nodes.append(node)
325
+ var b := _bounds(node)
326
+ merged = b["aabb"] if subjects.is_empty() else merged.merge(b["aabb"])
327
+ subjects.append({"path": path, "resolved": _rel(node), "has_geometry": b["has"], "visuals": b["count"], "aabb": _aabb_dict(b["aabb"])})
328
+ _result["subjects"] = subjects
329
+ if not missing.is_empty():
330
+ _result["missing"] = missing
331
+ _fail("node_not_found", "Node(s) not found in the scene: " + ", ".join(PackedStringArray(missing)))
332
+ return
333
+ var spawn_path := String(_cfg.get("spawn", ""))
334
+ if spawn_path != "":
335
+ var spawn := _resolve(spawn_path)
336
+ if spawn == null or not (spawn is Node3D):
337
+ _result["missing"] = [spawn_path]
338
+ _fail("node_not_found", "Spawn node not found or not a Node3D: " + spawn_path)
339
+ return
340
+ var s3 := spawn as Node3D
341
+ var info := {"path": spawn_path, "origin": _arr(s3.global_position), "forward": _arr(-s3.global_transform.basis.z.normalized())}
342
+ var cams := spawn.find_children("*", "Camera3D", true, false)
343
+ if spawn is Camera3D:
344
+ cams.push_front(spawn)
345
+ if not cams.is_empty():
346
+ var cam := cams[0] as Camera3D
347
+ info["camera"] = {"path": _rel(cam), "position": _arr(cam.global_position), "forward": _arr(-cam.global_transform.basis.z.normalized()), "fov": cam.fov}
348
+ _result["spawn"] = info
349
+ var key := _key_light()
350
+ if not key.is_empty():
351
+ _result["key_light"] = key
352
+ var tasks: Array = _cfg.get("tasks", [])
353
+ if tasks.has("corridor_scan") or tasks.has("measure") or tasks.has("occlusion"):
354
+ _build_physics(subject_nodes)
355
+ if _state == null:
356
+ _fail("physics_unavailable", "Could not create a physics space for the visibility pass.")
357
+ return
358
+ if tasks.has("corridor_scan"):
359
+ var spec: Dictionary = (_cfg.get("corridor", {}) as Dictionary).duplicate()
360
+ if not spec.has("aabb"):
361
+ spec["aabb"] = _aabb_dict(merged)
362
+ _result["corridor_scan"] = _corridor_scan(spec)
363
+ if tasks.has("measure"):
364
+ _result["measurements"] = _measure_all(_cfg.get("candidates", []))
365
+ if tasks.has("occlusion"):
366
+ _result["occlusion"] = _occlusion(_cfg.get("occlusion", {}))
367
+ _result["duration_ms"] = Time.get_ticks_msec() - started
368
+ _result["ok"] = true
369
+ _result["stage"] = "done"
370
+ _write_result()
371
+ _free_physics()
372
+
373
+
374
+ func _class_for(node: Node, box: AABB, subject_nodes: Array, occ: Dictionary) -> Dictionary:
375
+ var path := _rel(node)
376
+ if not bool(occ.get("subject_occludes", false)):
377
+ for s in subject_nodes:
378
+ if s == node or (s as Node).is_ancestor_of(node):
379
+ return {"cls": CLASS_SUBJECT, "why": "subject"}
380
+ for prefix in (occ.get("ignore", []) as Array):
381
+ if _path_has_prefix(path, String(prefix)):
382
+ return {"cls": -1, "why": "ignored"}
383
+ for prefix in (occ.get("hard", []) as Array):
384
+ if _path_has_prefix(path, String(prefix)):
385
+ return {"cls": CLASS_HARD, "why": "path"}
386
+ for prefix in (occ.get("soft", []) as Array):
387
+ if _path_has_prefix(path, String(prefix)):
388
+ return {"cls": CLASS_SOFT, "why": "path"}
389
+ var hard_layers := int(occ.get("hard_layers", 0))
390
+ var soft_layers := int(occ.get("soft_layers", 0))
391
+ if hard_layers != 0 or soft_layers != 0:
392
+ var layer := _collision_layer_near(node)
393
+ if layer != 0:
394
+ if layer & hard_layers:
395
+ return {"cls": CLASS_HARD, "why": "layer"}
396
+ if layer & soft_layers:
397
+ return {"cls": CLASS_SOFT, "why": "layer"}
398
+ var cur: Node = node
399
+ while cur != null and cur != _subject.get_parent():
400
+ if cur.is_in_group("camera_soft"):
401
+ return {"cls": CLASS_SOFT, "why": "group"}
402
+ if cur.is_in_group("camera_hard"):
403
+ return {"cls": CLASS_HARD, "why": "group"}
404
+ cur = cur.get_parent()
405
+ if node is MultiMeshInstance3D:
406
+ return {"cls": CLASS_SOFT, "why": "multimesh"}
407
+ # Deepest named segment wins: ".../House1/Front/lantern/Model/street_lamp"
408
+ # is a lamp (soft), ".../House1/Front/s0_c0/Model/wall_double_..." a wall.
409
+ var segments := path.split("/")
410
+ for i in range(segments.size() - 1, -1, -1):
411
+ var seg := segments[i]
412
+ if _soft_re != null and _soft_re.search(seg) != null:
413
+ return {"cls": CLASS_SOFT, "why": "name"}
414
+ if _hard_re != null and _hard_re.search(seg) != null:
415
+ return {"cls": CLASS_HARD, "why": "name"}
416
+ var longest := maxf(box.size.x, maxf(box.size.y, box.size.z))
417
+ if longest <= float(occ.get("soft_max_extent", 2.5)):
418
+ return {"cls": CLASS_SOFT, "why": "size"}
419
+ return {"cls": CLASS_HARD, "why": "size"}
420
+
421
+
422
+ static func _path_has_prefix(path: String, prefix: String) -> bool:
423
+ var p := prefix.strip_edges().trim_suffix("/")
424
+ if p == "":
425
+ return false
426
+ return path == p or path.begins_with(p + "/")
427
+
428
+
429
+ func _collision_layer_near(node: Node) -> int:
430
+ var cur: Node = node
431
+ for i in 4:
432
+ if cur == null:
433
+ break
434
+ if cur is CollisionObject3D:
435
+ return (cur as CollisionObject3D).collision_layer
436
+ cur = cur.get_parent()
437
+ for child in node.get_children():
438
+ if child is CollisionObject3D:
439
+ return (child as CollisionObject3D).collision_layer
440
+ return 0
441
+
442
+
443
+ func _build_physics(subject_nodes: Array) -> void:
444
+ var t0 := Time.get_ticks_msec()
445
+ _space = PhysicsServer3D.space_create()
446
+ PhysicsServer3D.space_set_active(_space, true)
447
+ var occ: Dictionary = _cfg.get("occluders", {})
448
+ _soft_re = RegEx.create_from_string("(?i)" + String(occ.get("soft_pattern", DEFAULT_SOFT_PATTERN)))
449
+ _hard_re = RegEx.create_from_string("(?i)" + String(occ.get("hard_pattern", DEFAULT_HARD_PATTERN)))
450
+ _alpha_re = RegEx.create_from_string("\\bALPHA(_SCISSOR_THRESHOLD|_HASH_SCALE)?\\s*=[^=]")
451
+ _render_mode_re = RegEx.create_from_string("render_mode[^;]*;")
452
+ var shape_cache: Dictionary = {}
453
+ var counts := {"hard": 0, "soft": 0, "subject": 0, "ignored": 0, "translucent": 0, "double_sided": 0}
454
+ var why_counts := {}
455
+ var examples := {"hard": [], "soft": [], "subject": [], "translucent": []}
456
+ var faces_total := 0
457
+ var face_cap := int(_cfg.get("face_cap", 4000000))
458
+ var capped := false
459
+ for item in _geometry_items(_subject, int(_cfg.get("multimesh_cap", 2000))):
460
+ var node: Node = item["node"]
461
+ var mesh: Mesh = item["mesh"]
462
+ var xform: Transform3D = item["xform"]
463
+ var box: AABB = xform * mesh.get_aabb()
464
+ var c := _class_for(node, box, subject_nodes, occ)
465
+ var cls := int(c["cls"])
466
+ if cls < 0:
467
+ counts["ignored"] += 1
468
+ continue
469
+ var path := _rel(node)
470
+ # One body per material behaviour: a mesh whose surfaces mix opaque,
471
+ # double-sided and transparent materials (bark + leaf cards) is split,
472
+ # so the leaves are see-through cover and the trunk stays solid.
473
+ for part in _mesh_parts(node, mesh):
474
+ var flags := int(part["flags"])
475
+ # Physics bodies do not take scale reliably: a scaled (or sheared)
476
+ # instance gets its own shape with the transform baked into the
477
+ # faces; unscaled instances share one shape per mesh part.
478
+ var body_xform := xform
479
+ var scaled := not _is_rigid(xform.basis)
480
+ var key := "%d:%s" % [mesh.get_instance_id(), String(part["key"])]
481
+ var shape := RID()
482
+ if not scaled and shape_cache.has(key):
483
+ shape = shape_cache[key]
484
+ else:
485
+ var faces := _part_faces(mesh, part)
486
+ if faces.is_empty():
487
+ if not scaled:
488
+ shape_cache[key] = RID()
489
+ continue
490
+ if faces_total + faces.size() / 3 > face_cap:
491
+ capped = true
492
+ continue
493
+ faces_total += faces.size() / 3
494
+ if scaled:
495
+ for k in faces.size():
496
+ faces[k] = xform * faces[k]
497
+ body_xform = Transform3D.IDENTITY
498
+ shape = PhysicsServer3D.concave_polygon_shape_create()
499
+ PhysicsServer3D.shape_set_data(shape, {"faces": faces, "backface_collision": true})
500
+ _shapes.append(shape)
501
+ if not scaled:
502
+ shape_cache[key] = shape
503
+ if not shape.is_valid():
504
+ continue
505
+ var body := PhysicsServer3D.body_create()
506
+ PhysicsServer3D.body_set_mode(body, PhysicsServer3D.BODY_MODE_STATIC)
507
+ PhysicsServer3D.body_add_shape(body, shape)
508
+ var layer: int = [LAYER_HARD, LAYER_SOFT, LAYER_SUBJECT][cls]
509
+ if flags & FLAG_SEE:
510
+ layer = LAYER_SEE
511
+ PhysicsServer3D.body_set_collision_layer(body, layer)
512
+ PhysicsServer3D.body_set_collision_mask(body, 0)
513
+ PhysicsServer3D.body_set_space(body, _space)
514
+ PhysicsServer3D.body_set_state(body, PhysicsServer3D.BODY_STATE_TRANSFORM, body_xform)
515
+ _bodies.append(body)
516
+ var cls_name: String = CLASS_NAMES[cls]
517
+ counts[cls_name] += 1
518
+ if flags & FLAG_SEE:
519
+ counts["translucent"] += 1
520
+ var tex: Array = examples["translucent"]
521
+ if tex.size() < 6 and not tex.has(path):
522
+ tex.append(path)
523
+ if flags & FLAG_DOUBLE:
524
+ counts["double_sided"] += 1
525
+ var why_key := cls_name + ":" + String(c["why"])
526
+ why_counts[why_key] = int(why_counts.get(why_key, 0)) + 1
527
+ _body_geom[body.get_id()] = {"path": path, "cls": cls, "flags": flags}
528
+ var ex: Array = examples[cls_name]
529
+ if ex.size() < 6 and not ex.has(path):
530
+ ex.append(path)
531
+ if capped:
532
+ _warn("The visibility pass hit its face budget; some large meshes were skipped.")
533
+ _state = PhysicsServer3D.space_get_direct_state(_space)
534
+ _result["occluders"] = {"counts": counts, "by_rule": why_counts, "examples": examples, "faces": faces_total, "build_ms": Time.get_ticks_msec() - t0}
535
+
536
+
537
+ # The mesh as one or more {flags, key, surfaces} parts, one per distinct
538
+ # material behaviour (FLAG_DOUBLE / FLAG_SEE). Only flags are read here (cheap,
539
+ # per instance); faces are built in _part_faces on a shape-cache miss. The
540
+ # common single-behaviour mesh is one part covering every surface.
541
+ func _mesh_parts(node: Node, mesh: Mesh) -> Array:
542
+ var count := mesh.get_surface_count()
543
+ var per_surface: Array = []
544
+ var distinct := {}
545
+ for s in count:
546
+ var f := _surface_flags(node, mesh, s)
547
+ per_surface.append(f)
548
+ distinct[f] = true
549
+ if distinct.size() <= 1:
550
+ var only := 0 if per_surface.is_empty() else int(per_surface[0])
551
+ return [{"flags": only, "key": "all", "surfaces": []}]
552
+ var parts: Array = []
553
+ for f in distinct.keys():
554
+ var used: Array = []
555
+ for s in count:
556
+ if int(per_surface[s]) == int(f):
557
+ used.append(s)
558
+ parts.append({"flags": int(f), "key": "s" + "_".join(PackedStringArray(used.map(func(v): return str(v)))), "surfaces": used})
559
+ return parts
560
+
561
+
562
+ func _part_faces(mesh: Mesh, part: Dictionary) -> PackedVector3Array:
563
+ var surfaces: Array = part.get("surfaces", [])
564
+ if surfaces.is_empty():
565
+ return mesh.get_faces()
566
+ var tmp := ArrayMesh.new()
567
+ for raw in surfaces:
568
+ var s := int(raw)
569
+ var prim := Mesh.PRIMITIVE_TRIANGLES
570
+ if mesh is ArrayMesh:
571
+ prim = (mesh as ArrayMesh).surface_get_primitive_type(s)
572
+ if prim != Mesh.PRIMITIVE_TRIANGLES and prim != Mesh.PRIMITIVE_TRIANGLE_STRIP:
573
+ continue
574
+ tmp.add_surface_from_arrays(prim, mesh.surface_get_arrays(s))
575
+ if tmp.get_surface_count() == 0:
576
+ return PackedVector3Array()
577
+ return tmp.get_faces()
578
+
579
+
580
+ # FLAG_DOUBLE when the surface is drawn from both sides (cull_disabled),
581
+ # FLAG_SEE when it is transparent in any way (alpha blend, scissor, hash,
582
+ # depth pre-pass, a shader that writes ALPHA, or the instance's fade).
583
+ func _surface_flags(node: Node, mesh: Mesh, surface: int) -> int:
584
+ var mat: Material = null
585
+ var geo := node as GeometryInstance3D
586
+ if geo != null and geo.material_override != null:
587
+ mat = geo.material_override
588
+ elif node is MeshInstance3D:
589
+ mat = (node as MeshInstance3D).get_active_material(surface)
590
+ else:
591
+ mat = mesh.surface_get_material(surface)
592
+ var flags := _material_flags(mat)
593
+ if geo != null and geo.transparency > 0.001:
594
+ flags |= FLAG_SEE
595
+ return flags
596
+
597
+
598
+ func _material_flags(mat: Material) -> int:
599
+ if mat == null:
600
+ return 0
601
+ var id := mat.get_instance_id()
602
+ if _material_flag_cache.has(id):
603
+ return int(_material_flag_cache[id])
604
+ var flags := 0
605
+ if mat is BaseMaterial3D:
606
+ var b := mat as BaseMaterial3D
607
+ if b.cull_mode == BaseMaterial3D.CULL_DISABLED:
608
+ flags |= FLAG_DOUBLE
609
+ if b.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED:
610
+ flags |= FLAG_SEE
611
+ elif mat is ShaderMaterial:
612
+ var shader: Shader = (mat as ShaderMaterial).shader
613
+ if shader != null:
614
+ var code := shader.code
615
+ var rm: RegExMatch = _render_mode_re.search(code)
616
+ if rm != null:
617
+ var modes := rm.get_string()
618
+ if modes.contains("cull_disabled"):
619
+ flags |= FLAG_DOUBLE
620
+ for m in ["blend_add", "blend_sub", "blend_mul", "depth_prepass_alpha"]:
621
+ if modes.contains(String(m)):
622
+ flags |= FLAG_SEE
623
+ if _alpha_re.search(code) != null:
624
+ flags |= FLAG_SEE
625
+ _material_flag_cache[id] = flags
626
+ return flags
627
+
628
+
629
+ static func _is_rigid(b: Basis) -> bool:
630
+ return absf(b.x.length() - 1.0) < 0.001 and absf(b.y.length() - 1.0) < 0.001 and absf(b.z.length() - 1.0) < 0.001 \
631
+ and absf(b.x.dot(b.y)) < 0.001 and absf(b.x.dot(b.z)) < 0.001 and absf(b.y.dot(b.z)) < 0.001
632
+
633
+
634
+ func _free_physics() -> void:
635
+ for body in _bodies:
636
+ PhysicsServer3D.free_rid(body)
637
+ _bodies.clear()
638
+ for shape in _shapes:
639
+ PhysicsServer3D.free_rid(shape)
640
+ _shapes.clear()
641
+ for r in _sphere_shapes.values():
642
+ PhysicsServer3D.free_rid(r)
643
+ _sphere_shapes.clear()
644
+ if _space.is_valid():
645
+ PhysicsServer3D.free_rid(_space)
646
+ _space = RID()
647
+ _state = null
648
+
649
+
650
+ func _sphere(radius: float) -> RID:
651
+ var key := snappedf(radius, 0.001)
652
+ if _sphere_shapes.has(key):
653
+ return _sphere_shapes[key]
654
+ var s := PhysicsServer3D.sphere_shape_create()
655
+ PhysicsServer3D.shape_set_data(s, key)
656
+ _sphere_shapes[key] = s
657
+ return s
658
+
659
+
660
+ func _ray(from: Vector3, to: Vector3, mask: int) -> Dictionary:
661
+ var q := PhysicsRayQueryParameters3D.create(from, to, mask)
662
+ q.hit_back_faces = true
663
+ return _state.intersect_ray(q)
664
+
665
+
666
+ func _geom(hit: Dictionary) -> Dictionary:
667
+ return _body_geom.get((hit["rid"] as RID).get_id(), {})
668
+
669
+
670
+ # Did a ray travelling along `dir` meet the surface at `point` from BEHIND?
671
+ # Both physics servers report hit normals turned toward the ray, so the side
672
+ # is read with a short FRONT-faces-only ray across the hit point instead: a
673
+ # front face there means the side facing the camera is drawn. A double-sided
674
+ # material is drawn from both sides and never counts as a back face.
675
+ func _is_back(point: Vector3, dir: Vector3, flags: int) -> bool:
676
+ if flags & FLAG_DOUBLE:
677
+ return false
678
+ # Only the hit's own kind of surface: a glass pane 1 cm in front of a
679
+ # wall's back must not count as that wall's front.
680
+ var mask: int = LAYER_HARD | LAYER_SOFT | LAYER_SUBJECT
681
+ if (flags & FLAG_SEE) != 0:
682
+ mask = LAYER_SEE
683
+ var q := PhysicsRayQueryParameters3D.create(point - dir * 0.02, point + dir * 0.02, mask)
684
+ q.hit_back_faces = false
685
+ return _state.intersect_ray(q).is_empty()
686
+
687
+
688
+ # What a line from `from` to `to` really crosses, in order, up to the first
689
+ # opaque surface met on its drawn (front or double-sided) side:
690
+ # "back": path of a hard or subject surface crossed from BEHIND first (its
691
+ # back face is culled, so the image looks through it): the camera is
692
+ # behind or inside that surface;
693
+ # "see": path of a transparent surface crossed (seen through, partial cover);
694
+ # "stop": class of the first opaque drawn surface (-1 = none), its path and
695
+ # distance.
696
+ # Soft surfaces crossed from behind are skipped here; the soft sweep, which
697
+ # collides on both sides, still counts them as framing.
698
+ func _walk(from: Vector3, to: Vector3, mask: int) -> Dictionary:
699
+ var out := {"back": "", "back_dist": -1.0, "see": "", "stop": -1, "stop_path": "", "stop_dist": -1.0}
700
+ var total := from.distance_to(to)
701
+ if total < 0.001:
702
+ return out
703
+ var dir := (to - from) / total
704
+ var start := from
705
+ for _step in 8:
706
+ var hit := _ray(start, to, mask | LAYER_SEE)
707
+ if hit.is_empty():
708
+ break
709
+ var point: Vector3 = hit["position"]
710
+ var g := _geom(hit)
711
+ var flags := int(g.get("flags", 0))
712
+ var cls := int(g.get("cls", CLASS_HARD))
713
+ var back := _is_back(point, dir, flags)
714
+ if flags & FLAG_SEE:
715
+ if not back and String(out["see"]) == "":
716
+ out["see"] = String(g.get("path", ""))
717
+ elif back:
718
+ if cls != CLASS_SOFT and String(out["back"]) == "":
719
+ out["back"] = String(g.get("path", ""))
720
+ out["back_dist"] = snappedf(from.distance_to(point), 0.01)
721
+ else:
722
+ out["stop"] = cls
723
+ out["stop_path"] = String(g.get("path", ""))
724
+ out["stop_dist"] = snappedf(from.distance_to(point), 0.01)
725
+ return out
726
+ start = point + dir * 0.01
727
+ if from.distance_to(start) >= total:
728
+ break
729
+ return out
730
+
731
+
732
+ # Six axis rays from the lens: how many meet a hard or subject surface from
733
+ # behind first. A lens inside a closed shell of one-sided walls (a hollow
734
+ # building, a prop's interior) sees back faces in (nearly) every direction.
735
+ func _inside_back_rays(pos: Vector3) -> int:
736
+ var count := 0
737
+ for d in [Vector3.RIGHT, Vector3.LEFT, Vector3.UP, Vector3.DOWN, Vector3.FORWARD, Vector3.BACK]:
738
+ var w := _walk(pos, pos + (d as Vector3) * 200.0, LAYER_HARD | LAYER_SOFT | LAYER_SUBJECT)
739
+ if String(w["back"]) != "":
740
+ count += 1
741
+ return count
742
+
743
+
744
+ # A hard or subject back face within `reach` of the lens (axis rays plus the
745
+ # view direction): the lens sits just behind a one-sided surface.
746
+ func _near_back_face(pos: Vector3, fwd: Vector3, reach: float) -> String:
747
+ for d in [fwd, -fwd, Vector3.RIGHT, Vector3.LEFT, Vector3.UP, Vector3.DOWN, Vector3.FORWARD, Vector3.BACK]:
748
+ var w := _walk(pos, pos + (d as Vector3) * reach, LAYER_HARD | LAYER_SOFT | LAYER_SUBJECT)
749
+ if String(w["back"]) != "":
750
+ return String(w["back"])
751
+ return ""
752
+
753
+
754
+ func _overlaps(pos: Vector3, radius: float, mask: int) -> Array:
755
+ var q := PhysicsShapeQueryParameters3D.new()
756
+ q.shape_rid = _sphere(radius)
757
+ q.transform = Transform3D(Basis(), pos)
758
+ q.collision_mask = mask
759
+ return _state.intersect_shape(q, 4)
760
+
761
+
762
+ const SWEEP_SEGMENT := 4.0
763
+
764
+
765
+ # Fraction [0..1] of the motion a sphere can travel before touching `mask`
766
+ # geometry, plus the path of what it touched. The motion is swept in short
767
+ # segments: a cast's cost grows with every body inside its swept bounds, and
768
+ # one 40 m diagonal sweep through a city block touches most of the scene.
769
+ func _sweep(from: Vector3, motion: Vector3, radius: float, mask: int) -> Dictionary:
770
+ var total := motion.length()
771
+ if total < 0.001:
772
+ return {"free": 1.0, "hit": ""}
773
+ var dir := motion / total
774
+ var steps := maxi(1, int(ceil(total / SWEEP_SEGMENT)))
775
+ var seg := total / steps
776
+ var q := PhysicsShapeQueryParameters3D.new()
777
+ q.shape_rid = _sphere(radius)
778
+ q.collision_mask = mask
779
+ for k in steps:
780
+ var start := from + dir * (seg * k)
781
+ q.transform = Transform3D(Basis(), start)
782
+ q.motion = dir * seg
783
+ var res: PackedFloat32Array = _state.cast_motion(q)
784
+ if res.size() < 2 or res[1] >= 1.0:
785
+ continue
786
+ var hit_path := ""
787
+ var q2 := PhysicsShapeQueryParameters3D.new()
788
+ q2.shape_rid = _sphere(radius * 1.05)
789
+ q2.transform = Transform3D(Basis(), start + dir * seg * res[1])
790
+ q2.collision_mask = mask
791
+ var info := _state.get_rest_info(q2)
792
+ if info.has("rid"):
793
+ hit_path = String((_body_geom.get((info["rid"] as RID).get_id(), {}) as Dictionary).get("path", ""))
794
+ return {"free": (seg * k + seg * float(res[0])) / total, "hit": hit_path}
795
+ return {"free": 1.0, "hit": ""}
796
+
797
+
798
+ # cast_motion resolves the free fraction in coarse steps of the motion, so a
799
+ # long run is refined with a second, short sweep around the first contact.
800
+ func _free_run(origin: Vector3, dir: Vector3, max_len: float, radius: float, mask: int) -> float:
801
+ var s := _sweep(origin, dir * max_len, radius, mask)
802
+ var coarse := float(s["free"]) * max_len
803
+ if float(s["free"]) >= 1.0:
804
+ return max_len
805
+ var start := maxf(0.0, coarse - 1.0)
806
+ var span := minf(max_len - start, 2.5)
807
+ var fine := _sweep(origin + dir * start, dir * span, radius, mask)
808
+ return minf(max_len, start + float(fine["free"]) * span)
809
+
810
+
811
+ # The first surface under the camera (from 1 m above it, so a camera sunk
812
+ # slightly into the ground still finds that ground, while roofs and wall caps
813
+ # overhead never count as "ground").
814
+ func _ground_below(pos: Vector3, mask: int) -> Variant:
815
+ var hit := _ray(pos + Vector3.UP * 1.0, pos + Vector3.DOWN * 200.0, mask)
816
+ if hit.is_empty():
817
+ return null
818
+ return float((hit["position"] as Vector3).y)
819
+
820
+
821
+ # The first surface straight below a point, cast from the point itself: what
822
+ # a camera there stands over. Nothing above the point ever counts.
823
+ func _surface_below(pos: Vector3, mask: int) -> Variant:
824
+ var hit := _ray(pos, pos + Vector3.DOWN * 500.0, mask)
825
+ if hit.is_empty():
826
+ return null
827
+ return float((hit["position"] as Vector3).y)
828
+
829
+
830
+ # Eye mode (eye_level and corridor candidates): stand the camera at eye height
831
+ # above the walkable surface under it, and never raise it. The surface is the
832
+ # walkable one straight below the lens (_floor_at, cast from the lens, so a
833
+ # duct, a balcony or a roof overhead never counts as ground), within
834
+ # EYE_STEP_UP / EYE_STEP_DOWN of the walkable floor under eye["stand"]. The height is eye["target"],
835
+ # or the camera's own height clamped into eye["min"]..eye["max"]. When that
836
+ # spot does not work (no walkable surface, or the lens is blocked) the camera
837
+ # moves HORIZONTALLY: along the view up to 1 m, then 0.3 / 0.6 m sideways.
838
+ # Returns {pos, ground, height}, or {rejected} when no spot has a walkable
839
+ # surface. A spot with a walkable surface but a blocked lens is returned as
840
+ # is, and the lens check rejects it with its reason.
841
+ func _eye_place(pos: Vector3, look: Vector3, eye: Dictionary, lens_r: float) -> Dictionary:
842
+ var lo := float(eye.get("min", 1.5))
843
+ var hi := maxf(lo, float(eye.get("max", 1.8)))
844
+ var has_target := eye.has("target")
845
+ var target := clampf(float(eye.get("target", 1.6)), lo, hi)
846
+ var ref: Variant = null
847
+ if eye.has("stand"):
848
+ var stand := _vec(eye["stand"])
849
+ ref = _floor_at(stand.x, stand.z, stand.y + 0.1, stand.y - 6.0, lo)
850
+ var view := (look - pos).normalized()
851
+ var flat := Vector3(view.x, 0.0, view.z)
852
+ if flat.length() < 0.001:
853
+ flat = Vector3(0, 0, -1)
854
+ flat = flat.normalized()
855
+ var side := Vector3(flat.z, 0.0, -flat.x)
856
+ var offsets: Array = [Vector3.ZERO]
857
+ for k in [1, 2, 3, 4]:
858
+ offsets.append(flat * 0.25 * float(k))
859
+ for s in [0.3, -0.3, 0.6, -0.6]:
860
+ offsets.append(side * float(s))
861
+ var reason := "no_walkable_ground"
862
+ var fallback := {}
863
+ for off in offsets:
864
+ var p: Vector3 = pos + off
865
+ var g: Variant = _floor_at(p.x, p.z, p.y, p.y - 6.0, lo)
866
+ if g == null:
867
+ continue
868
+ if ref != null and (float(g) > float(ref) + EYE_STEP_UP or float(g) < float(ref) - EYE_STEP_DOWN):
869
+ reason = "not_walkable"
870
+ continue
871
+ var h := target if has_target else clampf(p.y - float(g), lo, hi)
872
+ var placed := Vector3(p.x, float(g) + h, p.z)
873
+ var out := {"pos": placed, "ground": float(g), "height": h}
874
+ if _lens_problem(placed, view, lens_r) == "":
875
+ return out
876
+ if fallback.is_empty():
877
+ fallback = out
878
+ if not fallback.is_empty():
879
+ return fallback
880
+ return {"rejected": reason}
881
+
882
+
883
+ # The walkable surface at (x, z): walking a ray down from `top` to `bottom`,
884
+ # the first solid surface that is roughly horizontal, met on its drawn side
885
+ # (from above), with headroom for a standing eye (`height`) above it. Back
886
+ # faces on the way down (the underside of a closed box the ray starts in, a
887
+ # one-sided ceiling) are passed. null when there is none.
888
+ func _floor_at(x: float, z: float, top: float, bottom: float, height: float) -> Variant:
889
+ var from := Vector3(x, top, z)
890
+ var to := Vector3(x, bottom, z)
891
+ for _step in 8:
892
+ var hit := _ray(from, to, EYE_GROUND_MASK)
893
+ if hit.is_empty():
894
+ return null
895
+ var p: Vector3 = hit["position"]
896
+ var flags := int(_geom(hit).get("flags", 0))
897
+ # Hit normals are turned toward the ray, so this only says "horizontal";
898
+ # _is_back says which side was met.
899
+ if (hit["normal"] as Vector3).y > 0.7 and not _is_back(p, Vector3.DOWN, flags):
900
+ if _ray(p + Vector3.UP * 0.05, p + Vector3.UP * height, EYE_GROUND_MASK).is_empty():
901
+ return p.y
902
+ from = p + Vector3.DOWN * 0.01
903
+ if from.y <= bottom:
904
+ return null
905
+ return null
906
+
907
+
908
+ # The walkable floor inside the subject's footprint, for seeding the corridor
909
+ # scan. Walls sunk into the ground put the subject's lowest point under the
910
+ # floor, so seeds placed from it started underground. A 7 x 7 grid of downward rays from the
911
+ # subject's middle height finds the walkable surface under each point
912
+ # (_floor_at); the floor is the most common height (0.1 m bins, ties to the
913
+ # lower bin, the median of the winning bin), so a few wall tops, decks or car
914
+ # roofs never outvote the floor that covers most of the footprint. No
915
+ # walkable surface at all: the subject's lowest point, declared.
916
+ func _walkable_floor(box: AABB, height: float) -> Dictionary:
917
+ var top := box.position.y + box.size.y * 0.5
918
+ var bottom := box.position.y - 0.5
919
+ var n := 7
920
+ var bins := {}
921
+ var hits := 0
922
+ for gx in n:
923
+ for gz in n:
924
+ var x := box.position.x + box.size.x * (float(gx) + 0.5) / float(n)
925
+ var z := box.position.z + box.size.z * (float(gz) + 0.5) / float(n)
926
+ var g: Variant = _floor_at(x, z, top, bottom, height)
927
+ if g == null:
928
+ continue
929
+ hits += 1
930
+ var key := roundi(float(g) * 10.0)
931
+ if not bins.has(key):
932
+ bins[key] = []
933
+ (bins[key] as Array).append(float(g))
934
+ var best := 0
935
+ var best_count := 0
936
+ for key in bins:
937
+ var count := (bins[key] as Array).size()
938
+ if count > best_count or (count == best_count and int(key) < best):
939
+ best = int(key)
940
+ best_count = count
941
+ if best_count == 0:
942
+ return {"y": box.position.y, "source": "lowest_point", "rays": n * n, "hits": 0}
943
+ var ys: Array = bins[best]
944
+ ys.sort()
945
+ return {"y": float(ys[floori(ys.size() / 2.0)]), "source": "walkable", "rays": n * n, "hits": hits, "votes": best_count}
946
+
947
+
948
+ func _corridor_scan(spec: Dictionary) -> Dictionary:
949
+ var box := AABB(_vec((spec.get("aabb", {}) as Dictionary).get("position")), _vec((spec.get("aabb", {}) as Dictionary).get("size")))
950
+ var height := float(spec.get("height", 1.6))
951
+ # Seeds stand on the walkable floor, never on the subject's lowest point.
952
+ var floor_info := {"y": float(spec.get("floor_y", 0.0)), "source": "caller"} if spec.has("floor_y") else _walkable_floor(box, height)
953
+ var floor_y := float(floor_info["y"])
954
+ # Free space is physical: glass and leaf cards (LAYER_SEE) still end a run.
955
+ var mask := LAYER_HARD | LAYER_SOFT | LAYER_SUBJECT | LAYER_SEE
956
+ var radius := float(spec.get("radius", 0.25))
957
+ # Runs only matter inside the subject (+ a margin): the caller clips them
958
+ # to its bounds + 2 m, so sweeping further is wasted engine time.
959
+ var max_len := float(spec.get("max_len", clampf(maxf(box.size.x, box.size.z) + 4.0, 8.0, 120.0)))
960
+ var side_len := minf(20.0, max_len)
961
+ var grid := int(spec.get("grid", 3))
962
+ var dirs := int(spec.get("directions", 8))
963
+ var runs: Array = []
964
+ var blocked := 0
965
+ for gx in grid:
966
+ for gz in grid:
967
+ var fx := (gx + 0.5) / grid
968
+ var fz := (gz + 0.5) / grid
969
+ var seed := Vector3(box.position.x + box.size.x * fx, floor_y + height, box.position.z + box.size.z * fz)
970
+ # The walkable surface right under this seed (a kerb, a slope), cast
971
+ # from eye height so nothing overhead counts; a car roof or a wall cap
972
+ # more than a step above the floor does not lift the seed.
973
+ var g: Variant = _floor_at(seed.x, seed.z, seed.y, floor_y - 1.0, height)
974
+ if g != null and float(g) <= floor_y + EYE_STEP_UP:
975
+ seed.y = float(g) + height
976
+ if not _overlaps(seed, 0.3, mask).is_empty():
977
+ blocked += 1
978
+ continue
979
+ for k in dirs:
980
+ var a := PI * float(k) / float(dirs)
981
+ var d := Vector3(sin(a), 0.0, cos(a))
982
+ var perp := Vector3(d.z, 0.0, -d.x)
983
+ runs.append({
984
+ "seed": _arr(seed, 2), "dir": _arr(d, 4),
985
+ "fwd": snappedf(_free_run(seed, d, max_len, radius, mask), 0.01),
986
+ "back": snappedf(_free_run(seed, -d, max_len, radius, mask), 0.01),
987
+ "left": snappedf(_free_run(seed, perp, side_len, radius, mask), 0.01),
988
+ "right": snappedf(_free_run(seed, -perp, side_len, radius, mask), 0.01),
989
+ })
990
+ var out := {"runs": runs, "blocked_seeds": blocked, "floor_y": snappedf(floor_y, 0.001), "floor_source": floor_info["source"]}
991
+ if floor_info.has("rays"):
992
+ out["floor_rays"] = floor_info["rays"]
993
+ out["floor_hits"] = floor_info["hits"]
994
+ return out
995
+
996
+
997
+ func _look_basis(pos: Vector3, look: Vector3) -> Basis:
998
+ var fwd := (look - pos).normalized()
999
+ var up := Vector3.UP
1000
+ if absf(fwd.dot(up)) > 0.999:
1001
+ up = Vector3(0, 0, -1)
1002
+ return Basis.looking_at(fwd, up)
1003
+
1004
+
1005
+ var _t_lens := 0
1006
+ var _t_sweep := 0
1007
+ var _t_grid := 0
1008
+
1009
+
1010
+ func _measure_all(candidates: Array) -> Array:
1011
+ var spec: Dictionary = _cfg.get("measure", {})
1012
+ var out: Array = []
1013
+ for i in candidates.size():
1014
+ var rec := _measure_one(i, candidates[i], spec)
1015
+ out.append(rec)
1016
+ # The image check renders from the FINAL pose (after the low-angle rule
1017
+ # and the near-lens nudge); the draw comes after this deferred pass.
1018
+ if i < _checks.size() and not rec.has("rejected"):
1019
+ var cam: Camera3D = _checks[i]["cam"]
1020
+ var p := _vec(rec["position"])
1021
+ var l := _vec(rec["look_at"])
1022
+ cam.global_transform = Transform3D(_look_basis(p, l), p)
1023
+ cam.fov = float(rec["fov"])
1024
+ _result["measure_ms"] = {"lens": _t_lens / 1000, "sweeps": _t_sweep / 1000, "grid": _t_grid / 1000}
1025
+ _measurements = out
1026
+ return out
1027
+
1028
+
1029
+ func _setup_image_checks() -> void:
1030
+ var check: Dictionary = _cfg.get("image_check", {})
1031
+ if check.is_empty() or not (_cfg.get("tasks", []) as Array).has("measure"):
1032
+ return
1033
+ var size := _vec2i(check.get("size", [96, 56]))
1034
+ var world: World3D = get_viewport().find_world_3d()
1035
+ var i := 0
1036
+ for cand in (_cfg.get("candidates", []) as Array):
1037
+ var sv := SubViewport.new()
1038
+ sv.size = size
1039
+ # One render is enough: the deferred measure pass places the camera at
1040
+ # the final pose before the first draw (ScenePreview iterates the main
1041
+ # loop, which flushes deferred calls before it draws).
1042
+ sv.render_target_update_mode = SubViewport.UPDATE_ONCE
1043
+ sv.world_3d = world
1044
+ var cam := Camera3D.new()
1045
+ sv.add_child(cam)
1046
+ add_child(sv)
1047
+ var p := _vec((cand as Dictionary).get("position"))
1048
+ var l := _vec((cand as Dictionary).get("look_at"), p + Vector3(0, 0, -1))
1049
+ cam.global_transform = Transform3D(_look_basis(p, l), p)
1050
+ cam.fov = float((cand as Dictionary).get("fov", 60.0))
1051
+ cam.near = 0.05
1052
+ cam.far = 1000.0
1053
+ cam.current = true
1054
+ _checks.append({"vp": sv, "cam": cam, "i": i, "done": false})
1055
+ i += 1
1056
+ RenderingServer.frame_post_draw.connect(_on_post_draw)
1057
+
1058
+
1059
+ static func _image_blank(img: Image) -> bool:
1060
+ var probe := img.duplicate() as Image
1061
+ probe.resize(4, 4, Image.INTERPOLATE_BILINEAR)
1062
+ for y in 4:
1063
+ for x in 4:
1064
+ var c := probe.get_pixel(x, y)
1065
+ if c.r + c.g + c.b > 0.01:
1066
+ return false
1067
+ return true
1068
+
1069
+
1070
+ # Per cell of the cols x rows grid: luminance decile ("0".."9") and texture
1071
+ # (luminance spread inside the cell, "0" = featureless).
1072
+ func _image_codes(img: Image, cols: int, rows: int) -> Dictionary:
1073
+ var sub := 3
1074
+ var work := img.duplicate() as Image
1075
+ work.convert(Image.FORMAT_RGB8)
1076
+ work.resize(cols * sub, rows * sub, Image.INTERPOLATE_BILINEAR)
1077
+ var lum := ""
1078
+ var tex := ""
1079
+ for r in rows:
1080
+ for c in cols:
1081
+ var lo := 1.0
1082
+ var hi := 0.0
1083
+ var sum := 0.0
1084
+ for y in sub:
1085
+ for x in sub:
1086
+ var px := work.get_pixel(c * sub + x, r * sub + y)
1087
+ var v := px.r * 0.299 + px.g * 0.587 + px.b * 0.114
1088
+ sum += v
1089
+ lo = minf(lo, v)
1090
+ hi = maxf(hi, v)
1091
+ lum += str(clampi(int(sum / float(sub * sub) * 10.0), 0, 9))
1092
+ tex += str(clampi(int((hi - lo) * 40.0), 0, 9))
1093
+ return {"lum": lum, "tex": tex}
1094
+
1095
+
1096
+ func _measure_one(index: int, cand: Dictionary, spec: Dictionary) -> Dictionary:
1097
+ var pos := _vec(cand.get("position"))
1098
+ var look := _vec(cand.get("look_at"))
1099
+ var fov := float(cand.get("fov", 60.0))
1100
+ var aspect := float(spec.get("aspect", 16.0 / 9.0))
1101
+ var all_mask := LAYER_HARD | LAYER_SOFT | LAYER_SUBJECT
1102
+ # Ground under the camera: water and glass floors count (LAYER_SEE).
1103
+ var solid_mask := LAYER_HARD | LAYER_SUBJECT | LAYER_SEE
1104
+ # adjust:false (an explicit pose the caller asked for, e.g. frame_nodes
1105
+ # with from): measure it exactly as given and report what is wrong instead
1106
+ # of raising or nudging it.
1107
+ var adjust := bool(cand.get("adjust", true))
1108
+ var adjustments: Array = []
1109
+ var rec := {"i": index}
1110
+ # 0. Eye mode (eye_level, corridor): eye height above the walkable surface
1111
+ # under the camera, moved only horizontally, never raised (_eye_place).
1112
+ var eye_raw: Variant = cand.get("eye", null)
1113
+ var eye: Dictionary = eye_raw if eye_raw is Dictionary else {}
1114
+ var eye_mode := adjust and not eye.is_empty()
1115
+ if eye_mode:
1116
+ var placed := _eye_place(pos, look, eye, float(spec.get("near_lens_radius", 0.3)))
1117
+ if placed.has("rejected"):
1118
+ rec["rejected"] = placed["rejected"]
1119
+ return _finish_measure(rec, pos, look, fov, adjustments)
1120
+ # The surface the eye stands on is the one its height is measured from.
1121
+ rec["ground_y"] = snappedf(float(placed["ground"]), 0.001)
1122
+ var moved: Vector3 = (placed["pos"] as Vector3) - pos
1123
+ pos += moved
1124
+ look += moved
1125
+ adjustments.append({"kind": "eye_height", "height": snappedf(float(placed["height"]), 0.01), "ground_y": snappedf(float(placed["ground"]), 0.001)})
1126
+ var sideways := Vector2(moved.x, moved.z).length()
1127
+ if sideways > 0.001:
1128
+ adjustments.append({"kind": "moved_horizontally", "by": snappedf(sideways, 0.01)})
1129
+ # 1. Ground clearance and the low-angle rule: a camera that would end up in
1130
+ # the ground moves CLOSER along its line of sight and widens its FOV to keep
1131
+ # the framing, instead of being pushed up or buried. Not in eye mode: an
1132
+ # eye is never raised.
1133
+ var clearance := float(cand.get("min_clearance", spec.get("min_clearance", 0.3)))
1134
+ var max_fov := float(spec.get("max_fov", 100.0))
1135
+ for _attempt in (2 if adjust and not eye_mode else 0):
1136
+ var ground: Variant = _ground_below(pos, solid_mask)
1137
+ if ground == null or pos.y >= float(ground) + clearance:
1138
+ break
1139
+ var target_y := float(ground) + clearance
1140
+ var dir := (pos - look).normalized()
1141
+ var dist := pos.distance_to(look)
1142
+ if bool(cand.get("low_angle_rule", true)) and dir.y < -0.02:
1143
+ var d2 := (target_y - look.y) / dir.y
1144
+ if d2 < 0.5:
1145
+ rec["rejected"] = "below_ground"
1146
+ break
1147
+ var fov2 := rad_to_deg(2.0 * atan(tan(deg_to_rad(fov) * 0.5) * dist / d2))
1148
+ fov2 = clampf(fov2, fov, max_fov)
1149
+ adjustments.append({"kind": "low_angle", "distance_from": snappedf(dist, 0.01), "distance_to": snappedf(d2, 0.01), "fov_from": snappedf(fov, 0.1), "fov_to": snappedf(fov2, 0.1)})
1150
+ pos = look + dir * d2
1151
+ fov = fov2
1152
+ else:
1153
+ adjustments.append({"kind": "raised", "by": snappedf(target_y - pos.y, 0.01)})
1154
+ pos.y = target_y
1155
+ if rec.has("rejected"):
1156
+ return _finish_measure(rec, pos, look, fov, adjustments)
1157
+ # 2. Lens safeguards: a small sphere at the lens must be clear of every
1158
+ # kind of geometry, and the lens must not sit inside a closed shell or just
1159
+ # behind a one-sided surface (back faces are culled: the image would look
1160
+ # through the wall it stands behind). If not, nudge forward along the view
1161
+ # line a little (never for adjust:false, and not in eye mode: _eye_place
1162
+ # already tried its horizontal moves).
1163
+ var t_lens := Time.get_ticks_usec()
1164
+ var lens_r := float(spec.get("near_lens_radius", 0.3))
1165
+ var fwd := (look - pos).normalized()
1166
+ if _lens_problem(pos, fwd, lens_r) != "":
1167
+ var nudged := false
1168
+ var step := float(spec.get("nudge_step", 0.25))
1169
+ var max_nudge := minf(float(spec.get("max_nudge", 1.5)), pos.distance_to(look) * 0.25)
1170
+ var k := 1
1171
+ while adjust and not eye_mode and step * k <= max_nudge + 0.0001:
1172
+ var p2 := pos + fwd * step * k
1173
+ if _lens_problem(p2, fwd, lens_r) == "":
1174
+ adjustments.append({"kind": "near_lens_nudge", "by": snappedf(step * k, 0.01)})
1175
+ pos = p2
1176
+ nudged = true
1177
+ break
1178
+ k += 1
1179
+ if not nudged:
1180
+ var problem := _lens_problem(pos, fwd, lens_r)
1181
+ rec["rejected"] = problem
1182
+ var hit_path := ""
1183
+ if problem == "behind_surface":
1184
+ hit_path = _near_back_face(pos, fwd, lens_r + 0.05)
1185
+ else:
1186
+ var blockers := _overlaps(pos, lens_r, all_mask | LAYER_SEE)
1187
+ if not blockers.is_empty():
1188
+ hit_path = String((_body_geom.get((blockers[0]["rid"] as RID).get_id(), {}) as Dictionary).get("path", ""))
1189
+ if hit_path != "":
1190
+ rec["near_lens_hit"] = hit_path
1191
+ _t_lens += Time.get_ticks_usec() - t_lens
1192
+ return _finish_measure(rec, pos, look, fov, adjustments)
1193
+ _t_lens += Time.get_ticks_usec() - t_lens
1194
+ # 3. Visibility to sample points on the subject. Per line: a hard or
1195
+ # subject surface crossed from BEHIND first is "B" (the camera is behind a
1196
+ # wall it would look through); then the thick sweep: hard geometry blocks
1197
+ # ("H"), soft geometry (props, foliage, fences) only counts as framing
1198
+ # ("F"); a clear line through transparent surfaces is "T" (see-through
1199
+ # cover, partial weight); else "V". Transparent bodies sit on LAYER_SEE,
1200
+ # outside every sweep mask, so a foliage card never blocks like a wall.
1201
+ var t_sweep := Time.get_ticks_usec()
1202
+ var sweep_r := float(spec.get("sweep_radius", 0.15))
1203
+ var vis := ""
1204
+ var blockers_hard: Array = []
1205
+ var blockers_soft: Array = []
1206
+ var blockers_back: Array = []
1207
+ var seen_through: Array = []
1208
+ var sweep_hard_mask := LAYER_HARD
1209
+ var sweep_soft_mask := LAYER_SOFT
1210
+ if bool(spec.get("subject_occludes", false)):
1211
+ sweep_hard_mask = LAYER_HARD | LAYER_SUBJECT
1212
+ for raw in (cand.get("samples", []) as Array):
1213
+ var target := _vec(raw)
1214
+ var v := target - pos
1215
+ var length := v.length()
1216
+ if length < sweep_r * 2.0 + 0.05:
1217
+ vis += "V"
1218
+ continue
1219
+ var motion := v.normalized() * (length - sweep_r - 0.05)
1220
+ var line := _walk(pos, pos + motion, all_mask)
1221
+ var back_path := String(line["back"])
1222
+ if back_path != "":
1223
+ vis += "B"
1224
+ if not blockers_back.has(back_path) and blockers_back.size() < 4:
1225
+ blockers_back.append(back_path)
1226
+ continue
1227
+ # A thin ray that is already blocked settles it (the thick sweep would
1228
+ # be blocked too); only clear lines pay for the thick sweep, which is
1229
+ # what catches the corners a thin ray slips past.
1230
+ var thin := _ray(pos, pos + motion, sweep_hard_mask)
1231
+ if not thin.is_empty():
1232
+ vis += "H"
1233
+ var thin_path := String(_geom(thin).get("path", ""))
1234
+ if thin_path != "" and not blockers_hard.has(thin_path) and blockers_hard.size() < 4:
1235
+ blockers_hard.append(thin_path)
1236
+ continue
1237
+ var h := _sweep(pos, motion, sweep_r, sweep_hard_mask)
1238
+ if float(h["free"]) < 1.0:
1239
+ vis += "H"
1240
+ if h["hit"] != "" and not blockers_hard.has(h["hit"]) and blockers_hard.size() < 4:
1241
+ blockers_hard.append(h["hit"])
1242
+ continue
1243
+ var s := _sweep(pos, motion, sweep_r, sweep_soft_mask)
1244
+ if float(s["free"]) < 1.0:
1245
+ vis += "F"
1246
+ if s["hit"] != "" and not blockers_soft.has(s["hit"]) and blockers_soft.size() < 4:
1247
+ blockers_soft.append(s["hit"])
1248
+ continue
1249
+ var see_path := String(line["see"])
1250
+ if see_path != "":
1251
+ vis += "T"
1252
+ if not seen_through.has(see_path) and seen_through.size() < 4:
1253
+ seen_through.append(see_path)
1254
+ continue
1255
+ vis += "V"
1256
+ rec["vis"] = vis
1257
+ if not blockers_hard.is_empty():
1258
+ rec["blockers_hard"] = blockers_hard
1259
+ if not blockers_soft.is_empty():
1260
+ rec["blockers_soft"] = blockers_soft
1261
+ if not blockers_back.is_empty():
1262
+ rec["blockers_back"] = blockers_back
1263
+ if not seen_through.is_empty():
1264
+ rec["seen_through"] = seen_through
1265
+ _t_sweep += Time.get_ticks_usec() - t_sweep
1266
+ # 4. A ray grid through the frame: what each part of the image would show
1267
+ # and how far away it is. "." nothing (sky/void), "S" subject, "H" hard,
1268
+ # "F" soft, "T" a transparent surface (seen through), "B" a hard or subject
1269
+ # surface met from BEHIND (culled: the image looks through it). Back faces
1270
+ # of soft surfaces and transparent back faces are not drawn: the ray goes on.
1271
+ var t_grid := Time.get_ticks_usec()
1272
+ var cols := int(spec.get("grid_cols", 16))
1273
+ var rows := int(spec.get("grid_rows", 9))
1274
+ var max_dist := float(spec.get("max_ray", 600.0))
1275
+ var basis := _look_basis(pos, look)
1276
+ var tan_v := tan(deg_to_rad(fov) * 0.5)
1277
+ var tan_h := tan_v * aspect
1278
+ var codes := ""
1279
+ var dists: Array = []
1280
+ for j in rows:
1281
+ for i in cols:
1282
+ var x := ((i + 0.5) / cols * 2.0 - 1.0) * tan_h
1283
+ var y := (1.0 - (j + 0.5) / rows * 2.0) * tan_v
1284
+ var dir := (basis * Vector3(x, y, -1.0)).normalized()
1285
+ var cell := _grid_cell(pos, dir, max_dist, all_mask)
1286
+ codes += String(cell[0])
1287
+ dists.append(cell[1])
1288
+ rec["grid"] = codes
1289
+ rec["dist"] = dists
1290
+ _t_grid += Time.get_ticks_usec() - t_grid
1291
+ return _finish_measure(rec, pos, look, fov, adjustments)
1292
+
1293
+
1294
+ # "" when the lens is fine, else why not: "near_lens_blocked" (geometry
1295
+ # inside the lens sphere, or boxed in at arm's reach), "inside_volume" (inside
1296
+ # a closed shell of one-sided surfaces) or "behind_surface" (a hard or subject
1297
+ # back face within the lens radius).
1298
+ func _lens_problem(pos: Vector3, fwd: Vector3, lens_r: float) -> String:
1299
+ if _inside_back_rays(pos) >= INSIDE_BACK_RAYS:
1300
+ return "inside_volume"
1301
+ if _near_back_face(pos, fwd, lens_r + 0.05) != "":
1302
+ return "behind_surface"
1303
+ if not _overlaps(pos, lens_r, LAYER_HARD | LAYER_SOFT | LAYER_SUBJECT | LAYER_SEE).is_empty() or _enclosed(pos, fwd):
1304
+ return "near_lens_blocked"
1305
+ return ""
1306
+
1307
+
1308
+ func _grid_cell(pos: Vector3, dir: Vector3, max_dist: float, mask: int) -> Array:
1309
+ var start := pos
1310
+ var end := pos + dir * max_dist
1311
+ for _step in 6:
1312
+ var hit := _ray(start, end, mask | LAYER_SEE)
1313
+ if hit.is_empty():
1314
+ return [".", -1]
1315
+ var point: Vector3 = hit["position"]
1316
+ var g := _geom(hit)
1317
+ var flags := int(g.get("flags", 0))
1318
+ var cls := int(g.get("cls", CLASS_HARD))
1319
+ var back := _is_back(point, dir, flags)
1320
+ var d := snappedf(pos.distance_to(point), 0.1)
1321
+ if flags & FLAG_SEE:
1322
+ if not back:
1323
+ return ["S" if cls == CLASS_SUBJECT else "T", d]
1324
+ elif back:
1325
+ if cls != CLASS_SOFT:
1326
+ return ["B", d]
1327
+ else:
1328
+ return [["H", "F", "S"][cls], d]
1329
+ start = point + dir * 0.01
1330
+ return [".", -1]
1331
+
1332
+
1333
+ func _finish_measure(rec: Dictionary, pos: Vector3, look: Vector3, fov: float, adjustments: Array) -> Dictionary:
1334
+ rec["position"] = _arr(pos)
1335
+ rec["look_at"] = _arr(look)
1336
+ rec["fov"] = snappedf(fov, 0.01)
1337
+ # The real camera height: the first solid surface straight below the final
1338
+ # pose (null: nothing below it, the world edge). Eye poses already carry the
1339
+ # walkable surface they stand on.
1340
+ if not rec.has("ground_y"):
1341
+ var g: Variant = _surface_below(pos, EYE_GROUND_MASK) if _state != null else null
1342
+ rec["ground_y"] = null if g == null else snappedf(float(g), 0.001)
1343
+ if not adjustments.is_empty():
1344
+ rec["adjustments"] = adjustments
1345
+ return rec
1346
+
1347
+
1348
+ # Boxed in: geometry within arm's reach on all four horizontal sides AND
1349
+ # overhead. A camera there is inside a wall cavity, a prop or a solid block,
1350
+ # never a place to shoot from. (Ray normals cannot tell inside from outside:
1351
+ # the physics server reports normals facing the ray; _is_back reads the side.)
1352
+ func _enclosed(pos: Vector3, fwd: Vector3) -> bool:
1353
+ return _enclosure_sides(pos, fwd) >= 5
1354
+
1355
+
1356
+ func _enclosure_sides(pos: Vector3, fwd: Vector3) -> int:
1357
+ var flat := Vector3(fwd.x, 0.0, fwd.z).normalized()
1358
+ if flat.length() < 0.5:
1359
+ flat = Vector3(0, 0, -1)
1360
+ var side := Vector3(flat.z, 0.0, -flat.x)
1361
+ var reach := float((_cfg.get("measure", {}) as Dictionary).get("enclosure_reach", 1.2))
1362
+ var sides := 0
1363
+ for d in [flat, -flat, side, -side]:
1364
+ if not _ray(pos, pos + (d as Vector3) * reach, LAYER_HARD | LAYER_SOFT | LAYER_SUBJECT | LAYER_SEE).is_empty():
1365
+ sides += 1
1366
+ if not _ray(pos, pos + Vector3.UP * reach * 2.0, LAYER_HARD | LAYER_SOFT | LAYER_SUBJECT | LAYER_SEE).is_empty():
1367
+ sides += 1
1368
+ return sides
1369
+
1370
+
1371
+ # The strongest visible DirectionalLight3D (the key light): the direction its
1372
+ # light TRAVELS (the node's -Z), for the light-direction score.
1373
+ func _key_light() -> Dictionary:
1374
+ var best: DirectionalLight3D = null
1375
+ for n in _subject.find_children("*", "DirectionalLight3D", true, false):
1376
+ var light := n as DirectionalLight3D
1377
+ if light == null or not light.is_visible_in_tree() or light.light_energy <= 0.0:
1378
+ continue
1379
+ if best == null or light.light_energy > best.light_energy:
1380
+ best = light
1381
+ if best == null:
1382
+ return {}
1383
+ return {
1384
+ "path": _rel(best),
1385
+ "direction": _arr(-best.global_transform.basis.z.normalized(), 4),
1386
+ "energy": snappedf(best.light_energy, 0.01),
1387
+ "shadow": best.shadow_enabled,
1388
+ }
1389
+
1390
+
1391
+ # Mark occlusion: is each marked node really visible from the camera? Five
1392
+ # points per node (its visible-bounds centre plus four points across the box
1393
+ # face that faces the camera); a point is seen when the line to it reaches the
1394
+ # node itself, or nothing, before any OTHER opaque surface drawn from that
1395
+ # side. Back faces of one-sided surfaces and transparent surfaces are not
1396
+ # drawn, so they hide nothing; a node met only from behind is not drawn either.
1397
+ func _occlusion(spec: Dictionary) -> Dictionary:
1398
+ var cam := _vec(spec.get("position"))
1399
+ var mask := LAYER_HARD | LAYER_SOFT | LAYER_SUBJECT | LAYER_SEE
1400
+ var out: Array = []
1401
+ for raw in (spec.get("marks", []) as Array):
1402
+ var m: Dictionary = raw
1403
+ var path := String(m.get("path", ""))
1404
+ var entry := {"id": int(m.get("id", 0)), "path": path}
1405
+ var node := _resolve(path)
1406
+ if node == null:
1407
+ entry["missing"] = true
1408
+ out.append(entry)
1409
+ continue
1410
+ var b := _bounds(node)
1411
+ var own := _rel(node)
1412
+ var samples := _box_samples(b["aabb"], cam)
1413
+ var seen := 0
1414
+ var own_back := 0
1415
+ var blocker := ""
1416
+ for p in samples:
1417
+ var r := _sees_node(cam, p, own, mask)
1418
+ if bool(r["seen"]):
1419
+ seen += 1
1420
+ elif bool(r["own_back"]):
1421
+ own_back += 1
1422
+ elif blocker == "":
1423
+ blocker = String(r["blocker"])
1424
+ entry["visible"] = seen
1425
+ entry["samples"] = samples.size()
1426
+ if own_back > 0:
1427
+ entry["own_back"] = own_back
1428
+ if blocker != "":
1429
+ entry["blocker"] = blocker
1430
+ out.append(entry)
1431
+ return {"position": _arr(cam), "marks": out}
1432
+
1433
+
1434
+ func _render_occlusion() -> void:
1435
+ _build_physics([])
1436
+ if _state == null:
1437
+ _warn("occlusion check skipped: no physics space")
1438
+ else:
1439
+ _result["occlusion"] = _occlusion(_cfg.get("occlusion", {}))
1440
+ _free_physics()
1441
+ _write_result()
1442
+
1443
+
1444
+ func _box_samples(box: AABB, cam: Vector3) -> Array:
1445
+ var c := box.get_center()
1446
+ var view := c - cam
1447
+ # The two box axes least aligned with the view span the face it sees.
1448
+ var order := [0, 1, 2]
1449
+ order.sort_custom(func(a, b): return absf(view[a]) < absf(view[b]))
1450
+ var pts: Array = [c]
1451
+ for k in 2:
1452
+ var axis := int(order[k])
1453
+ for sgn in [-0.35, 0.35]:
1454
+ var off := Vector3.ZERO
1455
+ off[axis] = box.size[axis] * float(sgn)
1456
+ pts.append(c + off)
1457
+ return pts
1458
+
1459
+
1460
+ func _sees_node(cam: Vector3, target: Vector3, own: String, mask: int) -> Dictionary:
1461
+ var total := cam.distance_to(target)
1462
+ if total < 0.01:
1463
+ return {"seen": true, "own_back": false, "blocker": ""}
1464
+ var dir := (target - cam) / total
1465
+ var end := target + dir * 0.05
1466
+ var start := cam
1467
+ var met_own_back := false
1468
+ for _step in 8:
1469
+ var hit := _ray(start, end, mask)
1470
+ if hit.is_empty():
1471
+ break
1472
+ var point: Vector3 = hit["position"]
1473
+ var g := _geom(hit)
1474
+ var flags := int(g.get("flags", 0))
1475
+ var path := String(g.get("path", ""))
1476
+ var back := _is_back(point, dir, flags)
1477
+ var is_own := own == "." or path == own or path.begins_with(own + "/")
1478
+ if is_own:
1479
+ if not back:
1480
+ return {"seen": true, "own_back": false, "blocker": ""}
1481
+ met_own_back = true
1482
+ elif (flags & FLAG_SEE) == 0 and not back:
1483
+ return {"seen": false, "own_back": false, "blocker": path}
1484
+ start = point + dir * 0.01
1485
+ return {"seen": not met_own_back, "own_back": met_own_back, "blocker": ""}
1486
+
1487
+
1488
+ # ---------------------------------------------------------------------------
1489
+ # Render
1490
+ # ---------------------------------------------------------------------------
1491
+
1492
+ func _setup_render() -> void:
1493
+ var vp := get_viewport()
1494
+ # The preview viewport's own camera would render the whole scene once more,
1495
+ # hidden behind the opaque grid. Turn its 3D pass off; the tiles below render
1496
+ # through their own SubViewports that SHARE this world.
1497
+ vp.disable_3d = true
1498
+ var world: World3D = vp.find_world_3d()
1499
+ var canvas_size := _vec2i(_cfg.get("canvas", [1024, 576]))
1500
+ var layer := CanvasLayer.new()
1501
+ add_child(layer)
1502
+ var bg := ColorRect.new()
1503
+ bg.color = Color(0.06, 0.065, 0.075)
1504
+ bg.position = Vector2.ZERO
1505
+ bg.size = Vector2(canvas_size)
1506
+ layer.add_child(bg)
1507
+ var tiles: Array = _cfg.get("tiles", [])
1508
+ var tile_vps: Array = []
1509
+ var infos: Array = []
1510
+ for i in tiles.size():
1511
+ var t: Dictionary = tiles[i]
1512
+ var kind := String(t.get("kind", "shot"))
1513
+ var info := {"i": i, "kind": kind}
1514
+ var sv: SubViewport = null
1515
+ if kind == "shot":
1516
+ sv = _make_shot_tile(t, world, layer, info)
1517
+ elif kind == "prev":
1518
+ _make_image_tile(t, layer, info)
1519
+ elif kind == "note":
1520
+ _make_note_tile(t, layer)
1521
+ tile_vps.append(sv)
1522
+ infos.append(info)
1523
+ for i in tiles.size():
1524
+ var t: Dictionary = tiles[i]
1525
+ if String(t.get("kind", "")) == "diff":
1526
+ var now_index := int(t.get("now_tile", -1))
1527
+ var now_vp: SubViewport = tile_vps[now_index] if now_index >= 0 and now_index < tile_vps.size() else null
1528
+ _make_diff_tile(t, layer, now_vp, infos[i])
1529
+ for i in tiles.size():
1530
+ _add_label(tiles[i], layer)
1531
+ _result["tiles"] = infos
1532
+ _result["ok"] = true
1533
+ _result["stage"] = "render_setup"
1534
+ _write_result()
1535
+ RenderingServer.frame_post_draw.connect(_on_post_draw)
1536
+
1537
+
1538
+ static func _vec2i(value: Variant) -> Vector2i:
1539
+ if value is Array and (value as Array).size() >= 2:
1540
+ return Vector2i(int(value[0]), int(value[1]))
1541
+ return Vector2i(16, 16)
1542
+
1543
+
1544
+ static func _rect(t: Dictionary) -> Rect2:
1545
+ var r: Array = t.get("rect", [0, 0, 16, 16])
1546
+ return Rect2(float(r[0]), float(r[1]), float(r[2]), float(r[3]))
1547
+
1548
+
1549
+ func _apply_project_aa(sv: SubViewport) -> void:
1550
+ sv.msaa_3d = int(ProjectSettings.get_setting("rendering/anti_aliasing/quality/msaa_3d", 0))
1551
+ sv.screen_space_aa = int(ProjectSettings.get_setting("rendering/anti_aliasing/quality/screen_space_aa", 0))
1552
+ sv.use_taa = bool(ProjectSettings.get_setting("rendering/anti_aliasing/quality/use_taa", false))
1553
+ sv.use_debanding = bool(ProjectSettings.get_setting("rendering/anti_aliasing/quality/use_debanding", false))
1554
+
1555
+
1556
+ func _make_shot_tile(t: Dictionary, world: World3D, layer: CanvasLayer, info: Dictionary) -> SubViewport:
1557
+ var rect := _rect(t)
1558
+ var render_size := _vec2i(t.get("render_size", [int(rect.size.x), int(rect.size.y)]))
1559
+ var view := String(t.get("view", "beauty"))
1560
+ var sv := SubViewport.new()
1561
+ sv.size = render_size
1562
+ sv.render_target_update_mode = SubViewport.UPDATE_ALWAYS
1563
+ sv.transparent_bg = false
1564
+ _apply_project_aa(sv)
1565
+ var cam := Camera3D.new()
1566
+ var method := "beauty"
1567
+ match view:
1568
+ "lighting":
1569
+ sv.debug_draw = Viewport.DEBUG_DRAW_LIGHTING
1570
+ method = "debug_draw"
1571
+ "unshaded":
1572
+ sv.debug_draw = Viewport.DEBUG_DRAW_UNSHADED
1573
+ method = "debug_draw"
1574
+ "overdraw":
1575
+ sv.debug_draw = Viewport.DEBUG_DRAW_OVERDRAW
1576
+ method = "debug_draw"
1577
+ "wireframe":
1578
+ sv.debug_draw = Viewport.DEBUG_DRAW_WIREFRAME
1579
+ method = "debug_draw"
1580
+ "normals":
1581
+ method = "material_override"
1582
+ if view == "normals":
1583
+ sv.world_3d = _get_normals_world()
1584
+ var env := Environment.new()
1585
+ env.background_mode = Environment.BG_COLOR
1586
+ env.background_color = Color(0, 0, 0)
1587
+ cam.environment = env
1588
+ else:
1589
+ sv.world_3d = world
1590
+ info["view"] = view
1591
+ info["method"] = method
1592
+ sv.add_child(cam)
1593
+ add_child(sv)
1594
+ var pose: Dictionary = t.get("pose", {})
1595
+ var pos := _vec(pose.get("position"))
1596
+ var look := _vec(pose.get("look_at"), pos + Vector3(0, 0, -1))
1597
+ cam.global_transform = Transform3D(_look_basis(pos, look), pos)
1598
+ var near := float(pose.get("near", 0.05))
1599
+ var far := float(pose.get("far", 1000.0))
1600
+ var fov := float(pose.get("fov", 60.0))
1601
+ var crop: Array = pose.get("crop", [])
1602
+ if crop.size() == 4:
1603
+ # Exact sub-frustum of the reference frame: the crop renders at full
1604
+ # resolution (real texture detail), not an upscaled crop of pixels.
1605
+ var ref_aspect := float(pose.get("ref_aspect", 16.0 / 9.0))
1606
+ var h_near := 2.0 * near * tan(deg_to_rad(fov) * 0.5)
1607
+ var w_near := h_near * ref_aspect
1608
+ var u0 := float(crop[0])
1609
+ var v0 := float(crop[1])
1610
+ var u1 := float(crop[2])
1611
+ var v1 := float(crop[3])
1612
+ cam.projection = Camera3D.PROJECTION_FRUSTUM
1613
+ cam.size = h_near * (v1 - v0)
1614
+ cam.frustum_offset = Vector2(((u0 + u1) * 0.5 - 0.5) * w_near, (0.5 - (v0 + v1) * 0.5) * h_near)
1615
+ cam.near = near
1616
+ cam.far = far
1617
+ info["crop"] = crop
1618
+ else:
1619
+ cam.fov = fov
1620
+ cam.near = near
1621
+ cam.far = far
1622
+ cam.current = true
1623
+ var tr := TextureRect.new()
1624
+ tr.texture = sv.get_texture()
1625
+ tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
1626
+ tr.stretch_mode = TextureRect.STRETCH_SCALE
1627
+ tr.position = rect.position
1628
+ tr.size = rect.size
1629
+ layer.add_child(tr)
1630
+ var capture := String(t.get("capture_path", ""))
1631
+ if capture != "" and (not capture.begins_with(_out_dir + "/") or capture.contains("..")):
1632
+ _warn("capture path outside the run directory was ignored")
1633
+ capture = ""
1634
+ if capture != "":
1635
+ _captures.append({"vp": sv, "path": capture, "max_edge": int(t.get("capture_max_edge", 1024)), "i": int(info["i"])})
1636
+ return sv
1637
+
1638
+
1639
+ func _make_image_tile(t: Dictionary, layer: CanvasLayer, info: Dictionary) -> void:
1640
+ var rect := _rect(t)
1641
+ var path := String(t.get("image_path", ""))
1642
+ var img := Image.load_from_file(path) if path != "" else null
1643
+ if img == null or img.is_empty():
1644
+ info["error"] = "could not load " + path
1645
+ _make_note_tile({"rect": t.get("rect"), "text": "previous image unreadable"}, layer)
1646
+ return
1647
+ info["image_size"] = [img.get_width(), img.get_height()]
1648
+ var tr := TextureRect.new()
1649
+ tr.texture = ImageTexture.create_from_image(img)
1650
+ tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
1651
+ tr.stretch_mode = TextureRect.STRETCH_SCALE
1652
+ tr.position = rect.position
1653
+ tr.size = rect.size
1654
+ layer.add_child(tr)
1655
+
1656
+
1657
+ const DIFF_SHADER := """
1658
+ shader_type canvas_item;
1659
+ uniform sampler2D prev_tex : filter_linear;
1660
+ uniform sampler2D now_tex : filter_linear;
1661
+ uniform float gain = 3.0;
1662
+ void fragment() {
1663
+ // Compare 3x3-averaged neighbourhoods: JPEG blocks and fine texture
1664
+ // (bricks, gravel) must not light up as change, real shifts still do.
1665
+ vec2 px = TEXTURE_PIXEL_SIZE * 1.5;
1666
+ vec3 a = vec3(0.0);
1667
+ vec3 b = vec3(0.0);
1668
+ for (int x = -1; x <= 1; x++) {
1669
+ for (int y = -1; y <= 1; y++) {
1670
+ vec2 o = vec2(float(x), float(y)) * px;
1671
+ a += texture(prev_tex, UV + o).rgb;
1672
+ b += texture(now_tex, UV + o).rgb;
1673
+ }
1674
+ }
1675
+ a /= 9.0;
1676
+ b /= 9.0;
1677
+ float d = clamp(length(a - b) * gain, 0.0, 1.0);
1678
+ vec3 base = vec3(dot(b, vec3(0.299, 0.587, 0.114))) * 0.3;
1679
+ vec3 heat = mix(vec3(0.95, 0.12, 0.05), vec3(1.0, 0.95, 0.25), clamp(d * 2.0 - 1.0, 0.0, 1.0));
1680
+ COLOR = vec4(mix(base, heat, smoothstep(0.12, 0.4, d)), 1.0);
1681
+ }
1682
+ """
1683
+
1684
+
1685
+ func _make_diff_tile(t: Dictionary, layer: CanvasLayer, now_vp: SubViewport, info: Dictionary) -> void:
1686
+ var rect := _rect(t)
1687
+ var path := String(t.get("image_path", ""))
1688
+ var img := Image.load_from_file(path) if path != "" else null
1689
+ if img == null or img.is_empty() or now_vp == null:
1690
+ info["error"] = "diff needs a previous image and a current shot"
1691
+ _make_note_tile({"rect": t.get("rect"), "text": "no difference map"}, layer)
1692
+ return
1693
+ var shader := Shader.new()
1694
+ shader.code = DIFF_SHADER
1695
+ var mat := ShaderMaterial.new()
1696
+ mat.shader = shader
1697
+ mat.set_shader_parameter("prev_tex", ImageTexture.create_from_image(img))
1698
+ mat.set_shader_parameter("now_tex", now_vp.get_texture())
1699
+ var tr := TextureRect.new()
1700
+ tr.texture = now_vp.get_texture()
1701
+ tr.material = mat
1702
+ tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
1703
+ tr.stretch_mode = TextureRect.STRETCH_SCALE
1704
+ tr.position = rect.position
1705
+ tr.size = rect.size
1706
+ layer.add_child(tr)
1707
+ _diffs.append({"i": int(info["i"]), "vp": now_vp, "prev": img})
1708
+
1709
+
1710
+ func _make_note_tile(t: Dictionary, layer: CanvasLayer) -> void:
1711
+ var rect := _rect(t)
1712
+ var bg := ColorRect.new()
1713
+ bg.color = Color(0.12, 0.12, 0.14)
1714
+ bg.position = rect.position
1715
+ bg.size = rect.size
1716
+ layer.add_child(bg)
1717
+ var label := Label.new()
1718
+ label.text = String(t.get("text", ""))
1719
+ label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
1720
+ label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
1721
+ label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
1722
+ label.position = rect.position
1723
+ label.size = rect.size
1724
+ var settings := LabelSettings.new()
1725
+ settings.font_size = int(clampf(rect.size.y / 14.0, 12.0, 28.0))
1726
+ settings.font_color = Color(0.85, 0.85, 0.88)
1727
+ label.label_settings = settings
1728
+ layer.add_child(label)
1729
+
1730
+
1731
+ func _add_label(t: Dictionary, layer: CanvasLayer) -> void:
1732
+ var text := String(t.get("label", ""))
1733
+ if text == "":
1734
+ return
1735
+ var rect := _rect(t)
1736
+ var label := Label.new()
1737
+ label.text = text
1738
+ var settings := LabelSettings.new()
1739
+ settings.font_size = int(clampf(rect.size.y / 16.0, 12.0, 30.0))
1740
+ settings.font_color = Color(1, 1, 1)
1741
+ settings.outline_size = 4
1742
+ settings.outline_color = Color(0, 0, 0, 0.9)
1743
+ label.label_settings = settings
1744
+ var sb := StyleBoxFlat.new()
1745
+ sb.bg_color = Color(0, 0, 0, 0.55)
1746
+ sb.content_margin_left = 6
1747
+ sb.content_margin_right = 6
1748
+ sb.content_margin_top = 2
1749
+ sb.content_margin_bottom = 2
1750
+ label.add_theme_stylebox_override("normal", sb)
1751
+ label.position = rect.position + Vector2(4, 4)
1752
+ layer.add_child(label)
1753
+
1754
+
1755
+ func _get_normals_world() -> World3D:
1756
+ if _normals_world != null:
1757
+ return _normals_world
1758
+ _normals_world = World3D.new()
1759
+ var holder := SubViewport.new()
1760
+ holder.size = Vector2i(2, 2)
1761
+ holder.render_target_update_mode = SubViewport.UPDATE_DISABLED
1762
+ holder.world_3d = _normals_world
1763
+ add_child(holder)
1764
+ var packed: PackedScene = load(String(_cfg.get("scene_path", ""))) as PackedScene
1765
+ if packed == null:
1766
+ _warn("normals view: the scene could not be loaded a second time")
1767
+ return _normals_world
1768
+ var copy := packed.instantiate(PackedScene.GEN_EDIT_STATE_DISABLED)
1769
+ var shader := Shader.new()
1770
+ shader.code = """
1771
+ shader_type spatial;
1772
+ render_mode unshaded, cull_disabled;
1773
+ void fragment() {
1774
+ vec3 n = normalize((INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).xyz);
1775
+ ALBEDO = n * 0.5 + 0.5;
1776
+ }
1777
+ """
1778
+ var mat := ShaderMaterial.new()
1779
+ mat.shader = shader
1780
+ for g in copy.find_children("*", "GeometryInstance3D", true, false):
1781
+ (g as GeometryInstance3D).material_override = mat
1782
+ if copy is GeometryInstance3D:
1783
+ (copy as GeometryInstance3D).material_override = mat
1784
+ holder.add_child(copy)
1785
+ return _normals_world
1786
+
1787
+
1788
+ func _on_post_draw() -> void:
1789
+ _draws += 1
1790
+ _result["draws"] = _draws
1791
+ if not _checks.is_empty() and not _measurements.is_empty():
1792
+ var spec: Dictionary = _cfg.get("measure", {})
1793
+ var cols := int(spec.get("grid_cols", 16))
1794
+ var rows := int(spec.get("grid_rows", 9))
1795
+ for chk in _checks:
1796
+ var idx := int(chk["i"])
1797
+ if idx >= _measurements.size():
1798
+ continue
1799
+ var rec: Dictionary = _measurements[idx]
1800
+ if rec.has("rejected") or bool(chk["done"]):
1801
+ continue
1802
+ var img: Image = (chk["vp"] as SubViewport).get_texture().get_image()
1803
+ if img == null or img.is_empty() or _image_blank(img):
1804
+ continue
1805
+ var codes := _image_codes(img, cols, rows)
1806
+ rec["lum"] = codes["lum"]
1807
+ rec["tex"] = codes["tex"]
1808
+ chk["done"] = true
1809
+ _result["image_checked"] = _draws
1810
+ var captured: Array = []
1811
+ for c in _captures:
1812
+ var img: Image = (c["vp"] as SubViewport).get_texture().get_image()
1813
+ if img == null or img.is_empty():
1814
+ continue
1815
+ var max_edge := int(c["max_edge"])
1816
+ var longest := maxi(img.get_width(), img.get_height())
1817
+ if longest > max_edge:
1818
+ var scale := float(max_edge) / float(longest)
1819
+ img.resize(maxi(1, int(round(img.get_width() * scale))), maxi(1, int(round(img.get_height() * scale))), Image.INTERPOLATE_LANCZOS)
1820
+ if img.save_jpg(String(c["path"]), 0.85) == OK:
1821
+ captured.append({"i": c["i"], "path": c["path"], "size": [img.get_width(), img.get_height()]})
1822
+ if not _captures.is_empty():
1823
+ _result["captures"] = captured
1824
+ var diffs: Array = []
1825
+ for d in _diffs:
1826
+ var now_img: Image = (d["vp"] as SubViewport).get_texture().get_image()
1827
+ if now_img == null or now_img.is_empty():
1828
+ continue
1829
+ diffs.append(_diff_stats(int(d["i"]), d["prev"], now_img))
1830
+ if not _diffs.is_empty():
1831
+ _result["diffs"] = diffs
1832
+ _write_result()
1833
+
1834
+
1835
+ # Downsampled per-pixel comparison: mean absolute difference, the fraction of
1836
+ # pixels that changed visibly, and the normalized box around the changes.
1837
+ func _diff_stats(index: int, prev: Image, now: Image) -> Dictionary:
1838
+ var w := 160
1839
+ var h := maxi(1, int(round(160.0 * now.get_height() / maxf(1.0, now.get_width()))))
1840
+ var a := prev.duplicate() as Image
1841
+ var b := now.duplicate() as Image
1842
+ a.convert(Image.FORMAT_RGB8)
1843
+ b.convert(Image.FORMAT_RGB8)
1844
+ a.resize(w, h, Image.INTERPOLATE_BILINEAR)
1845
+ b.resize(w, h, Image.INTERPOLATE_BILINEAR)
1846
+ var total := 0.0
1847
+ var changed := 0
1848
+ var min_x := w
1849
+ var min_y := h
1850
+ var max_x := -1
1851
+ var max_y := -1
1852
+ for y in h:
1853
+ for x in w:
1854
+ var ca := a.get_pixel(x, y)
1855
+ var cb := b.get_pixel(x, y)
1856
+ var d := (absf(ca.r - cb.r) + absf(ca.g - cb.g) + absf(ca.b - cb.b)) / 3.0
1857
+ total += d
1858
+ if d > 0.08:
1859
+ changed += 1
1860
+ min_x = mini(min_x, x)
1861
+ min_y = mini(min_y, y)
1862
+ max_x = maxi(max_x, x)
1863
+ max_y = maxi(max_y, y)
1864
+ var stats := {
1865
+ "i": index,
1866
+ "mean_abs": snappedf(total / float(w * h), 0.0001),
1867
+ "changed_fraction": snappedf(float(changed) / float(w * h), 0.0001),
1868
+ "prev_size": [prev.get_width(), prev.get_height()],
1869
+ "now_size": [now.get_width(), now.get_height()],
1870
+ }
1871
+ if changed > 0:
1872
+ stats["changed_box"] = [snappedf(float(min_x) / w, 0.001), snappedf(float(min_y) / h, 0.001), snappedf(float(max_x + 1) / w, 0.001), snappedf(float(max_y + 1) / h, 0.001)]
1873
+ return stats