summer-engine 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +27 -5
  3. package/.codex-plugin/plugin.json +28 -6
  4. package/.cursor-plugin/plugin.json +27 -5
  5. package/.factory-plugin/plugin.json +27 -5
  6. package/.opencode/plugins/summer.js +1 -1
  7. package/CHANGELOG.md +102 -0
  8. package/README.md +18 -3
  9. package/assets/audit/scene_audit.gd +4136 -0
  10. package/assets/seeing/seeing_probe.gd +1873 -0
  11. package/dist/cli/commands/skills.js +20 -2
  12. package/dist/core/api-client.d.ts +9 -1
  13. package/dist/core/api-client.js +21 -4
  14. package/dist/core/capabilities/agent-playbook.js +46 -0
  15. package/dist/core/capabilities/audit/accept.d.ts +55 -0
  16. package/dist/core/capabilities/audit/accept.js +147 -0
  17. package/dist/core/capabilities/audit/args.d.ts +95 -0
  18. package/dist/core/capabilities/audit/args.js +89 -0
  19. package/dist/core/capabilities/audit/audit.d.ts +56 -0
  20. package/dist/core/capabilities/audit/audit.js +287 -0
  21. package/dist/core/capabilities/audit/frame.d.ts +14 -0
  22. package/dist/core/capabilities/audit/frame.js +35 -0
  23. package/dist/core/capabilities/audit/gaps.d.ts +96 -0
  24. package/dist/core/capabilities/audit/gaps.js +257 -0
  25. package/dist/core/capabilities/audit/judge.d.ts +162 -0
  26. package/dist/core/capabilities/audit/judge.js +1134 -0
  27. package/dist/core/capabilities/audit/math.d.ts +110 -0
  28. package/dist/core/capabilities/audit/math.js +263 -0
  29. package/dist/core/capabilities/audit/summary.d.ts +74 -0
  30. package/dist/core/capabilities/audit/summary.js +212 -0
  31. package/dist/core/capabilities/board.d.ts +31 -0
  32. package/dist/core/capabilities/board.js +43 -0
  33. package/dist/core/capabilities/camera-view.d.ts +4 -2
  34. package/dist/core/capabilities/camera-view.js +6 -3
  35. package/dist/core/capabilities/connect-signal.d.ts +85 -0
  36. package/dist/core/capabilities/connect-signal.js +285 -0
  37. package/dist/core/capabilities/engine-fallbacks.d.ts +5 -0
  38. package/dist/core/capabilities/engine-fallbacks.js +5 -0
  39. package/dist/core/capabilities/engine-ops.d.ts +5 -1
  40. package/dist/core/capabilities/engine-ops.js +0 -3
  41. package/dist/core/capabilities/engine-receipt.d.ts +12 -0
  42. package/dist/core/capabilities/engine-receipt.js +26 -0
  43. package/dist/core/capabilities/file-read.d.ts +64 -0
  44. package/dist/core/capabilities/file-read.js +325 -0
  45. package/dist/core/capabilities/game-task-plan.js +4 -2
  46. package/dist/core/capabilities/grep.d.ts +60 -0
  47. package/dist/core/capabilities/grep.js +190 -0
  48. package/dist/core/capabilities/inspect-node.d.ts +56 -0
  49. package/dist/core/capabilities/inspect-node.js +225 -0
  50. package/dist/core/capabilities/inspect-resource.d.ts +61 -0
  51. package/dist/core/capabilities/inspect-resource.js +376 -0
  52. package/dist/core/capabilities/math3d.d.ts +28 -0
  53. package/dist/core/capabilities/math3d.js +88 -0
  54. package/dist/core/capabilities/motion-generation.d.ts +62 -0
  55. package/dist/core/capabilities/motion-generation.js +138 -0
  56. package/dist/core/capabilities/placement-batch.d.ts +102 -0
  57. package/dist/core/capabilities/placement-batch.js +483 -0
  58. package/dist/core/capabilities/placement-math.d.ts +70 -0
  59. package/dist/core/capabilities/placement-math.js +209 -0
  60. package/dist/core/capabilities/placement-script.d.ts +42 -0
  61. package/dist/core/capabilities/placement-script.js +1096 -0
  62. package/dist/core/capabilities/placement.d.ts +485 -0
  63. package/dist/core/capabilities/placement.js +1731 -0
  64. package/dist/core/capabilities/project-context.d.ts +33 -2
  65. package/dist/core/capabilities/project-context.js +87 -4
  66. package/dist/core/capabilities/replace-node.d.ts +91 -0
  67. package/dist/core/capabilities/replace-node.js +423 -0
  68. package/dist/core/capabilities/runtime-control.d.ts +18 -2
  69. package/dist/core/capabilities/runtime-control.js +67 -2
  70. package/dist/core/capabilities/scene-batch.d.ts +63 -0
  71. package/dist/core/capabilities/scene-batch.js +394 -0
  72. package/dist/core/capabilities/scene-readback.d.ts +36 -0
  73. package/dist/core/capabilities/scene-readback.js +54 -0
  74. package/dist/core/capabilities/seeing/args.d.ts +348 -0
  75. package/dist/core/capabilities/seeing/args.js +178 -0
  76. package/dist/core/capabilities/seeing/candidates.d.ts +127 -0
  77. package/dist/core/capabilities/seeing/candidates.js +282 -0
  78. package/dist/core/capabilities/seeing/math.d.ts +134 -0
  79. package/dist/core/capabilities/seeing/math.js +269 -0
  80. package/dist/core/capabilities/seeing/probe.d.ts +62 -0
  81. package/dist/core/capabilities/seeing/probe.js +188 -0
  82. package/dist/core/capabilities/seeing/scoring.d.ts +235 -0
  83. package/dist/core/capabilities/seeing/scoring.js +639 -0
  84. package/dist/core/capabilities/seeing/seeing.d.ts +233 -0
  85. package/dist/core/capabilities/seeing/seeing.js +1341 -0
  86. package/dist/core/capabilities/seeing/shot-store.d.ts +52 -0
  87. package/dist/core/capabilities/seeing/shot-store.js +204 -0
  88. package/dist/core/capabilities/surface-snap-mesh.d.ts +88 -0
  89. package/dist/core/capabilities/surface-snap-mesh.js +425 -0
  90. package/dist/core/capabilities/surface-snap.d.ts +77 -0
  91. package/dist/core/capabilities/surface-snap.js +461 -0
  92. package/dist/core/capabilities/tool-dispatch.js +231 -72
  93. package/dist/core/capabilities/tscn.d.ts +85 -0
  94. package/dist/core/capabilities/tscn.js +391 -0
  95. package/dist/core/capabilities/world-snapshot.d.ts +58 -0
  96. package/dist/core/capabilities/world-snapshot.js +150 -0
  97. package/dist/core/capability-skew.d.ts +5 -5
  98. package/dist/core/capability-skew.js +14 -6
  99. package/dist/core/headless/worker-engine-client.d.ts +1 -1
  100. package/dist/core/headless/worker-engine-client.js +2 -2
  101. package/dist/core/library-read.d.ts +37 -5
  102. package/dist/core/library-read.js +264 -33
  103. package/dist/core/library-search.d.ts +2 -2
  104. package/dist/installer/agent-config.js +5 -3
  105. package/dist/installer/agent-table.d.ts +16 -0
  106. package/dist/installer/agent-table.js +52 -17
  107. package/dist/mcp/server.js +9 -3
  108. package/dist/mcp/tools/asset-tools.js +21 -3
  109. package/dist/mcp/tools/audit-tools.d.ts +23 -0
  110. package/dist/mcp/tools/audit-tools.js +60 -0
  111. package/dist/mcp/tools/debug-tools.js +3 -1
  112. package/dist/mcp/tools/file-tools.js +17 -4
  113. package/dist/mcp/tools/generate-tools.js +74 -66
  114. package/dist/mcp/tools/library-tools.js +2 -0
  115. package/dist/mcp/tools/perception-tools.js +6 -19
  116. package/dist/mcp/tools/placement-tools.d.ts +24 -0
  117. package/dist/mcp/tools/placement-tools.js +91 -0
  118. package/dist/mcp/tools/project-tools.js +18 -15
  119. package/dist/mcp/tools/scene-tools.js +71 -83
  120. package/dist/mcp/tools/seeing-tools.d.ts +24 -0
  121. package/dist/mcp/tools/seeing-tools.js +64 -0
  122. package/dist/mcp/tools/spatial-tools.js +17 -9
  123. package/dist/mcp/tools/visual-tools.js +59 -7
  124. package/gemini-extension.json +26 -4
  125. package/integrations/amp/README.md +1 -1
  126. package/integrations/crush/README.md +1 -1
  127. package/integrations/grok-build/README.md +1 -1
  128. package/integrations/hermes/README.md +1 -1
  129. package/integrations/kimi-code/README.md +1 -1
  130. package/integrations/mistral-vibe/README.md +1 -1
  131. package/integrations/opencode/README.md +1 -1
  132. package/integrations/rovo-dev/README.md +1 -1
  133. package/integrations/vscode-copilot/README.md +1 -1
  134. package/integrations/warp/README.md +1 -1
  135. package/integrations/windsurf/README.md +1 -1
  136. package/library/references/mcp-tools-reference/mcp-tools-reference.md +59 -15
  137. package/library/references/mcp-tools-reference/resource.yaml +1 -1
  138. package/library/references/summer-folder/resource.yaml +1 -1
  139. package/library/references/summer-folder/summer-folder.md +1 -1
  140. package/library/skills/3d-lighting/SKILL.md +1 -1
  141. package/library/skills/README.md +36 -14
  142. package/library/skills/adaptive-music/SKILL.md +1 -1
  143. package/library/skills/agent-playtesting/SKILL.md +1 -1
  144. package/library/skills/ai-characters-in-games/SKILL.md +220 -0
  145. package/library/skills/ai-characters-in-games/resource.yaml +30 -0
  146. package/library/skills/ambient-bed/SKILL.md +1 -1
  147. package/library/skills/animated-loop/SKILL.md +1 -1
  148. package/library/skills/animation-tree/SKILL.md +1 -1
  149. package/library/skills/art-direction/SKILL.md +1 -1
  150. package/library/skills/asset-strategy/SKILL.md +2 -1
  151. package/library/skills/asset-strategy/resource.yaml +2 -1
  152. package/library/skills/audio-direction/SKILL.md +1 -1
  153. package/library/skills/auto-fire-targeting/SKILL.md +1 -1
  154. package/library/skills/brainstorm-game/SKILL.md +1 -1
  155. package/library/skills/brainstorming/SKILL.md +1 -1
  156. package/library/skills/browse-templates/SKILL.md +3 -3
  157. package/library/skills/browse-templates/resource.yaml +1 -1
  158. package/library/skills/camera-rigs/SKILL.md +128 -0
  159. package/library/skills/camera-rigs/resource.yaml +29 -0
  160. package/library/skills/celeste-momentum-platforming/SKILL.md +1 -1
  161. package/library/skills/character-animation-wiring/SKILL.md +1 -1
  162. package/library/skills/character-model/SKILL.md +1 -1
  163. package/library/skills/character-movement/SKILL.md +326 -0
  164. package/library/skills/character-movement/references/default-third-person-controller.gd +201 -0
  165. package/library/skills/character-movement/references/default-third-person-verification-probe.gd +154 -0
  166. package/library/skills/character-movement/references/movement-3d.md +136 -0
  167. package/library/skills/character-movement/references/platformer-feel.md +83 -0
  168. package/library/skills/character-movement/resource.yaml +40 -0
  169. package/library/skills/character-portrait/SKILL.md +1 -1
  170. package/library/skills/cinematic-cutscene/SKILL.md +1 -1
  171. package/library/skills/combat-basics/SKILL.md +159 -0
  172. package/library/skills/combat-basics/references/projectiles-and-aoe.md +100 -0
  173. package/library/skills/combat-basics/resource.yaml +34 -0
  174. package/library/skills/concept-art/SKILL.md +1 -1
  175. package/library/skills/create-asset-sheet/SKILL.md +1 -1
  176. package/library/skills/debug/SKILL.md +1 -1
  177. package/library/skills/debugging-game-feel/SKILL.md +1 -1
  178. package/library/skills/design-level/SKILL.md +1 -1
  179. package/library/skills/design-mechanic/SKILL.md +1 -1
  180. package/library/skills/design-npc/SKILL.md +1 -1
  181. package/library/skills/diagnosing-perf-regressions/SKILL.md +1 -1
  182. package/library/skills/dispatching-parallel-agents/SKILL.md +1 -1
  183. package/library/skills/driving-the-editor-ui/SKILL.md +1 -1
  184. package/library/skills/environment-kit/SKILL.md +1 -1
  185. package/library/skills/export-and-ship/SKILL.md +1 -1
  186. package/library/skills/fabricating-assets/SKILL.md +1 -1
  187. package/library/skills/facial-and-lipsync/SKILL.md +1 -1
  188. package/library/skills/fps-controller/SKILL.md +9 -7
  189. package/library/skills/fps-controller/resource.yaml +4 -1
  190. package/library/skills/game-feel/SKILL.md +1 -1
  191. package/library/skills/gameskill/SKILL.md +1 -1
  192. package/library/skills/gdscript-patterns/SKILL.md +1 -1
  193. package/library/skills/generate-motion/SKILL.md +9 -9
  194. package/library/skills/generate-motion/resource.yaml +2 -1
  195. package/library/skills/godogen-visual-proof-game-generation/SKILL.md +1 -1
  196. package/library/skills/headless-scripting/SKILL.md +1 -1
  197. package/library/skills/in-game-purchases/SKILL.md +254 -0
  198. package/library/skills/in-game-purchases/resource.yaml +29 -0
  199. package/library/skills/instantiate-asset-pack/SKILL.md +1 -1
  200. package/library/skills/investigating-bugs/SKILL.md +1 -1
  201. package/library/skills/lumera-single-image-scene-reconstruction/SKILL.md +1 -1
  202. package/library/skills/make-game/SKILL.md +25 -3
  203. package/library/skills/make-game/references/2d-platformer-polish.md +93 -0
  204. package/library/skills/make-game/references/2d-platformer.md +117 -0
  205. package/library/skills/make-game/references/2d-rpg-content.md +234 -0
  206. package/library/skills/make-game/references/2d-rpg.md +241 -0
  207. package/library/skills/make-game/references/2d-survivors-progression.md +201 -0
  208. package/library/skills/make-game/references/2d-survivors.md +237 -0
  209. package/library/skills/make-game/references/3d-fps-systems.md +216 -0
  210. package/library/skills/make-game/references/3d-fps.md +177 -0
  211. package/library/skills/make-game/references/3d-horror-systems.md +186 -0
  212. package/library/skills/make-game/references/3d-horror.md +201 -0
  213. package/library/skills/make-game/references/3d-third-person-systems.md +220 -0
  214. package/library/skills/make-game/references/3d-third-person.md +215 -0
  215. package/library/skills/make-game/resource.yaml +6 -1
  216. package/library/skills/multiplayer/SKILL.md +150 -0
  217. package/library/skills/multiplayer/resource.yaml +33 -0
  218. package/library/skills/multiplayer-movement/SKILL.md +510 -0
  219. package/library/skills/multiplayer-movement/files.md +501 -0
  220. package/library/skills/multiplayer-movement/resource.yaml +30 -0
  221. package/library/skills/multiplayer-project/SKILL.md +521 -0
  222. package/library/skills/multiplayer-project/files.md +448 -0
  223. package/library/skills/multiplayer-project/resource.yaml +36 -0
  224. package/library/skills/multiplayer-publish/SKILL.md +229 -0
  225. package/library/skills/multiplayer-publish/export-errors.md +89 -0
  226. package/library/skills/multiplayer-publish/resource.yaml +28 -0
  227. package/library/skills/multiplayer-state/SKILL.md +438 -0
  228. package/library/skills/multiplayer-state/files.md +592 -0
  229. package/library/skills/multiplayer-state/resource.yaml +35 -0
  230. package/library/skills/multiplayer-testing/SKILL.md +236 -0
  231. package/library/skills/multiplayer-testing/resource.yaml +39 -0
  232. package/library/skills/music-track/SKILL.md +1 -1
  233. package/library/skills/navigate-summer/SKILL.md +1 -1
  234. package/library/skills/new-project/SKILL.md +1 -1
  235. package/library/skills/organic-model/SKILL.md +1 -1
  236. package/library/skills/pixel-art/SKILL.md +1 -1
  237. package/library/skills/play/SKILL.md +1 -1
  238. package/library/skills/playtesting-a-feature/SKILL.md +1 -1
  239. package/library/skills/procedural-animation/SKILL.md +2 -2
  240. package/library/skills/procedural-animation/resource.yaml +1 -1
  241. package/library/skills/prop-model/SKILL.md +1 -1
  242. package/library/skills/psx-retro-rendering/SKILL.md +1 -1
  243. package/library/skills/realtime-wet-surfaces/SKILL.md +2 -2
  244. package/library/skills/remote-deploy/SKILL.md +1 -1
  245. package/library/skills/retarget/SKILL.md +1 -1
  246. package/library/skills/running-in-the-cloud/SKILL.md +1 -1
  247. package/library/skills/save-load/SKILL.md +156 -0
  248. package/library/skills/save-load/resource.yaml +27 -0
  249. package/library/skills/scene-composition/SKILL.md +1 -1
  250. package/library/skills/scene-hierarchy-design/SKILL.md +1 -1
  251. package/library/skills/scene-scripting/SKILL.md +1 -1
  252. package/library/skills/scene-to-level/SKILL.md +1 -1
  253. package/library/skills/skill-create/SKILL.md +1 -1
  254. package/library/skills/skill-improve/SKILL.md +1 -1
  255. package/library/skills/skill-test/SKILL.md +1 -1
  256. package/library/skills/skintokens-auto-rigging/SKILL.md +1 -1
  257. package/library/skills/skybox-panorama/SKILL.md +1 -1
  258. package/library/skills/sound-effect/SKILL.md +1 -1
  259. package/library/skills/spatial-placement/SKILL.md +134 -3
  260. package/library/skills/spatial-placement/references/kit-placement-tools.md +364 -0
  261. package/library/skills/spatial-placement/resource.yaml +16 -2
  262. package/library/skills/sprite-sheet/SKILL.md +1 -1
  263. package/library/skills/summer-analytics/SKILL.md +147 -0
  264. package/library/skills/summer-analytics/resource.yaml +28 -0
  265. package/library/skills/summer-friends/SKILL.md +226 -0
  266. package/library/skills/summer-friends/resource.yaml +28 -0
  267. package/library/skills/summer-leaderboards/SKILL.md +231 -0
  268. package/library/skills/summer-leaderboards/resource.yaml +30 -0
  269. package/library/skills/summer-match-results/SKILL.md +286 -0
  270. package/library/skills/summer-match-results/resource.yaml +32 -0
  271. package/library/skills/summer-matchmaking/SKILL.md +301 -0
  272. package/library/skills/summer-matchmaking/resource.yaml +32 -0
  273. package/library/skills/summer-parties/SKILL.md +253 -0
  274. package/library/skills/summer-parties/resource.yaml +30 -0
  275. package/library/skills/summer-player-data/SKILL.md +322 -0
  276. package/library/skills/summer-player-data/resource.yaml +30 -0
  277. package/library/skills/summer-store/SKILL.md +271 -0
  278. package/library/skills/summer-store/resource.yaml +29 -0
  279. package/library/skills/summer-world-chat/SKILL.md +195 -0
  280. package/library/skills/summer-world-chat/resource.yaml +30 -0
  281. package/library/skills/summer-world-saves/SKILL.md +284 -0
  282. package/library/skills/summer-world-saves/resource.yaml +29 -0
  283. package/library/skills/text-to-motion/SKILL.md +99 -0
  284. package/library/skills/text-to-motion/prompts-and-controls.md +34 -0
  285. package/library/skills/text-to-motion/resource.yaml +41 -0
  286. package/library/skills/text-to-motion/rigging-for-text-motion.md +54 -0
  287. package/library/skills/tileable-texture/SKILL.md +1 -1
  288. package/library/skills/trailer-shot/SKILL.md +1 -1
  289. package/library/skills/tune-performance/SKILL.md +1 -1
  290. package/library/skills/ui-basics/SKILL.md +10 -1
  291. package/library/skills/ui-graphics/SKILL.md +1 -1
  292. package/library/skills/use-widget-asset/SKILL.md +1 -1
  293. package/library/skills/using-summer/SKILL.md +2 -2
  294. package/library/skills/using-summer/resource.yaml +2 -2
  295. package/library/skills/vehicle-model/SKILL.md +1 -1
  296. package/library/skills/verification-before-completion/SKILL.md +1 -1
  297. package/library/skills/verifying-scenes/SKILL.md +76 -2
  298. package/library/skills/verifying-scenes/resource.yaml +13 -2
  299. package/library/skills/vfx-dissolve/SKILL.md +1 -1
  300. package/library/skills/vfx-fire/SKILL.md +1 -1
  301. package/library/skills/vfx-hit-spark/SKILL.md +1 -1
  302. package/library/skills/vfx-lightning/SKILL.md +1 -1
  303. package/library/skills/vfx-magic-glow/SKILL.md +1 -1
  304. package/library/skills/vfx-muzzle-flash/SKILL.md +1 -1
  305. package/library/skills/vfx-smoke/SKILL.md +1 -1
  306. package/library/skills/vfx-water-ripple/SKILL.md +1 -1
  307. package/library/skills/voice-line/SKILL.md +1 -1
  308. package/library/skills/web-and-touch-input/SKILL.md +38 -0
  309. package/library/skills/web-and-touch-input/resource.yaml +28 -0
  310. package/library/skills/web-compatibility-look/SKILL.md +62 -0
  311. package/library/skills/web-compatibility-look/resource.yaml +30 -0
  312. package/library/skills/world-building-3d/SKILL.md +8 -1
  313. package/library/skills/world-building-3d/references/spatial-tools.md +1 -1
  314. package/library/skills/world-building-3d/resource.yaml +2 -1
  315. package/library/skills/writing-plans/SKILL.md +1 -1
  316. package/library/skills/writing-skills/SKILL.md +1 -1
  317. package/library/templates/3d-lan-multiplayer-starter/resource.yaml +2 -2
  318. package/library/tools/attach-to-surface/resource.yaml +142 -0
  319. package/library/tools/batch/resource.yaml +9 -3
  320. package/library/tools/connect-ports/resource.yaml +120 -0
  321. package/library/tools/connect-signal/resource.yaml +3 -2
  322. package/library/tools/debug-views/resource.yaml +94 -0
  323. package/library/tools/frame-nodes/resource.yaml +104 -0
  324. package/library/tools/frame-shot/resource.yaml +134 -0
  325. package/library/tools/generate-motion/resource.yaml +44 -9
  326. package/library/tools/get-board/resource.yaml +50 -0
  327. package/library/tools/get-project-context/resource.yaml +13 -2
  328. package/library/tools/grep/resource.yaml +77 -0
  329. package/library/tools/inspect-asset/resource.yaml +71 -0
  330. package/library/tools/inspect-node/resource.yaml +9 -2
  331. package/library/tools/inspect-resource/resource.yaml +16 -5
  332. package/library/tools/instantiate-scene/resource.yaml +27 -2
  333. package/library/tools/measure/resource.yaml +96 -0
  334. package/library/tools/place-adjacent/resource.yaml +113 -0
  335. package/library/tools/play/resource.yaml +15 -2
  336. package/library/tools/raycast/resource.yaml +101 -0
  337. package/library/tools/read-file/resource.yaml +36 -2
  338. package/library/tools/read-library/resource.yaml +2 -2
  339. package/library/tools/repeat-along/resource.yaml +129 -0
  340. package/library/tools/replace-node/resource.yaml +11 -5
  341. package/library/tools/scene-audit/resource.yaml +144 -0
  342. package/library/tools/screenshot/resource.yaml +13 -2
  343. package/library/tools/search-assets/resource.yaml +18 -0
  344. package/library/tools/shot-sheet/resource.yaml +110 -0
  345. package/library/tools/snap-to-surface/resource.yaml +4 -1
  346. package/library/tools/world-snapshot/resource.yaml +24 -3
  347. package/library/tools/zoom/resource.yaml +105 -0
  348. package/package.json +1 -1
  349. package/registry/generated/aliases.json +17 -3
  350. package/registry/generated/counts.json +3 -3
  351. package/registry/generated/gemini-extension.json +26 -4
  352. package/registry/generated/index.json +2154 -469
  353. package/registry/generated/marketplace.claude.json +2 -2
  354. package/registry/generated/plugin.claude.json +27 -5
  355. package/registry/generated/plugin.codex.json +28 -6
  356. package/registry/generated/plugin.cursor.json +27 -5
  357. package/registry/generated/plugin.factory.json +27 -5
  358. package/registry/generated/skills-index.md +36 -14
  359. package/registry/generated/skills-registry.json +421 -124
  360. package/registry/generated/templates-registry.json +2 -2
  361. package/library/skills/host-authoritative-state/SKILL.md +0 -332
  362. package/library/skills/host-authoritative-state/resource.yaml +0 -28
  363. package/library/skills/peer-to-peer-multiplayer/SKILL.md +0 -373
  364. package/library/skills/peer-to-peer-multiplayer/resource.yaml +0 -28
  365. package/library/skills/setup-multiplayer/SKILL.md +0 -336
  366. package/library/skills/setup-multiplayer/resource.yaml +0 -28
@@ -0,0 +1,4136 @@
1
+ @tool
2
+ extends Node
3
+ ## Summer scene-audit kernel (summer_scene_audit).
4
+ ##
5
+ ## The MCP tool writes a throwaway wrapper scene into a private per-call OS
6
+ ## temp directory: the wrapper instances the target scene as "Subject" and
7
+ ## carries this script as a built-in @tool script. The wrapper is rendered with
8
+ ## the engine's ScenePreview op, which instantiates it under an offscreen
9
+ ## SubViewport with its own World3D, so this node runs inside a private COPY of
10
+ ## the SAVED scene. Nothing here touches the edited scene, the open tab, the
11
+ ## undo history or any project file. The only file written is result.json in
12
+ ## the wrapper's own temp directory.
13
+ ##
14
+ ## Arguments arrive as DATA only: config.json next to the wrapper, found
15
+ ## through this built-in script's own resource path and parsed as JSON.
16
+ ##
17
+ ## This kernel MEASURES; it does not judge. It walks every node, groups the
18
+ ## geometry into instances, gives each one a role from its geometry and
19
+ ## engine data alone (never its name or any metadata: see _classify_shapes),
20
+ ## builds one private physics space from the VISIBLE meshes (one-sided like
21
+ ## the renderer: a ray only hits a triangle from its front, so back faces are
22
+ ## as see-through as on screen; shapes are cached per mesh resource and
23
+ ## shared by every unscaled instance), casts capped ray grids and
24
+ ## per-instance probes, and writes raw evidence. The TypeScript side
25
+ ## (core/capabilities/audit/) clusters, applies thresholds, assigns
26
+ ## severities, sorts, pages and frames.
27
+
28
+ const SUBJECT_NAME := "Subject"
29
+
30
+ const L_WALL := 1
31
+ const L_STRUCT := 2
32
+ const L_FLOOR := 4
33
+ const L_UNDERLAY := 8
34
+ const L_PROP := 16
35
+ const L_INSERT := 64
36
+ const ROLE_LAYER := {"wall": L_WALL, "struct": L_STRUCT, "floor": L_FLOOR, "underlay": L_UNDERLAY, "prop": L_PROP, "insert": L_INSERT}
37
+ const MASK_FACADE := L_WALL | L_STRUCT | L_INSERT
38
+ # What covers an opening: walls, structure and inserts (a window, door or
39
+ # shutter in its opening). Props never close a hole.
40
+ const MASK_HOLE := MASK_FACADE
41
+ const MASK_SOLID := L_WALL | L_STRUCT | L_INSERT | L_FLOOR | L_UNDERLAY | L_PROP
42
+
43
+ # Roles (_classify_shapes, _classify_rest), in metres. A slab: upright, at most SLAB_THICK
44
+ # thick, at least SLAB_MIN across both ways, its top faces covering at least
45
+ # SLAB_COVER of its footprint. A sheet: upright, at least SHEET_H tall and
46
+ # SHEET_W wide, at most SHEET_THICK thick. A facade member touches a wall face
47
+ # within MEMBER_TOUCH; a band run is at most BAND_MAX_H tall and BAND_MAX_D
48
+ # deep, and at least BAND_MIN_LEN and BAND_ASPECT times its height long.
49
+ # PROP_MAX: the longest side of a prop.
50
+ const SLAB_THICK := 0.6
51
+ const SLAB_MIN := 1.5
52
+ const SLAB_COVER := 0.5
53
+ const SHEET_H := 1.5
54
+ const SHEET_W := 1.0
55
+ const SHEET_THICK := 0.6
56
+ const MEMBER_TOUCH := 0.03
57
+ const MEMBER_REACH := 0.8
58
+ const BAND_MAX_H := 1.2
59
+ const BAND_MAX_D := 0.6
60
+ const BAND_MIN_LEN := 1.0
61
+ const BAND_ASPECT := 3.0
62
+ const PROP_MAX := 3.5
63
+ # floor_gap strips: edge step along a tile edge, and how far out a wall bounds
64
+ # the strip between that edge and the wall (walkable area enclosed by walls).
65
+ const STRIP_STEP := 0.25
66
+ const STRIP_REACH := 1.0
67
+ # Usual share of a check's editor time (the budget's weights).
68
+ const STAGE_WEIGHT := {"through_hole": 4.0, "floor_gap": 2.0, "floating_sunken": 1.0, "interpenetration": 2.0, "orientation": 1.0, "uv_stretch": 1.0, "z_fight_geometry": 3.0, "gap_setup": 0.3, "exposed_edge": 3.0, "open_fixture_end": 0.3, "depth_step": 2.0, "lights": 0.3, "resource": 0.5, "poses": 1.0}
69
+ # The gap detectors' stages (budget tier 2, run after every other check).
70
+ const GAP_STAGES := ["gap_setup", "open_fixture_end", "exposed_edge", "depth_step"]
71
+ # z_fight geometry pass: planar face groups per mesh resource.
72
+ # Triangles under ZF_MIN_TRI m2 are not bucketed (relief, props); a group
73
+ # needs ZF_MIN_AREA m2 (the smallest overlap reported); at most
74
+ # ZF_MAX_GROUPS per mesh, largest first. Normals bin at 1/30 per component,
75
+ # plane offsets at ZF_MAX_GAP (the widest gap a candidate may have).
76
+ const ZF_MIN_TRI := 0.0005
77
+ const ZF_MIN_AREA := 0.01
78
+ const ZF_MAX_GROUPS := 96
79
+ const ZF_MAX_GAP := 0.025
80
+ const ZF_DEPTH_STEPS := 16777216.0
81
+ # Shader code that offsets depth or moves the vertex along its normal, and
82
+ # shader code that makes a surface see-through (alpha-tested or blended).
83
+ const DEPTH_OFFSET_RE := "(\\bDEPTH\\s*=|VERTEX\\s*[-+]?=[^;\\n]*NORMAL|NORMAL\\s*\\*[^;\\n]*VERTEX)"
84
+ const ALPHA_CODE_RE := "(\\bALPHA\\s*=[^=]|\\bALPHA_SCISSOR_THRESHOLD\\b|\\bALPHA_HASH_SCALE\\b|\\bdiscard\\b|render_mode[^;]*\\bblend_(add|sub|mul|premul_alpha)\\b)"
85
+
86
+ var _cfg: Dictionary = {}
87
+ var _out_dir := ""
88
+ var _result: Dictionary = {}
89
+ var _subject: Node = null
90
+ var _checks: Dictionary = {}
91
+ var _report_root: Node = null
92
+ var _want_poses := false
93
+
94
+ var _t0 := 0
95
+ var _ms: Dictionary = {}
96
+
97
+ var _nodes := 0
98
+ var _hidden := 0
99
+ var _multimesh := 0
100
+ var _screen_space := 0
101
+ var _inst: Array = []
102
+ var _lights: Array = []
103
+ var _cameras: Array = []
104
+ var _players: Array = []
105
+ var _empty_meshes: Array = []
106
+ var _shader_no_code: Array = []
107
+
108
+ # The role counts by the evidence that set them.
109
+ var _role_stats: Dictionary = {}
110
+
111
+ var _mesh_info: Dictionary = {}
112
+ var _space := RID()
113
+ var _state: PhysicsDirectSpaceState3D = null
114
+ var _bodies: Array = []
115
+ var _shapes: Array = []
116
+ var _hulls: Dictionary = {}
117
+ var _body_inst: Dictionary = {}
118
+ var _q: PhysicsRayQueryParameters3D = null
119
+ var _ex: Array[RID] = []
120
+ var _ex_set := false
121
+ var _rays := 0
122
+ var _floor_gap_count := 0
123
+ var _floor_gap_void := false
124
+ var _floor_gap_covered := false
125
+ var _floor_recs: Array = []
126
+
127
+ # Editor-time budget (budget_ms): each check gets a share of what is left,
128
+ # weighted by its usual cost; time a fast check leaves unused passes on to
129
+ # the next ones. A check past its share stops and is reported partial:
130
+ # _tally[stage] = [units done, units planned].
131
+ var _budget_us := 0
132
+ var _check_end := 0
133
+ var _pending: Array = []
134
+ var _tally: Dictionary = {}
135
+
136
+ var _re_offset: RegEx = null
137
+ var _re_alpha: RegEx = null
138
+
139
+ # z_fight geometry: finalized face groups per mesh (instance id), viewpoints
140
+ # [[position, kind]], the main camera's near / far, floors for eye points.
141
+ var _zf_groups: Dictionary = {}
142
+ var _views: Array = []
143
+ var _near := 0.05
144
+ var _far := 4000.0
145
+ var _walk: Array = []
146
+
147
+
148
+ func _ready() -> void:
149
+ _result = {"ok": false, "stage": "ready", "warnings": [], "errors": []}
150
+ var script_path := String((get_script() as Script).resource_path)
151
+ var wrapper_path := script_path.get_slice("::", 0)
152
+ _out_dir = wrapper_path.get_base_dir()
153
+ if wrapper_path == "" or not wrapper_path.ends_with("wrapper.tscn"):
154
+ push_error("Summer scene audit: cannot locate its wrapper scene (script path '%s')." % script_path)
155
+ return
156
+ var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(_out_dir.path_join("config.json")))
157
+ if typeof(parsed) != TYPE_DICTIONARY:
158
+ _fail("config_unreadable", "Could not read the audit config.")
159
+ return
160
+ _cfg = parsed
161
+ _result["renderer"] = RenderingServer.get_current_rendering_method()
162
+ _subject = get_parent().get_node_or_null(SUBJECT_NAME)
163
+ if _subject == null:
164
+ _fail("no_subject", "The wrapper scene has no Subject instance (the target scene failed to instantiate).")
165
+ return
166
+ get_viewport().disable_3d = true
167
+ _result["stage"] = "configured"
168
+ _write_result()
169
+ # Deferred so CSG roots and @tool scripts that settle with call_deferred
170
+ # have run first. ScenePreview iterates the main loop, which flushes this.
171
+ call_deferred("_run")
172
+
173
+
174
+ func _exit_tree() -> void:
175
+ _free_physics()
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Plumbing
180
+ # ---------------------------------------------------------------------------
181
+
182
+ func _fail(reason: String, message: String) -> void:
183
+ _result["ok"] = false
184
+ _result["failure_reason"] = reason
185
+ (_result["errors"] as Array).append(message)
186
+ _write_result()
187
+
188
+
189
+ func _warn(message: String) -> void:
190
+ var w: Array = _result["warnings"]
191
+ if w.size() < 24 and not w.has(message):
192
+ w.append(message)
193
+
194
+
195
+ func _write_result() -> void:
196
+ var f := FileAccess.open(_out_dir.path_join("result.json"), FileAccess.WRITE)
197
+ if f == null:
198
+ return
199
+ f.store_string(JSON.stringify(_result))
200
+ f.close()
201
+
202
+
203
+ func _lap(stage: String, since: int) -> int:
204
+ var now := Time.get_ticks_usec()
205
+ _ms[stage] = snappedf(float(now - since) / 1000.0, 0.1)
206
+ return now
207
+
208
+
209
+ # Start a check: its deadline is its weighted share of the budget left.
210
+ func _begin(stage: String) -> void:
211
+ # Two tiers: the gap detectors run last and share what the other checks
212
+ # leave, so adding them never shortens another check's share.
213
+ var tier2 := GAP_STAGES.has(stage)
214
+ var w_all := 0.0
215
+ for s in _pending:
216
+ if GAP_STAGES.has(s) == tier2:
217
+ w_all += float(STAGE_WEIGHT.get(s, 1.0))
218
+ var w := float(STAGE_WEIGHT.get(stage, 1.0))
219
+ _pending.erase(stage)
220
+ if _budget_us <= 0:
221
+ _check_end = 0
222
+ return
223
+ var now := Time.get_ticks_usec()
224
+ var left := maxi(0, _budget_us - (now - _t0))
225
+ var share := int(float(left) * w / maxf(w_all, w))
226
+ # Every check gets a little time, even when setup used up the budget.
227
+ _check_end = now + maxi(share, int(float(_budget_us) / 30.0))
228
+
229
+
230
+ # The running check is past its share of the budget.
231
+ func _over() -> bool:
232
+ return _check_end > 0 and Time.get_ticks_usec() > _check_end
233
+
234
+
235
+ func _count(stage: String, done: int, total: int) -> void:
236
+ var tl: Array = _tally.get(stage, [0, 0])
237
+ _tally[stage] = [int(tl[0]) + done, int(tl[1]) + total]
238
+
239
+
240
+ static func _a3(v: Vector3, step := 0.001) -> Array:
241
+ return [snappedf(v.x, step), snappedf(v.y, step), snappedf(v.z, step)]
242
+
243
+
244
+ static func _b9(b: Basis) -> Array:
245
+ return [snappedf(b.x.x, 0.0001), snappedf(b.x.y, 0.0001), snappedf(b.x.z, 0.0001), snappedf(b.y.x, 0.0001), snappedf(b.y.y, 0.0001), snappedf(b.y.z, 0.0001), snappedf(b.z.x, 0.0001), snappedf(b.z.y, 0.0001), snappedf(b.z.z, 0.0001)]
246
+
247
+
248
+ func _rel(node: Node) -> String:
249
+ if node == _subject:
250
+ return "."
251
+ return String(_subject.get_path_to(node))
252
+
253
+
254
+ func _in_root(node: Node) -> bool:
255
+ return _report_root == null or node == _report_root or _report_root.is_ancestor_of(node)
256
+
257
+
258
+ func _resolve(path: String) -> Node:
259
+ var p := path.strip_edges()
260
+ if p == "" or p == ".":
261
+ return _subject
262
+ var n := _subject.get_node_or_null(NodePath(p))
263
+ var root_name := String(_subject.name)
264
+ if n == null and p.begins_with(root_name + "/"):
265
+ n = _subject.get_node_or_null(NodePath(p.substr(root_name.length() + 1)))
266
+ return n
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # Run
271
+ # ---------------------------------------------------------------------------
272
+
273
+ func _run() -> void:
274
+ _t0 = Time.get_ticks_usec()
275
+ var t := _t0
276
+ _result["stage"] = "collect"
277
+ for c in (_cfg.get("checks", []) as Array):
278
+ _checks[String(c)] = true
279
+ _want_poses = bool(_cfg.get("poses", false))
280
+ _budget_us = int(float(_cfg.get("budget_ms", 0)) * 1000.0)
281
+ var root_path := String(_cfg.get("root", ""))
282
+ if root_path != "" and root_path != ".":
283
+ _report_root = _resolve(root_path)
284
+ if _report_root == null:
285
+ _fail("node_not_found", "root node not found in the scene: " + root_path)
286
+ return
287
+ _re_offset = RegEx.create_from_string(DEPTH_OFFSET_RE)
288
+ _re_alpha = RegEx.create_from_string(ALPHA_CODE_RE)
289
+
290
+ _collect()
291
+ t = _lap("collect", t)
292
+ # Roles need the meshes' face areas; see-through cards (dressing) are
293
+ # decided from their materials first and skip the mesh pass.
294
+ for rec in _inst:
295
+ if _see_through(rec):
296
+ rec["role"] = "dressing"
297
+ rec["why"] = "see_through"
298
+ _mesh_pass()
299
+ t = _lap("mesh_pass", t)
300
+ var sheets := _classify_shapes()
301
+ t = _lap("roles", t)
302
+
303
+ var need_physics := false
304
+ for c in ["through_hole", "floor_gap", "floating", "sunken", "interpenetration", "orientation", "uv_stretch", "z_fight", "exposed_edge", "open_fixture_end", "depth_step"]:
305
+ if _checks.has(c):
306
+ need_physics = true
307
+ if need_physics:
308
+ _build_space()
309
+ t = _lap("physics_build", t)
310
+ if _state == null:
311
+ _fail("physics_unavailable", "Could not create a private physics space for the audit.")
312
+ return
313
+ _q = PhysicsRayQueryParameters3D.new()
314
+ _q.collide_with_areas = false
315
+ _q.collide_with_bodies = true
316
+ _classify_rest(sheets)
317
+ var t_roles := Time.get_ticks_usec()
318
+ _ms["roles"] = snappedf(float(_ms.get("roles", 0.0)) + float(t_roles - t) / 1000.0, 0.1)
319
+ t = t_roles
320
+
321
+ _result["stage"] = "checks"
322
+ var run_th := _checks.has("through_hole") or _checks.has("z_fight")
323
+ var run_fl := _checks.has("floor_gap") or _checks.has("z_fight")
324
+ var run_su := _checks.has("floating") or _checks.has("sunken")
325
+ var run_po := _want_poses and _state != null
326
+ var run_zg := _checks.has("z_fight") and _state != null
327
+ var run_ee := _checks.has("exposed_edge") and _state != null
328
+ var run_fe := _checks.has("open_fixture_end") and _state != null
329
+ var run_ds := _checks.has("depth_step") and _state != null
330
+ var run_gk := run_ee or run_fe or run_ds
331
+ for stage_on in [["through_hole", run_th], ["floor_gap", run_fl], ["floating_sunken", run_su], ["interpenetration", _checks.has("interpenetration")], ["orientation", _checks.has("orientation")], ["uv_stretch", _checks.has("uv_stretch")], ["z_fight_geometry", run_zg], ["lights", _checks.has("lights")], ["resource", _checks.has("resource")], ["poses", run_po], ["gap_setup", run_gk], ["open_fixture_end", run_fe], ["exposed_edge", run_ee], ["depth_step", run_ds]]:
332
+ if bool(stage_on[1]):
333
+ _pending.append(String(stage_on[0]))
334
+ if run_th:
335
+ _begin("through_hole")
336
+ _result["lines"] = _scan_facades()
337
+ t = _lap("through_hole", t)
338
+ if run_fl:
339
+ _begin("floor_gap")
340
+ _result["floors"] = _scan_floors()
341
+ t = _lap("floor_gap", t)
342
+ if run_su:
343
+ _begin("floating_sunken")
344
+ _result["support"] = _scan_support()
345
+ t = _lap("floating_sunken", t)
346
+ if _checks.has("interpenetration"):
347
+ _begin("interpenetration")
348
+ _result["overlaps"] = _scan_overlaps()
349
+ t = _lap("interpenetration", t)
350
+ if _checks.has("orientation"):
351
+ _begin("orientation")
352
+ _result["long_props"] = _scan_long_props()
353
+ t = _lap("orientation", t)
354
+ if _checks.has("uv_stretch"):
355
+ _begin("uv_stretch")
356
+ _result["uv"] = _scan_uv()
357
+ t = _lap("uv_stretch", t)
358
+ if run_zg:
359
+ _begin("z_fight_geometry")
360
+ _result["zfight_geo"] = _scan_zfight_geometry()
361
+ t = _lap("z_fight_geometry", t)
362
+ if _checks.has("lights"):
363
+ _begin("lights")
364
+ _result["lights"] = _scan_lights()
365
+ t = _lap("lights", t)
366
+ if _checks.has("resource"):
367
+ _begin("resource")
368
+ _result["resources"] = _scan_resources()
369
+ t = _lap("resource", t)
370
+ if run_po:
371
+ _begin("poses")
372
+ _scan_clearances()
373
+ t = _lap("poses", t)
374
+ if run_gk:
375
+ _begin("gap_setup")
376
+ _gk_setup()
377
+ _result["gk"] = {"eyes": _gk_eyes.size(), "indoor_cells": _gk_indoor.size(), "floor_y": snappedf(_gk_floor_y, 0.001), "lines": _gk_lines_rows(), "doors": _gk_doors()}
378
+ t = _lap("gap_setup", t)
379
+ if run_fe:
380
+ _begin("open_fixture_end")
381
+ _result["fixture_ends"] = _scan_fixture_ends()
382
+ t = _lap("open_fixture_end", t)
383
+ if run_ee:
384
+ _begin("exposed_edge")
385
+ _result["exposed"] = _scan_exposed_edges()
386
+ t = _lap("exposed_edge", t)
387
+ if run_ds:
388
+ _begin("depth_step")
389
+ _result["depth_steps"] = _scan_depth_steps()
390
+ t = _lap("depth_step", t)
391
+ # Checks a budget stopped early: [units done, units planned].
392
+ var partial: Dictionary = {}
393
+ for stage in _tally:
394
+ var tl: Array = _tally[stage]
395
+ if int(tl[0]) < int(tl[1]):
396
+ partial[stage] = [int(tl[0]), int(tl[1])]
397
+ _result["partial"] = partial
398
+ _result["instances"] = _instance_rows()
399
+ t = _lap("emit", t)
400
+ _ms["total"] = snappedf(float(Time.get_ticks_usec() - _t0) / 1000.0, 0.1)
401
+ _result["ms"] = _ms
402
+ _result["stats"] = {
403
+ "nodes": _nodes, "hidden_skipped": _hidden, "instances": _inst.size(), "mesh_instances": _count_meshes(),
404
+ "unique_meshes": _mesh_info.size(), "tris_unique": _tris_unique(), "bodies": _bodies.size(), "lights": _lights.size(),
405
+ "multimesh_skipped": _multimesh, "screen_space_skipped": _screen_space, "rays": _rays,
406
+ }
407
+ _result["roles"] = _role_stats
408
+ # Look items the agent accepted (written by the MCP side, read-only here).
409
+ var accept_path := "res://.summer/audit-accept.json"
410
+ if FileAccess.file_exists(accept_path):
411
+ var acc: Variant = JSON.parse_string(FileAccess.get_file_as_string(accept_path))
412
+ if typeof(acc) == TYPE_DICTIONARY:
413
+ _result["accept"] = acc
414
+ _result["ok"] = true
415
+ _result["stage"] = "done"
416
+ _write_result()
417
+ _free_physics()
418
+
419
+
420
+ func _count_meshes() -> int:
421
+ var n := 0
422
+ for rec in _inst:
423
+ n += (rec["meshes"] as Array).size()
424
+ return n
425
+
426
+
427
+ func _tris_unique() -> int:
428
+ var n := 0
429
+ for k in _mesh_info:
430
+ n += int((_mesh_info[k] as Dictionary).get("tris", 0))
431
+ return n
432
+
433
+
434
+ # ---------------------------------------------------------------------------
435
+ # Collect: every node -> kit instances (outermost instanced scenes) and loose
436
+ # geometry, lights, empty meshes.
437
+ # ---------------------------------------------------------------------------
438
+
439
+ func _collect() -> void:
440
+ var loose: Dictionary = {}
441
+ var stack: Array = [[_subject, -1]]
442
+ while not stack.is_empty():
443
+ var item: Array = stack.pop_back()
444
+ var node: Node = item[0]
445
+ var owner_i: int = item[1]
446
+ _nodes += 1
447
+ if node is Node3D and not (node as Node3D).visible:
448
+ _hidden += 1
449
+ continue
450
+ if node != _subject and owner_i < 0 and node.scene_file_path != "":
451
+ owner_i = _new_inst(node, node.scene_file_path)
452
+ if node is Camera3D:
453
+ _cameras.append(node)
454
+ elif node is CharacterBody3D and _players.size() < 16:
455
+ _players.append(node)
456
+ if node is Light3D:
457
+ _lights.append(node)
458
+ elif node is MeshInstance3D:
459
+ var mi := node as MeshInstance3D
460
+ # Screen-space effects (a post-process quad, never culled) are not
461
+ # scene geometry: they sit "at the origin" and "inside" whatever is there.
462
+ if mi.extra_cull_margin >= 1000.0 or mi.custom_aabb.size.length() > 1000.0:
463
+ _screen_space += 1
464
+ continue
465
+ if mi.mesh == null or mi.mesh.get_surface_count() == 0:
466
+ if _empty_meshes.size() < 64:
467
+ _empty_meshes.append([_rel(mi), owner_i])
468
+ else:
469
+ var oi := owner_i if owner_i >= 0 else _loose_owner(mi, loose)
470
+ (_inst[oi]["meshes"] as Array).append([mi, mi.mesh, mi.global_transform])
471
+ elif node is CSGShape3D:
472
+ var csg := node as CSGShape3D
473
+ if csg.is_root_shape():
474
+ var meshes: Array = csg.get_meshes()
475
+ if meshes.size() >= 2 and meshes[1] is Mesh:
476
+ var oi2 := owner_i if owner_i >= 0 else _loose_owner(csg, loose)
477
+ (_inst[oi2]["meshes"] as Array).append([csg, meshes[1], csg.global_transform * Transform3D(meshes[0])])
478
+ continue
479
+ elif node is MultiMeshInstance3D:
480
+ _multimesh += 1
481
+ for child in node.get_children():
482
+ stack.push_back([child, owner_i])
483
+ var kept: Array = []
484
+ for rec in _inst:
485
+ if (rec["meshes"] as Array).is_empty():
486
+ continue
487
+ rec["i"] = kept.size()
488
+ kept.append(rec)
489
+ _inst = kept
490
+ for rec in _inst:
491
+ _bounds(rec)
492
+
493
+
494
+ func _new_inst(node: Node, scene: String) -> int:
495
+ var xf := Transform3D.IDENTITY
496
+ if node is Node3D:
497
+ xf = (node as Node3D).global_transform
498
+ var path := _rel(node)
499
+ _inst.append({
500
+ "i": _inst.size(), "node": node, "path": path, "scene": scene, "piece": scene.get_file().get_basename(),
501
+ "name": String(node.name), "xf": xf, "meshes": [], "bodies": [], "role": "", "in": _in_root(node),
502
+ "top": path.get_slice("/", 0),
503
+ })
504
+ return _inst.size() - 1
505
+
506
+
507
+ func _loose_owner(node: Node3D, loose: Dictionary) -> int:
508
+ var key: Node = node
509
+ var parent := node.get_parent()
510
+ if parent is CollisionObject3D and parent != _subject:
511
+ key = parent
512
+ if loose.has(key):
513
+ return int(loose[key])
514
+ var i := _new_inst(key, "")
515
+ _inst[i]["piece"] = String(key.name)
516
+ loose[key] = i
517
+ return i
518
+
519
+
520
+ static func _corners(box: AABB) -> Array:
521
+ var p := box.position
522
+ var s := box.size
523
+ return [p, p + Vector3(s.x, 0, 0), p + Vector3(0, s.y, 0), p + Vector3(0, 0, s.z), p + Vector3(s.x, s.y, 0), p + Vector3(s.x, 0, s.z), p + Vector3(0, s.y, s.z), p + s]
524
+
525
+
526
+ func _bounds(rec: Dictionary) -> void:
527
+ var xf: Transform3D = rec["xf"]
528
+ var inv := xf.affine_inverse()
529
+ var la := AABB()
530
+ var wa := AABB()
531
+ var first := true
532
+ for m in (rec["meshes"] as Array):
533
+ var mesh: Mesh = m[1]
534
+ var mxf: Transform3D = m[2]
535
+ var mb := mesh.get_aabb()
536
+ var lx := inv * mxf
537
+ for c in _corners(mb):
538
+ var lp: Vector3 = lx * c
539
+ var wp: Vector3 = mxf * c
540
+ if first:
541
+ la = AABB(lp, Vector3.ZERO)
542
+ wa = AABB(wp, Vector3.ZERO)
543
+ first = false
544
+ else:
545
+ la = la.expand(lp)
546
+ wa = wa.expand(wp)
547
+ rec["laabb"] = la
548
+ rec["waabb"] = wa
549
+
550
+
551
+ # ---------------------------------------------------------------------------
552
+ # Roles, from geometry and engine data only: never a node or scene name, never
553
+ # metadata. In this order:
554
+ # - dressing: every surface the piece draws is see-through (an alpha-tested or
555
+ # alpha-blended material: foliage cards, overlay sheets). Drawn, not solid:
556
+ # it stays out of the physics space.
557
+ # - flat (upright, at most SLAB_THICK thick, at least SLAB_MIN across both
558
+ # ways): a slab when its upward faces cover SLAB_COVER of its footprint,
559
+ # else ground cover (struct: scattered pebbles, a grate). A slab is a floor;
560
+ # a roof (struct) when it is raised 2 m or more above the lowest slab and a
561
+ # wall stands under it (rising from at least 1 m below its underside to
562
+ # within 1.5 m of it, or up to 1.5 m above it: a parapet, within 10 cm of
563
+ # its footprint); the underlay when it is 8x the median floor and below the
564
+ # floor level.
565
+ # - sheet (upright, at least SHEET_H tall and SHEET_W wide, at most
566
+ # SHEET_THICK thick): a wall facing along its thin axis, to the side with
567
+ # more outward face area.
568
+ # - insert: a piece inside a larger wall's rectangle (80% of its own) that
569
+ # reaches at least 3 cm through the wall's front plane where the wall
570
+ # itself is open (rays through its rectangle miss the wall): a window, door
571
+ # or shutter in its opening. A piece pushed into a solid wall is not one.
572
+ # (Checks that build no physics space skip the opening test.)
573
+ # - facade member (struct): walls on one plane form a facade line; a piece
574
+ # whose bounds touch a wall of the line (crossing or within MEMBER_TOUCH of
575
+ # its slab, overlapping it along the line and in height) and stick out at
576
+ # most MEMBER_REACH is
577
+ # - a band: a run of such pieces at one height (bottoms and tops within
578
+ # 5 cm, end to end within 5 cm), at most BAND_MAX_H tall and BAND_MAX_D
579
+ # deep, together at least BAND_MIN_LEN and BAND_ASPECT x its height long;
580
+ # - a pier: at least BAND_MIN_LEN tall and BAND_ASPECT x taller than wide,
581
+ # at most 1 m wide;
582
+ # - a joint: a piece touching a band at that band's height (bottom within
583
+ # 10 cm, top within 15 cm) and sticking out at most 30 cm further: a
584
+ # corner or end block.
585
+ # - anything else: a prop up to PROP_MAX on its longest side, else struct.
586
+ # rec.why keeps the evidence that set each role; result.roles counts them.
587
+ # ---------------------------------------------------------------------------
588
+
589
+ # Every surface the instance draws uses a see-through material.
590
+ func _see_through(rec: Dictionary) -> bool:
591
+ var any := false
592
+ for m in (rec["meshes"] as Array):
593
+ var node: Node = m[0]
594
+ var mesh: Mesh = m[1]
595
+ for s in mesh.get_surface_count():
596
+ any = true
597
+ if not _mat_see_through(_surface_mat(node, mesh, s)):
598
+ return false
599
+ return any
600
+
601
+
602
+ # Alpha-tested or alpha-blended: BaseMaterial3D transparency (alpha, scissor,
603
+ # hash, depth pre-pass), or a shader that writes ALPHA, discards or blends.
604
+ func _mat_see_through(mat: Variant) -> bool:
605
+ if mat is BaseMaterial3D:
606
+ return (mat as BaseMaterial3D).transparency != BaseMaterial3D.TRANSPARENCY_DISABLED
607
+ if mat is ShaderMaterial:
608
+ var sh: Shader = (mat as ShaderMaterial).shader
609
+ return sh != null and _re_alpha.search(sh.code) != null
610
+ return false
611
+
612
+
613
+ # Size in the instance's own scaled frame when that frame stands upright
614
+ # (local +Y along world +Y or -Y, so local x and z are horizontal), else the
615
+ # world bounds.
616
+ func _shape_of(rec: Dictionary) -> void:
617
+ var xf: Transform3D = rec["xf"]
618
+ var la: AABB = rec["laabb"]
619
+ var b := xf.basis
620
+ var sc := Vector3(b.x.length(), b.y.length(), b.z.length())
621
+ var up := sc.y > 0.000001 and absf(b.y.y / sc.y) >= 0.98
622
+ rec["upright"] = up
623
+ rec["dims"] = Vector3(la.size.x * sc.x, la.size.y * sc.y, la.size.z * sc.z) if up else (rec["waabb"] as AABB).size
624
+
625
+
626
+ # Outward face area per instance axis [+x, -x, +y, -y, +z, -z] (m2), from
627
+ # each mesh's cached areas mapped through the mesh's transform.
628
+ func _inst_axis_area(rec: Dictionary) -> PackedFloat32Array:
629
+ var out := PackedFloat32Array([0, 0, 0, 0, 0, 0])
630
+ var inv := (rec["xf"] as Transform3D).affine_inverse()
631
+ for m in (rec["meshes"] as Array):
632
+ var info: Dictionary = _mesh_info.get((m[1] as Mesh).get_instance_id(), {})
633
+ var areas: PackedFloat32Array = info.get("axis_area", PackedFloat32Array())
634
+ if areas.size() < 6:
635
+ continue
636
+ var mb: Basis = (m[2] as Transform3D).basis
637
+ var rel: Basis = (inv * (m[2] as Transform3D)).basis
638
+ var cols := [mb.x.length(), mb.y.length(), mb.z.length()]
639
+ for a in 3:
640
+ var f: float = float(cols[0]) * float(cols[1]) * float(cols[2]) / maxf(float(cols[a]), 0.000001)
641
+ for sgn in 2:
642
+ var e := Vector3.ZERO
643
+ e[a] = 1.0 if sgn == 0 else -1.0
644
+ var d := rel * e
645
+ var k := 0 if absf(d.x) >= absf(d.y) and absf(d.x) >= absf(d.z) else (1 if absf(d.y) >= absf(d.z) else 2)
646
+ out[k * 2 + (0 if d[k] > 0.0 else 1)] += areas[a * 2 + sgn] * f
647
+ return out
648
+
649
+
650
+ func _role(rec: Dictionary, role: String, why: String) -> void:
651
+ rec["role"] = role
652
+ rec["why"] = why
653
+
654
+
655
+ # First pass, before the physics space exists: slabs, roofs, the underlay and
656
+ # sheets. Returns the sheets.
657
+ func _classify_shapes() -> Array:
658
+ var sheets: Array = []
659
+ for rec in _inst:
660
+ _shape_of(rec)
661
+ rec["front"] = Vector3.ZERO
662
+ if rec["role"] == "dressing":
663
+ continue
664
+ var d: Vector3 = rec["dims"]
665
+ if bool(rec["upright"]) and d.y <= SLAB_THICK and d.x >= SLAB_MIN and d.z >= SLAB_MIN:
666
+ var ar := _inst_axis_area(rec)
667
+ var upk := 2 if (rec["xf"] as Transform3D).basis.y.y > 0.0 else 3
668
+ if ar[upk] >= SLAB_COVER * d.x * d.z:
669
+ _role(rec, "floor", "slab")
670
+ else:
671
+ # Flat but open (scattered pebbles, leaves, a grate): ground
672
+ # cover, part of the ground, not a floor and not a prop.
673
+ _role(rec, "struct", "flat")
674
+ continue
675
+ if bool(rec["upright"]) and d.y >= SHEET_H and maxf(d.x, d.z) >= SHEET_W and minf(d.x, d.z) <= SHEET_THICK:
676
+ _role(rec, "wall", "sheet")
677
+ _sheet_front(rec)
678
+ sheets.append(rec)
679
+ var ground := INF
680
+ for rec in _inst:
681
+ if rec["role"] == "floor":
682
+ ground = minf(ground, (rec["waabb"] as AABB).end.y)
683
+ for rec in _inst:
684
+ if rec["role"] == "floor" and _walls_under(rec, sheets, ground):
685
+ _role(rec, "struct", "roof")
686
+ _resolve_underlays()
687
+ return sheets
688
+
689
+
690
+ # Second pass (the physics space, when there is one, holds every solid piece
691
+ # on a provisional layer): inserts, facade lines, facade members, the rest by
692
+ # size; then every body gets its role's layer.
693
+ func _classify_rest(sheets: Array) -> void:
694
+ _find_inserts(sheets)
695
+ _find_members()
696
+ for rec in _inst:
697
+ if rec["role"] == "":
698
+ var wa: AABB = rec["waabb"]
699
+ _role(rec, "struct" if maxf(wa.size.x, maxf(wa.size.y, wa.size.z)) > PROP_MAX else "prop", "size")
700
+ for rec in _inst:
701
+ var k := String(rec["role"]) + ":" + String(rec.get("why", ""))
702
+ _role_stats[k] = int(_role_stats.get(k, 0)) + 1
703
+ _layers()
704
+
705
+
706
+ # Every body on its role's layer (pieces without a role yet: the prop layer).
707
+ func _layers() -> void:
708
+ if _state == null:
709
+ return
710
+ for rec in _inst:
711
+ var role := String(rec["role"])
712
+ var layer := int(ROLE_LAYER.get(role, L_PROP if role == "" else 0))
713
+ for b in (rec["bodies"] as Array):
714
+ PhysicsServer3D.body_set_collision_layer(b, layer)
715
+
716
+
717
+ # The local axis nearest a local direction, as a unit vector.
718
+ static func _axis_snap(v: Vector3) -> Vector3:
719
+ var k := 0 if absf(v.x) >= absf(v.y) and absf(v.x) >= absf(v.z) else (1 if absf(v.y) >= absf(v.z) else 2)
720
+ var out := Vector3.ZERO
721
+ out[k] = 1.0 if v[k] >= 0.0 else -1.0
722
+ return out
723
+
724
+
725
+ # A sheet faces along its thin axis, to the side with more outward area.
726
+ func _sheet_front(rec: Dictionary) -> void:
727
+ var d: Vector3 = rec["dims"]
728
+ var axis := 2 if d.z <= d.x else 0
729
+ var ar := _inst_axis_area(rec)
730
+ var v := Vector3.ZERO
731
+ v[axis] = 1.0 if ar[axis * 2] >= ar[axis * 2 + 1] else -1.0
732
+ rec["front"] = v
733
+
734
+
735
+ # A roof: the slab is raised at least 2 m above the lowest slab (the ground)
736
+ # and walls stand under it: one rises from at least 1 m below its underside to
737
+ # within 1.5 m of it (or up to 1.5 m above: a parapet), within 10 cm of its
738
+ # footprint.
739
+ func _walls_under(rec: Dictionary, sheets: Array, ground: float) -> bool:
740
+ var wa: AABB = rec["waabb"]
741
+ var bottom := wa.position.y
742
+ if bottom < ground + 2.0:
743
+ return false
744
+ var foot := AABB(Vector3(wa.position.x - 0.1, -1.0e6, wa.position.z - 0.1), Vector3(wa.size.x + 0.2, 2.0e6, wa.size.z + 0.2))
745
+ for w in sheets:
746
+ var ww: AABB = w["waabb"]
747
+ if ww.end.y < bottom - 1.5 or ww.end.y > wa.end.y + 1.5 or ww.position.y > bottom - 1.0:
748
+ continue
749
+ if foot.intersects(ww):
750
+ return true
751
+ return false
752
+
753
+
754
+ # Inserts: see the section comment. Without a physics space (only checks
755
+ # that need none) the opening test is skipped.
756
+ func _find_inserts(sheets: Array) -> void:
757
+ for w in sheets:
758
+ var pl := _wall_plane(w)
759
+ if pl.is_empty():
760
+ continue
761
+ w["plane"] = pl
762
+ for rec in _inst:
763
+ var role := String(rec["role"])
764
+ if role == "dressing" or role == "floor" or role == "underlay" or (role == "struct" and String(rec.get("why", "")) == "roof"):
765
+ continue
766
+ var pa: AABB = rec["waabb"]
767
+ for w in sheets:
768
+ if w == rec or not w.has("plane") or String(w["role"]) != "wall":
769
+ continue
770
+ if not pa.intersects((w["waabb"] as AABB).grow(0.05)):
771
+ continue
772
+ var pl: Dictionary = w["plane"]
773
+ var n: Vector3 = pl["n"]
774
+ var tax := Vector3(n.z, 0, -n.x)
775
+ var wr := _rect(w, tax)
776
+ var r := _rect(rec, tax)
777
+ var area: float = (r[1] - r[0]) * (r[3] - r[2])
778
+ var warea: float = (wr[1] - wr[0]) * (wr[3] - wr[2])
779
+ if area <= 0.0 or area > 0.9 * warea:
780
+ continue
781
+ var ot: float = minf(r[1], wr[1]) - maxf(r[0], wr[0])
782
+ var oy: float = minf(r[3], wr[3]) - maxf(r[2], wr[2])
783
+ if ot <= 0.0 or oy <= 0.0 or ot * oy < 0.8 * area:
784
+ continue
785
+ var nr := _span(rec, n)
786
+ var df := float(pl["d"])
787
+ if nr[0] > df - 0.03 or nr[1] > df + MEMBER_REACH:
788
+ continue
789
+ if _state != null and not _open_at(w, rec, n, tax, r, df - float(pl["thick"])):
790
+ continue
791
+ _role(rec, "insert", "in_opening")
792
+ rec["front"] = _axis_snap((rec["xf"] as Transform3D).basis.inverse() * n)
793
+ rec["host"] = int(w["i"])
794
+ break
795
+
796
+
797
+ # The extent of an instance's bounds along a world direction: [min, max].
798
+ func _span(rec: Dictionary, dir: Vector3) -> Array:
799
+ var la: AABB = rec["laabb"]
800
+ var xf: Transform3D = rec["xf"]
801
+ var lo := INF
802
+ var hi := -INF
803
+ for c in _corners(la):
804
+ var v := dir.dot(xf * c)
805
+ lo = minf(lo, v)
806
+ hi = maxf(hi, v)
807
+ return [lo, hi]
808
+
809
+
810
+ # Rays through the piece's rectangle (its centre and a quarter in from each
811
+ # side along the wall) miss the wall itself at two of the three points: the
812
+ # wall is open there.
813
+ func _open_at(w: Dictionary, rec: Dictionary, n: Vector3, tax: Vector3, r: Array, back: float) -> bool:
814
+ var probe_layer := 1 << 19
815
+ for b in (w["bodies"] as Array):
816
+ PhysicsServer3D.body_set_collision_layer(b, probe_layer)
817
+ var front := float((w["plane"] as Dictionary)["d"])
818
+ var yc: float = (float(r[2]) + float(r[3])) * 0.5
819
+ var open := 0
820
+ for f in [0.5, 0.25, 0.75]:
821
+ var t: float = float(r[0]) + (float(r[1]) - float(r[0])) * float(f)
822
+ var p := tax * t + Vector3(0, yc, 0)
823
+ var hit := _ray(p + n * (front + 0.05), p + n * (back - 0.05), probe_layer, [], true)
824
+ if hit.is_empty():
825
+ open += 1
826
+ for b in (w["bodies"] as Array):
827
+ PhysicsServer3D.body_set_collision_layer(b, L_WALL)
828
+ return open >= 2
829
+
830
+
831
+ # Facade lines: walls whose fronts share a plane (normals within 2 deg, planes
832
+ # within 20 cm). [{n, d, thick, walls}]
833
+ func _facade_lines() -> Array:
834
+ var lines: Array = []
835
+ for rec in _inst:
836
+ if rec["role"] != "wall":
837
+ continue
838
+ var pl := _wall_plane(rec)
839
+ if pl.is_empty():
840
+ continue
841
+ var n: Vector3 = pl["n"]
842
+ var placed := false
843
+ for line in lines:
844
+ var ln: Vector3 = line["n"]
845
+ if ln.dot(n) > 0.9994 and absf(float(pl["d"]) - float(line["d"])) < 0.2:
846
+ (line["walls"] as Array).append(rec)
847
+ line["thick"] = maxf(float(line["thick"]), float(pl["thick"]))
848
+ placed = true
849
+ break
850
+ if not placed:
851
+ lines.append({"n": n, "d": float(pl["d"]), "thick": float(pl["thick"]), "walls": [rec]})
852
+ return lines
853
+
854
+
855
+ # Facade members (see the section comment): pieces whose bounds touch a
856
+ # facade line's wall.
857
+ func _find_members() -> void:
858
+ var lines := _facade_lines()
859
+ for line in lines:
860
+ var n: Vector3 = line["n"]
861
+ var tax := Vector3(n.z, 0, -n.x)
862
+ line["t"] = tax
863
+ var rects: Array = []
864
+ var zone := AABB()
865
+ for w in (line["walls"] as Array):
866
+ rects.append(_rect(w, tax))
867
+ zone = (w["waabb"] as AABB) if zone.size == Vector3.ZERO else zone.merge(w["waabb"] as AABB)
868
+ line["rects"] = rects
869
+ line["zone"] = zone.grow(MEMBER_REACH)
870
+ var cand: Array = []
871
+ for rec in _inst:
872
+ if rec["role"] != "" or not bool(rec["upright"]):
873
+ continue
874
+ var d: Vector3 = rec["dims"]
875
+ var long := maxf(d.x, d.z)
876
+ var short := minf(d.x, d.z)
877
+ var pier := d.y >= BAND_MIN_LEN and d.y >= BAND_ASPECT * long and long <= 1.0
878
+ var band := d.y <= BAND_MAX_H and short <= BAND_MAX_D and long >= d.y
879
+ if not pier and not band:
880
+ continue
881
+ var c := _touch_line(rec, lines)
882
+ if c.is_empty():
883
+ continue
884
+ var n: Vector3 = c[0]
885
+ if pier:
886
+ _member(rec, "pier", n)
887
+ continue
888
+ var tax := Vector3(n.z, 0, -n.x)
889
+ var wa: AABB = rec["waabb"]
890
+ var r := _span(rec, tax)
891
+ cand.append([rec, n, float(c[1]), float(r[0]), float(r[1]), wa.position.y, wa.end.y])
892
+ # Band runs: pieces on one surface (normals within 2 deg, planes within
893
+ # 10 cm) at one height laid end to end.
894
+ var used: Dictionary = {}
895
+ for k in cand.size():
896
+ if used.has(k):
897
+ continue
898
+ var n: Vector3 = cand[k][1]
899
+ var run: Array = [k]
900
+ used[k] = true
901
+ var lo: float = cand[k][3]
902
+ var hi: float = cand[k][4]
903
+ var y0: float = cand[k][5]
904
+ var y1: float = cand[k][6]
905
+ var grew := true
906
+ while grew:
907
+ grew = false
908
+ for j in cand.size():
909
+ if used.has(j):
910
+ continue
911
+ var cj: Array = cand[j]
912
+ if (cj[1] as Vector3).dot(n) < 0.9994 or absf(float(cj[2]) - float(cand[k][2])) > 0.1:
913
+ continue
914
+ if absf(float(cj[5]) - y0) > 0.05 or absf(float(cj[6]) - y1) > 0.05:
915
+ continue
916
+ if float(cj[3]) > hi + 0.05 or float(cj[4]) < lo - 0.05:
917
+ continue
918
+ run.append(j)
919
+ used[j] = true
920
+ lo = minf(lo, float(cj[3]))
921
+ hi = maxf(hi, float(cj[4]))
922
+ grew = true
923
+ if hi - lo < maxf(BAND_MIN_LEN, BAND_ASPECT * (y1 - y0)):
924
+ continue
925
+ for j in run:
926
+ _member(cand[j][0], "band", cand[j][1])
927
+ # Joints: touch a band at its height, stick out at most 30 cm further.
928
+ var bands: Array = []
929
+ for rec in _inst:
930
+ if rec["role"] == "struct" and String(rec.get("why", "")) == "band":
931
+ bands.append(rec)
932
+ for rec in _inst:
933
+ if rec["role"] != "":
934
+ continue
935
+ var pa: AABB = rec["waabb"]
936
+ for b in bands:
937
+ var ba: AABB = b["waabb"]
938
+ if not pa.intersects(ba.grow(MEMBER_TOUCH)):
939
+ continue
940
+ if absf(pa.position.y - ba.position.y) > 0.1 or absf(pa.end.y - ba.end.y) > 0.15:
941
+ continue
942
+ var n: Vector3 = b["n_out"]
943
+ if float(_span(rec, n)[1]) > float(_span(b, n)[1]) + 0.3:
944
+ continue
945
+ _member(rec, "joint", n)
946
+ break
947
+
948
+
949
+ func _member(rec: Dictionary, why: String, n: Vector3) -> void:
950
+ _role(rec, "struct", why)
951
+ rec["n_out"] = n
952
+ rec["front"] = _axis_snap((rec["xf"] as Transform3D).basis.inverse() * n)
953
+
954
+
955
+ # [the line's front, the line's plane] when the piece's bounds touch one of
956
+ # the line's walls: crossing or within MEMBER_TOUCH of the wall's slab,
957
+ # overlapping it along the line and in height (5 cm slack), sticking out at
958
+ # most MEMBER_REACH. [] otherwise.
959
+ func _touch_line(rec: Dictionary, lines: Array) -> Array:
960
+ var wa: AABB = rec["waabb"]
961
+ for line in lines:
962
+ if not (line["zone"] as AABB).intersects(wa):
963
+ continue
964
+ var n: Vector3 = line["n"]
965
+ var d := float(line["d"])
966
+ var nr := _span(rec, n)
967
+ if nr[1] < d - float(line["thick"]) - MEMBER_TOUCH or nr[0] > d + MEMBER_TOUCH or nr[1] > d + MEMBER_REACH:
968
+ continue
969
+ var r := _rect(rec, line["t"] as Vector3)
970
+ for wr in (line["rects"] as Array):
971
+ if r[0] <= float(wr[1]) + 0.05 and r[1] >= float(wr[0]) - 0.05 and r[2] <= float(wr[3]) + 0.05 and r[3] >= float(wr[2]) - 0.05:
972
+ return [n, d]
973
+ return []
974
+
975
+
976
+ func _resolve_underlays() -> void:
977
+ var areas: Array = []
978
+ for rec in _inst:
979
+ if rec["role"] == "floor":
980
+ var wa: AABB = rec["waabb"]
981
+ areas.append(wa.size.x * wa.size.z)
982
+ if areas.size() < 2:
983
+ return
984
+ areas.sort()
985
+ var median: float = areas[areas.size() / 2]
986
+ var tops: Array = []
987
+ for rec in _inst:
988
+ if rec["role"] == "floor":
989
+ tops.append((rec["waabb"] as AABB).end.y)
990
+ tops.sort()
991
+ var median_top: float = tops[tops.size() / 2]
992
+ for rec in _inst:
993
+ if rec["role"] != "floor":
994
+ continue
995
+ var wa: AABB = rec["waabb"]
996
+ if wa.size.x * wa.size.z >= 8.0 * median and wa.end.y < median_top - 0.005:
997
+ _role(rec, "underlay", "below_floors")
998
+
999
+
1000
+ # ---------------------------------------------------------------------------
1001
+ # One pass per mesh RESOURCE (cached by instance id): physics faces in index
1002
+ # order (double-sided surfaces get both windings), outward face area per
1003
+ # local axis (roles), and UV-stretch candidate triangles.
1004
+ # ---------------------------------------------------------------------------
1005
+
1006
+ func _mesh_pass() -> void:
1007
+ var want_uv := _checks.has("uv_stretch")
1008
+ var want_groups := _checks.has("z_fight")
1009
+ for rec in _inst:
1010
+ if rec["role"] == "dressing":
1011
+ continue
1012
+ for m in (rec["meshes"] as Array):
1013
+ var mesh: Mesh = m[1]
1014
+ var key := mesh.get_instance_id()
1015
+ if not _mesh_info.has(key):
1016
+ _mesh_info[key] = _analyze_mesh(mesh, want_uv, want_groups)
1017
+
1018
+
1019
+ func _double_sided(mat: Material) -> bool:
1020
+ if mat is BaseMaterial3D:
1021
+ return (mat as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED
1022
+ if mat is ShaderMaterial:
1023
+ var sh: Shader = (mat as ShaderMaterial).shader
1024
+ if sh == null:
1025
+ return false
1026
+ return sh.code.contains("cull_disabled")
1027
+ return false
1028
+
1029
+
1030
+ func _analyze_mesh(mesh: Mesh, want_uv: bool, want_groups := false) -> Dictionary:
1031
+ var faces := PackedVector3Array()
1032
+ var axis_area := PackedFloat32Array([0, 0, 0, 0, 0, 0])
1033
+ # z_fight: per surface, triangles bucketed by (normal, plane offset):
1034
+ # [surface, material, double-sided, {Vector4i -> [area, normal * area, offset * area, [vertices]]}].
1035
+ var zf_raw: Array = []
1036
+ var uv_cands: Array = []
1037
+ var tris := 0
1038
+ var no_material := 0
1039
+ for s in mesh.get_surface_count():
1040
+ # Only ArrayMesh exposes the primitive; PrimitiveMesh surfaces are triangles.
1041
+ if mesh is ArrayMesh and (mesh as ArrayMesh).surface_get_primitive_type(s) != Mesh.PRIMITIVE_TRIANGLES:
1042
+ continue
1043
+ var arrays := mesh.surface_get_arrays(s)
1044
+ var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
1045
+ if verts.is_empty():
1046
+ continue
1047
+ var mat := mesh.surface_get_material(s)
1048
+ if mat == null:
1049
+ no_material += 1
1050
+ var double := mat != null and _double_sided(mat)
1051
+ var sbins: Dictionary = {}
1052
+ if want_groups:
1053
+ zf_raw.append([s, mat, double, sbins])
1054
+ var idx: PackedInt32Array
1055
+ if arrays[Mesh.ARRAY_INDEX] != null:
1056
+ idx = arrays[Mesh.ARRAY_INDEX]
1057
+ else:
1058
+ idx = PackedInt32Array()
1059
+ idx.resize(verts.size())
1060
+ for k in verts.size():
1061
+ idx[k] = k
1062
+ var uvs := PackedVector2Array()
1063
+ if want_uv and arrays[Mesh.ARRAY_TEX_UV] != null:
1064
+ uvs = arrays[Mesh.ARRAY_TEX_UV]
1065
+ var has_uv := uvs.size() == verts.size()
1066
+ var n := idx.size() - idx.size() % 3
1067
+ var base := faces.size()
1068
+ faces.resize(base + (n * 2 if double else n))
1069
+ var w := base
1070
+ var j := 0
1071
+ while j < n:
1072
+ var i0 := idx[j]
1073
+ var i1 := idx[j + 1]
1074
+ var i2 := idx[j + 2]
1075
+ var p0: Vector3 = verts[i0]
1076
+ var p1: Vector3 = verts[i1]
1077
+ var p2: Vector3 = verts[i2]
1078
+ faces[w] = p0
1079
+ faces[w + 1] = p1
1080
+ faces[w + 2] = p2
1081
+ w += 3
1082
+ if double:
1083
+ faces[w] = p0
1084
+ faces[w + 1] = p2
1085
+ faces[w + 2] = p1
1086
+ w += 3
1087
+ # Plane(p0, p1, p2) normal: the side physics and the renderer call front.
1088
+ var cr: Vector3 = (p0 - p2).cross(p0 - p1)
1089
+ var a2 := cr.length()
1090
+ if a2 > 0.000001:
1091
+ var nn := cr / a2
1092
+ var area := a2 * 0.5
1093
+ if absf(nn.x) > 0.7:
1094
+ axis_area[0 if nn.x > 0 else 1] += area
1095
+ if absf(nn.y) > 0.7:
1096
+ axis_area[2 if nn.y > 0 else 3] += area
1097
+ if absf(nn.z) > 0.7:
1098
+ axis_area[4 if nn.z > 0 else 5] += area
1099
+ if want_groups and area >= ZF_MIN_TRI:
1100
+ var dd := nn.dot(p0)
1101
+ var bk := Vector4i(roundi(nn.x * 30.0), roundi(nn.y * 30.0), roundi(nn.z * 30.0), roundi(dd / 0.004))
1102
+ var bg: Array = sbins.get(bk, [])
1103
+ if bg.is_empty():
1104
+ bg = [0.0, Vector3.ZERO, 0.0, []]
1105
+ sbins[bk] = bg
1106
+ bg[0] = float(bg[0]) + area
1107
+ bg[1] = (bg[1] as Vector3) + nn * area
1108
+ bg[2] = float(bg[2]) + dd * area
1109
+ (bg[3] as Array).append_array([p0, p1, p2])
1110
+ if has_uv and area >= 0.015:
1111
+ var t0: Vector2 = uvs[i0]
1112
+ var u1: Vector2 = uvs[i1] - t0
1113
+ var u2: Vector2 = uvs[i2] - t0
1114
+ var e1 := p1 - p0
1115
+ var e2 := p2 - p0
1116
+ var det := u1.x * u2.y - u2.x * u1.y
1117
+ var ratio := 1000.0
1118
+ if absf(det) > 1e-12:
1119
+ var inv := 1.0 / det
1120
+ var du := (e1 * u2.y - e2 * u1.y) * inv
1121
+ var dv := (e2 * u1.x - e1 * u2.x) * inv
1122
+ var aa := du.dot(du)
1123
+ var bb := dv.dot(dv)
1124
+ var ab := du.dot(dv)
1125
+ var disc := sqrt(maxf(0.0, (aa - bb) * (aa - bb) + 4.0 * ab * ab))
1126
+ var s2 := (aa + bb - disc) * 0.5
1127
+ var s1 := (aa + bb + disc) * 0.5
1128
+ ratio = 1000.0 if s2 <= 1e-14 else sqrt(s1 / s2)
1129
+ if ratio > 4.0:
1130
+ uv_cands.append([area, ratio, p0, p1, p2, uvs[i0], uvs[i1], uvs[i2], nn])
1131
+ j += 3
1132
+ tris += n / 3
1133
+ uv_cands.sort_custom(func(a, b): return float(a[0]) > float(b[0]))
1134
+ if uv_cands.size() > 6:
1135
+ uv_cands.resize(6)
1136
+ return {"faces": faces, "axis_area": axis_area, "zf_raw": zf_raw, "uv": uv_cands, "tris": tris, "no_material": no_material, "shape": RID(), "name": _mesh_name(mesh)}
1137
+
1138
+
1139
+ func _mesh_name(mesh: Mesh) -> String:
1140
+ var p := String(mesh.resource_path)
1141
+ if p != "":
1142
+ return p.get_file()
1143
+ return String(mesh.resource_name) if String(mesh.resource_name) != "" else "mesh"
1144
+
1145
+
1146
+ # ---------------------------------------------------------------------------
1147
+ # Physics space from the visible meshes (dressing excluded).
1148
+ # ---------------------------------------------------------------------------
1149
+
1150
+ static func _is_identity(xf: Transform3D) -> bool:
1151
+ return xf.origin.length() < 0.001 and (xf.basis.x - Vector3(1, 0, 0)).length() < 0.0001 and (xf.basis.y - Vector3(0, 1, 0)).length() < 0.0001 and (xf.basis.z - Vector3(0, 0, 1)).length() < 0.0001
1152
+
1153
+
1154
+ static func _is_rigid(b: Basis) -> bool:
1155
+ 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 \
1156
+ 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
1157
+
1158
+
1159
+ func _build_space() -> void:
1160
+ _space = PhysicsServer3D.space_create()
1161
+ PhysicsServer3D.space_set_active(_space, true)
1162
+ for rec in _inst:
1163
+ # Every solid piece; pieces without a role yet sit on the prop layer
1164
+ # until _classify_rest gives each body its role's layer.
1165
+ if rec["role"] == "dressing":
1166
+ continue
1167
+ var layer := int(ROLE_LAYER.get(String(rec["role"]), L_PROP))
1168
+ for m in (rec["meshes"] as Array):
1169
+ var mesh: Mesh = m[1]
1170
+ var xf: Transform3D = m[2]
1171
+ var info: Dictionary = _mesh_info.get(mesh.get_instance_id(), {})
1172
+ var faces: PackedVector3Array = info.get("faces", PackedVector3Array())
1173
+ if faces.is_empty():
1174
+ continue
1175
+ var shape := RID()
1176
+ var body_xf := xf
1177
+ if _is_rigid(xf.basis):
1178
+ shape = info["shape"]
1179
+ if not shape.is_valid():
1180
+ shape = PhysicsServer3D.concave_polygon_shape_create()
1181
+ PhysicsServer3D.shape_set_data(shape, {"faces": faces, "backface_collision": true})
1182
+ info["shape"] = shape
1183
+ _shapes.append(shape)
1184
+ else:
1185
+ var baked := faces.duplicate()
1186
+ for k in baked.size():
1187
+ baked[k] = xf * baked[k]
1188
+ shape = PhysicsServer3D.concave_polygon_shape_create()
1189
+ PhysicsServer3D.shape_set_data(shape, {"faces": baked, "backface_collision": true})
1190
+ _shapes.append(shape)
1191
+ body_xf = Transform3D.IDENTITY
1192
+ var body := PhysicsServer3D.body_create()
1193
+ PhysicsServer3D.body_set_mode(body, PhysicsServer3D.BODY_MODE_STATIC)
1194
+ PhysicsServer3D.body_add_shape(body, shape)
1195
+ PhysicsServer3D.body_set_collision_layer(body, layer)
1196
+ PhysicsServer3D.body_set_collision_mask(body, 0)
1197
+ PhysicsServer3D.body_set_space(body, _space)
1198
+ PhysicsServer3D.body_set_state(body, PhysicsServer3D.BODY_STATE_TRANSFORM, body_xf)
1199
+ _bodies.append(body)
1200
+ _body_inst[body.get_id()] = int(rec["i"])
1201
+ (rec["bodies"] as Array).append(body)
1202
+ _state = PhysicsServer3D.space_get_direct_state(_space)
1203
+
1204
+
1205
+ func _free_physics() -> void:
1206
+ for body in _bodies:
1207
+ PhysicsServer3D.free_rid(body)
1208
+ _bodies.clear()
1209
+ for shape in _shapes:
1210
+ PhysicsServer3D.free_rid(shape)
1211
+ _shapes.clear()
1212
+ _hulls.clear()
1213
+ if _space.is_valid():
1214
+ PhysicsServer3D.free_rid(_space)
1215
+ _space = RID()
1216
+ _state = null
1217
+
1218
+
1219
+ # One-sided (renderer-like) ray unless `two_sided`. Returns {} on a miss.
1220
+ func _ray(from: Vector3, to: Vector3, mask: int, exclude: Array = [], two_sided := false) -> Dictionary:
1221
+ _rays += 1
1222
+ _q.from = from
1223
+ _q.to = to
1224
+ _q.collision_mask = mask
1225
+ _q.hit_back_faces = two_sided
1226
+ # Re-set the exclude list only when it changes (most rays exclude nothing,
1227
+ # and setting it costs more than the ray).
1228
+ if not exclude.is_empty():
1229
+ _ex.clear()
1230
+ for r in exclude:
1231
+ _ex.append(r)
1232
+ _q.exclude = _ex
1233
+ _ex_set = true
1234
+ elif _ex_set:
1235
+ _ex.clear()
1236
+ _q.exclude = _ex
1237
+ _ex_set = false
1238
+ return _state.intersect_ray(_q)
1239
+
1240
+
1241
+ func _hit_inst(hit: Dictionary) -> int:
1242
+ if hit.is_empty():
1243
+ return -1
1244
+ return int(_body_inst.get((hit["rid"] as RID).get_id(), -1))
1245
+
1246
+
1247
+ # Clear distance along dir from p (two-sided: any surface, front or back).
1248
+ func _clear(p: Vector3, dir: Vector3, max_len: float, exclude: Array = []) -> float:
1249
+ var hit := _ray(p, p + dir * max_len, MASK_SOLID, exclude, true)
1250
+ if hit.is_empty():
1251
+ return max_len
1252
+ return (hit["position"] as Vector3).distance_to(p)
1253
+
1254
+
1255
+ # ---------------------------------------------------------------------------
1256
+ # through_hole (+ wall z-fight samples): facade lines -> capped ray grid
1257
+ # ---------------------------------------------------------------------------
1258
+
1259
+ func _wall_plane(rec: Dictionary) -> Dictionary:
1260
+ var lf: Vector3 = rec["front"]
1261
+ if lf == Vector3.ZERO:
1262
+ return {}
1263
+ var xf: Transform3D = rec["xf"]
1264
+ var wn: Vector3 = xf.basis * lf
1265
+ var h := Vector3(wn.x, 0, wn.z)
1266
+ if h.length() < 0.7 * wn.length() or h.length() < 0.000001:
1267
+ return {}
1268
+ var n := h.normalized()
1269
+ var la: AABB = rec["laabb"]
1270
+ var c := la.get_center()
1271
+ var half := absf(lf.dot(la.size)) * 0.5
1272
+ var wf: Vector3 = xf * (c + lf * half)
1273
+ var wb: Vector3 = xf * (c - lf * half)
1274
+ return {"n": n, "d": n.dot(wf), "thick": maxf(0.0, n.dot(wf - wb))}
1275
+
1276
+
1277
+ func _rect(rec: Dictionary, tax: Vector3) -> Array:
1278
+ var la: AABB = rec["laabb"]
1279
+ var xf: Transform3D = rec["xf"]
1280
+ var t0 := INF
1281
+ var t1 := -INF
1282
+ var y0 := INF
1283
+ var y1 := -INF
1284
+ for c in _corners(la):
1285
+ var w: Vector3 = xf * c
1286
+ var t := tax.dot(w)
1287
+ t0 = minf(t0, t)
1288
+ t1 = maxf(t1, t)
1289
+ y0 = minf(y0, w.y)
1290
+ y1 = maxf(y1, w.y)
1291
+ return [t0, t1, y0, y1]
1292
+
1293
+
1294
+ func _scan_facades() -> Array:
1295
+ var lines: Array = _facade_lines()
1296
+ # Rects, union area, spacing.
1297
+ var total_area := 0.0
1298
+ for line in lines:
1299
+ var n: Vector3 = line["n"]
1300
+ var tax := Vector3(n.z, 0, -n.x)
1301
+ line["t"] = tax
1302
+ var rects: Array = []
1303
+ for rec in (line["walls"] as Array):
1304
+ var r := _rect(rec, tax)
1305
+ rects.append([int(rec["i"]), r[0], r[1], r[2], r[3]])
1306
+ total_area += (r[1] - r[0]) * (r[3] - r[2])
1307
+ line["rects"] = rects
1308
+ var ins: Array = []
1309
+ for rec in _inst:
1310
+ if rec["role"] != "insert":
1311
+ continue
1312
+ var pl := _wall_plane(rec)
1313
+ if pl.is_empty() or (pl["n"] as Vector3).dot(n) < 0.996 or absf(float(pl["d"]) - float(line["d"])) > 0.6:
1314
+ continue
1315
+ var r := _rect(rec, tax)
1316
+ if _overlaps_any(rects, r):
1317
+ ins.append([int(rec["i"]), r[0], r[1], r[2], r[3]])
1318
+ line["inserts"] = ins
1319
+ var cap := int(_cfg.get("max_hole_rays", 24000))
1320
+ var s := clampf(sqrt(maxf(total_area, 1.0) / float(maxi(cap, 1000))), 0.15, 0.4)
1321
+ # Opposite-facing lines, for "reached the far side of the building".
1322
+ var out: Array = []
1323
+ for li in lines.size():
1324
+ var line: Dictionary = lines[li]
1325
+ out.append(_scan_line(line, lines, s))
1326
+ return out
1327
+
1328
+
1329
+ static func _overlaps_any(rects: Array, r: Array) -> bool:
1330
+ for q in rects:
1331
+ if r[0] < q[2] and r[1] > q[1] and r[2] < q[4] and r[3] > q[3]:
1332
+ return true
1333
+ return false
1334
+
1335
+
1336
+ # How far a ray that passed this facade must still travel to have reached the
1337
+ # far side of the building: up to just before the nearest opposite-facing
1338
+ # facade line behind it (its inside is the far side; what is on it, a shutter
1339
+ # back or culled wall backs, is what the hole shows), else 4 m of open space.
1340
+ func _far_distance(line: Dictionary, lines: Array, p: Vector3) -> float:
1341
+ var n: Vector3 = line["n"]
1342
+ var best_s := INF
1343
+ var stop := 4.0
1344
+ for other in lines:
1345
+ var on: Vector3 = other["n"]
1346
+ if on.dot(n) > -0.99:
1347
+ continue
1348
+ var s := float(other["d"]) - on.dot(p)
1349
+ # on ~ -n: the plane on.X = d lies at distance s behind p along -n.
1350
+ if s <= 0.3 or s >= best_s or s > 40.0:
1351
+ continue
1352
+ var tax: Vector3 = other["t"]
1353
+ var t := tax.dot(p)
1354
+ var covered := false
1355
+ for r in (other["rects"] as Array):
1356
+ if t >= float(r[1]) - 1.0 and t <= float(r[2]) + 1.0 and p.y >= float(r[3]) - 0.5 and p.y <= float(r[4]) + 0.5:
1357
+ covered = true
1358
+ break
1359
+ if covered:
1360
+ best_s = s
1361
+ stop = maxf(0.3, s - float(other["thick"]) - 0.15)
1362
+ return stop
1363
+
1364
+
1365
+ func _scan_line(line: Dictionary, lines: Array, s: float) -> Dictionary:
1366
+ var n: Vector3 = line["n"]
1367
+ var tax: Vector3 = line["t"]
1368
+ var d := float(line["d"])
1369
+ var rects: Array = line["rects"]
1370
+ var inserts: Array = line["inserts"]
1371
+ var slab := clampf(float(line["thick"]) + 0.25, 0.35, 1.0)
1372
+ var in_scope := false
1373
+ for rec in (line["walls"] as Array):
1374
+ if bool(rec["in"]):
1375
+ in_scope = true
1376
+ var samples: Array = [] # [t, y, kind]
1377
+ if in_scope:
1378
+ # Grid over the union of the piece rects (cell centers, 1 cm inside).
1379
+ var seen: Dictionary = {}
1380
+ for r in rects:
1381
+ var i0 := int(floor((float(r[1]) + 0.01) / s))
1382
+ var i1 := int(floor((float(r[2]) - 0.01) / s))
1383
+ var j0 := int(floor((float(r[3]) + 0.01) / s))
1384
+ var j1 := int(floor((float(r[4]) - 0.01) / s))
1385
+ for i in range(i0, i1 + 1):
1386
+ var tc := (i + 0.5) * s
1387
+ if tc < float(r[1]) + 0.01 or tc > float(r[2]) - 0.01:
1388
+ continue
1389
+ for j in range(j0, j1 + 1):
1390
+ var yc := (j + 0.5) * s
1391
+ if yc < float(r[3]) + 0.01 or yc > float(r[4]) - 0.01:
1392
+ continue
1393
+ var key := Vector2i(i, j)
1394
+ if seen.has(key):
1395
+ continue
1396
+ seen[key] = true
1397
+ samples.append([tc, yc, 0])
1398
+ # Seams: AABB gaps between neighbours in the line (5 mm .. 1.5 m).
1399
+ for a in rects:
1400
+ for b in rects:
1401
+ if a == b:
1402
+ continue
1403
+ var gap := float(b[1]) - float(a[2])
1404
+ if gap <= 0.005 or gap > 1.5:
1405
+ continue
1406
+ var ylo := maxf(float(a[3]), float(b[3]))
1407
+ var yhi := minf(float(a[4]), float(b[4]))
1408
+ if yhi - ylo < 0.3:
1409
+ continue
1410
+ var tm := (float(a[2]) + float(b[1])) * 0.5
1411
+ var y := ylo + 0.05
1412
+ while y < yhi - 0.04:
1413
+ samples.append([tm, y, 1])
1414
+ y += 0.1
1415
+ # Insert borders: just outside each insert's rect, inside a host rect.
1416
+ for r in inserts:
1417
+ var t0 := float(r[1]) - 0.04
1418
+ var t1 := float(r[2]) + 0.04
1419
+ var y0 := float(r[3]) - 0.04
1420
+ var y1 := float(r[4]) + 0.04
1421
+ var y := float(r[3]) + 0.05
1422
+ while y < float(r[4]):
1423
+ for t in [t0, t1]:
1424
+ if _inside_rects(rects, t, y):
1425
+ samples.append([t, y, 2])
1426
+ y += 0.1
1427
+ var t := float(r[1]) + 0.05
1428
+ while t < float(r[2]):
1429
+ for yy in [y0, y1]:
1430
+ if _inside_rects(rects, t, yy):
1431
+ samples.append([t, yy, 2])
1432
+ t += 0.1
1433
+ var through: Array = []
1434
+ var zf: Dictionary = {}
1435
+ var blocked := 0
1436
+ var backed := 0
1437
+ var want_zf := _checks.has("z_fight")
1438
+ var want_holes := _checks.has("through_hole")
1439
+ var done := 0
1440
+ var stopped := false
1441
+ for smp in samples:
1442
+ if _over():
1443
+ stopped = true
1444
+ break
1445
+ done += 1
1446
+ var t := float(smp[0])
1447
+ var y := float(smp[1])
1448
+ var p := tax * t + n * d + Vector3(0, y, 0)
1449
+ var o := p + n * 0.6
1450
+ var e := p - n * slab
1451
+ var hit := _ray(o, e, MASK_HOLE)
1452
+ if not hit.is_empty():
1453
+ blocked += 1
1454
+ if want_zf and int(smp[2]) == 0:
1455
+ _zfight_probe(o, e, hit, MASK_HOLE, zf)
1456
+ continue
1457
+ if not want_holes:
1458
+ continue
1459
+ var far := _far_distance(line, lines, e)
1460
+ var hit2 := _ray(e, e - n * far, MASK_HOLE | L_FLOOR)
1461
+ if not hit2.is_empty():
1462
+ backed += 1
1463
+ continue
1464
+ var row := [snappedf(t, 0.001), snappedf(y, 0.001), int(smp[2]), snappedf(far, 0.01)]
1465
+ # Extent of the opening around this ray (3 cm steps, up to one cell).
1466
+ if through.size() < 400:
1467
+ row.append(snappedf(t - _extent(line, lines, tax, n, d, slab, t, y, -1.0, 0.0, s), 0.001))
1468
+ row.append(snappedf(t + _extent(line, lines, tax, n, d, slab, t, y, 1.0, 0.0, s), 0.001))
1469
+ row.append(snappedf(y - _extent(line, lines, tax, n, d, slab, t, y, 0.0, -1.0, s), 0.001))
1470
+ row.append(snappedf(y + _extent(line, lines, tax, n, d, slab, t, y, 0.0, 1.0, s), 0.001))
1471
+ else:
1472
+ row.append_array([row[0], row[0], row[1], row[1]])
1473
+ if _want_poses:
1474
+ row.append(snappedf(_clear(p + n * 0.05, n, 12.0), 0.01))
1475
+ through.append(row)
1476
+ if through.size() >= 4000:
1477
+ _warn("A facade line had more than 4000 see-through rays; the rest were not recorded.")
1478
+ break
1479
+ _count("through_hole", done if stopped else samples.size(), samples.size())
1480
+ var rect_rows: Array = []
1481
+ for r in rects:
1482
+ rect_rows.append([r[0], snappedf(r[1], 0.001), snappedf(r[2], 0.001), snappedf(r[3], 0.001), snappedf(r[4], 0.001)])
1483
+ var ins_rows: Array = []
1484
+ for r in inserts:
1485
+ ins_rows.append([r[0], snappedf(r[1], 0.001), snappedf(r[2], 0.001), snappedf(r[3], 0.001), snappedf(r[4], 0.001)])
1486
+ return {
1487
+ "n": _a3(n, 0.0001), "t": _a3(tax, 0.0001), "d": snappedf(d, 0.001), "slab": snappedf(slab, 0.01), "spacing": snappedf(s, 0.001),
1488
+ "in": in_scope, "rays": samples.size(), "blocked": blocked, "backed": backed, "through": through,
1489
+ "pieces": rect_rows, "inserts": ins_rows, "zfight": zf.values(),
1490
+ }
1491
+
1492
+
1493
+ # A ray at (t, y) passes the facade slab AND reaches the far side.
1494
+ func _passes(line: Dictionary, lines: Array, tax: Vector3, n: Vector3, d: float, slab: float, t: float, y: float) -> bool:
1495
+ var p := tax * t + n * d + Vector3(0, y, 0)
1496
+ var e := p - n * slab
1497
+ if not _ray(p + n * 0.6, e, MASK_HOLE).is_empty():
1498
+ return false
1499
+ return _ray(e, e - n * _far_distance(line, lines, e), MASK_HOLE | L_FLOOR).is_empty()
1500
+
1501
+
1502
+ # How far the opening extends from (t, y) along (dt, dy), in 3 cm steps.
1503
+ func _extent(line: Dictionary, lines: Array, tax: Vector3, n: Vector3, d: float, slab: float, t: float, y: float, dt: float, dy: float, limit: float) -> float:
1504
+ var step := 0.03
1505
+ var k := 1
1506
+ while step * k <= limit + 0.0001:
1507
+ if not _passes(line, lines, tax, n, d, slab, t + dt * step * k, y + dy * step * k):
1508
+ return step * (k - 1) + step * 0.5
1509
+ k += 1
1510
+ return limit
1511
+
1512
+
1513
+ static func _inside_rects(rects: Array, t: float, y: float) -> bool:
1514
+ for r in rects:
1515
+ if t > float(r[1]) and t < float(r[2]) and y > float(r[3]) and y < float(r[4]):
1516
+ return true
1517
+ return false
1518
+
1519
+
1520
+ # Second visible surface within 3 mm of the first, parallel, on a DIFFERENT
1521
+ # instance: coplanar overlapping faces (z-fighting).
1522
+ func _zfight_probe(o: Vector3, e: Vector3, hit: Dictionary, mask: int, acc: Dictionary) -> void:
1523
+ var a := _hit_inst(hit)
1524
+ if a < 0:
1525
+ return
1526
+ var p1: Vector3 = hit["position"]
1527
+ var n1: Vector3 = hit["normal"]
1528
+ var dir := (e - o).normalized()
1529
+ var start := p1 - dir * 0.01
1530
+ var hit2 := _ray(start, p1 + dir * 0.01, mask, [hit["rid"]])
1531
+ if hit2.is_empty():
1532
+ return
1533
+ var b := _hit_inst(hit2)
1534
+ if b < 0 or b == a:
1535
+ return
1536
+ var p2: Vector3 = hit2["position"]
1537
+ if absf(dir.dot(p2 - p1)) > 0.003 or (hit2["normal"] as Vector3).dot(n1) < 0.999:
1538
+ return
1539
+ var key := str(mini(a, b)) + ":" + str(maxi(a, b))
1540
+ if not acc.has(key):
1541
+ acc[key] = [mini(a, b), maxi(a, b), 0, _a3(p1), _a3(n1, 0.0001), _a3(p1), _a3(p1), (snappedf(_clear(p1 + n1 * 0.05, n1, 8.0), 0.01) if _want_poses else 0.0)]
1542
+ var row: Array = acc[key]
1543
+ row[2] = int(row[2]) + 1
1544
+ var lo: Array = row[5]
1545
+ var hi: Array = row[6]
1546
+ row[5] = [minf(lo[0], p1.x), minf(lo[1], p1.y), minf(lo[2], p1.z)]
1547
+ row[6] = [maxf(hi[0], p1.x), maxf(hi[1], p1.y), maxf(hi[2], p1.z)]
1548
+
1549
+
1550
+ # ---------------------------------------------------------------------------
1551
+ # floor_gap (+ floor z-fight samples): down rays over the floor tiles
1552
+ # ---------------------------------------------------------------------------
1553
+
1554
+ func _scan_floors() -> Dictionary:
1555
+ var floors: Array = []
1556
+ var underlays: Array = []
1557
+ var area := 0.0
1558
+ for rec in _inst:
1559
+ if rec["role"] == "floor":
1560
+ floors.append(rec)
1561
+ var wa: AABB = rec["waabb"]
1562
+ area += wa.size.x * wa.size.z
1563
+ elif rec["role"] == "underlay":
1564
+ underlays.append(int(rec["i"]))
1565
+ if floors.is_empty():
1566
+ return {"floors": 0, "underlays": underlays, "gaps": [], "rays": 0}
1567
+ var cap := int(_cfg.get("max_floor_rays", 16000))
1568
+ var cell := clampf(sqrt(area / float(maxi(cap, 1000))), 0.25, 0.6)
1569
+ # Occupancy: cell -> owning floor (oriented footprint from the local AABB).
1570
+ var occ: Dictionary = {}
1571
+ var top_of: Dictionary = {}
1572
+ for rec in floors:
1573
+ var la: AABB = rec["laabb"]
1574
+ var xf: Transform3D = rec["xf"]
1575
+ var inv := xf.affine_inverse()
1576
+ var wa: AABB = rec["waabb"]
1577
+ var i0 := int(floor(wa.position.x / cell))
1578
+ var i1 := int(floor(wa.end.x / cell))
1579
+ var k0 := int(floor(wa.position.z / cell))
1580
+ var k1 := int(floor(wa.end.z / cell))
1581
+ for i in range(i0, i1 + 1):
1582
+ for k in range(k0, k1 + 1):
1583
+ var wp := Vector3((i + 0.5) * cell, wa.get_center().y, (k + 0.5) * cell)
1584
+ var lp: Vector3 = inv * wp
1585
+ if lp.x < la.position.x + 0.005 or lp.x > la.end.x - 0.005 or lp.z < la.position.z + 0.005 or lp.z > la.end.z - 0.005:
1586
+ continue
1587
+ var key := Vector2i(i, k)
1588
+ if not occ.has(key):
1589
+ occ[key] = int(rec["i"])
1590
+ top_of[key] = wa.end.y
1591
+ else:
1592
+ top_of[key] = maxf(float(top_of[key]), wa.end.y)
1593
+ var reach := int(ceil(0.5 / cell))
1594
+ var gaps: Array = []
1595
+ var zf: Dictionary = {}
1596
+ var rays := 0
1597
+ var want_gaps := _checks.has("floor_gap")
1598
+ var want_zf := _checks.has("z_fight")
1599
+ var interior := 0
1600
+ var per_owner: Dictionary = {}
1601
+ var keys_done := 0
1602
+ var stopped := false
1603
+ for key in occ:
1604
+ if _over():
1605
+ stopped = true
1606
+ break
1607
+ keys_done += 1
1608
+ var owner_i := int(occ[key])
1609
+ if not bool((_inst[owner_i] as Dictionary)["in"]):
1610
+ continue
1611
+ var c: Vector2i = key
1612
+ if not (occ.has(c + Vector2i(reach, 0)) and occ.has(c - Vector2i(reach, 0)) and occ.has(c + Vector2i(0, reach)) and occ.has(c - Vector2i(0, reach))):
1613
+ continue
1614
+ interior += 1
1615
+ var x := (c.x + 0.5) * cell
1616
+ var z := (c.y + 0.5) * cell
1617
+ var before := _floor_gap_count
1618
+ _floor_sample(x, z, float(top_of[key]), owner_i, gaps, zf, want_gaps, want_zf, 0, cell, cell)
1619
+ rays += 1
1620
+ # Per tile: cells, holes (the ray fell past the tile), of which void,
1621
+ # and cells where an underlay plane covers the tile's own low surface.
1622
+ if not per_owner.has(owner_i):
1623
+ per_owner[owner_i] = [owner_i, 0, 0, 0, 0]
1624
+ var po: Array = per_owner[owner_i]
1625
+ po[1] = int(po[1]) + 1
1626
+ if _floor_gap_count > before:
1627
+ if _floor_gap_covered:
1628
+ po[4] = int(po[4]) + 1
1629
+ else:
1630
+ po[2] = int(po[2]) + 1
1631
+ if _floor_gap_void:
1632
+ po[3] = int(po[3]) + 1
1633
+ _count("floor_gap", keys_done if stopped else occ.size(), occ.size())
1634
+ # Seams between neighbouring tiles (world AABB gaps of 2 mm .. 1 m).
1635
+ var seams := 0
1636
+ if want_gaps:
1637
+ var seam_done := 0
1638
+ for a in floors:
1639
+ if _over():
1640
+ break
1641
+ seam_done += 1
1642
+ for b in floors:
1643
+ if a == b or not (bool(a["in"]) or bool(b["in"])):
1644
+ continue
1645
+ var wa: AABB = a["waabb"]
1646
+ var wb: AABB = b["waabb"]
1647
+ for ax in [0, 2]:
1648
+ var axis: int = ax
1649
+ var other: int = 2 - axis
1650
+ var gap: float = wb.position[axis] - wa.end[axis]
1651
+ if gap <= 0.002 or gap > 1.0:
1652
+ continue
1653
+ var lo: float = maxf(wa.position[other], wb.position[other])
1654
+ var hi: float = minf(wa.end[other], wb.end[other])
1655
+ if hi - lo < 0.3:
1656
+ continue
1657
+ var mid: float = (wa.end[axis] + wb.position[axis]) * 0.5
1658
+ # Each seam ray stands for 10 cm of the seam, the seam's width across.
1659
+ var ssx: float = gap if axis == 0 else 0.1
1660
+ var ssz: float = 0.1 if axis == 0 else gap
1661
+ var u: float = lo + 0.05
1662
+ while u < hi - 0.04:
1663
+ var x: float = mid if axis == 0 else u
1664
+ var z: float = u if axis == 0 else mid
1665
+ _floor_sample(x, z, maxf(wa.end.y, wb.end.y), int(a["i"]), gaps, zf, true, false, 1, ssx, ssz)
1666
+ seams += 1
1667
+ u += 0.1
1668
+ _count("floor_gap", seam_done, floors.size())
1669
+ var strips := 0
1670
+ if want_gaps:
1671
+ strips = _scan_strips(floors, gaps, zf)
1672
+ return {"floors": floors.size(), "underlays": underlays, "cell": snappedf(cell, 0.001), "rays": rays + seams + strips, "interior_cells": interior, "seam_rays": seams, "strip_rays": strips, "gaps": gaps, "gap_rays": _floor_gap_count, "per_owner": per_owner.values(), "zfight": zf.values()}
1673
+
1674
+
1675
+ # Bare strips OUTSIDE the tile footprints: a tile row that stops short of a
1676
+ # wall (a back wall a few decimetres past the last tile) leaves the underlay
1677
+ # showing at the wall base, where no grid cell or seam ray looks. Walk each
1678
+ # in-scope tile's footprint edges; where a horizontal ray finds a wall within
1679
+ # STRIP_REACH outside the edge, step down rays out from the edge to the wall
1680
+ # base, stopping at the first floor (the next tile). An edge with no wall in
1681
+ # reach is an open edge, not walkable area enclosed by walls: left alone.
1682
+ func _scan_strips(floors: Array, gaps: Array, zf: Dictionary) -> int:
1683
+ var rays := 0
1684
+ var done := 0
1685
+ for rec in floors:
1686
+ if _over():
1687
+ break
1688
+ done += 1
1689
+ if not bool(rec["in"]):
1690
+ continue
1691
+ var la: AABB = rec["laabb"]
1692
+ var xf: Transform3D = rec["xf"]
1693
+ var top := (rec["waabb"] as AABB).end.y
1694
+ var owner_i := int(rec["i"])
1695
+ var x0 := la.position.x
1696
+ var x1 := la.end.x
1697
+ var z0 := la.position.z
1698
+ var z1 := la.end.z
1699
+ var ly := la.get_center().y
1700
+ # The four footprint edges in local XZ: [start, end, outward direction].
1701
+ var edges := [
1702
+ [Vector3(x0, ly, z0), Vector3(x0, ly, z1), Vector3(-1, 0, 0)],
1703
+ [Vector3(x1, ly, z0), Vector3(x1, ly, z1), Vector3(1, 0, 0)],
1704
+ [Vector3(x0, ly, z0), Vector3(x1, ly, z0), Vector3(0, 0, -1)],
1705
+ [Vector3(x0, ly, z1), Vector3(x1, ly, z1), Vector3(0, 0, 1)],
1706
+ ]
1707
+ for edge in edges:
1708
+ var a: Vector3 = xf * (edge[0] as Vector3)
1709
+ var b: Vector3 = xf * (edge[1] as Vector3)
1710
+ var out: Vector3 = xf.basis * (edge[2] as Vector3)
1711
+ out.y = 0
1712
+ if out.length() < 0.2:
1713
+ continue
1714
+ out = out.normalized()
1715
+ var along := Vector3(b.x - a.x, 0, b.z - a.z)
1716
+ var edge_len := along.length()
1717
+ if edge_len < STRIP_STEP:
1718
+ continue
1719
+ along = along / edge_len
1720
+ var n := int(floor(edge_len / STRIP_STEP))
1721
+ var step := edge_len / float(n)
1722
+ for k in n:
1723
+ var p := Vector3(a.x, top, a.z) + along * (step * (float(k) + 0.5))
1724
+ var h := p + Vector3(0, 0.3, 0)
1725
+ rays += 1
1726
+ var wall := _ray(h, h + out * STRIP_REACH, MASK_FACADE, [], true)
1727
+ if wall.is_empty():
1728
+ continue
1729
+ var wp: Vector3 = wall["position"]
1730
+ var span := Vector3(wp.x - p.x, 0, wp.z - p.z).length() - 0.02
1731
+ if span < 0.03:
1732
+ continue
1733
+ var wi := _hit_inst(wall)
1734
+ var m := maxi(1, int(ceil(span / 0.1)))
1735
+ var ds := span / float(m)
1736
+ var ssx := absf(out.x) * ds + absf(along.x) * step
1737
+ var ssz := absf(out.z) * ds + absf(along.z) * step
1738
+ for j in m:
1739
+ var q := p + out * (ds * (float(j) + 0.5))
1740
+ rays += 1
1741
+ if _floor_sample(q.x, q.z, top, owner_i, gaps, zf, true, false, 2, ssx, ssz, wi, ds * step) == 0:
1742
+ break
1743
+ _count("floor_gap", done, floors.size())
1744
+ return rays
1745
+
1746
+
1747
+ # One down ray at (x, z). Returns 0 when the floor (or a wall base over it)
1748
+ # is there, 1 when a gap row was recorded.
1749
+ # kind: 0 grid cell, 1 seam between tiles, 2 bare strip between a tile edge
1750
+ # and a wall. sx / sz: the footprint the sample stands for (axis-aligned
1751
+ # bounds); area: its area (default sx * sz). Gap row:
1752
+ # [x, z, top, owner, first hit, first hit y, clearance, kind, sx, sz, area,
1753
+ # floor y under an underlay, that floor, the wall a strip runs along].
1754
+ func _floor_sample(x: float, z: float, top: float, owner_i: int, gaps: Array, zf: Dictionary, want_gaps: bool, want_zf: bool, kind := 0, sx := 0.0, sz := 0.0, wall_i := -1, area := -1.0) -> int:
1755
+ var o := Vector3(x, top + 0.6, z)
1756
+ var e := Vector3(x, top - 1.5, z)
1757
+ var hit := _ray(o, e, L_FLOOR | L_UNDERLAY | MASK_FACADE)
1758
+ var hi := _hit_inst(hit)
1759
+ var role := "" if hi < 0 else String((_inst[hi] as Dictionary)["role"])
1760
+ if not hit.is_empty() and role != "underlay":
1761
+ if want_zf and role == "floor":
1762
+ _zfight_probe(o, e, hit, L_FLOOR, zf)
1763
+ return 0
1764
+ if not want_gaps:
1765
+ return 0
1766
+ # Missed the floor: under a wall or kerb (inside it) does not show.
1767
+ var cover := _ray(o, Vector3(x, top - 0.02, z), MASK_FACADE, [], true)
1768
+ if not cover.is_empty():
1769
+ return 0
1770
+ # The first surface is an underlay. A floor surface right under it means
1771
+ # the underlay plane sits ABOVE the floor's own low surface (a drain
1772
+ # channel, a dip in the slab) and paints over it: not a hole in the tile.
1773
+ var below: Variant = null
1774
+ var below_i := -1
1775
+ if not hit.is_empty():
1776
+ var hy := (hit["position"] as Vector3).y
1777
+ var under := _ray(Vector3(x, hy - 0.001, z), Vector3(x, hy - 0.5, z), L_FLOOR)
1778
+ if not under.is_empty():
1779
+ below = snappedf((under["position"] as Vector3).y, 0.001)
1780
+ below_i = _hit_inst(under)
1781
+ _floor_gap_count += 1
1782
+ _floor_gap_void = hit.is_empty()
1783
+ _floor_gap_covered = below != null
1784
+ if gaps.size() >= 6000:
1785
+ return 1
1786
+ var clear: Variant = null
1787
+ if _want_poses:
1788
+ clear = snappedf(_clear(Vector3(x, top + 0.05, z), Vector3(0.3, 1.0, 0.25).normalized(), 8.0), 0.01)
1789
+ var a := area if area >= 0.0 else sx * sz
1790
+ gaps.append([snappedf(x, 0.001), snappedf(z, 0.001), snappedf(top, 0.001), owner_i, hi, (snappedf((hit["position"] as Vector3).y, 0.001) if not hit.is_empty() else null), clear, kind, snappedf(sx, 0.001), snappedf(sz, 0.001), snappedf(a, 0.0001), below, below_i, wall_i])
1791
+ return 1
1792
+
1793
+
1794
+ # ---------------------------------------------------------------------------
1795
+ # floating / sunken: support under each prop's footprint
1796
+ # ---------------------------------------------------------------------------
1797
+
1798
+ func _scan_support() -> Array:
1799
+ var out: Array = []
1800
+ var want_above := _checks.has("sunken")
1801
+ _floor_recs.clear()
1802
+ for f in _inst:
1803
+ if f["role"] == "floor":
1804
+ _floor_recs.append(f)
1805
+ var done := 0
1806
+ var total := 0
1807
+ for rec in _inst:
1808
+ if not bool(rec["in"]):
1809
+ continue
1810
+ var role := String(rec["role"])
1811
+ if role != "prop" and role != "dressing":
1812
+ continue
1813
+ total += 1
1814
+ if _over():
1815
+ continue
1816
+ done += 1
1817
+ var la: AABB = rec["laabb"]
1818
+ var wa: AABB = rec["waabb"]
1819
+ var xf: Transform3D = rec["xf"]
1820
+ var ymin := wa.position.y
1821
+ var oy := ymin + clampf(0.5 * wa.size.y, 0.1, 1.0)
1822
+ var c := la.get_center()
1823
+ var hx := la.size.x * 0.35
1824
+ var hz := la.size.z * 0.35
1825
+ var pts := [c, c + Vector3(hx, 0, hz), c + Vector3(-hx, 0, hz), c + Vector3(hx, 0, -hz), c + Vector3(-hx, 0, -hz)]
1826
+ var hits: Array = []
1827
+ var above: Array = []
1828
+ var ex: Array = rec["bodies"]
1829
+ var top := -INF
1830
+ for lp in pts:
1831
+ var w: Vector3 = xf * lp
1832
+ var hit := _ray(Vector3(w.x, oy, w.z), Vector3(w.x, ymin - 1.0, w.z), MASK_SOLID, ex)
1833
+ if hit.is_empty():
1834
+ hits.append(null)
1835
+ else:
1836
+ var hi := _hit_inst(hit)
1837
+ var hy := (hit["position"] as Vector3).y
1838
+ top = maxf(top, hy)
1839
+ hits.append([snappedf(hy, 0.0001), hi])
1840
+ # The first surface from ABOVE, down through the footprint: what the
1841
+ # prop stands in. A prop buried deeper than the support ray's start
1842
+ # (a bottle stood upright with its origin on the floor) has the
1843
+ # floor's top above that start, where the ray from oy never looks.
1844
+ if want_above:
1845
+ var ah := _ray(Vector3(w.x, wa.end.y + 0.02, w.z), Vector3(w.x, ymin - 0.5, w.z), MASK_SOLID, ex)
1846
+ if ah.is_empty():
1847
+ above.append(null)
1848
+ else:
1849
+ above.append([snappedf((ah["position"] as Vector3).y, 0.0001), _hit_inst(ah)])
1850
+ # Held by a wall? Only asked when it would otherwise float clearly.
1851
+ var touch: Variant = null
1852
+ if top == -INF or ymin - top > 0.3:
1853
+ var wc: Vector3 = xf * c
1854
+ for ldir in [Vector3(1, 0, 0), Vector3(-1, 0, 0), Vector3(0, 0, 1), Vector3(0, 0, -1)]:
1855
+ var wd: Vector3 = xf.basis * ldir
1856
+ wd.y = 0
1857
+ if wd.length() < 0.2:
1858
+ continue
1859
+ wd = wd.normalized()
1860
+ var ext := absf((ldir as Vector3).dot(la.size)) * 0.5
1861
+ var hit := _ray(wc, wc + wd * (ext + 0.3), MASK_FACADE, ex)
1862
+ if not hit.is_empty():
1863
+ var gap := (hit["position"] as Vector3).distance_to(wc) - ext
1864
+ if touch == null or gap < float(touch):
1865
+ touch = snappedf(gap, 0.001)
1866
+ # The floor level around it (top of the floor tiles whose footprint holds
1867
+ # its centre): a prop at that level over an underlay sits over a hole.
1868
+ var floor_top: Variant = null
1869
+ var wcen: Vector3 = wa.get_center()
1870
+ for f in _floor_recs:
1871
+ var fa: AABB = f["waabb"]
1872
+ if wcen.x >= fa.position.x and wcen.x <= fa.end.x and wcen.z >= fa.position.z and wcen.z <= fa.end.z:
1873
+ floor_top = fa.end.y if floor_top == null else maxf(float(floor_top), fa.end.y)
1874
+ out.append([int(rec["i"]), snappedf(ymin, 0.0001), snappedf(oy, 0.001), hits, touch, (snappedf(float(floor_top), 0.001) if floor_top != null else null), above])
1875
+ _count("floating_sunken", done, total)
1876
+ return out
1877
+
1878
+
1879
+ # ---------------------------------------------------------------------------
1880
+ # interpenetration: each prop's per-mesh convex hulls against props, walls,
1881
+ # structure and inserts (floors excluded: that is support).
1882
+ # ---------------------------------------------------------------------------
1883
+
1884
+ func _hull_for(mesh: Mesh) -> Shape3D:
1885
+ var key := mesh.get_instance_id()
1886
+ if _hulls.has(key):
1887
+ return _hulls[key]
1888
+ var hull: Shape3D = mesh.create_convex_shape(true, false)
1889
+ _hulls[key] = hull
1890
+ return hull
1891
+
1892
+
1893
+ func _scan_overlaps() -> Array:
1894
+ var out: Array = []
1895
+ var seen: Dictionary = {}
1896
+ var q := PhysicsShapeQueryParameters3D.new()
1897
+ q.collide_with_areas = false
1898
+ q.margin = 0.0
1899
+ var mask := L_PROP | L_WALL | L_STRUCT | L_INSERT
1900
+ q.collision_mask = mask
1901
+ var done := 0
1902
+ var total := 0
1903
+ for rec in _inst:
1904
+ if rec["role"] != "prop" or not bool(rec["in"]):
1905
+ continue
1906
+ total += 1
1907
+ if _over():
1908
+ continue
1909
+ done += 1
1910
+ var own: Array = rec["bodies"]
1911
+ var depth_by: Dictionary = {}
1912
+ var point_by: Dictionary = {}
1913
+ for m in (rec["meshes"] as Array):
1914
+ var mesh: Mesh = m[1]
1915
+ var mxf: Transform3D = m[2]
1916
+ if not _is_rigid(mxf.basis):
1917
+ continue
1918
+ var hull := _hull_for(mesh)
1919
+ if hull == null:
1920
+ continue
1921
+ q.shape_rid = hull.get_rid()
1922
+ q.transform = mxf
1923
+ var ex: Array[RID] = []
1924
+ for r in own:
1925
+ ex.append(r)
1926
+ q.exclude = ex
1927
+ var touching := _state.intersect_shape(q, 32)
1928
+ if touching.is_empty():
1929
+ continue
1930
+ var by_inst: Dictionary = {}
1931
+ for t in touching:
1932
+ var bi := int(_body_inst.get((t["rid"] as RID).get_id(), -1))
1933
+ if bi < 0 or bi == int(rec["i"]):
1934
+ continue
1935
+ if not by_inst.has(bi):
1936
+ by_inst[bi] = []
1937
+ (by_inst[bi] as Array).append(t["rid"])
1938
+ for bi in by_inst:
1939
+ var ex2: Array[RID] = []
1940
+ for r in own:
1941
+ ex2.append(r)
1942
+ for t in touching:
1943
+ if not (by_inst[bi] as Array).has(t["rid"]):
1944
+ ex2.append(t["rid"])
1945
+ q.exclude = ex2
1946
+ var pairs := _state.collide_shape(q, 32)
1947
+ var depth := 0.0
1948
+ var at := Vector3.ZERO
1949
+ var k := 0
1950
+ while k + 1 < pairs.size():
1951
+ var dd := (pairs[k] as Vector3).distance_to(pairs[k + 1])
1952
+ if dd > depth:
1953
+ depth = dd
1954
+ at = (pairs[k] + pairs[k + 1]) * 0.5
1955
+ k += 2
1956
+ if depth > float(depth_by.get(bi, 0.0)):
1957
+ depth_by[bi] = depth
1958
+ point_by[bi] = at
1959
+ for bi in depth_by:
1960
+ var depth := float(depth_by[bi])
1961
+ if depth < 0.01:
1962
+ continue
1963
+ var a := int(rec["i"])
1964
+ var key := str(mini(a, bi)) + ":" + str(maxi(a, bi))
1965
+ if seen.has(key):
1966
+ if depth > float((out[int(seen[key])] as Array)[2]):
1967
+ (out[int(seen[key])] as Array)[2] = snappedf(depth, 0.001)
1968
+ continue
1969
+ seen[key] = out.size()
1970
+ out.append([a, int(bi), snappedf(depth, 0.001), _a3(point_by[bi])])
1971
+ _count("interpenetration", done, total)
1972
+ return out
1973
+
1974
+
1975
+ # ---------------------------------------------------------------------------
1976
+ # orientation of long props near a wall
1977
+ # ---------------------------------------------------------------------------
1978
+
1979
+ func _scan_long_props() -> Array:
1980
+ var out: Array = []
1981
+ var done := 0
1982
+ var total := 0
1983
+ for rec in _inst:
1984
+ if rec["role"] != "prop" or not bool(rec["in"]):
1985
+ continue
1986
+ total += 1
1987
+ if _over():
1988
+ continue
1989
+ done += 1
1990
+ var la: AABB = rec["laabb"]
1991
+ var xf: Transform3D = rec["xf"]
1992
+ var sx := la.size.x
1993
+ var sz := la.size.z
1994
+ var length := maxf(sx, sz)
1995
+ var depth := minf(sx, sz)
1996
+ if length < 0.6 or length < 1.8 * maxf(depth, 0.001):
1997
+ continue
1998
+ var lax := Vector3(1, 0, 0) if sx >= sz else Vector3(0, 0, 1)
1999
+ var wl: Vector3 = xf.basis * lax
2000
+ wl.y = 0
2001
+ if wl.length() < 0.2:
2002
+ continue
2003
+ wl = wl.normalized()
2004
+ var wx: Vector3 = (xf.basis * Vector3(1, 0, 0))
2005
+ var wz: Vector3 = (xf.basis * Vector3(0, 0, 1))
2006
+ wx.y = 0
2007
+ wz.y = 0
2008
+ var c := la.get_center()
2009
+ var wc: Vector3 = xf * c
2010
+ var wa: AABB = rec["waabb"]
2011
+ wc.y = wa.position.y + minf(0.5, wa.size.y * 0.5)
2012
+ var ex: Array = rec["bodies"]
2013
+ var best: Array = []
2014
+ for k in 8:
2015
+ var ang := TAU * float(k) / 8.0
2016
+ var dir := Vector3(sin(ang), 0, cos(ang))
2017
+ var ext := absf(dir.dot(wx.normalized())) * sx * 0.5 + absf(dir.dot(wz.normalized())) * sz * 0.5
2018
+ var hit := _ray(wc, wc + dir * (ext + 1.4), MASK_FACADE, ex)
2019
+ if hit.is_empty():
2020
+ continue
2021
+ var nrm: Vector3 = hit["normal"]
2022
+ if absf(nrm.y) > 0.35:
2023
+ continue
2024
+ var clearance := (hit["position"] as Vector3).distance_to(wc) - ext
2025
+ if best.is_empty() or clearance < float(best[0]):
2026
+ var hn := Vector3(nrm.x, 0, nrm.z).normalized()
2027
+ best = [snappedf(clearance, 0.001), _hit_inst(hit), _a3(hn, 0.0001), _a3(dir, 0.0001)]
2028
+ if best.is_empty():
2029
+ continue
2030
+ out.append([int(rec["i"]), _a3(wl, 0.0001), snappedf(length, 0.001), snappedf(depth, 0.001), best])
2031
+ _count("orientation", done, total)
2032
+ return out
2033
+
2034
+
2035
+ # ---------------------------------------------------------------------------
2036
+ # uv_stretch: candidate triangles per mesh, visibility per instance
2037
+ # ---------------------------------------------------------------------------
2038
+
2039
+ func _scan_uv() -> Array:
2040
+ var users: Dictionary = {}
2041
+ for rec in _inst:
2042
+ if not bool(rec["in"]):
2043
+ continue
2044
+ var role := String(rec["role"])
2045
+ if role == "dressing" or role == "underlay":
2046
+ continue
2047
+ for m in (rec["meshes"] as Array):
2048
+ var key := (m[1] as Mesh).get_instance_id()
2049
+ var info: Dictionary = _mesh_info.get(key, {})
2050
+ if (info.get("uv", []) as Array).is_empty():
2051
+ continue
2052
+ if not users.has(key):
2053
+ users[key] = []
2054
+ if (users[key] as Array).size() < 64:
2055
+ (users[key] as Array).append([rec, m[2]])
2056
+ var out: Array = []
2057
+ var done := 0
2058
+ for key in users:
2059
+ if _over():
2060
+ break
2061
+ done += 1
2062
+ var info: Dictionary = _mesh_info[key]
2063
+ var cands: Array = info["uv"]
2064
+ var tris: Array = []
2065
+ for cnd in cands:
2066
+ var t0: Vector2 = cnd[5]
2067
+ var t1: Vector2 = cnd[6]
2068
+ var t2: Vector2 = cnd[7]
2069
+ tris.append([_a3(cnd[2], 0.0001), _a3(cnd[3], 0.0001), _a3(cnd[4], 0.0001), [snappedf(t0.x, 0.00001), snappedf(t0.y, 0.00001)], [snappedf(t1.x, 0.00001), snappedf(t1.y, 0.00001)], [snappedf(t2.x, 0.00001), snappedf(t2.y, 0.00001)]])
2070
+ var shown: Array = []
2071
+ for pair in (users[key] as Array):
2072
+ var rec: Dictionary = pair[0]
2073
+ var mxf: Transform3D = pair[1]
2074
+ var front: Vector3 = rec.get("front", Vector3.ZERO)
2075
+ var wfront := Vector3.ZERO
2076
+ if front != Vector3.ZERO:
2077
+ wfront = ((rec["xf"] as Transform3D).basis * front).normalized()
2078
+ # Per candidate triangle: visible (the view ray lands on it), or
2079
+ # covered (an insert sits within 0.3 m in front of it: a door, a
2080
+ # shutter). Covered somewhere + visible here = exposed here.
2081
+ var mask_bits := 0
2082
+ var covered_bits := 0
2083
+ var viewer_out: Variant = null
2084
+ var center_out: Variant = null
2085
+ for ti in cands.size():
2086
+ var cnd: Array = cands[ti]
2087
+ var cw: Vector3 = mxf * ((cnd[2] + cnd[3] + cnd[4]) / 3.0)
2088
+ var nw: Vector3 = (mxf.basis * (cnd[8] as Vector3)).normalized()
2089
+ var viewer := cw + nw * 1.2 + wfront * 0.8
2090
+ var dir := (cw - viewer).normalized()
2091
+ var hit := _ray(viewer, cw + dir * 0.02, MASK_SOLID)
2092
+ if hit.is_empty():
2093
+ continue
2094
+ var hp: Vector3 = hit["position"]
2095
+ var hi := _hit_inst(hit)
2096
+ if hi == int(rec["i"]):
2097
+ if hp.distance_to(cw) <= 0.05:
2098
+ mask_bits |= 1 << ti
2099
+ if viewer_out == null:
2100
+ viewer_out = _a3(viewer)
2101
+ center_out = _a3(cw)
2102
+ elif hi >= 0 and hp.distance_to(cw) <= 0.3 and String((_inst[hi] as Dictionary)["role"]) == "insert":
2103
+ covered_bits |= 1 << ti
2104
+ if mask_bits != 0 or covered_bits != 0:
2105
+ shown.append([int(rec["i"]), mask_bits, viewer_out, center_out, covered_bits])
2106
+ out.append({"mesh": String(info["name"]), "tris": tris, "users": (users[key] as Array).size(), "shown": shown})
2107
+ _count("uv_stretch", done, users.size())
2108
+ return out
2109
+
2110
+
2111
+ # ---------------------------------------------------------------------------
2112
+ # z_fight geometry: coplanar overlapping faces anywhere, not only where the
2113
+ # facade and floor ray grids sample. Per mesh resource (cached): planar face
2114
+ # groups (normal, offset, 2D hull, own triangles). Between instances: plane
2115
+ # bins (canonical normal + offset) as the broadphase, then parallel normals
2116
+ # (|dot| > 0.999; opposite only when both materials are double-sided), plane
2117
+ # gap under the depth-precision tolerance, hull overlap clipped in the plane
2118
+ # and verified with samples against both groups' OWN triangles. Within one
2119
+ # mesh: the same test between groups of different surfaces. Measures only;
2120
+ # the judge applies the tolerance and the severity.
2121
+ # ---------------------------------------------------------------------------
2122
+
2123
+ func _scan_zfight_geometry() -> Dictionary:
2124
+ _zf_setup_views()
2125
+ var units_done := 0
2126
+ var units_total := 0
2127
+ # Face groups per mesh resource, and which instances use each mesh.
2128
+ var users: Dictionary = {}
2129
+ var mesh_of: Dictionary = {}
2130
+ var meshes: Array = []
2131
+ for rec in _inst:
2132
+ for m in (rec["meshes"] as Array):
2133
+ var mid := (m[1] as Mesh).get_instance_id()
2134
+ if not users.has(mid):
2135
+ users[mid] = []
2136
+ mesh_of[mid] = m[1]
2137
+ meshes.append([mid, m[1]])
2138
+ (users[mid] as Array).append([int(rec["i"]), m[0], m[2]])
2139
+ var stopped := false
2140
+ for km in meshes:
2141
+ units_total += 1
2142
+ if stopped or _over():
2143
+ stopped = true
2144
+ continue
2145
+ units_done += 1
2146
+ var fid: int = km[0]
2147
+ if not _mesh_info.has(fid):
2148
+ # Dressing (decals, overlay cards) skips the mesh pass.
2149
+ _mesh_info[fid] = _analyze_mesh(km[1] as Mesh, false, true)
2150
+ _zf_groups[fid] = _finalize_groups(_mesh_info[fid] as Dictionary)
2151
+ # Within one mesh: groups of different surfaces on one plane.
2152
+ var in_mesh: Array = []
2153
+ for km2 in meshes:
2154
+ var wid: int = km2[0]
2155
+ if not _zf_groups.has(wid):
2156
+ continue
2157
+ units_total += 1
2158
+ if stopped or _over():
2159
+ stopped = true
2160
+ continue
2161
+ units_done += 1
2162
+ var row := _zf_within(wid, String((_mesh_info[wid] as Dictionary).get("name", "mesh")), users[wid] as Array)
2163
+ if not row.is_empty() and in_mesh.size() < 64:
2164
+ in_mesh.append(row)
2165
+ # Between instances: world plane records in canonical bins.
2166
+ var recs: Array = []
2167
+ var bins: Dictionary = {}
2168
+ for gid in _zf_groups:
2169
+ for use in (users[gid] as Array):
2170
+ var rec_i: int = use[0]
2171
+ var node: Node = use[1]
2172
+ var xf: Transform3D = use[2]
2173
+ var nb := xf.basis.inverse().transposed()
2174
+ var groups: Array = _zf_groups[gid]
2175
+ for gi in groups.size():
2176
+ var g: Dictionary = groups[gi]
2177
+ var nw := (nb * (g["n"] as Vector3)).normalized()
2178
+ var ow: Vector3 = xf * (g["o"] as Vector3)
2179
+ var dw := nw.dot(ow)
2180
+ var cn := nw
2181
+ var cd := dw
2182
+ if cn.x < -0.0001 or (absf(cn.x) <= 0.0001 and (cn.y < -0.0001 or (absf(cn.y) <= 0.0001 and cn.z < 0.0))):
2183
+ cn = -cn
2184
+ cd = -cd
2185
+ var mat := _surface_mat(node, mesh_of[gid] as Mesh, int(g["s"]))
2186
+ var r := {"i": rec_i, "node": node, "xf": xf, "g": g, "n": nw, "o": ow, "box": _group_box(g, xf), "mat": mat, "double": mat != null and _double_sided(mat)}
2187
+ var pkey := Vector4i(roundi(cn.x * 30.0), roundi(cn.y * 30.0), roundi(cn.z * 30.0), roundi(cd / ZF_MAX_GAP))
2188
+ if not bins.has(pkey):
2189
+ bins[pkey] = []
2190
+ (bins[pkey] as Array).append(recs.size())
2191
+ recs.append(r)
2192
+ var pairs: Dictionary = {}
2193
+ for bin_key in bins:
2194
+ units_total += 1
2195
+ if stopped or _over():
2196
+ stopped = true
2197
+ continue
2198
+ units_done += 1
2199
+ var here: Array = bins[bin_key]
2200
+ var k4: Vector4i = bin_key
2201
+ for dq in [0, 1]:
2202
+ var there: Array = bins.get(Vector4i(k4.x, k4.y, k4.z, k4.w + int(dq)), [])
2203
+ for ia in here.size():
2204
+ var jb0 := ia + 1 if int(dq) == 0 else 0
2205
+ for jb in range(jb0, there.size()):
2206
+ _zf_pair(recs[int(here[ia])] as Dictionary, recs[int(there[jb])] as Dictionary, pairs)
2207
+ _count("z_fight_geometry", units_done, units_total)
2208
+ var rows: Array = pairs.values()
2209
+ rows.sort_custom(func(a, b): return float(a[2]) > float(b[2]))
2210
+ if rows.size() > 300:
2211
+ rows.resize(300)
2212
+ return {"near": snappedf(_near, 0.0001), "far": snappedf(_far, 0.01), "views": _views.size(), "groups": recs.size(), "pairs": rows, "in_mesh": in_mesh}
2213
+
2214
+
2215
+ # The main camera's near / far (the current Camera3D, else the first; 0.05 /
2216
+ # 4000 without one), and the viewpoints: scene cameras, camera bookmarks
2217
+ # (res://.summer/camera_bookmarks.json, read-only) and the floors for eye
2218
+ # points.
2219
+ func _zf_setup_views() -> void:
2220
+ var main: Camera3D = null
2221
+ for c in _cameras:
2222
+ var cam := c as Camera3D
2223
+ if main == null or cam.current:
2224
+ main = cam
2225
+ _views.append([cam.global_position, "camera " + String(cam.name)])
2226
+ if main != null and main.projection == Camera3D.PROJECTION_PERSPECTIVE:
2227
+ _near = main.near
2228
+ _far = main.far
2229
+ var path := "res://.summer/camera_bookmarks.json"
2230
+ if FileAccess.file_exists(path):
2231
+ var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
2232
+ if typeof(parsed) == TYPE_DICTIONARY:
2233
+ var re := RegEx.create_from_string("Vector3\\(\\s*([-+0-9.eE]+)\\s*,\\s*([-+0-9.eE]+)\\s*,\\s*([-+0-9.eE]+)\\s*\\)")
2234
+ for name in (parsed as Dictionary):
2235
+ var bm: Variant = (parsed as Dictionary)[name]
2236
+ if typeof(bm) != TYPE_DICTIONARY or _views.size() >= 64:
2237
+ continue
2238
+ var m := re.search(String((bm as Dictionary).get("position", "")))
2239
+ if m != null:
2240
+ _views.append([Vector3(float(m.get_string(1)), float(m.get_string(2)), float(m.get_string(3))), "bookmark " + String(name)])
2241
+ for rec in _inst:
2242
+ if rec["role"] == "floor":
2243
+ _walk.append(rec)
2244
+ if _walk.is_empty():
2245
+ for rec in _inst:
2246
+ if rec["role"] == "underlay":
2247
+ _walk.append(rec)
2248
+
2249
+
2250
+ # The nearest viewpoint to p: a camera, a bookmark, or eye height (1.6 m)
2251
+ # over the nearest floor. [distance, position, kind], or [] without any.
2252
+ func _zf_view(p: Vector3) -> Array:
2253
+ var best: Array = []
2254
+ for vw in _views:
2255
+ var vp: Vector3 = vw[0]
2256
+ if best.is_empty() or vp.distance_to(p) < float(best[0]):
2257
+ best = [vp.distance_to(p), vp, String(vw[1])]
2258
+ for f in _walk:
2259
+ var fa: AABB = f["waabb"]
2260
+ var ep := Vector3(clampf(p.x, fa.position.x, fa.end.x), fa.end.y + 1.6, clampf(p.z, fa.position.z, fa.end.z))
2261
+ if best.is_empty() or ep.distance_to(p) < float(best[0]):
2262
+ best = [ep.distance_to(p), ep, "walkable area"]
2263
+ return best
2264
+
2265
+
2266
+ # Twice the 24-bit depth step at `dist` for the main camera's near / far
2267
+ # (Compatibility / WebGL2), never under 0.1 mm (float noise in transforms).
2268
+ func _zf_tol(dist: float) -> float:
2269
+ var n := maxf(0.0001, _near)
2270
+ var f := maxf(n * 1.0001, _far)
2271
+ return maxf(0.0001, 2.0 * dist * dist * (f - n) / (f * n * ZF_DEPTH_STEPS))
2272
+
2273
+
2274
+ # p is seen from vp: a one-sided ray (like the renderer) reaches it.
2275
+ func _zf_seen(vp: Vector3, p: Vector3) -> bool:
2276
+ var dir := p - vp
2277
+ var dist := dir.length()
2278
+ if dist < 0.01:
2279
+ return true
2280
+ var hit := _ray(vp, p + dir / dist * 0.05, MASK_SOLID)
2281
+ return hit.is_empty() or (hit["position"] as Vector3).distance_to(p) <= 0.15
2282
+
2283
+
2284
+ func _finalize_groups(info: Dictionary) -> Array:
2285
+ var cands: Array = []
2286
+ for sr in (info.get("zf_raw", []) as Array):
2287
+ var sb: Dictionary = sr[3]
2288
+ for bk in sb:
2289
+ var bg: Array = sb[bk]
2290
+ if float(bg[0]) >= ZF_MIN_AREA:
2291
+ cands.append([float(bg[0]), int(sr[0]), sr[1], bool(sr[2]), bg])
2292
+ cands.sort_custom(func(a, b): return float(a[0]) > float(b[0]))
2293
+ if cands.size() > ZF_MAX_GROUPS:
2294
+ cands.resize(ZF_MAX_GROUPS)
2295
+ var out: Array = []
2296
+ for cnd in cands:
2297
+ var bg: Array = cnd[4]
2298
+ var area := float(cnd[0])
2299
+ var n := (bg[1] as Vector3).normalized()
2300
+ var o := n * (float(bg[2]) / area)
2301
+ var u := n.cross(Vector3.UP)
2302
+ if u.length() < 0.1:
2303
+ u = n.cross(Vector3.RIGHT)
2304
+ u = u.normalized()
2305
+ var v := n.cross(u).normalized()
2306
+ var verts: Array = bg[3]
2307
+ var tri := PackedVector2Array()
2308
+ tri.resize(verts.size())
2309
+ for k in verts.size():
2310
+ var p: Vector3 = verts[k]
2311
+ tri[k] = Vector2((p - o).dot(u), (p - o).dot(v))
2312
+ var hull := Geometry2D.convex_hull(tri)
2313
+ if hull.size() >= 2 and hull[0] == hull[hull.size() - 1]:
2314
+ hull.remove_at(hull.size() - 1)
2315
+ if hull.size() < 3:
2316
+ continue
2317
+ var lo := hull[0]
2318
+ var hi := hull[0]
2319
+ for q in hull:
2320
+ lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y))
2321
+ hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y))
2322
+ # Triangles bucketed on a 2D grid for point-in-face tests.
2323
+ var cell := maxf(0.05, sqrt(area / 64.0))
2324
+ var grid: Dictionary = {}
2325
+ var t := 0
2326
+ while t + 2 < tri.size():
2327
+ var a: Vector2 = tri[t]
2328
+ var b: Vector2 = tri[t + 1]
2329
+ var c: Vector2 = tri[t + 2]
2330
+ for gx in range(floori(minf(a.x, minf(b.x, c.x)) / cell), floori(maxf(a.x, maxf(b.x, c.x)) / cell) + 1):
2331
+ for gy in range(floori(minf(a.y, minf(b.y, c.y)) / cell), floori(maxf(a.y, maxf(b.y, c.y)) / cell) + 1):
2332
+ var gk := Vector2i(gx, gy)
2333
+ if not grid.has(gk):
2334
+ grid[gk] = []
2335
+ (grid[gk] as Array).append(t)
2336
+ t += 3
2337
+ out.append({"s": int(cnd[1]), "mat": cnd[2], "double": bool(cnd[3]), "area": area, "n": n, "o": o, "u": u, "v": v, "tri": tri, "hull": hull, "lo": lo, "hi": hi, "cell": cell, "grid": grid})
2338
+ return out
2339
+
2340
+
2341
+ # World bounds of a face group (its 2D bounds' corners on its plane).
2342
+ func _group_box(g: Dictionary, xf: Transform3D) -> AABB:
2343
+ var o: Vector3 = g["o"]
2344
+ var u: Vector3 = g["u"]
2345
+ var v: Vector3 = g["v"]
2346
+ var lo: Vector2 = g["lo"]
2347
+ var hi: Vector2 = g["hi"]
2348
+ var box := AABB(xf * (o + u * lo.x + v * lo.y), Vector3.ZERO)
2349
+ box = box.expand(xf * (o + u * hi.x + v * lo.y))
2350
+ box = box.expand(xf * (o + u * lo.x + v * hi.y))
2351
+ box = box.expand(xf * (o + u * hi.x + v * hi.y))
2352
+ return box
2353
+
2354
+
2355
+ func _in_group(g: Dictionary, local: Vector3) -> bool:
2356
+ var o: Vector3 = g["o"]
2357
+ var q := Vector2((local - o).dot(g["u"] as Vector3), (local - o).dot(g["v"] as Vector3))
2358
+ var cell := float(g["cell"])
2359
+ var list: Variant = (g["grid"] as Dictionary).get(Vector2i(floori(q.x / cell), floori(q.y / cell)), null)
2360
+ if list == null:
2361
+ return false
2362
+ var tri: PackedVector2Array = g["tri"]
2363
+ for t in (list as Array):
2364
+ var b := int(t)
2365
+ if Geometry2D.point_is_inside_triangle(q, tri[b], tri[b + 1], tri[b + 2]):
2366
+ return true
2367
+ return false
2368
+
2369
+
2370
+ static func _poly_area(poly: PackedVector2Array) -> float:
2371
+ var a := 0.0
2372
+ for k in poly.size():
2373
+ var p: Vector2 = poly[k]
2374
+ var q: Vector2 = poly[(k + 1) % poly.size()]
2375
+ a += p.x * q.y - q.x * p.y
2376
+ return absf(a) * 0.5
2377
+
2378
+
2379
+ # Overlap of group ga (instance transform xa) and gb (xb) on about one
2380
+ # plane: both hulls clipped in ga's world plane, then a grid of up to ~120
2381
+ # samples inside the clip, each tested against BOTH groups' own triangles (a
2382
+ # wall face's window opening is not wall). [overlap m2, world centre] or [].
2383
+ func _group_overlap(ga: Dictionary, xa: Transform3D, gb: Dictionary, xb: Transform3D) -> Array:
2384
+ var oa: Vector3 = xa * (ga["o"] as Vector3)
2385
+ var na := (xa.basis.inverse().transposed() * (ga["n"] as Vector3)).normalized()
2386
+ var ua: Vector3 = xa.basis * (ga["u"] as Vector3)
2387
+ ua = (ua - na * ua.dot(na)).normalized()
2388
+ var va := na.cross(ua)
2389
+ var polys := Geometry2D.intersect_polygons(_hull_in(ga, xa, oa, ua, va), _hull_in(gb, xb, oa, ua, va))
2390
+ var inv_a := xa.affine_inverse()
2391
+ var inv_b := xb.affine_inverse()
2392
+ var overlap := 0.0
2393
+ var centre := Vector3.ZERO
2394
+ var weight := 0.0
2395
+ for poly in polys:
2396
+ var pa := _poly_area(poly)
2397
+ if pa < ZF_MIN_AREA * 0.25:
2398
+ continue
2399
+ var lo: Vector2 = poly[0]
2400
+ var hi: Vector2 = poly[0]
2401
+ for q in poly:
2402
+ lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y))
2403
+ hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y))
2404
+ var step := maxf(0.01, sqrt(pa / 100.0))
2405
+ var inside := 0
2406
+ var both := 0
2407
+ var cx := 0.0
2408
+ var cy := 0.0
2409
+ var y := lo.y + step * 0.5
2410
+ while y < hi.y and inside < 160:
2411
+ var x := lo.x + step * 0.5
2412
+ while x < hi.x and inside < 160:
2413
+ var q := Vector2(x, y)
2414
+ if Geometry2D.is_point_in_polygon(q, poly):
2415
+ inside += 1
2416
+ var w := oa + ua * x + va * y
2417
+ if _in_group(ga, inv_a * w) and _in_group(gb, inv_b * w):
2418
+ both += 1
2419
+ cx += x
2420
+ cy += y
2421
+ x += step
2422
+ y += step
2423
+ if inside == 0 or both == 0:
2424
+ continue
2425
+ var ov := pa * float(both) / float(inside)
2426
+ overlap += ov
2427
+ centre += (oa + ua * (cx / float(both)) + va * (cy / float(both))) * ov
2428
+ weight += ov
2429
+ if overlap < ZF_MIN_AREA or weight <= 0.0:
2430
+ return []
2431
+ return [overlap, centre / weight]
2432
+
2433
+
2434
+ func _hull_in(g: Dictionary, xf: Transform3D, o: Vector3, u: Vector3, v: Vector3) -> PackedVector2Array:
2435
+ var go: Vector3 = g["o"]
2436
+ var gu: Vector3 = g["u"]
2437
+ var gv: Vector3 = g["v"]
2438
+ var out := PackedVector2Array()
2439
+ for q in (g["hull"] as PackedVector2Array):
2440
+ var w: Vector3 = xf * (go + gu * q.x + gv * q.y)
2441
+ out.append(Vector2((w - o).dot(u), (w - o).dot(v)))
2442
+ return out
2443
+
2444
+
2445
+ # Parallel enough and close enough to be candidates (gap is the caller's).
2446
+ func _zf_parallel(na: Vector3, nb: Vector3, double_a: bool, double_b: bool) -> int:
2447
+ var dot := na.dot(nb)
2448
+ if dot > 0.999:
2449
+ return 1
2450
+ if dot < -0.999 and double_a and double_b:
2451
+ return -1
2452
+ return 0
2453
+
2454
+
2455
+ func _zf_pair(ra: Dictionary, rb: Dictionary, pairs: Dictionary) -> void:
2456
+ var a: int = ra["i"]
2457
+ var b: int = rb["i"]
2458
+ if ra["node"] == rb["node"]:
2459
+ return
2460
+ if not (bool((_inst[a] as Dictionary)["in"]) or bool((_inst[b] as Dictionary)["in"])):
2461
+ return
2462
+ var box_a: AABB = ra["box"]
2463
+ var box_b: AABB = rb["box"]
2464
+ if not box_a.grow(0.03).intersects(box_b):
2465
+ return
2466
+ var na: Vector3 = ra["n"]
2467
+ var facing := _zf_parallel(na, rb["n"] as Vector3, bool(ra["double"]), bool(rb["double"]))
2468
+ if facing == 0:
2469
+ return
2470
+ var gap := absf(na.dot((rb["o"] as Vector3) - (ra["o"] as Vector3)))
2471
+ if gap > ZF_MAX_GAP:
2472
+ return
2473
+ var mid := box_a.grow(0.03).intersection(box_b).get_center()
2474
+ var view := _zf_view(mid)
2475
+ var dist := 30.0 if view.is_empty() else float(view[0])
2476
+ if gap > minf(ZF_MAX_GAP, 1.5 * _zf_tol(dist)):
2477
+ return
2478
+ var ov := _group_overlap(ra["g"] as Dictionary, ra["xf"] as Transform3D, rb["g"] as Dictionary, rb["xf"] as Transform3D)
2479
+ if ov.is_empty():
2480
+ return
2481
+ var centre: Vector3 = ov[1]
2482
+ view = _zf_view(centre)
2483
+ var seen := false
2484
+ if not view.is_empty():
2485
+ seen = _zf_seen(view[1] as Vector3, centre)
2486
+ var key := str(mini(a, b)) + ":" + str(maxi(a, b))
2487
+ var prev: Variant = pairs.get(key, null)
2488
+ var area := float(ov[0])
2489
+ if prev != null:
2490
+ var pr: Array = prev
2491
+ pr[14] = snappedf(float(pr[14]) + area, 0.001)
2492
+ pr[15] = int(pr[15]) + 1
2493
+ if area <= float(pr[2]):
2494
+ return
2495
+ var ga: Dictionary = ra["g"]
2496
+ var gb: Dictionary = rb["g"]
2497
+ var row := [a, b, snappedf(area, 0.001), snappedf(gap, 0.00001), _a3(centre), _a3(na, 0.0001), snappedf(30.0 if view.is_empty() else float(view[0]), 0.01), ("typical view distance" if view.is_empty() else String(view[2])), seen, _zf_flags(ra["mat"]), _zf_flags(rb["mat"]), _mat_name(ra["mat"], int(ga["s"])), _mat_name(rb["mat"], int(gb["s"])), facing < 0, snappedf(area, 0.001), 1, _rel(ra["node"] as Node), _rel(rb["node"] as Node)]
2498
+ if prev != null:
2499
+ row[14] = (prev as Array)[14]
2500
+ row[15] = (prev as Array)[15]
2501
+ pairs[key] = row
2502
+
2503
+
2504
+ # Within one mesh: groups of DIFFERENT surfaces on one plane (an overlay
2505
+ # layer with no offset). Once per mesh resource, with up to 4 instances that
2506
+ # show it (the tolerance and visibility are theirs). [] when clean.
2507
+ func _zf_within(key: int, mesh_name: String, uses: Array) -> Array:
2508
+ var groups: Array = _zf_groups[key]
2509
+ var best: Array = []
2510
+ for i in groups.size():
2511
+ var ga: Dictionary = groups[i]
2512
+ for j in range(i + 1, groups.size()):
2513
+ var gb: Dictionary = groups[j]
2514
+ if int(ga["s"]) == int(gb["s"]):
2515
+ continue
2516
+ var facing := _zf_parallel(ga["n"] as Vector3, gb["n"] as Vector3, bool(ga["double"]), bool(gb["double"]))
2517
+ if facing == 0:
2518
+ continue
2519
+ var gap := absf((ga["n"] as Vector3).dot((gb["o"] as Vector3) - (ga["o"] as Vector3)))
2520
+ if gap > ZF_MAX_GAP:
2521
+ continue
2522
+ var ov := _group_overlap(ga, Transform3D.IDENTITY, gb, Transform3D.IDENTITY)
2523
+ if ov.is_empty() or (not best.is_empty() and float(ov[0]) <= float(best[0])):
2524
+ continue
2525
+ best = [float(ov[0]), gap, ov[1], ga, gb, facing]
2526
+ if best.is_empty():
2527
+ return []
2528
+ var shows: Array = []
2529
+ var view_d := -1.0
2530
+ var view_k := "typical view distance"
2531
+ var seen := false
2532
+ var centre_w := Vector3.ZERO
2533
+ var normal_w := Vector3.ZERO
2534
+ var ga2: Dictionary = best[3]
2535
+ var gb2: Dictionary = best[4]
2536
+ for use in uses:
2537
+ var rec_i: int = use[0]
2538
+ if not bool((_inst[rec_i] as Dictionary)["in"]) or shows.size() >= 4:
2539
+ continue
2540
+ var xf: Transform3D = use[2]
2541
+ var c: Vector3 = xf * (best[2] as Vector3)
2542
+ var view := _zf_view(c)
2543
+ var dist := 30.0 if view.is_empty() else float(view[0])
2544
+ if float(best[1]) > minf(ZF_MAX_GAP, 1.5 * _zf_tol(dist)):
2545
+ continue
2546
+ shows.append(rec_i)
2547
+ if shows.size() == 1:
2548
+ centre_w = c
2549
+ normal_w = (xf.basis.inverse().transposed() * (ga2["n"] as Vector3)).normalized()
2550
+ if dist > view_d:
2551
+ view_d = dist
2552
+ view_k = "typical view distance" if view.is_empty() else String(view[2])
2553
+ if not seen and not view.is_empty():
2554
+ seen = _zf_seen(view[1] as Vector3, c)
2555
+ if shows.is_empty():
2556
+ return []
2557
+ return [mesh_name, int(ga2["s"]), int(gb2["s"]), _mat_name(ga2["mat"], int(ga2["s"])), _mat_name(gb2["mat"], int(gb2["s"])), snappedf(float(best[0]), 0.001), snappedf(float(best[1]), 0.00001), shows, uses.size(), _a3(centre_w), _a3(normal_w, 0.0001), snappedf(view_d, 0.01), view_k, seen, _zf_flags(ga2["mat"]), _zf_flags(gb2["mat"]), int(best[5]) < 0]
2558
+
2559
+
2560
+ # The material a surface renders with on this node: override, surface
2561
+ # override, then the mesh's own.
2562
+ func _surface_mat(node: Node, mesh: Mesh, s: int) -> Material:
2563
+ if node is GeometryInstance3D and (node as GeometryInstance3D).material_override != null:
2564
+ return (node as GeometryInstance3D).material_override
2565
+ if node is MeshInstance3D and s < (node as MeshInstance3D).get_surface_override_material_count():
2566
+ var ov := (node as MeshInstance3D).get_surface_override_material(s)
2567
+ if ov != null:
2568
+ return ov
2569
+ if mesh != null and s < mesh.get_surface_count():
2570
+ return mesh.surface_get_material(s)
2571
+ return null
2572
+
2573
+
2574
+ static func _mat_name(mat: Variant, s: int) -> String:
2575
+ if mat is Material:
2576
+ var m := mat as Material
2577
+ if String(m.resource_name) != "":
2578
+ return String(m.resource_name)
2579
+ if String(m.resource_path) != "":
2580
+ return String(m.resource_path).get_file()
2581
+ return "surface %d" % s
2582
+
2583
+
2584
+ # Why a coplanar pair may be intentional (the judge demotes it to look), from
2585
+ # the material alone: render_priority, a material or shader that offsets
2586
+ # depth or the vertex along the normal, a see-through (alpha) material drawn
2587
+ # after the opaque pass like decals and overlays, or no depth test / write.
2588
+ func _zf_flags(mat: Variant) -> Array:
2589
+ var out: Array = []
2590
+ if mat is Material:
2591
+ var m := mat as Material
2592
+ if m.render_priority != 0:
2593
+ out.append("render_priority %d" % m.render_priority)
2594
+ if m is BaseMaterial3D:
2595
+ var bm := m as BaseMaterial3D
2596
+ if bm.grow and absf(bm.grow_amount) > 0.0:
2597
+ out.append("material grows along the normal")
2598
+ if bm.no_depth_test or bm.depth_draw_mode == BaseMaterial3D.DEPTH_DRAW_DISABLED:
2599
+ out.append("material skips the depth test or depth writes")
2600
+ if m is ShaderMaterial and (m as ShaderMaterial).shader != null:
2601
+ var code := (m as ShaderMaterial).shader.code
2602
+ if _re_offset.search(code) != null:
2603
+ out.append("shader offsets depth or the vertex along the normal")
2604
+ if code.contains("depth_test_disabled") or code.contains("depth_draw_never"):
2605
+ out.append("shader skips the depth test or depth writes")
2606
+ if _mat_see_through(m):
2607
+ out.append("see-through material (alpha): drawn after opaque surfaces, like a decal or overlay")
2608
+ return out
2609
+
2610
+
2611
+ # ---------------------------------------------------------------------------
2612
+ # Gap detectors: exposed_edge (A), open_fixture_end and depth_step (C). They
2613
+ # share one setup (_gk_setup): walkable eye points, indoor cells and
2614
+ # axis-aligned facade lines grouped into buildings. Which pieces they look at
2615
+ # comes from the roles (geometry only): walls, and the facade members
2616
+ # (bands, piers, joints) for edges and band levels; any piece whose mesh has
2617
+ # two or more open rims of one size for open ends. Everything here MEASURES;
2618
+ # gaps.ts thresholds, confirms (A by C) and grades.
2619
+ #
2620
+ # Sight lines are two-sided rays (any face blocks).
2621
+ # A point is "visible from walkable space" when an eye point (1.7 m over an
2622
+ # open-sky floor cell, flood-filled from the scene's cameras and characters
2623
+ # when it has any) sees it within 30 m without the sight line crossing an
2624
+ # indoor cell (a cell under a roof-sized piece: looking through a building).
2625
+ # ---------------------------------------------------------------------------
2626
+
2627
+ const GK_EYE_H := 1.7
2628
+ const GK_EYE_STEP := 1.5
2629
+ const GK_EYE_CELL := 8.0
2630
+ const GK_IN_CELL := 0.5
2631
+ const GK_VIS_CELL := 0.5
2632
+ const GK_EDGE_SPACING := 0.25
2633
+ const GK_WELD := 10000.0
2634
+ # depth_step: wall rows every C_WALL_ROW m; runs merge across one row step.
2635
+ const C_WALL_ROW := 1.25
2636
+ const C_MERGE_Y := 1.3
2637
+
2638
+ var _gk_ready := false
2639
+ var _gk_eyes := PackedVector3Array()
2640
+ var _gk_eye_grid: Dictionary = {}
2641
+ var _gk_ring: Array = []
2642
+ var _gk_indoor: Dictionary = {}
2643
+ var _gk_lines: Array = []
2644
+ var _gk_vis_cache: Dictionary = {}
2645
+ var _gk_sphere := RID()
2646
+ var _gk_sphere_q: PhysicsShapeQueryParameters3D = null
2647
+ var _gk_stats: Dictionary = {}
2648
+ var _gk_floor_y := 0.0
2649
+
2650
+
2651
+ func _gk_setup() -> void:
2652
+ if _gk_ready:
2653
+ return
2654
+ _gk_ready = true
2655
+ var b := _gk_struct_bounds()
2656
+ if b.size == Vector3.ZERO:
2657
+ _gk_stats = {"eyes": 0, "lines": 0}
2658
+ return
2659
+ _gk_build_eyes(b)
2660
+ _gk_build_indoor(b)
2661
+ _gk_build_lines()
2662
+ _gk_stats = {"eyes": _gk_eyes.size(), "indoor_cells": _gk_indoor.size(), "lines": _gk_lines.size()}
2663
+
2664
+
2665
+ func _gk_struct_bounds() -> AABB:
2666
+ var b := AABB()
2667
+ var first := true
2668
+ var tops: Array = []
2669
+ for rec in _inst:
2670
+ var role := String(rec["role"])
2671
+ if role == "underlay" or role == "dressing" or role == "":
2672
+ continue
2673
+ var wa: AABB = rec["waabb"]
2674
+ if wa.size.x > 120.0 or wa.size.z > 120.0:
2675
+ continue
2676
+ if role == "floor":
2677
+ tops.append(wa.end.y)
2678
+ b = wa if first else b.merge(wa)
2679
+ first = false
2680
+ if not tops.is_empty():
2681
+ tops.sort()
2682
+ _gk_floor_y = float(tops[tops.size() / 2])
2683
+ elif not first:
2684
+ _gk_floor_y = b.position.y
2685
+ return b
2686
+
2687
+
2688
+ func _gk_build_eyes(b: AABB) -> void:
2689
+ var floors := false
2690
+ for rec in _inst:
2691
+ if rec["role"] == "floor":
2692
+ floors = true
2693
+ break
2694
+ var top := b.end.y + 5.0
2695
+ var bot := b.position.y - 5.0
2696
+ var x0 := b.position.x - 8.0
2697
+ var z0 := b.position.z - 8.0
2698
+ var nx := int((b.size.x + 16.0) / GK_EYE_STEP) + 1
2699
+ var nz := int((b.size.z + 16.0) / GK_EYE_STEP) + 1
2700
+ var raw: Dictionary = {}
2701
+ for i in nx:
2702
+ for k in nz:
2703
+ var x := x0 + i * GK_EYE_STEP
2704
+ var z := z0 + k * GK_EYE_STEP
2705
+ # The first surface from above (two-sided): a floor with open sky
2706
+ # over it; under a roof or a balcony the roof is hit first.
2707
+ var hit := _ray(Vector3(x, top, z), Vector3(x, bot, z), MASK_SOLID, [], true)
2708
+ if hit.is_empty() or absf((hit["normal"] as Vector3).y) <= 0.7:
2709
+ continue
2710
+ var hp: Vector3 = hit["position"]
2711
+ if floors:
2712
+ var hi := _hit_inst(hit)
2713
+ if hi < 0 or String((_inst[hi] as Dictionary)["role"]) != "floor":
2714
+ continue
2715
+ elif hp.y > _gk_floor_y + 2.0:
2716
+ continue
2717
+ raw[Vector2i(i, k)] = hp + Vector3(0, GK_EYE_H, 0)
2718
+ # Walkable: reachable from the scene's cameras and characters through
2719
+ # 4-neighbour steps not blocked at 0.5 and 1.2 m (without any, every
2720
+ # open-sky floor cell counts).
2721
+ var seeds: Array = []
2722
+ for c in _cameras:
2723
+ seeds.append((c as Node3D).global_position)
2724
+ for ch in _players:
2725
+ seeds.append((ch as Node3D).global_position)
2726
+ var keep: Dictionary = {}
2727
+ if seeds.is_empty():
2728
+ for key in raw:
2729
+ keep[key] = true
2730
+ else:
2731
+ var queue: Array = []
2732
+ for sd in seeds:
2733
+ var best: Variant = null
2734
+ var bd := 3.0
2735
+ var sp: Vector3 = sd
2736
+ for key in raw:
2737
+ var e: Vector3 = raw[key]
2738
+ var dd := Vector2(e.x - sp.x, e.z - sp.z).length()
2739
+ if dd < bd:
2740
+ bd = dd
2741
+ best = key
2742
+ if best != null and not keep.has(best):
2743
+ keep[best] = true
2744
+ queue.append(best)
2745
+ if queue.is_empty():
2746
+ for key in raw:
2747
+ keep[key] = true
2748
+ while not queue.is_empty():
2749
+ var kk: Vector2i = queue.pop_back()
2750
+ var e1: Vector3 = raw[kk]
2751
+ for off in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
2752
+ var k2: Vector2i = kk + off
2753
+ if keep.has(k2) or not raw.has(k2):
2754
+ continue
2755
+ var e2: Vector3 = raw[k2]
2756
+ if absf(e2.y - e1.y) > 0.5:
2757
+ continue
2758
+ var lo := Vector3(0, 0.5 - GK_EYE_H, 0)
2759
+ var mid := Vector3(0, 1.2 - GK_EYE_H, 0)
2760
+ if not _ray(e1 + lo, e2 + lo, MASK_SOLID, [], true).is_empty() or not _ray(e1 + mid, e2 + mid, MASK_SOLID, [], true).is_empty():
2761
+ continue
2762
+ keep[k2] = true
2763
+ queue.append(k2)
2764
+ for key in keep:
2765
+ var e: Vector3 = raw[key]
2766
+ var cell := Vector2i(floori(e.x / GK_EYE_CELL), floori(e.z / GK_EYE_CELL))
2767
+ if not _gk_eye_grid.has(cell):
2768
+ _gk_eye_grid[cell] = PackedInt32Array()
2769
+ var arr: PackedInt32Array = _gk_eye_grid[cell]
2770
+ arr.append(_gk_eyes.size())
2771
+ _gk_eye_grid[cell] = arr
2772
+ _gk_eyes.append(e)
2773
+ var r := int(ceil(32.0 / GK_EYE_CELL))
2774
+ for dx in range(-r, r + 1):
2775
+ for dz in range(-r, r + 1):
2776
+ _gk_ring.append(Vector2i(dx, dz))
2777
+ _gk_ring.sort_custom(func(a, c): return (a as Vector2i).length_squared() < (c as Vector2i).length_squared())
2778
+
2779
+
2780
+ # Indoor cells: 0.5 m cells whose up-ray from 0.5 m over the floor level hits a
2781
+ # roof-sized piece (2.5 m or more in x and z). A sight line through one looks
2782
+ # through a building (a hole, a window without glass), not from walkable space.
2783
+ func _gk_build_indoor(b: AABB) -> void:
2784
+ var y := _gk_floor_y + 0.5
2785
+ var x := b.position.x - 2.0
2786
+ while x <= b.end.x + 2.0:
2787
+ var z := b.position.z - 2.0
2788
+ while z <= b.end.z + 2.0:
2789
+ var hit := _ray(Vector3(x, y, z), Vector3(x, y + 60.0, z), MASK_SOLID, [], true)
2790
+ if not hit.is_empty():
2791
+ var hi := _hit_inst(hit)
2792
+ if hi >= 0:
2793
+ var ab: AABB = (_inst[hi] as Dictionary)["waabb"]
2794
+ if ab.size.x >= 2.5 and ab.size.z >= 2.5:
2795
+ _gk_indoor[Vector2i(floori(x / GK_IN_CELL), floori(z / GK_IN_CELL))] = true
2796
+ z += GK_IN_CELL
2797
+ x += GK_IN_CELL
2798
+
2799
+
2800
+ func _gk_crosses_indoor(e: Vector3, p: Vector3) -> bool:
2801
+ var v := Vector2(p.x - e.x, p.z - e.z)
2802
+ var L := v.length()
2803
+ if L < 0.7:
2804
+ return false
2805
+ var u := v / L
2806
+ var t := 0.25
2807
+ while t < L - 0.6:
2808
+ var q := Vector2(e.x, e.z) + u * t
2809
+ if _gk_indoor.has(Vector2i(floori(q.x / GK_IN_CELL), floori(q.y / GK_IN_CELL))):
2810
+ return true
2811
+ t += 0.45
2812
+ return false
2813
+
2814
+
2815
+ # Eye points that see p (at most max_eyes, at most max_tests sight lines,
2816
+ # nearest cells first). facing != ZERO: only eyes on that side of p.
2817
+ func _gk_visible_eyes(p: Vector3, max_eyes: int, max_tests: int, facing := Vector3.ZERO, facing_min := 0.2) -> PackedVector3Array:
2818
+ var out := PackedVector3Array()
2819
+ if _gk_eyes.is_empty():
2820
+ return out
2821
+ var c0 := Vector2i(floori(p.x / GK_EYE_CELL), floori(p.z / GK_EYE_CELL))
2822
+ var tests := 0
2823
+ for off in _gk_ring:
2824
+ var c: Vector2i = c0 + (off as Vector2i)
2825
+ if not _gk_eye_grid.has(c):
2826
+ continue
2827
+ var cell_eyes: PackedInt32Array = _gk_eye_grid[c]
2828
+ var stride := maxi(1, cell_eyes.size() / 4)
2829
+ var j := 0
2830
+ while j < cell_eyes.size():
2831
+ var e: Vector3 = _gk_eyes[cell_eyes[j]]
2832
+ j += stride
2833
+ var dv := p - e
2834
+ var dist := dv.length()
2835
+ if dist > 30.0 or dist < 0.3:
2836
+ continue
2837
+ if facing != Vector3.ZERO and (-dv).dot(facing) < facing_min * dist:
2838
+ continue
2839
+ tests += 1
2840
+ if _ray(e, p - dv / dist * 0.01, MASK_SOLID, [], true).is_empty() and not _gk_crosses_indoor(e, p):
2841
+ out.append(e)
2842
+ if out.size() >= max_eyes:
2843
+ return out
2844
+ if tests >= max_tests:
2845
+ return out
2846
+ return out
2847
+
2848
+
2849
+ # Cached per 50 cm cell (exposed_edge tests thousands of edge samples).
2850
+ func _gk_visible_cached(p: Vector3) -> PackedVector3Array:
2851
+ var key := Vector3i(floori(p.x / GK_VIS_CELL), floori(p.y / GK_VIS_CELL), floori(p.z / GK_VIS_CELL))
2852
+ if _gk_vis_cache.has(key):
2853
+ return _gk_vis_cache[key]
2854
+ var ve := _gk_visible_eyes(p, 3, 12)
2855
+ _gk_vis_cache[key] = ve
2856
+ return ve
2857
+
2858
+
2859
+ # Facade point visible: 15 cm off the facade on either side of its thin axis.
2860
+ func _gk_point_visible(p: Vector3, th: int) -> bool:
2861
+ for sgn in [1.0, -1.0]:
2862
+ var q := p
2863
+ q[th] += 0.15 * float(sgn)
2864
+ if not _gk_visible_eyes(q, 1, 60).is_empty():
2865
+ return true
2866
+ return false
2867
+
2868
+
2869
+ # Facade lines: wall-like pieces (1-40 m tall, at least 0.8 m wide
2870
+ # along one horizontal axis and at most 0.6 m along the other), buildings =
2871
+ # pieces whose bounds touch within 0.15 m, lines = one building's pieces on
2872
+ # one axis within 0.35 m of one plane, merged along with gaps up to 1 m;
2873
+ # sides = the sides with walkable eyes 0.3-12 m in front.
2874
+ func _gk_build_lines() -> void:
2875
+ var cands: Array = []
2876
+ for rec in _inst:
2877
+ var role := String(rec["role"])
2878
+ if role != "wall" and role != "struct":
2879
+ continue
2880
+ var ab: AABB = rec["waabb"]
2881
+ if ab.size.y < 1.0 or ab.size.y > 40.0:
2882
+ continue
2883
+ var ax := -1
2884
+ if ab.size.x >= 0.8 and ab.size.z <= 0.6:
2885
+ ax = 0
2886
+ elif ab.size.z >= 0.8 and ab.size.x <= 0.6:
2887
+ ax = 2
2888
+ if ax < 0:
2889
+ continue
2890
+ var th := 2 - ax
2891
+ cands.append({"i": int(rec["i"]), "ax": ax, "c": ab.get_center()[th], "lo": ab.position[ax], "hi": ab.end[ax], "y0": ab.position.y, "y1": ab.end.y, "tmin": ab.position[th], "tmax": ab.end[th], "ab": ab})
2892
+ # Buildings: union-find over touching bounds, through a 4 m grid.
2893
+ var par := PackedInt32Array()
2894
+ par.resize(cands.size())
2895
+ for i in cands.size():
2896
+ par[i] = i
2897
+ var grid: Dictionary = {}
2898
+ for i in cands.size():
2899
+ var a: AABB = (cands[i]["ab"] as AABB).grow(0.15)
2900
+ for gx in range(floori(a.position.x / 4.0), floori(a.end.x / 4.0) + 1):
2901
+ for gz in range(floori(a.position.z / 4.0), floori(a.end.z / 4.0) + 1):
2902
+ var gk := Vector2i(gx, gz)
2903
+ if not grid.has(gk):
2904
+ grid[gk] = []
2905
+ for j in (grid[gk] as Array):
2906
+ if a.intersects(cands[j]["ab"] as AABB):
2907
+ var ri := _gk_find(par, i)
2908
+ var rj := _gk_find(par, int(j))
2909
+ if ri != rj:
2910
+ par[ri] = rj
2911
+ (grid[gk] as Array).append(i)
2912
+ for i in cands.size():
2913
+ cands[i]["b"] = _gk_find(par, i)
2914
+ cands.sort_custom(func(a, c): return int(a["ax"]) < int(c["ax"]) or (int(a["ax"]) == int(c["ax"]) and float(a["c"]) < float(c["c"])))
2915
+ var groups: Array = []
2916
+ for c in cands:
2917
+ var placed := false
2918
+ for g in groups:
2919
+ if int(g["b"]) == int(c["b"]) and int(g["ax"]) == int(c["ax"]) and absf(float(g["c"]) - float(c["c"])) <= 0.35:
2920
+ (g["items"] as Array).append(c)
2921
+ placed = true
2922
+ break
2923
+ if not placed:
2924
+ groups.append({"b": c["b"], "ax": c["ax"], "c": c["c"], "items": [c]})
2925
+ for g in groups:
2926
+ var items: Array = g["items"]
2927
+ items.sort_custom(func(a, c): return float(a["lo"]) < float(c["lo"]))
2928
+ var cur: Variant = null
2929
+ for it in items:
2930
+ if cur != null and float(it["lo"]) <= float(cur["hi"]) + 1.0:
2931
+ cur["hi"] = maxf(float(cur["hi"]), float(it["hi"]))
2932
+ cur["y0"] = minf(float(cur["y0"]), float(it["y0"]))
2933
+ cur["y1"] = maxf(float(cur["y1"]), float(it["y1"]))
2934
+ cur["tmin"] = minf(float(cur["tmin"]), float(it["tmin"]))
2935
+ cur["tmax"] = maxf(float(cur["tmax"]), float(it["tmax"]))
2936
+ (cur["pieces"] as Array).append(it["i"])
2937
+ else:
2938
+ if cur != null:
2939
+ _gk_lines.append(cur)
2940
+ cur = {"b": g["b"], "ax": g["ax"], "c": g["c"], "lo": it["lo"], "hi": it["hi"], "y0": it["y0"], "y1": it["y1"], "tmin": it["tmin"], "tmax": it["tmax"], "pieces": [it["i"]]}
2941
+ if cur != null:
2942
+ _gk_lines.append(cur)
2943
+ for ln in _gk_lines:
2944
+ var sides: Array = []
2945
+ var ax: int = ln["ax"]
2946
+ var th := 2 - ax
2947
+ for s in [1, -1]:
2948
+ var face: float = float(ln["tmax"]) if s == 1 else float(ln["tmin"])
2949
+ for e in _gk_eyes:
2950
+ var off: float = (e[th] - face) * float(s)
2951
+ if off > 0.3 and off < 12.0 and e[ax] > float(ln["lo"]) - 2.0 and e[ax] < float(ln["hi"]) + 2.0:
2952
+ sides.append(s)
2953
+ break
2954
+ ln["sides"] = sides
2955
+
2956
+
2957
+ static func _gk_find(par: PackedInt32Array, i: int) -> int:
2958
+ while par[i] != i:
2959
+ par[i] = par[par[i]]
2960
+ i = par[i]
2961
+ return i
2962
+
2963
+
2964
+ func _gk_line_of(ab: AABB, margin: float) -> int:
2965
+ var best := -1
2966
+ var bd := INF
2967
+ for li in _gk_lines.size():
2968
+ var ln: Dictionary = _gk_lines[li]
2969
+ var ax: int = ln["ax"]
2970
+ var th := 2 - ax
2971
+ if ab.end[ax] < float(ln["lo"]) - margin or ab.position[ax] > float(ln["hi"]) + margin:
2972
+ continue
2973
+ if ab.end.y < float(ln["y0"]) - margin or ab.position.y > float(ln["y1"]) + margin:
2974
+ continue
2975
+ if ab.end[th] < float(ln["tmin"]) - margin or ab.position[th] > float(ln["tmax"]) + margin:
2976
+ continue
2977
+ var d := absf(ab.get_center()[th] - float(ln["c"]))
2978
+ if d < bd:
2979
+ bd = d
2980
+ best = li
2981
+ return best
2982
+
2983
+
2984
+ # Door-like openings: inserts at least 1.8 m tall whose bottom is within 30 cm
2985
+ # of the floor level. [[lo, hi]] world bounds (depth_step skips the recess a
2986
+ # door leaves).
2987
+ func _gk_doors() -> Array:
2988
+ var out: Array = []
2989
+ for rec in _inst:
2990
+ if rec["role"] != "insert":
2991
+ continue
2992
+ var ab: AABB = rec["waabb"]
2993
+ if ab.size.y >= 1.8 and absf(ab.position.y - _gk_floor_y) <= 0.3 and out.size() < 200:
2994
+ out.append([_a3(ab.position), _a3(ab.end)])
2995
+ return out
2996
+
2997
+
2998
+ func _gk_lines_rows() -> Array:
2999
+ var out: Array = []
3000
+ for ln in _gk_lines:
3001
+ out.append({"b": int(ln["b"]), "ax": int(ln["ax"]), "c": snappedf(float(ln["c"]), 0.001), "lo": snappedf(float(ln["lo"]), 0.001), "hi": snappedf(float(ln["hi"]), 0.001), "y0": snappedf(float(ln["y0"]), 0.001), "y1": snappedf(float(ln["y1"]), 0.001), "tmin": snappedf(float(ln["tmin"]), 0.001), "tmax": snappedf(float(ln["tmax"]), 0.001), "sides": ln["sides"], "pieces": ln["pieces"]})
3002
+ return out
3003
+
3004
+
3005
+ # ---------------------------------------------------------------------------
3006
+ # exposed_edge (A): open outline edges of walls and facade members (bands,
3007
+ # piers, joints) that nothing covers within 4-7 mm and that walkable space
3008
+ # sees, with what shows behind them. Per mesh resource (cached): welded
3009
+ # boundary edges of the opaque surfaces, sampled every 25 cm, only those on
3010
+ # the mesh's own bounds (openings inside a module are by design).
3011
+ # ---------------------------------------------------------------------------
3012
+
3013
+ # Raw boundary edges of a mesh's opaque surfaces: [a, b, n, d] per edge,
3014
+ # mesh-local (n: the edge's sheet normal, d: outward from its triangle).
3015
+ func _gk_boundary(mesh: Mesh, info: Dictionary) -> PackedVector3Array:
3016
+ if info.has("gk_boundary"):
3017
+ return info["gk_boundary"]
3018
+ var out := PackedVector3Array()
3019
+ var weld: Dictionary = {}
3020
+ var pos := PackedVector3Array()
3021
+ var cnt: Dictionary = {}
3022
+ var third: Dictionary = {}
3023
+ for s in mesh.get_surface_count():
3024
+ if mesh is ArrayMesh and (mesh as ArrayMesh).surface_get_primitive_type(s) != Mesh.PRIMITIVE_TRIANGLES:
3025
+ continue
3026
+ var mat := mesh.surface_get_material(s)
3027
+ if mat is BaseMaterial3D and (mat as BaseMaterial3D).transparency != BaseMaterial3D.TRANSPARENCY_DISABLED:
3028
+ continue
3029
+ var arrays := mesh.surface_get_arrays(s)
3030
+ var v: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
3031
+ if v.is_empty():
3032
+ continue
3033
+ var idx := PackedInt32Array()
3034
+ if arrays[Mesh.ARRAY_INDEX] != null:
3035
+ idx = arrays[Mesh.ARRAY_INDEX]
3036
+ var n := idx.size() if idx.size() > 0 else v.size()
3037
+ n -= n % 3
3038
+ var remap := PackedInt32Array()
3039
+ remap.resize(v.size())
3040
+ for i in v.size():
3041
+ var k := Vector3i((v[i] * GK_WELD).round())
3042
+ var id: int = weld.get(k, -1)
3043
+ if id == -1:
3044
+ id = pos.size()
3045
+ weld[k] = id
3046
+ pos.append(v[i])
3047
+ remap[i] = id
3048
+ var t := 0
3049
+ while t < n:
3050
+ var ia := remap[idx[t] if idx.size() > 0 else t]
3051
+ var ib := remap[idx[t + 1] if idx.size() > 0 else t + 1]
3052
+ var ic := remap[idx[t + 2] if idx.size() > 0 else t + 2]
3053
+ t += 3
3054
+ if ia == ib or ib == ic or ia == ic:
3055
+ continue
3056
+ for e in [[ia, ib, ic], [ib, ic, ia], [ic, ia, ib]]:
3057
+ var key := mini(int(e[0]), int(e[1])) * 4194304 + maxi(int(e[0]), int(e[1]))
3058
+ cnt[key] = int(cnt.get(key, 0)) + 1
3059
+ if not third.has(key):
3060
+ third[key] = int(e[2])
3061
+ for key in cnt:
3062
+ if int(cnt[key]) != 1:
3063
+ continue
3064
+ var a: Vector3 = pos[int(key) / 4194304]
3065
+ var b: Vector3 = pos[int(key) % 4194304]
3066
+ var c: Vector3 = pos[int(third[key])]
3067
+ var e := b - a
3068
+ var L := e.length()
3069
+ if L < 0.0005:
3070
+ continue
3071
+ e /= L
3072
+ var w := c - a
3073
+ var inward := w - e * w.dot(e)
3074
+ if inward.length() < 1e-7:
3075
+ continue
3076
+ inward = inward.normalized()
3077
+ out.append_array([a, b, e.cross(inward).normalized(), -inward])
3078
+ info["gk_boundary"] = out
3079
+ return out
3080
+
3081
+
3082
+ static func _gk_on_aabb(p: Vector3, b: AABB, tol: float) -> bool:
3083
+ for k in 3:
3084
+ if absf(p[k] - b.position[k]) <= tol or absf(p[k] - b.end[k]) <= tol:
3085
+ return true
3086
+ return false
3087
+
3088
+
3089
+ # Outline samples of a mesh: [p, n, d, (length, 0, 0)] every 25 cm along each
3090
+ # boundary edge that lies on the mesh's bounds (2 cm).
3091
+ func _gk_outline(mesh: Mesh, info: Dictionary) -> PackedVector3Array:
3092
+ if info.has("gk_outline"):
3093
+ return info["gk_outline"]
3094
+ var bnd := _gk_boundary(mesh, info)
3095
+ var out := PackedVector3Array()
3096
+ var maab := mesh.get_aabb()
3097
+ for k in range(0, bnd.size(), 4):
3098
+ var a: Vector3 = bnd[k]
3099
+ var b: Vector3 = bnd[k + 1]
3100
+ var L := a.distance_to(b)
3101
+ var ns := maxi(1, ceili(L / GK_EDGE_SPACING))
3102
+ for s in ns:
3103
+ var p := a.lerp(b, (float(s) + 0.5) / float(ns))
3104
+ if not _gk_on_aabb(p, maab, 0.02):
3105
+ continue
3106
+ out.append_array([p, bnd[k + 2], bnd[k + 3], Vector3(L / float(ns), 0, 0)])
3107
+ info["gk_outline"] = out
3108
+ return out
3109
+
3110
+
3111
+ # Covered: another surface continues the edge's sheet within 4 or 7 mm (a
3112
+ # ray across the sheet's plane just beyond the edge), or a face meets or
3113
+ # abuts the edge (rays along d from 3 mm inside to 7 mm beyond, 2 mm to
3114
+ # either side of the sheet: a side wall turning the corner, a neighbour's
3115
+ # end face). Any face counts, the piece's own too.
3116
+ # (The query is set up once per stage: every gap ray is two-sided over all
3117
+ # solid layers and excludes nothing, so only the end points change.)
3118
+ func _gk_covered(p: Vector3, n: Vector3, d: Vector3) -> bool:
3119
+ var q := p + d * 0.004
3120
+ var n6 := n * 0.006
3121
+ _rays += 1
3122
+ _q.from = q - n6
3123
+ _q.to = q + n6
3124
+ if not _state.intersect_ray(_q).is_empty():
3125
+ return true
3126
+ var n2 := n * 0.002
3127
+ _rays += 2
3128
+ _q.from = p + n2 - d * 0.003
3129
+ _q.to = p + n2 + d * 0.007
3130
+ if not _state.intersect_ray(_q).is_empty():
3131
+ return true
3132
+ _q.from = p - n2 - d * 0.003
3133
+ _q.to = p - n2 + d * 0.007
3134
+ if not _state.intersect_ray(_q).is_empty():
3135
+ return true
3136
+ q = p + d * 0.007
3137
+ _rays += 1
3138
+ _q.from = q - n6
3139
+ _q.to = q + n6
3140
+ return not _state.intersect_ray(_q).is_empty()
3141
+
3142
+
3143
+ # Every gap-detector ray: two-sided, all solid layers, nothing excluded.
3144
+ func _gk_query() -> void:
3145
+ _q.collision_mask = MASK_SOLID
3146
+ _q.hit_back_faces = true
3147
+ if _ex_set:
3148
+ _ex.clear()
3149
+ _q.exclude = _ex
3150
+ _ex_set = false
3151
+
3152
+
3153
+ # A coplanar continuation a little further out: a seam (within 4 cm) or a
3154
+ # bridged gap (within 35 cm). Returns the width, -1 without one.
3155
+ func _gk_seam_width(p: Vector3, n: Vector3, d: Vector3) -> float:
3156
+ for w in [0.008, 0.012, 0.018, 0.025, 0.035, 0.05, 0.075, 0.1, 0.14, 0.19, 0.25, 0.35]:
3157
+ var q := p + d * float(w)
3158
+ if not _ray(q - n * 0.006, q + n * 0.006, MASK_SOLID, [], true).is_empty():
3159
+ return float(w)
3160
+ return -1.0
3161
+
3162
+
3163
+ # What lies just beyond an open edge at p (outward d) seen from eye e: the
3164
+ # reveal depth (along the sheet normal n when it is within 0.6 m), 50 when
3165
+ # the reveal is the ground, 99 when nothing is within 2 m, -1 when blocked in
3166
+ # front. [depth, hit instance].
3167
+ func _gk_beyond(e: Vector3, p: Vector3, d: Vector3, n: Vector3) -> Array:
3168
+ var v := (p - e).normalized()
3169
+ var dp := d - v * d.dot(v)
3170
+ if dp.length() < 0.2:
3171
+ dp = d
3172
+ var b := p + dp.normalized() * 0.02
3173
+ var dist := (p - e).length()
3174
+ var dir := (b - e).normalized()
3175
+ var hit := _ray(e, e + dir * (dist + 2.0), MASK_SOLID, [], true)
3176
+ if hit.is_empty():
3177
+ return [99.0, -1]
3178
+ var hp: Vector3 = hit["position"]
3179
+ var t := (hp - e).length()
3180
+ var hi := _hit_inst(hit)
3181
+ if t < dist - 0.005:
3182
+ return [-1.0, hi]
3183
+ if absf((hit["normal"] as Vector3).y) > 0.7 and hp.y < _gk_floor_y + 0.35 and t - dist <= 0.6:
3184
+ return [50.0, hi]
3185
+ if t - dist <= 0.6:
3186
+ return [maxf(absf((hp - p).dot(n)), 0.0), hi]
3187
+ return [t - dist, hi]
3188
+
3189
+
3190
+ func _gk_sheet(ab: AABB) -> bool:
3191
+ var dims := [ab.size.x, ab.size.y, ab.size.z]
3192
+ dims.sort()
3193
+ var hmax := maxf(ab.size.x, ab.size.z)
3194
+ return (ab.size.y >= 1.0 and hmax >= 0.8) or (hmax >= 0.8 and ab.size.y <= 1.2 and float(dims[0]) <= 0.6) or (ab.size.y >= 1.5 and hmax <= 0.8)
3195
+
3196
+
3197
+ func _scan_exposed_edges() -> Dictionary:
3198
+ _gk_setup()
3199
+ var pts := PackedVector3Array()
3200
+ var lens := PackedFloat32Array()
3201
+ var steps := PackedFloat32Array()
3202
+ var owners := PackedInt32Array()
3203
+ var eyes_at := PackedVector3Array()
3204
+ var hits := PackedInt32Array()
3205
+ var total := 0
3206
+ var done := 0
3207
+ var samples := 0
3208
+ var uncovered := 0
3209
+ var inside := 0
3210
+ # Band pieces first (they warn), then piers and joints, then walls: a
3211
+ # budget stop leaves the walls. Facade pieces only: walls and the facade
3212
+ # members the roles found (a crate leaning on a wall is not one).
3213
+ var order: Array = []
3214
+ for rec in _inst:
3215
+ var role := String(rec["role"])
3216
+ var why := String(rec.get("why", ""))
3217
+ if not bool(rec["in"]):
3218
+ continue
3219
+ if role == "wall":
3220
+ order.append([2, rec])
3221
+ elif role == "struct" and (why == "band" or why == "pier" or why == "joint"):
3222
+ order.append([0 if why == "band" else 1, rec])
3223
+ order.sort_custom(func(a, b): return int(a[0]) < int(b[0]))
3224
+ for item in order:
3225
+ var rec: Dictionary = item[1]
3226
+ var ab: AABB = rec["waabb"]
3227
+ if ab.size.y < 0.25 and ab.end.y < _gk_floor_y + 0.5 and maxf(ab.size.x, ab.size.z) > 1.5:
3228
+ continue
3229
+ if ab.size.length() < 0.15 or not _gk_sheet(ab):
3230
+ continue
3231
+ var xf: Transform3D = rec["xf"]
3232
+ if xf.basis.y.normalized().y < 0.98:
3233
+ continue
3234
+ var li := _gk_line_of(ab, 0.8)
3235
+ if li < 0 or ((_gk_lines[li] as Dictionary)["sides"] as Array).is_empty():
3236
+ continue
3237
+ total += 1
3238
+ if _over():
3239
+ continue
3240
+ done += 1
3241
+ # A facade seen from one side only: samples behind its back face, away
3242
+ # from the line's ends (where a side street may see them), are inside.
3243
+ var ln: Dictionary = _gk_lines[li]
3244
+ var lsides: Array = ln["sides"]
3245
+ var lax: int = ln["ax"]
3246
+ var lth := 2 - lax
3247
+ var back := INF
3248
+ if lsides.size() == 1:
3249
+ back = float(ln["tmin"]) - 0.02 if int(lsides[0]) == 1 else -(float(ln["tmax"]) + 0.02)
3250
+ for m in (rec["meshes"] as Array):
3251
+ var mesh: Mesh = m[1]
3252
+ var info: Dictionary = _mesh_info.get(mesh.get_instance_id(), {})
3253
+ if info.is_empty():
3254
+ continue
3255
+ var ol := _gk_outline(mesh, info)
3256
+ var mxf: Transform3D = m[2]
3257
+ var bas := mxf.basis
3258
+ var sc := bas.get_scale().x
3259
+ for k in range(0, ol.size(), 4):
3260
+ samples += 1
3261
+ var p: Vector3 = mxf * ol[k]
3262
+ if back < INF and p[lax] > float(ln["lo"]) + 0.5 and p[lax] < float(ln["hi"]) - 0.5:
3263
+ var depth_behind: float = (back - p[lth]) if int(lsides[0]) == 1 else (p[lth] + back)
3264
+ if depth_behind > 0.0:
3265
+ inside += 1
3266
+ continue
3267
+ var n: Vector3 = (bas * ol[k + 1]).normalized()
3268
+ var d: Vector3 = (bas * ol[k + 2]).normalized()
3269
+ _gk_query()
3270
+ if _gk_covered(p, n, d):
3271
+ continue
3272
+ uncovered += 1
3273
+ var ve := _gk_visible_cached(p)
3274
+ if ve.is_empty():
3275
+ continue
3276
+ var best := [-1.0, Vector3.ZERO, -1]
3277
+ for e in ve:
3278
+ var bs := _gk_beyond(e, p, d, n)
3279
+ var st: float = bs[0]
3280
+ if st >= 0.02 and st <= 0.6:
3281
+ best = [st, e, bs[1]]
3282
+ break
3283
+ if st > float(best[0]):
3284
+ best = [st, e, bs[1]]
3285
+ if float(best[0]) < 0.0:
3286
+ continue
3287
+ var sw := _gk_seam_width(p, n, d)
3288
+ pts.append(p)
3289
+ lens.append(ol[k + 3].x * sc)
3290
+ steps.append((-2.0 if sw <= 0.04 else -3.0) if sw > 0.0 else float(best[0]))
3291
+ owners.append(int(rec["i"]))
3292
+ eyes_at.append(best[1])
3293
+ hits.append(int(best[2]))
3294
+ _count("exposed_edge", done, total)
3295
+ var flags := _gk_flags(pts, lens, steps, owners, eyes_at, hits)
3296
+ return {"flags": flags, "stats": {"pieces": total, "samples": samples, "inside": inside, "uncovered": uncovered, "exposed": pts.size(), "vis_cells": _gk_vis_cache.size()}}
3297
+
3298
+
3299
+ # Exposed samples -> clusters (25 cm links, one piece each), longest first:
3300
+ # [owner, class, length, samples, centre, min, max, deepest reveal, gap,
3301
+ # seam, bridge, sky, far, an eye that sees it, what the reveal hit].
3302
+ func _gk_flags(pts: PackedVector3Array, lens: PackedFloat32Array, steps: PackedFloat32Array, owners: PackedInt32Array, eyes_at: PackedVector3Array, hits: PackedInt32Array) -> Array:
3303
+ var par := PackedInt32Array()
3304
+ par.resize(pts.size())
3305
+ for i in pts.size():
3306
+ par[i] = i
3307
+ var grid: Dictionary = {}
3308
+ for i in pts.size():
3309
+ var c := Vector3i((pts[i] / 0.25).floor())
3310
+ if not grid.has(c):
3311
+ grid[c] = PackedInt32Array()
3312
+ var arr: PackedInt32Array = grid[c]
3313
+ arr.append(i)
3314
+ grid[c] = arr
3315
+ for c in grid:
3316
+ for dx in [-1, 0, 1]:
3317
+ for dy in [-1, 0, 1]:
3318
+ for dz in [-1, 0, 1]:
3319
+ var c2: Vector3i = (c as Vector3i) + Vector3i(dx, dy, dz)
3320
+ if not grid.has(c2):
3321
+ continue
3322
+ for i in (grid[c] as PackedInt32Array):
3323
+ for j in (grid[c2] as PackedInt32Array):
3324
+ if j <= i or owners[i] != owners[j]:
3325
+ continue
3326
+ if pts[i].distance_to(pts[j]) <= 0.25:
3327
+ var ri := _gk_find(par, i)
3328
+ var rj := _gk_find(par, j)
3329
+ if ri != rj:
3330
+ par[ri] = rj
3331
+ var groups: Dictionary = {}
3332
+ for i in pts.size():
3333
+ var g := _gk_find(par, i)
3334
+ if not groups.has(g):
3335
+ groups[g] = []
3336
+ (groups[g] as Array).append(i)
3337
+ var flags: Array = []
3338
+ for g in groups:
3339
+ var ids: Array = groups[g]
3340
+ var L := 0.0
3341
+ var c := Vector3.ZERO
3342
+ var bmin: Vector3 = pts[ids[0]]
3343
+ var bmax: Vector3 = pts[ids[0]]
3344
+ var ngap := 0
3345
+ var nseam := 0
3346
+ var nbridge := 0
3347
+ var nsky := 0
3348
+ var nfar := 0
3349
+ var smax := 0.0
3350
+ for i in ids:
3351
+ L += lens[i]
3352
+ c += pts[i]
3353
+ bmin = bmin.min(pts[i])
3354
+ bmax = bmax.max(pts[i])
3355
+ var st: float = steps[i]
3356
+ if st == -2.0:
3357
+ nseam += 1
3358
+ elif st == -3.0:
3359
+ nbridge += 1
3360
+ elif st >= 0.02 and st <= 0.6:
3361
+ ngap += 1
3362
+ smax = maxf(smax, st)
3363
+ elif st >= 98.0:
3364
+ nsky += 1
3365
+ else:
3366
+ nfar += 1
3367
+ if L < 0.05:
3368
+ continue
3369
+ c /= float(ids.size())
3370
+ var cls := "mixed"
3371
+ if nseam * 2 >= ids.size():
3372
+ cls = "seam"
3373
+ elif nbridge * 2 >= ids.size():
3374
+ cls = "bridge"
3375
+ elif (ngap + nbridge + nseam) * 2 >= ids.size():
3376
+ cls = "gap"
3377
+ elif nsky * 2 >= ids.size():
3378
+ cls = "sky"
3379
+ elif nfar * 2 >= ids.size():
3380
+ cls = "far"
3381
+ var mid: int = ids[ids.size() / 2]
3382
+ flags.append([owners[ids[0]], cls, snappedf(L, 0.01), ids.size(), _a3(c), _a3(bmin), _a3(bmax), snappedf(smax, 0.001), ngap, nseam, nbridge, nsky, nfar, _a3(eyes_at[mid], 0.01), hits[mid]])
3383
+ flags.sort_custom(func(a, b): return float(a[2]) > float(b[2]))
3384
+ if flags.size() > 400:
3385
+ flags.resize(400)
3386
+ return flags
3387
+
3388
+
3389
+ # ---------------------------------------------------------------------------
3390
+ # open_fixture_end: the open ends of run pieces (pipe, duct or gutter
3391
+ # sections, elbows, tees: a piece whose mesh has two or more open rims of one
3392
+ # size, the rims being open boundary loops on the mesh's bounds, cached per
3393
+ # mesh) that nothing covers within a sleeve tolerance and that walkable space
3394
+ # sees into. A piece with one rim, or rims of different sizes (a cap, a
3395
+ # funnel, an outlet), is open by design. Meshes over FE_MAX_TRIS triangles
3396
+ # are not read (run pieces are simple).
3397
+ # ---------------------------------------------------------------------------
3398
+
3399
+ const FE_MAX_TRIS := 20000
3400
+
3401
+
3402
+ # Two rims within 20% of each other's radius that face different ways.
3403
+ static func _run_piece(cands: Array) -> bool:
3404
+ for i in cands.size():
3405
+ for j in range(i + 1, cands.size()):
3406
+ var ri: float = cands[i][2]
3407
+ var rj: float = cands[j][2]
3408
+ if absf(ri - rj) <= 0.2 * maxf(ri, rj) and (cands[i][1] as Vector3).dot(cands[j][1] as Vector3) < 0.9:
3409
+ return true
3410
+ return false
3411
+
3412
+ # Open ends of a mesh: its boundary edges chained into
3413
+ # loops; a loop counts when every vertex lies on one face of the mesh's
3414
+ # bounds (2% of its size, at least 1 cm), it encloses at least half of its
3415
+ # bounding rectangle (a rim, not a seam along a side) and spans 3 cm or
3416
+ # more. [centre, outward, (radius, vertices, 0)] per end, cached per mesh.
3417
+ func _gk_mesh_ends(mesh: Mesh, info: Dictionary) -> PackedVector3Array:
3418
+ if info.has("gk_ends"):
3419
+ return info["gk_ends"]
3420
+ var bnd := _gk_boundary(mesh, info)
3421
+ var maab := mesh.get_aabb()
3422
+ var by_pt: Dictionary = {}
3423
+ var nedge := bnd.size() / 4
3424
+ for k in nedge:
3425
+ for q in [bnd[k * 4], bnd[k * 4 + 1]]:
3426
+ var key := Vector3i(((q as Vector3) * GK_WELD).round())
3427
+ if not by_pt.has(key):
3428
+ by_pt[key] = []
3429
+ (by_pt[key] as Array).append(k)
3430
+ var used := PackedByteArray()
3431
+ used.resize(nedge)
3432
+ var out := PackedVector3Array()
3433
+ for k0 in nedge:
3434
+ if used[k0] == 1:
3435
+ continue
3436
+ var loop := PackedVector3Array()
3437
+ var k := k0
3438
+ var cur: Vector3 = bnd[k * 4 + 1]
3439
+ used[k] = 1
3440
+ loop.append(bnd[k * 4])
3441
+ var guard := 0
3442
+ while guard < 4096:
3443
+ guard += 1
3444
+ loop.append(cur)
3445
+ var nxt := -1
3446
+ for e in (by_pt.get(Vector3i((cur * GK_WELD).round()), []) as Array):
3447
+ if used[int(e)] == 0:
3448
+ nxt = int(e)
3449
+ break
3450
+ if nxt < 0:
3451
+ break
3452
+ used[nxt] = 1
3453
+ var a: Vector3 = bnd[nxt * 4]
3454
+ var b: Vector3 = bnd[nxt * 4 + 1]
3455
+ cur = b if Vector3i((a * GK_WELD).round()) == Vector3i((cur * GK_WELD).round()) else a
3456
+ if loop.size() < 4:
3457
+ continue
3458
+ var face := -1
3459
+ for ax in 3:
3460
+ for side in 2:
3461
+ var plane: float = maab.end[ax] if side == 1 else maab.position[ax]
3462
+ var tol := maxf(0.01, maab.size[ax] * 0.02)
3463
+ var all_on := true
3464
+ for q in loop:
3465
+ if absf(q[ax] - plane) > tol:
3466
+ all_on = false
3467
+ break
3468
+ if all_on:
3469
+ face = ax * 2 + side
3470
+ if face < 0:
3471
+ continue
3472
+ var fa := face / 2
3473
+ var centre := Vector3.ZERO
3474
+ var lo := loop[0]
3475
+ var hi := loop[0]
3476
+ for q in loop:
3477
+ centre += q
3478
+ lo = lo.min(q)
3479
+ hi = hi.max(q)
3480
+ centre /= float(loop.size())
3481
+ # Enclosed area (Newell) against the loop's bounding rectangle.
3482
+ var area := Vector3.ZERO
3483
+ for i in loop.size():
3484
+ var p0: Vector3 = loop[i]
3485
+ var p1: Vector3 = loop[(i + 1) % loop.size()]
3486
+ area += p0.cross(p1)
3487
+ var ext := hi - lo
3488
+ var rect := 1.0
3489
+ for kk in 3:
3490
+ if kk != fa:
3491
+ rect *= maxf(ext[kk], 1e-6)
3492
+ var r := 0.0
3493
+ for q in loop:
3494
+ r = maxf(r, (q as Vector3).distance_to(centre))
3495
+ # A rim, not a bolt hole in a flange: at least a quarter of the face.
3496
+ var face_min := INF
3497
+ for kk in 3:
3498
+ if kk != fa:
3499
+ face_min = minf(face_min, maab.size[kk])
3500
+ if r < 0.015 or r < 0.25 * face_min or absf(area[fa]) * 0.5 < 0.5 * rect:
3501
+ continue
3502
+ var nrm := Vector3.ZERO
3503
+ nrm[fa] = 1.0 if face % 2 == 1 else -1.0
3504
+ out.append_array([centre, nrm, Vector3(r, loop.size(), 0)])
3505
+ info["gk_ends"] = out
3506
+ return out
3507
+
3508
+
3509
+ func _scan_fixture_ends() -> Dictionary:
3510
+ _gk_setup()
3511
+ if not _gk_sphere.is_valid():
3512
+ _gk_sphere = PhysicsServer3D.sphere_shape_create()
3513
+ _shapes.append(_gk_sphere)
3514
+ _gk_sphere_q = PhysicsShapeQueryParameters3D.new()
3515
+ _gk_sphere_q.collide_with_areas = false
3516
+ _gk_sphere_q.collision_mask = MASK_SOLID
3517
+ _gk_sphere_q.shape_rid = _gk_sphere
3518
+ var out: Array = []
3519
+ var dbg: Array = []
3520
+ var total := 0
3521
+ var done := 0
3522
+ var ends := 0
3523
+ for rec in _inst:
3524
+ var role := String(rec["role"])
3525
+ if not bool(rec["in"]) or (role != "prop" and role != "struct"):
3526
+ continue
3527
+ total += 1
3528
+ if _over():
3529
+ continue
3530
+ done += 1
3531
+ # The open rims of each simple mesh (at most FE_MAX_TRIS triangles):
3532
+ # [world centre, outward, radius].
3533
+ var cands: Array = []
3534
+ for m in (rec["meshes"] as Array):
3535
+ var mesh: Mesh = m[1]
3536
+ var info: Dictionary = _mesh_info.get(mesh.get_instance_id(), {})
3537
+ if info.is_empty() or int(info.get("tris", 0)) > FE_MAX_TRIS:
3538
+ continue
3539
+ var mxf: Transform3D = m[2]
3540
+ var ol := _gk_mesh_ends(mesh, info)
3541
+ for k in range(0, ol.size(), 3):
3542
+ cands.append([mxf * ol[k], (mxf.basis * ol[k + 1]).normalized(), ol[k + 2].x * mxf.basis.get_scale().x])
3543
+ # A run piece (a section, an elbow, a tee) has two or more rims of one
3544
+ # size facing different ways; an end piece (a cap, a funnel, an outlet
3545
+ # whose mouth differs from its socket) is open by design.
3546
+ if not _run_piece(cands):
3547
+ continue
3548
+ var ex: Array[RID] = []
3549
+ for r in (rec["bodies"] as Array):
3550
+ ex.append(r)
3551
+ for cd in cands:
3552
+ ends += 1
3553
+ var c: Vector3 = cd[0]
3554
+ var o: Vector3 = cd[1]
3555
+ var r: float = cd[2]
3556
+ # Sleeve tolerance: anything just beyond the opening, out to its
3557
+ # radius plus 2.5 cm (the next section, a coupler sleeve around the
3558
+ # joint, the wall the end runs into).
3559
+ PhysicsServer3D.shape_set_data(_gk_sphere, 0.5 * r + 0.025)
3560
+ _gk_sphere_q.transform = Transform3D(Basis(), c + o * (0.5 * r))
3561
+ _gk_sphere_q.exclude = ex
3562
+ var cover := _state.intersect_shape(_gk_sphere_q, 1)
3563
+ if not cover.is_empty():
3564
+ if dbg.size() < 120:
3565
+ dbg.append([int(rec["i"]), _a3(c), _a3(o, 0.01), snappedf(r, 0.001), "covered", int(_body_inst.get((cover[0]["rid"] as RID).get_id(), -1))])
3566
+ continue
3567
+ # Seen from the open side or beside it (a downpipe ending in mid-air
3568
+ # shows its cut end from eye height): within 107 deg of its axis.
3569
+ var ve := _gk_visible_eyes(c + o * 0.05, 1, 40, o, -0.3)
3570
+ if ve.is_empty():
3571
+ if dbg.size() < 120:
3572
+ dbg.append([int(rec["i"]), _a3(c), _a3(o, 0.01), snappedf(r, 0.001), "unseen", -1])
3573
+ continue
3574
+ out.append([int(rec["i"]), _a3(c), _a3(o, 0.001), snappedf(r, 0.001), _a3(ve[0], 0.01), "loops"])
3575
+ if out.size() >= 200:
3576
+ break
3577
+ _count("open_fixture_end", done, total)
3578
+ return {"ends": out, "dropped": dbg, "stats": {"pieces": total, "ends": ends}}
3579
+
3580
+
3581
+ # ---------------------------------------------------------------------------
3582
+ # depth_step (C): perpendicular ray rows across each facade line's visible
3583
+ # sides, at the band levels (from the band pieces on the line, else a 2 m
3584
+ # depth profile) and every 1.25 m between them; runs of recessed or proud
3585
+ # depth: band recesses and missing runs, seams, proud modules, holes. One
3586
+ # look-level row per merged run that walkable space sees.
3587
+ # ---------------------------------------------------------------------------
3588
+
3589
+ func _scan_depth_steps() -> Dictionary:
3590
+ _gk_setup()
3591
+ var raw: Array = []
3592
+ var rays_before := _rays
3593
+ var band_levels: Dictionary = {}
3594
+ for rec in _inst:
3595
+ if rec["role"] != "struct" or String(rec.get("why", "")) != "band":
3596
+ continue
3597
+ var ab: AABB = rec["waabb"]
3598
+ if ab.size.y >= 1.2 or maxf(ab.size.x, ab.size.z) < 0.5:
3599
+ continue
3600
+ var li := _gk_line_of(ab, 0.6)
3601
+ if li < 0:
3602
+ continue
3603
+ if not band_levels.has(li):
3604
+ band_levels[li] = []
3605
+ (band_levels[li] as Array).append([ab.position.y, ab.end.y])
3606
+ # The row plan per visible line side; every band row of every line runs
3607
+ # before any wall row (a budget stop leaves the wall rows).
3608
+ var band_rows: Array = []
3609
+ var wall_rows: Array = []
3610
+ for li in _gk_lines.size():
3611
+ var ln: Dictionary = _gk_lines[li]
3612
+ var in_scope := false
3613
+ for pi in (ln["pieces"] as Array):
3614
+ if bool((_inst[int(pi)] as Dictionary)["in"]):
3615
+ in_scope = true
3616
+ break
3617
+ if not in_scope:
3618
+ continue
3619
+ for sd in (ln["sides"] as Array):
3620
+ for row in _depth_rows(li, ln, int(sd), band_levels.get(li, []) as Array):
3621
+ if String(row["kind"]) == "band":
3622
+ band_rows.append(row)
3623
+ else:
3624
+ wall_rows.append(row)
3625
+ var total := band_rows.size() + wall_rows.size()
3626
+ var done := 0
3627
+ _q.collision_mask = MASK_SOLID
3628
+ _q.hit_back_faces = true
3629
+ _ex.clear()
3630
+ _q.exclude = _ex
3631
+ _ex_set = false
3632
+ for row in band_rows + wall_rows:
3633
+ if _over():
3634
+ break
3635
+ done += 1
3636
+ _depth_row(row, raw)
3637
+ _count("depth_step", done, total)
3638
+ var merged := _gk_merge_c(raw)
3639
+ return {"flags": merged, "stats": {"rows": total, "band_rows": band_rows.size(), "raw": raw.size(), "rays": _rays - rays_before}}
3640
+
3641
+
3642
+ # One line side's rows: each band level at mid height (and a quarter up when
3643
+ # 30 cm or taller), 3 cm steps; wall rows every 1.25 m between the bands, 5 cm
3644
+ # steps. Band levels come from the band pieces on the line, else from a depth
3645
+ # profile every 2 m (heights where 30% of the positions stand 1.5 cm or more
3646
+ # proud).
3647
+ func _depth_rows(li: int, ln: Dictionary, s: int, levels: Array) -> Array:
3648
+ var ax: int = ln["ax"]
3649
+ var th := 2 - ax
3650
+ var D := 1.2
3651
+ var face: float = float(ln["tmax"]) if s == 1 else float(ln["tmin"])
3652
+ var o_th := face + float(s) * D
3653
+ var t_th := face - float(s) * D
3654
+ var y0: float = ln["y0"]
3655
+ var y1: float = float(ln["y1"]) + 0.8
3656
+ var lo: float = ln["lo"]
3657
+ var hi: float = ln["hi"]
3658
+ var base := {"li": li, "ax": ax, "th": th, "face": face, "o_th": o_th, "t_th": t_th, "s": s, "lo": lo, "hi": hi, "D": D}
3659
+ var bands: Array = []
3660
+ if not levels.is_empty():
3661
+ levels.sort_custom(func(a, b): return float(a[0]) < float(b[0]))
3662
+ for lv in levels:
3663
+ if not bands.is_empty() and float(lv[0]) <= float(bands[-1][1]) + 0.02:
3664
+ bands[-1][1] = maxf(float(bands[-1][1]), float(lv[1]))
3665
+ else:
3666
+ bands.append([float(lv[0]), float(lv[1])])
3667
+ else:
3668
+ var prof: Array = []
3669
+ var sp := lo + 0.2537
3670
+ while sp < hi - 0.1:
3671
+ prof.append(sp)
3672
+ sp += 2.0
3673
+ var nh := int((y1 - y0) / 0.02)
3674
+ if prof.is_empty() or nh < 5:
3675
+ return []
3676
+ var depth_by_h: Array = []
3677
+ var all_d := PackedFloat32Array()
3678
+ for h in nh:
3679
+ var row := PackedFloat32Array()
3680
+ for spos in prof:
3681
+ var o := Vector3.ZERO
3682
+ o[ax] = float(spos)
3683
+ o[th] = o_th
3684
+ o.y = y0 + 0.0113 + h * 0.02
3685
+ var t := o
3686
+ t[th] = t_th
3687
+ var hit := _ray(o, t, MASK_SOLID, [], true)
3688
+ var dd: float = 9.0 if hit.is_empty() else (hit["position"] as Vector3).distance_to(o) - D
3689
+ row.append(dd)
3690
+ if dd < 2.0:
3691
+ all_d.append(dd)
3692
+ depth_by_h.append(row)
3693
+ if all_d.size() < 10:
3694
+ return []
3695
+ all_d.sort()
3696
+ var wall_d: float = all_d[int(all_d.size() * 0.6)]
3697
+ var h := 0
3698
+ while h < nh:
3699
+ var hj := h - 1
3700
+ while hj + 1 < nh:
3701
+ var r2: PackedFloat32Array = depth_by_h[hj + 1]
3702
+ var c2 := 0
3703
+ for dd in r2:
3704
+ if dd < wall_d - 0.015 and dd > wall_d - 0.6:
3705
+ c2 += 1
3706
+ if c2 * 10 < r2.size() * 3:
3707
+ break
3708
+ hj += 1
3709
+ if hj >= h:
3710
+ var by0 := y0 + h * 0.02
3711
+ var by1 := y0 + (hj + 1) * 0.02
3712
+ if by1 - by0 >= 0.04 and by1 - by0 <= 1.2:
3713
+ bands.append([by0, by1])
3714
+ h = hj + 1
3715
+ else:
3716
+ h += 1
3717
+ var rows: Array = []
3718
+ for b in bands:
3719
+ var bh: float = float(b[1]) - float(b[0])
3720
+ var r := base.duplicate()
3721
+ r.merge({"y": (float(b[0]) + float(b[1])) * 0.5, "kind": "band", "step": 0.03})
3722
+ rows.append(r)
3723
+ if bh >= 0.3:
3724
+ var r2 := base.duplicate()
3725
+ r2.merge({"y": float(b[0]) + bh * 0.25, "kind": "band", "step": 0.03})
3726
+ rows.append(r2)
3727
+ var wy := y0 + 0.2571
3728
+ while wy < y1 - 0.1:
3729
+ var inband := false
3730
+ for b in bands:
3731
+ if wy >= float(b[0]) - 0.03 and wy <= float(b[1]) + 0.03:
3732
+ inband = true
3733
+ if not inband:
3734
+ var r3 := base.duplicate()
3735
+ r3.merge({"y": wy, "kind": "wall", "step": 0.05})
3736
+ rows.append(r3)
3737
+ wy += C_WALL_ROW
3738
+ return rows
3739
+
3740
+
3741
+ func _depth_row(row: Dictionary, raw: Array) -> void:
3742
+ var ax: int = row["ax"]
3743
+ var th: int = row["th"]
3744
+ var step: float = row["step"]
3745
+ var lo: float = row["lo"]
3746
+ var hi: float = row["hi"]
3747
+ var D: float = row["D"]
3748
+ var s_lo := lo + 0.0037
3749
+ var npos := int((hi - s_lo) / step) + 1
3750
+ var dep := PackedFloat32Array()
3751
+ dep.resize(npos)
3752
+ var vals := PackedFloat32Array()
3753
+ var o := Vector3.ZERO
3754
+ o[th] = float(row["o_th"])
3755
+ o.y = float(row["y"])
3756
+ var t := o
3757
+ t[th] = float(row["t_th"])
3758
+ for k in npos:
3759
+ o[ax] = s_lo + k * step
3760
+ t[ax] = o[ax]
3761
+ _q.from = o
3762
+ _q.to = t
3763
+ var hit := _state.intersect_ray(_q)
3764
+ dep[k] = 9.0 if hit.is_empty() else (hit["position"] as Vector3).distance_to(o) - D
3765
+ if dep[k] < 2.0:
3766
+ vals.append(dep[k])
3767
+ _rays += npos
3768
+ if vals.size() < 10:
3769
+ return
3770
+ vals.sort()
3771
+ var ref: float = vals[int(vals.size() * 0.5)]
3772
+ var rf: Array = []
3773
+ _gk_scan_runs(rf, dep, ref, row, ax, th, float(row["face"]), int(row["s"]), s_lo, step, lo, hi, int(row["li"]))
3774
+ if String(row["kind"]) == "band":
3775
+ # A periodic row (sills, lintels, window heads) is not a band.
3776
+ var nrec := 0
3777
+ for f in rf:
3778
+ if String(f["kind"]).begins_with("band"):
3779
+ nrec += 1
3780
+ if nrec > maxi(3, int((hi - lo) / 3.5)):
3781
+ return
3782
+ raw.append_array(rf)
3783
+
3784
+
3785
+ func _gk_scan_runs(flags: Array, dep: PackedFloat32Array, ref: float, row: Dictionary, ax: int, th: int, face: float, s: int, s_lo: float, step: float, lo: float, hi: float, li: int) -> void:
3786
+ var n := dep.size()
3787
+ var k := 0
3788
+ var band := String(row["kind"]) == "band"
3789
+ while k < n:
3790
+ var rel := dep[k] - ref
3791
+ var rec := rel >= 0.03
3792
+ var proud := rel <= -0.015 and rel >= -0.10
3793
+ if not rec and not proud:
3794
+ k += 1
3795
+ continue
3796
+ var j := k
3797
+ if rec:
3798
+ while j + 1 < n and dep[j + 1] - ref >= 0.03:
3799
+ j += 1
3800
+ else:
3801
+ while j + 1 < n and dep[j + 1] - ref <= -0.015 and dep[j + 1] - ref >= -0.10:
3802
+ j += 1
3803
+ var w := (j - k + 1) * step
3804
+ var flush_l := k > 0 and absf(dep[k - 1] - ref) < 0.015
3805
+ var flush_r := j < n - 1 and absf(dep[j + 1] - ref) < 0.015
3806
+ var s0 := s_lo + k * step
3807
+ var s1 := s_lo + (j + 1) * step
3808
+ var inside := s0 >= lo - 0.02 and s1 <= hi + 0.02
3809
+ var mx := 0.0
3810
+ for q in range(k, j + 1):
3811
+ mx = maxf(mx, dep[q] - ref)
3812
+ var kind := ""
3813
+ # A miss (9.0: nothing within 1.2 m behind the face) is either an
3814
+ # opening right through or sky over a lower roof; _gk_merge_c keeps it
3815
+ # only where the facade bounds it (open: true).
3816
+ var open := mx >= 2.0
3817
+ if rec and band:
3818
+ if w <= 1.5 and (flush_l or flush_r) and mx <= 0.30:
3819
+ kind = "band_recess" if (flush_l and flush_r) else "band_end_recess"
3820
+ elif w > 1.5 and mx <= 0.30 and (flush_l or flush_r):
3821
+ kind = "band_missing_run"
3822
+ elif (flush_l or flush_r) and mx > 0.30 and w >= 0.06 and not open:
3823
+ kind = "band_through"
3824
+ elif rec:
3825
+ if w <= 0.06 and flush_l and flush_r:
3826
+ kind = "seam"
3827
+ elif w >= 0.9 and mx >= 0.50 and inside:
3828
+ kind = "hole"
3829
+ elif proud and not band:
3830
+ if w >= 0.9 and inside:
3831
+ kind = "proud"
3832
+ if kind != "":
3833
+ var p := Vector3.ZERO
3834
+ p[ax] = (s0 + s1) * 0.5
3835
+ p[th] = face
3836
+ p.y = float(row["y"])
3837
+ flags.append({"kind": kind, "c": p, "s0": s0, "s1": s1, "y": float(row["y"]), "depth": mx, "line": li, "side": s, "w": w, "open": open})
3838
+ k = j + 1
3839
+
3840
+
3841
+ # An open hole (rays that find nothing behind the face) is a hole only when
3842
+ # the facade bounds it: line pieces reaching its top row on both sides along
3843
+ # the line, or a piece above it. Sky over a lower block's roof is not.
3844
+ func _gk_bounded(li: int, s0: float, s1: float, y0: float, y1: float) -> bool:
3845
+ var ln: Dictionary = _gk_lines[li]
3846
+ var ax: int = ln["ax"]
3847
+ var left := false
3848
+ var right := false
3849
+ var above := false
3850
+ var mid := (s0 + s1) * 0.5
3851
+ for pi in (ln["pieces"] as Array):
3852
+ var b: AABB = (_inst[int(pi)] as Dictionary)["waabb"]
3853
+ if b.position[ax] <= mid and b.end[ax] >= mid and b.position.y >= y1 - 0.1:
3854
+ above = true
3855
+ if b.position.y > y1 or b.end.y < y1 - 0.05:
3856
+ continue
3857
+ if b.end[ax] >= s0 - 0.15 and b.position[ax] < s0 - 0.05:
3858
+ left = true
3859
+ if b.position[ax] <= s1 + 0.15 and b.end[ax] > s1 + 0.05:
3860
+ right = true
3861
+ return above or (left and right)
3862
+
3863
+
3864
+ # Merge runs of one line, side and kind that overlap along the line and lie
3865
+ # within one wall-row step (1.3 m) in height; holes and proud runs need two
3866
+ # rows; every run must be seen from walkable space. [kind, line, side, s0, s1, y0, y1, rows,
3867
+ # depth, centre].
3868
+ func _gk_merge_c(raw: Array) -> Array:
3869
+ var out: Array = []
3870
+ for f in raw:
3871
+ var merged := false
3872
+ for o in out:
3873
+ if int(o["line"]) == int(f["line"]) and int(o["side"]) == int(f["side"]) and o["kind"] == f["kind"] and float(f["s0"]) <= float(o["s1"]) + 0.03 and float(f["s1"]) >= float(o["s0"]) - 0.03 and float(f["y"]) >= float(o["y0"]) - C_MERGE_Y and float(f["y"]) <= float(o["y1"]) + C_MERGE_Y:
3874
+ o["s0"] = minf(float(o["s0"]), float(f["s0"]))
3875
+ o["s1"] = maxf(float(o["s1"]), float(f["s1"]))
3876
+ o["y0"] = minf(float(o["y0"]), float(f["y"]))
3877
+ o["y1"] = maxf(float(o["y1"]), float(f["y"]))
3878
+ o["rows"] = int(o["rows"]) + 1
3879
+ o["depth"] = maxf(float(o["depth"]), float(f["depth"]))
3880
+ o["open"] = bool(o["open"]) or bool(f.get("open", false))
3881
+ merged = true
3882
+ break
3883
+ if not merged:
3884
+ out.append({"kind": f["kind"], "line": f["line"], "side": f["side"], "s0": f["s0"], "s1": f["s1"], "y0": f["y"], "y1": f["y"], "rows": 1, "depth": f["depth"], "c": f["c"], "open": bool(f.get("open", false))})
3885
+ var res: Array = []
3886
+ for o in out:
3887
+ if (o["kind"] == "hole" or o["kind"] == "proud") and int(o["rows"]) < 2:
3888
+ continue
3889
+ if o["kind"] == "hole" and bool(o["open"]) and not _gk_bounded(int(o["line"]), float(o["s0"]), float(o["s1"]), float(o["y0"]), float(o["y1"])):
3890
+ continue
3891
+ var ln: Dictionary = _gk_lines[int(o["line"])]
3892
+ var ax: int = ln["ax"]
3893
+ var p: Vector3 = o["c"]
3894
+ p[ax] = (float(o["s0"]) + float(o["s1"])) * 0.5
3895
+ p.y = (float(o["y0"]) + float(o["y1"])) * 0.5
3896
+ if not _gk_point_visible(p, 2 - ax):
3897
+ continue
3898
+ res.append([String(o["kind"]), int(o["line"]), int(o["side"]), snappedf(float(o["s0"]), 0.01), snappedf(float(o["s1"]), 0.01), snappedf(float(o["y0"]), 0.01), snappedf(float(o["y1"]), 0.01), int(o["rows"]), snappedf(float(o["depth"]), 0.001), _a3(p)])
3899
+ if res.size() >= 300:
3900
+ break
3901
+ return res
3902
+
3903
+
3904
+ return out
3905
+
3906
+
3907
+ # ---------------------------------------------------------------------------
3908
+ # lights: per-mesh omni/spot pairing (light culling AABB vs mesh AABB),
3909
+ # soft-rim spots, shadowed lights
3910
+ # ---------------------------------------------------------------------------
3911
+
3912
+ func _light_aabb(light: Light3D) -> AABB:
3913
+ var xf := light.global_transform
3914
+ var local := AABB()
3915
+ if light is OmniLight3D:
3916
+ var r := (light as OmniLight3D).omni_range
3917
+ local = AABB(-Vector3(r, r, r), Vector3(r, r, r) * 2.0)
3918
+ elif light is SpotLight3D:
3919
+ var sp := light as SpotLight3D
3920
+ var size := tan(deg_to_rad(minf(sp.spot_angle, 89.0))) * sp.spot_range
3921
+ local = AABB(Vector3(-size, -size, -sp.spot_range), Vector3(size * 2.0, size * 2.0, sp.spot_range))
3922
+ var out := AABB()
3923
+ var first := true
3924
+ for c in _corners(local):
3925
+ var w: Vector3 = xf * c
3926
+ if first:
3927
+ out = AABB(w, Vector3.ZERO)
3928
+ first = false
3929
+ else:
3930
+ out = out.expand(w)
3931
+ return out
3932
+
3933
+
3934
+ func _scan_lights() -> Dictionary:
3935
+ var limit := int(ProjectSettings.get_setting("rendering/limits/opengl/max_lights_per_object", 8))
3936
+ var omni: Array = []
3937
+ var spot: Array = []
3938
+ var counts := {"omni": 0, "spot": 0, "directional": 0}
3939
+ var shadowed := {"omni": 0, "spot": 0, "directional": 0}
3940
+ var hard_rim: Array = []
3941
+ for light in _lights:
3942
+ var l := light as Light3D
3943
+ if not l.is_visible_in_tree():
3944
+ continue
3945
+ var kind := "directional"
3946
+ if l is OmniLight3D:
3947
+ kind = "omni"
3948
+ omni.append([l, _light_aabb(l)])
3949
+ elif l is SpotLight3D:
3950
+ kind = "spot"
3951
+ spot.append([l, _light_aabb(l)])
3952
+ var att := (l as SpotLight3D).spot_angle_attenuation
3953
+ if att < 3.0 and _in_root(l):
3954
+ hard_rim.append([_rel(l), snappedf(att, 0.001), _a3(l.global_position)])
3955
+ counts[kind] = int(counts[kind]) + 1
3956
+ if l.shadow_enabled:
3957
+ shadowed[kind] = int(shadowed[kind]) + 1
3958
+ var over: Array = []
3959
+ var done := 0
3960
+ var total := 0
3961
+ for rec in _inst:
3962
+ if not bool(rec["in"]):
3963
+ continue
3964
+ total += 1
3965
+ if _over():
3966
+ continue
3967
+ done += 1
3968
+ var worst: Array = []
3969
+ for m in (rec["meshes"] as Array):
3970
+ var node: Node = m[0]
3971
+ var layers := 1
3972
+ if node is VisualInstance3D:
3973
+ layers = (node as VisualInstance3D).layers
3974
+ var box: AABB = (m[2] as Transform3D) * (m[1] as Mesh).get_aabb()
3975
+ var no := 0
3976
+ var ns := 0
3977
+ var names: Array = []
3978
+ for pair in omni:
3979
+ if (pair[0] as Light3D).light_cull_mask & layers and (pair[1] as AABB).intersects(box):
3980
+ no += 1
3981
+ if names.size() < 3:
3982
+ names.append(String((pair[0] as Node).name))
3983
+ for pair in spot:
3984
+ if (pair[0] as Light3D).light_cull_mask & layers and (pair[1] as AABB).intersects(box):
3985
+ ns += 1
3986
+ if names.size() < 3:
3987
+ names.append(String((pair[0] as Node).name))
3988
+ if (no > limit or ns > limit) and (worst.is_empty() or maxi(no, ns) > maxi(int(worst[2]), int(worst[3]))):
3989
+ worst = [int(rec["i"]), _rel(node), no, ns, names]
3990
+ if not worst.is_empty():
3991
+ over.append(worst)
3992
+ _count("lights", done, total)
3993
+ var renderer := RenderingServer.get_current_rendering_method()
3994
+ return {"renderer": renderer, "limit": limit, "counts": counts, "shadowed": shadowed, "over": over, "hard_rim": hard_rim}
3995
+
3996
+
3997
+ # ---------------------------------------------------------------------------
3998
+ # resource: missing dependency files, empty meshes, surfaces without material
3999
+ # ---------------------------------------------------------------------------
4000
+
4001
+ func _scan_resources() -> Dictionary:
4002
+ var missing: Array = []
4003
+ var checked: Dictionary = {}
4004
+ var scenes: Dictionary = {}
4005
+ var root_scene := String(_cfg.get("scene_path", ""))
4006
+ if root_scene != "":
4007
+ scenes[root_scene] = -1
4008
+ for rec in _inst:
4009
+ var sc := String(rec["scene"])
4010
+ if sc != "" and not scenes.has(sc):
4011
+ scenes[sc] = int(rec["i"])
4012
+ var budget := 3000
4013
+ var queue: Array = []
4014
+ for sc in scenes:
4015
+ queue.append([sc, sc, int(scenes[sc]), 0])
4016
+ while not queue.is_empty() and budget > 0:
4017
+ if _over():
4018
+ break
4019
+ var item: Array = queue.pop_front()
4020
+ var path := String(item[0])
4021
+ if checked.has(path):
4022
+ continue
4023
+ checked[path] = true
4024
+ budget -= 1
4025
+ if not path.begins_with("res://"):
4026
+ continue
4027
+ for dep in ResourceLoader.get_dependencies(path):
4028
+ var dp := String(dep)
4029
+ if dp.get_slice_count("::") >= 3:
4030
+ dp = dp.get_slice("::", 2)
4031
+ if dp == "" or not dp.begins_with("res://"):
4032
+ continue
4033
+ if not ResourceLoader.exists(dp):
4034
+ if missing.size() < 32:
4035
+ missing.append([dp, String(item[1]), int(item[2])])
4036
+ continue
4037
+ var ext := dp.get_extension().to_lower()
4038
+ if int(item[3]) < 2 and (ext == "tres" or ext == "res" or ext == "material" or ext == "tscn"):
4039
+ queue.append([dp, String(item[1]), int(item[2]), int(item[3]) + 1])
4040
+ _count("resource", checked.size(), checked.size() + (queue.size() if budget > 0 else 0))
4041
+ var no_mat: Array = []
4042
+ for rec in _inst:
4043
+ if not bool(rec["in"]):
4044
+ continue
4045
+ var count := 0
4046
+ for m in (rec["meshes"] as Array):
4047
+ var node: Node = m[0]
4048
+ var mesh: Mesh = m[1]
4049
+ if not (node is MeshInstance3D):
4050
+ continue
4051
+ var mi := node as MeshInstance3D
4052
+ if mi.material_override != null:
4053
+ continue
4054
+ for s in mesh.get_surface_count():
4055
+ if mesh.surface_get_material(s) == null and mi.get_surface_override_material(s) == null:
4056
+ count += 1
4057
+ else:
4058
+ var mat := mi.get_surface_override_material(s)
4059
+ if mat == null:
4060
+ mat = mesh.surface_get_material(s)
4061
+ if mat is ShaderMaterial and (mat as ShaderMaterial).shader == null and _shader_no_code.size() < 16:
4062
+ _shader_no_code.append([int(rec["i"]), _rel(mi), s])
4063
+ if count > 0 and no_mat.size() < 64:
4064
+ no_mat.append([int(rec["i"]), count])
4065
+ var empty: Array = []
4066
+ for e in _empty_meshes:
4067
+ empty.append(e)
4068
+ return {"missing": missing, "checked": checked.size(), "empty": empty, "no_material": no_mat, "shader_missing": _shader_no_code}
4069
+
4070
+
4071
+ # ---------------------------------------------------------------------------
4072
+ # Clearance around each instance for framing (render: "sheet"): 8 directions
4073
+ # at 20 deg and 45 deg up, two-sided (any surface counts, front or back).
4074
+ # ---------------------------------------------------------------------------
4075
+
4076
+ func _scan_clearances() -> void:
4077
+ var done := 0
4078
+ var total := 0
4079
+ for rec in _inst:
4080
+ if not bool(rec["in"]):
4081
+ continue
4082
+ total += 1
4083
+ if _over():
4084
+ continue
4085
+ done += 1
4086
+ var wa: AABB = rec["waabb"]
4087
+ var c := wa.get_center()
4088
+ var ex: Array = rec["bodies"]
4089
+ var rows: Array = []
4090
+ for elev in [20.0, 45.0]:
4091
+ var ce := cos(deg_to_rad(elev))
4092
+ var se := sin(deg_to_rad(elev))
4093
+ for k in 8:
4094
+ var ang := TAU * float(k) / 8.0
4095
+ var dir := Vector3(sin(ang) * ce, se, cos(ang) * ce)
4096
+ rows.append(snappedf(_clear(c, dir, 10.0, ex), 0.01))
4097
+ rec["clear"] = rows
4098
+ _count("poses", done, total)
4099
+
4100
+
4101
+ # ---------------------------------------------------------------------------
4102
+ # Instance rows (compact): what the TypeScript side needs for every check.
4103
+ # ---------------------------------------------------------------------------
4104
+
4105
+ func _instance_rows() -> Array:
4106
+ var rows: Array = []
4107
+ for rec in _inst:
4108
+ var xf: Transform3D = rec["xf"]
4109
+ var b := xf.basis
4110
+ var wa: AABB = rec["waabb"]
4111
+ var la: AABB = rec["laabb"]
4112
+ var bad := not (is_finite(xf.origin.x) and is_finite(xf.origin.y) and is_finite(xf.origin.z) and is_finite(b.x.x) and is_finite(b.y.y) and is_finite(b.z.z) and is_finite(b.x.y) and is_finite(b.x.z) and is_finite(b.y.x) and is_finite(b.y.z) and is_finite(b.z.x) and is_finite(b.z.y))
4113
+ var row := {
4114
+ "p": rec["path"], "k": rec["piece"], "s": rec["scene"], "r": rec["role"], "in": rec["in"],
4115
+ "o": _a3(xf.origin, 0.0001), "b": _b9(b), "sc": [snappedf(b.x.length(), 0.0001), snappedf(b.y.length(), 0.0001), snappedf(b.z.length(), 0.0001)],
4116
+ "det": snappedf(b.determinant(), 0.0001), "c": _a3(wa.get_center()), "e": _a3(wa.size), "le": _a3(la.size), "lc": _a3(la.get_center()),
4117
+ "m": (rec["meshes"] as Array).size(), "f": _a3(rec.get("front", Vector3.ZERO), 1.0),
4118
+ }
4119
+ if bad:
4120
+ row["nan"] = true
4121
+ var node: Node = rec["node"]
4122
+ if node is Node3D:
4123
+ row["lo"] = _a3((node as Node3D).position, 0.0001)
4124
+ # transform: an identity LOCAL transform under an identity parent is
4125
+ # what a never-placed piece looks like; a placed one is not.
4126
+ row["li"] = _is_identity((node as Node3D).transform)
4127
+ var par := node.get_parent()
4128
+ row["pi"] = not (par is Node3D) or _is_identity((par as Node3D).global_transform)
4129
+ if rec.has("clear"):
4130
+ row["cl"] = rec["clear"]
4131
+ # Why it has its role (geometry and engine data: see _classify_shapes).
4132
+ row["w"] = String(rec.get("why", ""))
4133
+ if rec.has("line"):
4134
+ row["ln"] = int(rec["line"])
4135
+ rows.append(row)
4136
+ return rows