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,1134 @@
1
+ /**
2
+ * Judgment for summer_scene_audit: the kernel's raw measurements (rays that
3
+ * passed, gaps under props, hull overlaps, UV candidates, ...)
4
+ * become issues with a check, a severity, a node path, a world position, a
5
+ * one-line reason, the evidence numbers and the next tool to use.
6
+ *
7
+ * Every issue is a flag for the agent to LOOK at, never an auto-fix. The
8
+ * thresholds are the spec's (2 cm floating, 3 cm embed/overlap, 15 deg
9
+ * parallelism, UV stretch 8); the severity grading above them is ours and
10
+ * documented next to each check. Roles (wall, floor, insert, prop, ...) come
11
+ * from the kernel, which derives them from geometry and engine data only.
12
+ */
13
+ import { add, length, normalize, scale, sub } from "../seeing/math.js";
14
+ import { axisName, basisAngleDegrees, clusterSamples, DEFAULT_FAR, DEFAULT_NEAR, footprintExtent, lineAngleDegrees, localAxisName, median, mostCommon, TYPICAL_VIEW_M, robustBounds, triangleArea, uvStretchRatio, wallDirection, zFightTolerance, } from "./math.js";
15
+ // ---------------------------------------------------------------------------
16
+ // Small helpers
17
+ // ---------------------------------------------------------------------------
18
+ // `|| 0` turns -0 into 0.
19
+ const r2 = (n) => Math.round(n * 100) / 100 || 0;
20
+ const r3 = (n) => Math.round(n * 1000) / 1000 || 0;
21
+ const v2 = (v) => [r2(v[0]), r2(v[1]), r2(v[2])];
22
+ const fmt = (v) => `(${r2(v[0])},${r2(v[1])},${r2(v[2])})`;
23
+ const cm = (m) => `${Math.round(m * 1000) / 10} cm`;
24
+ function num(v, fallback = 0) {
25
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
26
+ }
27
+ function vec(v) {
28
+ return Array.isArray(v) && v.length >= 3 ? [num(v[0]), num(v[1]), num(v[2])] : [0, 0, 0];
29
+ }
30
+ function arr(v) {
31
+ return Array.isArray(v) ? v : [];
32
+ }
33
+ /** The 16 clearance directions the kernel measures around every instance
34
+ * (8 azimuths at 20 deg up, then 8 at 45 deg up). */
35
+ export function clearanceDirections() {
36
+ const out = [];
37
+ for (const elev of [20, 45]) {
38
+ const ce = Math.cos((elev * Math.PI) / 180);
39
+ const se = Math.sin((elev * Math.PI) / 180);
40
+ for (let k = 0; k < 8; k++) {
41
+ const a = (2 * Math.PI * k) / 8;
42
+ out.push([Math.sin(a) * ce, se, Math.cos(a) * ce]);
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+ const CLEAR_DIRS = clearanceDirections();
48
+ function instDirs(row, pref = (_d, k) => (k < 8 ? 1 : 0.7)) {
49
+ if (!row?.cl || row.cl.length !== CLEAR_DIRS.length)
50
+ return [];
51
+ return CLEAR_DIRS.map((dir, k) => ({ dir, clear: num(row.cl[k]), pref: pref(dir, k) }));
52
+ }
53
+ function instFrame(row, focus, pref) {
54
+ if (!row)
55
+ return undefined;
56
+ const dirs = instDirs(row, pref);
57
+ if (!dirs.length)
58
+ return undefined;
59
+ return { focus: focus ?? row.c, size: Math.max(0.6, Math.max(row.e[0], row.e[1], row.e[2])), dirs };
60
+ }
61
+ function framesNodes(paths, from) {
62
+ return `summer_frame_nodes nodes=[${paths.join(",")}] from=${fmt(from)}`;
63
+ }
64
+ function parseLine(raw) {
65
+ const r = raw;
66
+ if (!r || typeof r !== "object")
67
+ return null;
68
+ return {
69
+ n: vec(r.n),
70
+ t: vec(r.t),
71
+ d: num(r.d),
72
+ spacing: num(r.spacing, 0.25),
73
+ through: arr(r.through).filter(Array.isArray),
74
+ pieces: arr(r.pieces).filter(Array.isArray),
75
+ inserts: arr(r.inserts).filter(Array.isArray),
76
+ zfight: arr(r.zfight),
77
+ };
78
+ }
79
+ /** Clusters of see-through rays on each facade line; clusters beside the same
80
+ * host and insert (both sides of a too-narrow door) become one issue. */
81
+ export function judgeThroughHoles(lines, inst) {
82
+ const out = [];
83
+ for (const raw of lines) {
84
+ const line = parseLine(raw);
85
+ if (!line || !line.through.length)
86
+ continue;
87
+ const s = line.spacing;
88
+ const groups = clusterSamples(line.through.map((row) => ({ u: num(row[0]), v: num(row[1]) })), s * 1.5);
89
+ const clusters = [];
90
+ for (const group of groups) {
91
+ const rows = group.map((i) => line.through[i]);
92
+ const tlo = Math.min(...rows.map((r) => num(r[4], num(r[0]))));
93
+ const thi = Math.max(...rows.map((r) => num(r[5], num(r[0]))));
94
+ const ylo = Math.min(...rows.map((r) => num(r[6], num(r[1]))));
95
+ const yhi = Math.max(...rows.map((r) => num(r[7], num(r[1]))));
96
+ const tc = (tlo + thi) / 2;
97
+ const yc = (ylo + yhi) / 2;
98
+ // Host: the smallest piece rect containing the centre (a frame beats a wall).
99
+ const containing = line.pieces
100
+ .filter((p) => num(p[1]) - 0.05 <= tc && tc <= num(p[2]) + 0.05 && num(p[3]) - 0.05 <= yc && yc <= num(p[4]) + 0.05)
101
+ .sort((a, b) => (num(a[2]) - num(a[1])) * (num(a[4]) - num(a[3])) - (num(b[2]) - num(b[1])) * (num(b[4]) - num(b[3])));
102
+ const host = inst[num(containing[0]?.[0], -1)];
103
+ const near = (rects) => rects
104
+ .filter((p) => num(p[1]) < thi + 0.25 && num(p[2]) > tlo - 0.25 && num(p[3]) < yhi + 0.25 && num(p[4]) > ylo - 0.25)
105
+ .map((p) => inst[num(p[0], -1)]?.p)
106
+ .filter((p) => !!p && p !== host?.p);
107
+ const beside = [...near(line.inserts), ...near(line.pieces)].filter((p, i, a) => a.indexOf(p) === i).slice(0, 3);
108
+ const clears = rows.map((r) => num(r[8], NaN)).filter((c) => Number.isFinite(c));
109
+ const centerRow = rows.reduce((best, r) => (Math.hypot(num(r[0]) - tc, num(r[1]) - yc) < Math.hypot(num(best[0]) - tc, num(best[1]) - yc) ? r : best), rows[0]);
110
+ clusters.push({
111
+ rays: rows.length,
112
+ w: Math.max(0.03, thi - tlo),
113
+ h: Math.max(0.03, yhi - ylo),
114
+ pos: add(add(scale(line.t, tc), scale(line.n, line.d)), [0, yc, 0]),
115
+ ...(host ? { host } : {}),
116
+ beside,
117
+ far: Math.min(...rows.map((r) => num(r[3], 4))),
118
+ clear: clears.length ? median(clears) : 6,
119
+ ray: { origin: add(add(scale(line.t, num(centerRow[0])), scale(line.n, line.d + 0.6)), [0, num(centerRow[1]), 0]), direction: scale(line.n, -1) },
120
+ });
121
+ }
122
+ const merged = new Map();
123
+ clusters.forEach((c, k) => {
124
+ const key = c.host ? `${c.host.p}|${c.beside[0] ?? ""}` : `#${k}`;
125
+ const list = merged.get(key);
126
+ if (list)
127
+ list.push(c);
128
+ else
129
+ merged.set(key, [c]);
130
+ });
131
+ for (const list of merged.values()) {
132
+ list.sort((a, b) => b.rays - a.rays);
133
+ const main = list[0];
134
+ const rays = list.reduce((sum, c) => sum + c.rays, 0);
135
+ const path = main.host?.p ?? main.beside[0] ?? "?";
136
+ const severity = rays >= 3 || main.h >= 0.4 ? "error" : "warn";
137
+ const sizes = `${list.map((c) => `${r2(c.w)}x${r2(c.h)}`).join(" + ")} m`;
138
+ out.push({
139
+ check: "through_hole",
140
+ severity,
141
+ path,
142
+ pos: v2(main.pos),
143
+ why: `see-through ${list.length > 1 ? "gaps" : "gap"} ${sizes}: ${rays} rays pass the wall to the far side of the building`,
144
+ ev: {
145
+ w: r2(main.w),
146
+ h: r2(main.h),
147
+ rays,
148
+ ...(list.length > 1 ? { gaps: list.slice(0, 3).map((c) => [...v2(c.pos), r2(c.w), r2(c.h)]) } : {}),
149
+ ...(main.beside.length ? { beside: main.beside.slice(0, 2) } : {}),
150
+ ray: [v2(main.ray.origin), v2(main.ray.direction)],
151
+ },
152
+ next: framesNodes([path, ...main.beside.slice(0, 1)], add(line.n, [0, 0.2, 0])),
153
+ score: rays * s * s,
154
+ frame: {
155
+ focus: main.pos,
156
+ size: Math.max(1, main.w, main.h),
157
+ dirs: [{ dir: normalize(line.n), clear: main.clear, pref: 1 }],
158
+ },
159
+ });
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+ function parseGapRow(g, cell) {
165
+ const sx = num(g[8], cell);
166
+ const sz = num(g[9], cell);
167
+ return {
168
+ x: num(g[0]),
169
+ z: num(g[1]),
170
+ top: num(g[2]),
171
+ owner: num(g[3], -1),
172
+ hit: num(g[4], -1),
173
+ hitY: typeof g[5] === "number" ? g[5] : null,
174
+ clear: num(g[6], NaN),
175
+ kind: num(g[7], 0),
176
+ sx,
177
+ sz,
178
+ area: num(g[10], sx * sz),
179
+ below: typeof g[11] === "number" ? g[11] : null,
180
+ belowInst: num(g[12], -1),
181
+ wall: num(g[13], -1),
182
+ };
183
+ }
184
+ /** "8.4 x 0.04 m": the long side first; centimetre precision below 10 cm. */
185
+ function dims(long, short) {
186
+ const f = (n) => (n < 0.1 ? r3(n) : r2(n));
187
+ return `${f(long)} x ${f(short)} m`;
188
+ }
189
+ const TOP_DOWN = normalize([0.3, 1, 0.25]);
190
+ /**
191
+ * floor_gap. Down rays over the tile footprints (a grid, plus rays along the
192
+ * seams between tiles) and along each tile edge that a wall bounds within
193
+ * 1 m (the strip between the last tile and the wall base). Each row says
194
+ * which surface the ray hit FIRST:
195
+ * - nothing: the void shows (error);
196
+ * - an underlay with the floor's own surface right under it: the underlay
197
+ * plane sits above a drain channel or dip and paints over it ("covers the
198
+ * floor", warn), not a hole;
199
+ * - an underlay with nothing under it: a hole in the tile, or a bare strip
200
+ * outside the tiles.
201
+ * A piece whose own mesh has holes (or whose low surfaces the underlay
202
+ * covers) in every tile is ONE issue for the piece. Areas are the sum of the
203
+ * missed rays' own footprints, with the strip's dimensions.
204
+ */
205
+ export function judgeFloorGaps(floors, inst) {
206
+ if (!floors)
207
+ return [];
208
+ const out = [];
209
+ const cell = num(floors.cell, 0.3);
210
+ const rows = arr(floors.gaps).filter(Array.isArray).map((g) => parseGapRow(g, cell));
211
+ const pieceOf = (g) => inst[g.owner]?.k ?? "";
212
+ const perOwner = arr(floors.per_owner).filter(Array.isArray);
213
+ // A piece whose OWN mesh has holes shows them in every instance: one issue
214
+ // per piece, not one per hole.
215
+ const byPiece = new Map();
216
+ for (const row of perOwner) {
217
+ const i = num(row[0], -1);
218
+ const cells = num(row[1]);
219
+ const r = inst[i];
220
+ if (!r || cells < 10)
221
+ continue;
222
+ const list = byPiece.get(r.k) ?? [];
223
+ list.push({ i, holes: num(row[2]) / cells, void: num(row[3]) / cells, covered: num(row[4]) / cells });
224
+ byPiece.set(r.k, list);
225
+ }
226
+ const holed = new Set();
227
+ const coveredPieces = new Set();
228
+ for (const [piece, list] of byPiece) {
229
+ const showing = list.filter((x) => x.holes >= 0.05);
230
+ if (showing.length >= 2) {
231
+ holed.add(piece);
232
+ const worst = showing.reduce((a, b) => (b.holes > a.holes ? b : a));
233
+ const r = inst[worst.i];
234
+ const meanFrac = showing.reduce((sum, x) => sum + x.holes, 0) / showing.length;
235
+ const voidShare = showing.reduce((sum, x) => sum + x.void, 0) / showing.length;
236
+ const shows = voidShare > meanFrac / 2 ? "the void" : "the underlay";
237
+ const focus = [r.c[0], r.c[1] + r.e[1] / 2, r.c[2]];
238
+ out.push({
239
+ check: "floor_gap",
240
+ severity: shows === "the void" ? "error" : "warn",
241
+ path: r.p,
242
+ pos: v2(focus),
243
+ why: `${piece} has holes in its own mesh: ${Math.round(meanFrac * 100)}% of each tile shows ${shows} (${showing.length}/${list.length} tiles)`,
244
+ ev: { piece, tiles: showing.length, fraction: r2(meanFrac), worst: r2(worst.holes), also: showing.filter((x) => x !== worst).slice(0, 2).map((x) => inst[x.i].p) },
245
+ next: `summer_frame_nodes nodes=[${r.p}] direction=top`,
246
+ score: meanFrac * showing.length,
247
+ frame: { focus, size: Math.max(r.e[0], r.e[2]) * 0.6, dirs: [{ dir: TOP_DOWN, clear: 8, pref: 1 }] },
248
+ });
249
+ }
250
+ const covering = list.filter((x) => x.covered >= 0.05);
251
+ if (covering.length >= 2) {
252
+ coveredPieces.add(piece);
253
+ const worst = covering.reduce((a, b) => (b.covered > a.covered ? b : a));
254
+ const r = inst[worst.i];
255
+ const meanFrac = covering.reduce((sum, x) => sum + x.covered, 0) / covering.length;
256
+ const mine = rows.filter((g) => g.below !== null && g.kind !== 2 && pieceOf(g) === piece);
257
+ const underlay = inst[mostCommon(mine.map((g) => g.hit))];
258
+ const lowest = mine.length ? Math.min(...mine.map((g) => g.below)) : null;
259
+ const focus = [r.c[0], r.c[1] + r.e[1] / 2, r.c[2]];
260
+ out.push({
261
+ check: "floor_gap",
262
+ severity: "warn",
263
+ path: r.p,
264
+ pos: v2(focus),
265
+ why: `${underlay?.p ?? "the underlay"} covers ${piece}'s own low surfaces: ${Math.round(meanFrac * 100)}% of each tile (${covering.length}/${list.length} tiles); the underlay plane sits above the floor there and paints over it, not a hole`,
266
+ ev: { piece, tiles: covering.length, fraction: r2(meanFrac), ...(underlay ? { underlay: underlay.p } : {}), ...(lowest !== null ? { floor_low_y: r3(lowest) } : {}) },
267
+ next: `summer_set_prop ${underlay?.p ?? "<underlay>"} position: lower it${lowest !== null ? ` under y ${r3(lowest - 0.005)}` : ""} (the floor's lowest surface), then re-seat props`,
268
+ score: meanFrac * covering.length,
269
+ frame: { focus, size: Math.max(r.e[0], r.e[2]) * 0.6, dirs: [{ dir: TOP_DOWN, clear: 8, pref: 1 }] },
270
+ });
271
+ }
272
+ }
273
+ // The rest cluster by location, one class at a time.
274
+ const covered = rows.filter((g) => g.below !== null && !(g.kind !== 2 && coveredPieces.has(pieceOf(g))));
275
+ const strips = rows.filter((g) => g.below === null && g.kind === 2);
276
+ const holes = rows.filter((g) => g.below === null && g.kind !== 2 && !holed.has(pieceOf(g)));
277
+ const clusters = (list) => clusterSamples(list.map((g) => ({ u: g.x, v: g.z })), cell * 1.5).map((group) => group.map((i) => list[i]));
278
+ const place = (group) => {
279
+ const ext = footprintExtent(group);
280
+ const top = Math.max(...group.map((g) => g.top));
281
+ const ownerIdx = mostCommon(group.map((g) => g.owner));
282
+ const pos = [(ext.x0 + ext.x1) / 2, top, (ext.z0 + ext.z1) / 2];
283
+ const clears = group.map((g) => g.clear).filter((c) => Number.isFinite(c));
284
+ const frame = { focus: pos, size: Math.max(1.2, Math.sqrt(ext.area) * 2, Math.min(ext.long, 6) * 0.6), dirs: [{ dir: TOP_DOWN, clear: clears.length ? median(clears) : 4, pref: 1 }] };
285
+ const ray = `summer_raycast origin=${fmt([group[0].x, top + 0.6, group[0].z])} direction=(0,-1,0)`;
286
+ return { ext, top, ownerIdx, owner: inst[ownerIdx], pos, frame, ray, size: { rays: group.length, w: r3(ext.w), d: r3(ext.d), area_m2: r3(ext.area) } };
287
+ };
288
+ for (const group of clusters(holes)) {
289
+ const { ext, ownerIdx, owner, pos, frame, ray, size } = place(group);
290
+ const voidRows = group.filter((g) => g.hit < 0).length;
291
+ const fellTo = voidRows > group.length / 2 ? "void" : (inst[group.find((g) => g.hit >= 0)?.hit ?? -1]?.p ?? "void");
292
+ const severity = fellTo === "void" ? "error" : group.length >= 3 ? "warn" : "look";
293
+ const beside = [...new Set(group.map((g) => g.owner))].filter((k) => k !== ownerIdx).map((k) => inst[k]?.p).filter((p) => !!p).slice(0, 2);
294
+ out.push({
295
+ check: "floor_gap",
296
+ severity,
297
+ path: owner?.p ?? "?",
298
+ pos: v2(pos),
299
+ why: `floor gap ${r2(ext.area)} m2 (${dims(ext.long, ext.short)}): ${group.length} down rays miss the floor and fall to ${fellTo === "void" ? "the void" : fellTo}`,
300
+ ev: { ...size, ...(beside.length ? { beside } : {}) },
301
+ next: ray,
302
+ score: ext.area,
303
+ frame,
304
+ });
305
+ }
306
+ for (const group of clusters(covered)) {
307
+ const { ext, owner, pos, frame, ray, size } = place(group);
308
+ const underlay = inst[mostCommon(group.map((g) => g.hit))];
309
+ const floor = inst[mostCommon(group.map((g) => g.belowInst).filter((i) => i >= 0))] ?? owner;
310
+ const lowest = Math.min(...group.map((g) => g.below));
311
+ const above = Math.max(...group.map((g) => (g.hitY ?? g.top) - g.below));
312
+ out.push({
313
+ check: "floor_gap",
314
+ severity: "warn",
315
+ path: floor?.p ?? owner?.p ?? "?",
316
+ pos: v2(pos),
317
+ why: `${underlay?.p ?? "the underlay"} covers the floor over ${r2(ext.area)} m2 (${dims(ext.long, ext.short)}): it sits up to ${cm(above)} above the floor's own surface (a drain channel or dip) and paints over it; not a hole`,
318
+ ev: { ...size, ...(underlay ? { underlay: underlay.p } : {}), floor_low_y: r3(lowest), above_m: r3(above) },
319
+ next: `${ray}; lower ${underlay?.p ?? "the underlay"} under y ${r3(lowest - 0.005)} with summer_set_prop`,
320
+ score: ext.area,
321
+ frame,
322
+ });
323
+ }
324
+ for (const group of clusters(strips)) {
325
+ const { ext, owner, pos, frame, size } = place(group);
326
+ const wall = inst[mostCommon(group.map((g) => g.wall).filter((i) => i >= 0))];
327
+ const voidRows = group.filter((g) => g.hit < 0).length;
328
+ const shows = voidRows > group.length / 2 ? "the void" : (inst[mostCommon(group.map((g) => g.hit).filter((i) => i >= 0))]?.p ?? "the underlay");
329
+ out.push({
330
+ check: "floor_gap",
331
+ severity: shows === "the void" ? "error" : ext.area >= 0.05 ? "warn" : "look",
332
+ path: owner?.p ?? "?",
333
+ pos: v2(pos),
334
+ why: `bare strip ${dims(ext.long, ext.short)} (${r2(ext.area)} m2) between ${owner?.p ?? "the floor"}'s edge and ${wall?.p ?? "a wall"}: the floor stops short of the wall and ${shows} shows`,
335
+ ev: { ...size, ...(wall ? { wall: wall.p } : {}) },
336
+ next: `summer_measure ${owner?.p ?? "<floor>"} vs ${wall?.p ?? "<wall>"}; extend the floor to the wall`,
337
+ score: ext.area,
338
+ frame,
339
+ });
340
+ }
341
+ return out;
342
+ }
343
+ // ---------------------------------------------------------------------------
344
+ // floating / sunken
345
+ // ---------------------------------------------------------------------------
346
+ export function judgeSupport(support, inst) {
347
+ const out = [];
348
+ for (const raw of support) {
349
+ if (!Array.isArray(raw))
350
+ continue;
351
+ const i = num(raw[0], -1);
352
+ const r = inst[i];
353
+ if (!r)
354
+ continue;
355
+ const ymin = num(raw[1]);
356
+ const hits = arr(raw[3]).map((h) => (Array.isArray(h) ? { y: num(h[0]), at: num(h[1], -1) } : null));
357
+ const touch = typeof raw[4] === "number" ? raw[4] : null;
358
+ const floorTop = typeof raw[5] === "number" ? raw[5] : null;
359
+ const found = hits.filter((h) => h !== null);
360
+ const focus = [r.c[0], ymin + Math.min(0.3, r.e[1] / 2), r.c[2]];
361
+ // Framed on the instance centre: that is where the clearances were measured.
362
+ const frame = instFrame(r, r.c, (_d, k) => (k < 8 ? 1 : 0.5));
363
+ // Clear of the ground but against a wall (a lantern, a sign, an AC unit):
364
+ // held by the wall, not floating. It must touch the wall (within 5 cm of
365
+ // its bounds) and be well off the ground: a crate 5 cm up a wall still
366
+ // floats.
367
+ const topY = found.length ? Math.max(...found.map((h) => h.y)) : -Infinity;
368
+ if (touch !== null && touch <= 0.05 && ymin - topY > 0.3)
369
+ continue;
370
+ // Sunken, from ABOVE: the first surface a ray down through the footprint
371
+ // meets (below the prop's own top) is what the prop stands in. The
372
+ // support ray from mid-height never sees a floor top above its start, so
373
+ // a bottle buried 16.7 cm in a floor tile read as "sunk into the
374
+ // underlay" under it. Older kernels send no rows here: the support rays
375
+ // below decide.
376
+ const above = arr(raw[6])
377
+ .map((h) => (Array.isArray(h) ? { y: num(h[0]), at: num(h[1], -1) } : null))
378
+ .filter((h) => h !== null && h.y <= ymin + r.e[1] - 0.005);
379
+ if (above.length) {
380
+ const embed = median(above.map((h) => h.y)) - ymin;
381
+ if (embed > 0.03) {
382
+ if (r.r === "dressing")
383
+ continue;
384
+ const buried = above.filter((h) => h.y - ymin > 0.03);
385
+ const at = mostCommon(buried.map((h) => h.at));
386
+ const support = inst[at];
387
+ const surfaceY = median(buried.filter((h) => h.at === at).map((h) => h.y));
388
+ const height = Math.max(0.01, r.e[1]);
389
+ const below = found.length ? inst[found.reduce((a, b) => (b.y > a.y ? b : a)).at] : undefined;
390
+ out.push({
391
+ check: "sunken",
392
+ severity: embed >= height * 0.5 ? "error" : embed > 0.1 ? "warn" : "look",
393
+ path: r.p,
394
+ pos: v2(focus),
395
+ why: `sunk ${cm(embed)} into ${support?.p ?? "the surface"} (the first surface from above, at y ${r3(surfaceY)})${embed >= height * 0.5 ? `: ${Math.round((embed / height) * 100)}% of its height` : ""}`,
396
+ ev: { embed_m: r3(embed), height_m: r3(height), support: support?.p ?? null, support_y: r3(surfaceY), ...(below && below !== support ? { under_it: below.p } : {}) },
397
+ next: `summer_snap_to_surface ${r.p}`,
398
+ score: embed,
399
+ ...(frame ? { frame } : {}),
400
+ });
401
+ continue;
402
+ }
403
+ }
404
+ if (!found.length) {
405
+ out.push({
406
+ check: "floating",
407
+ severity: "error",
408
+ path: r.p,
409
+ pos: v2(focus),
410
+ why: `nothing under its footprint within 1 m${touch !== null ? ` and no wall within 5 cm (nearest ${cm(Math.max(0, touch))})` : ""}: it hangs in the air`,
411
+ ev: { gap_m: ">1", samples: 0, ...(touch !== null ? { wall_m: r3(touch) } : {}) },
412
+ next: `summer_snap_to_surface ${r.p}`,
413
+ score: 2,
414
+ ...(frame ? { frame } : {}),
415
+ });
416
+ continue;
417
+ }
418
+ const best = found.reduce((a, b) => (b.y > a.y ? b : a));
419
+ const gap = ymin - best.y;
420
+ const support = inst[best.at];
421
+ if (gap > 0.02) {
422
+ // Standing at the floor level over a hole in the floor tile, with only
423
+ // the underlay below: the floor is the problem (floor_gap), not the prop.
424
+ const overHole = support?.r === "underlay" && (gap <= 0.06 || (floorTop !== null && Math.abs(ymin - floorTop) <= 0.04));
425
+ out.push({
426
+ check: "floating",
427
+ severity: overHole ? "look" : gap > 0.1 ? "error" : "warn",
428
+ path: r.p,
429
+ pos: v2(focus),
430
+ why: overHole ? `stands at floor level over a hole in the floor: only ${support?.p ?? "the underlay"} is under it, ${cm(gap)} down` : `floats ${cm(gap)} above ${support?.p ?? "the surface below"}`,
431
+ ev: { gap_m: r3(gap), support: support?.p ?? null, samples: `${found.length}/${hits.length}` },
432
+ next: `summer_snap_to_surface ${r.p}`,
433
+ score: gap,
434
+ ...(frame ? { frame } : {}),
435
+ });
436
+ continue;
437
+ }
438
+ if (r.r === "dressing" || above.length)
439
+ continue;
440
+ const ys = found.map((h) => h.y);
441
+ const embed = median(ys) - ymin;
442
+ if (embed > 0.03) {
443
+ const height = Math.max(0.01, r.e[1]);
444
+ const deepest = found.reduce((a, b) => (b.y > a.y ? b : a));
445
+ out.push({
446
+ check: "sunken",
447
+ severity: embed >= height * 0.5 ? "error" : embed > 0.1 ? "warn" : "look",
448
+ path: r.p,
449
+ pos: v2(focus),
450
+ why: `sunk ${cm(embed)} into ${inst[deepest.at]?.p ?? "the surface"}${embed >= height * 0.5 ? ` (${Math.round((embed / height) * 100)}% of its height)` : ""}`,
451
+ ev: { embed_m: r3(embed), height_m: r3(height), support: inst[deepest.at]?.p ?? null },
452
+ next: `summer_snap_to_surface ${r.p}`,
453
+ score: embed,
454
+ ...(frame ? { frame } : {}),
455
+ });
456
+ }
457
+ }
458
+ return out;
459
+ }
460
+ // ---------------------------------------------------------------------------
461
+ // interpenetration
462
+ // ---------------------------------------------------------------------------
463
+ /** Overlaps the kernel reports start at 1 cm; a prop is an issue when its
464
+ * deepest overlap is over 3 cm, and then every partner it cuts is named. */
465
+ export const OVERLAP_MIN = 0.03;
466
+ export const OVERLAP_PARTNERS = 3;
467
+ export function judgeOverlaps(overlaps, inst) {
468
+ // One issue per prop: its deepest overlap first, then every other piece it
469
+ // cuts (up to 3 in all), shallower ones included: a lamp 18.7 cm into a
470
+ // duct that also clips the door band beside it is one problem with two
471
+ // partners, and moving it out of the duct alone does not fix it.
472
+ const byProp = new Map();
473
+ for (const raw of overlaps) {
474
+ if (!Array.isArray(raw))
475
+ continue;
476
+ const ai = num(raw[0], -1);
477
+ const b = inst[num(raw[1], -1)];
478
+ const depth = num(raw[2]);
479
+ if (!inst[ai] || !b || depth < 0.01)
480
+ continue;
481
+ const list = byProp.get(ai) ?? [];
482
+ list.push({ b, depth, point: vec(raw[3]) });
483
+ byProp.set(ai, list);
484
+ }
485
+ const kindOf = (b) => (b.r === "prop" ? "another prop" : b.r === "insert" ? "an insert" : "the structure");
486
+ const out = [];
487
+ for (const [ai, list] of byProp) {
488
+ const a = inst[ai];
489
+ list.sort((x, y) => y.depth - x.depth);
490
+ const { b, depth, point } = list[0];
491
+ if (depth <= OVERLAP_MIN)
492
+ continue;
493
+ const partners = list.slice(0, OVERLAP_PARTNERS);
494
+ const also = partners.slice(1).map((o) => `${o.b.p} (${kindOf(o.b)}) ${cm(o.depth)}`);
495
+ out.push({
496
+ check: "interpenetration",
497
+ severity: depth >= 0.1 ? "warn" : "look",
498
+ path: a.p,
499
+ pos: v2(point),
500
+ why: `overlaps ${b.p} (${kindOf(b)}) by ${cm(depth)}${also.length ? `; also ${also.join(", ")}` : ""}`,
501
+ ev: {
502
+ depth_m: r3(depth),
503
+ other: b.p,
504
+ other_role: b.r,
505
+ partners: partners.map((o) => [o.b.p, r3(o.depth)]),
506
+ ...(list.length > OVERLAP_PARTNERS ? { more: list.length - OVERLAP_PARTNERS } : {}),
507
+ },
508
+ next: `summer_test_placement ${a.p} (lists every overlap), then summer_measure ${a.p} vs ${b.p}`,
509
+ score: depth,
510
+ ...(instFrame(a) ? { frame: instFrame(a) } : {}),
511
+ });
512
+ }
513
+ return out;
514
+ }
515
+ // ---------------------------------------------------------------------------
516
+ // Geometry helpers
517
+ // ---------------------------------------------------------------------------
518
+ /** World bounds of two pieces touch (within `slack`) on all three axes. */
519
+ export function boundsTouch(a, b, slack = 0.02) {
520
+ for (let k = 0; k < 3; k++) {
521
+ if (Math.abs(a.c[k] - b.c[k]) > (a.e[k] + b.e[k]) / 2 + slack)
522
+ return false;
523
+ }
524
+ return true;
525
+ }
526
+ // ---------------------------------------------------------------------------
527
+ // orientation: long props near a wall
528
+ // ---------------------------------------------------------------------------
529
+ export function judgeLongProps(rows, inst) {
530
+ const out = [];
531
+ for (const raw of rows) {
532
+ if (!Array.isArray(raw))
533
+ continue;
534
+ const r = inst[num(raw[0], -1)];
535
+ const best = arr(raw[4]);
536
+ if (!r || best.length < 3)
537
+ continue;
538
+ const axis = vec(raw[1]);
539
+ const len = num(raw[2]);
540
+ const depth = num(raw[3]);
541
+ const clearance = num(best[0]);
542
+ const wall = inst[num(best[1], -1)];
543
+ const normal = vec(best[2]);
544
+ if (clearance > 1.2)
545
+ continue;
546
+ const angle = lineAngleDegrees(axis, wallDirection(normal));
547
+ if (angle <= 15)
548
+ continue;
549
+ out.push({
550
+ check: "orientation",
551
+ severity: "look",
552
+ path: r.p,
553
+ pos: v2(r.c),
554
+ why: `its long axis (${r2(len)} m) is ${Math.round(angle)} deg off the wall ${wall?.p ?? ""} ${clearance < 0 ? `it pokes ${cm(-clearance)} into` : `${cm(clearance)} away`}`,
555
+ ev: { angle: Math.round(angle), clearance_m: r3(clearance), length_m: r2(len), depth_m: r2(depth), wall: wall?.p ?? null, long_axis: v2(axis) },
556
+ next: `summer_frame_nodes nodes=[${r.p}] direction=top; decide the facing yourself`,
557
+ score: angle / 90,
558
+ ...(instFrame(r, r.c, (_d, k) => (k >= 8 ? 1 : 0.6)) ? { frame: instFrame(r, r.c, (_d, k) => (k >= 8 ? 1 : 0.6)) } : {}),
559
+ });
560
+ }
561
+ return out;
562
+ }
563
+ // ---------------------------------------------------------------------------
564
+ // uv_stretch
565
+ // ---------------------------------------------------------------------------
566
+ export const UV_STRETCH_LIMIT = 8;
567
+ export const UV_MIN_AREA = 0.02;
568
+ /** UVs of a triangle all within this distance sample one texel: a flat
569
+ * colour, never a visible stretch. */
570
+ export const UV_POINT_EPSILON = 0.002;
571
+ export function judgeUv(rows, inst) {
572
+ const out = [];
573
+ for (const raw of rows) {
574
+ const u = raw;
575
+ if (!u || typeof u !== "object")
576
+ continue;
577
+ const tris = arr(u.tris).filter(Array.isArray);
578
+ const meshName = String(u.mesh ?? "its mesh").split("::")[0];
579
+ const ratios = [];
580
+ const areas = [];
581
+ let bad = 0;
582
+ tris.forEach((t, k) => {
583
+ const p = [vec(t[0]), vec(t[1]), vec(t[2])];
584
+ const uv = [t[3], t[4], t[5]].map((x) => (Array.isArray(x) ? [num(x[0]), num(x[1])] : [0, 0]));
585
+ const span = Math.max(Math.hypot(uv[0][0] - uv[1][0], uv[0][1] - uv[1][1]), Math.hypot(uv[1][0] - uv[2][0], uv[1][1] - uv[2][1]), Math.hypot(uv[0][0] - uv[2][0], uv[0][1] - uv[2][1]));
586
+ const ratio = span < UV_POINT_EPSILON ? 1 : uvStretchRatio(p[0], p[1], p[2], uv[0], uv[1], uv[2]);
587
+ const area = triangleArea(p[0], p[1], p[2]);
588
+ ratios.push(ratio);
589
+ areas.push(area);
590
+ if (ratio > UV_STRETCH_LIMIT && area >= UV_MIN_AREA)
591
+ bad |= 1 << k;
592
+ });
593
+ if (!bad)
594
+ continue;
595
+ const users = num(u.users, 1);
596
+ const entries = arr(u.shown).filter(Array.isArray);
597
+ const shown = entries.filter((s) => (num(s[1]) & bad) !== 0);
598
+ if (!shown.length)
599
+ continue;
600
+ // A stretched face that inserts cover in other
601
+ // instances (a door, a shutter in front of it) is meant to be hidden:
602
+ // where it shows, it is EXPOSED (warn). Otherwise it is the asset's own
603
+ // mapping as it looks in normal use: one look item for the mesh.
604
+ // Normal use covers a face when at least as many instances cover it as
605
+ // show it (doors in 2 of 4 frames, shutters in the other 2).
606
+ let normallyCovered = 0;
607
+ for (let k = 0; k < tris.length; k++) {
608
+ const covered = entries.filter((e) => num(e[4]) & (1 << k)).length;
609
+ const visible = entries.filter((e) => num(e[1]) & (1 << k)).length;
610
+ if (covered >= 1 && covered >= visible)
611
+ normallyCovered |= 1 << k;
612
+ }
613
+ const exposedBits = bad & normallyCovered;
614
+ const everywhere = bad & ~normallyCovered;
615
+ const exposed = shown.filter((s) => (num(s[1]) & exposedBits) !== 0);
616
+ const describe = (bits) => {
617
+ let area = 0;
618
+ let worst = 0;
619
+ for (let k = 0; k < tris.length; k++) {
620
+ if (!(bits & (1 << k)))
621
+ continue;
622
+ area += areas[k];
623
+ worst = Math.max(worst, ratios[k]);
624
+ }
625
+ return { area, worst, text: Number.isFinite(worst) ? `${Math.round(worst * 10) / 10}:1` : "UVs collapsed to a line" };
626
+ };
627
+ const emit = (list, bits, severity, why) => {
628
+ const first = list[0];
629
+ const r = inst[num(first[0], -1)];
630
+ if (!r)
631
+ return;
632
+ const d = describe(bits);
633
+ const viewer = Array.isArray(first[2]) ? vec(first[2]) : undefined;
634
+ const center = Array.isArray(first[3]) ? vec(first[3]) : r.c;
635
+ out.push({
636
+ check: "uv_stretch",
637
+ severity,
638
+ path: r.p,
639
+ pos: v2(center),
640
+ why: why(d),
641
+ ev: {
642
+ mesh: meshName,
643
+ ratio: Number.isFinite(d.worst) ? r2(d.worst) : "line",
644
+ area_m2: r2(d.area),
645
+ shown_in: list.length,
646
+ users,
647
+ ...(list.length > 1 ? { more: list.slice(1, 3).map((s) => inst[num(s[0], -1)]?.p).filter(Boolean) } : {}),
648
+ },
649
+ next: `summer_zoom at ${fmt(center)}`,
650
+ score: d.area,
651
+ frame: { focus: center, size: 1.2, dirs: viewer ? [{ dir: normalize(sub(viewer, center)), clear: length(sub(viewer, center)) + 0.3, pref: 1 }] : [], ...(viewer ? { viewer } : {}) },
652
+ });
653
+ };
654
+ if (exposed.length) {
655
+ emit(exposed, exposedBits, "warn", (d) => `stretched texture (${d.text}) on ${r2(d.area)} m2 of ${meshName}, a face other instances cover: exposed in ${exposed.length} of ${users}`);
656
+ }
657
+ const plain = shown.filter((s) => (num(s[1]) & everywhere) !== 0);
658
+ if (plain.length) {
659
+ emit(plain, everywhere, "look", (d) => `stretched texture (${d.text}) on ${r2(d.area)} m2 of ${meshName} in ${plain.length}/${users} instance(s), not normally covered: the asset's own mapping`);
660
+ }
661
+ }
662
+ // The asset's own mappings are one look item for the whole scene.
663
+ const own = out.filter((i) => i.severity === "look");
664
+ if (own.length < 2)
665
+ return out;
666
+ own.sort((a, b) => b.score - a.score);
667
+ const first = own[0];
668
+ const merged = {
669
+ ...first,
670
+ why: `${own.length} meshes stretch their texture in normal use (the assets' own UV mapping): ${own.slice(0, 3).map((i) => String(i.ev.mesh)).join(", ")}${own.length > 3 ? ` +${own.length - 3}` : ""}`,
671
+ ev: { meshes: own.length, area_m2: r2(own.reduce((sum, i) => sum + num(i.ev.area_m2), 0)), worst: first.ev.mesh, at: first.path },
672
+ score: own.reduce((sum, i) => sum + i.score, 0),
673
+ };
674
+ return [...out.filter((i) => i.severity !== "look"), merged];
675
+ }
676
+ // ---------------------------------------------------------------------------
677
+ // duplicate / z_fight
678
+ // ---------------------------------------------------------------------------
679
+ export function judgeDuplicates(inst) {
680
+ const issues = [];
681
+ const pairs = new Set();
682
+ const groups = new Map();
683
+ inst.forEach((r, i) => {
684
+ if (!r.s)
685
+ return;
686
+ const key = `${r.s}|${Math.round(r.o[0] * 100)}|${Math.round(r.o[1] * 100)}|${Math.round(r.o[2] * 100)}`;
687
+ const g = groups.get(key);
688
+ if (g)
689
+ g.push(i);
690
+ else
691
+ groups.set(key, [i]);
692
+ });
693
+ for (const list of groups.values()) {
694
+ if (list.length < 2)
695
+ continue;
696
+ for (let a = 0; a < list.length; a++) {
697
+ for (let b = a + 1; b < list.length; b++) {
698
+ const ra = inst[list[a]];
699
+ const rb = inst[list[b]];
700
+ if (basisAngleDegrees(ra.b, rb.b) > 1)
701
+ continue;
702
+ if (Math.abs(ra.sc[0] - rb.sc[0]) > 0.01 || Math.abs(ra.sc[1] - rb.sc[1]) > 0.01 || Math.abs(ra.sc[2] - rb.sc[2]) > 0.01)
703
+ continue;
704
+ pairs.add(`${Math.min(list[a], list[b])}:${Math.max(list[a], list[b])}`);
705
+ if (!ra.in && !rb.in)
706
+ continue;
707
+ issues.push({
708
+ check: "duplicate",
709
+ severity: "error",
710
+ path: rb.p,
711
+ pos: v2(rb.c),
712
+ why: `a second ${rb.k} at the same transform as ${ra.p} (doubled geometry, z-fighting)`,
713
+ ev: { other: ra.p, scene: rb.s },
714
+ next: `summer_remove_node ${rb.p}`,
715
+ score: 1,
716
+ ...(instFrame(rb) ? { frame: instFrame(rb) } : {}),
717
+ });
718
+ }
719
+ }
720
+ }
721
+ return { issues, pairs };
722
+ }
723
+ export function judgeZFight(lines, floors, inst, duplicatePairs, alreadyReported = new Set()) {
724
+ const rows = [];
725
+ for (const raw of lines)
726
+ rows.push(...(parseLine(raw)?.zfight.filter(Array.isArray) ?? []));
727
+ rows.push(...arr(floors?.zfight).filter(Array.isArray));
728
+ const byPair = new Map();
729
+ for (const row of rows) {
730
+ const a = num(row[0], -1);
731
+ const b = num(row[1], -1);
732
+ const key = `${a}:${b}`;
733
+ const lo = vec(row[5]);
734
+ const hi = vec(row[6]);
735
+ const prev = byPair.get(key);
736
+ if (prev) {
737
+ prev.count += num(row[2]);
738
+ prev.lo = [Math.min(prev.lo[0], lo[0]), Math.min(prev.lo[1], lo[1]), Math.min(prev.lo[2], lo[2])];
739
+ prev.hi = [Math.max(prev.hi[0], hi[0]), Math.max(prev.hi[1], hi[1]), Math.max(prev.hi[2], hi[2])];
740
+ }
741
+ else {
742
+ byPair.set(key, { a, b, count: num(row[2]), p: vec(row[3]), n: vec(row[4]), lo, hi, clear: num(row[7], 0) });
743
+ }
744
+ }
745
+ const out = [];
746
+ for (const [key, z] of byPair) {
747
+ if (duplicatePairs.has(key) || alreadyReported.has(key))
748
+ continue;
749
+ const ra = inst[z.a];
750
+ const rb = inst[z.b];
751
+ if (!ra || !rb || (!ra.in && !rb.in))
752
+ continue;
753
+ const ext = sub(z.hi, z.lo);
754
+ const center = scale(add(z.lo, z.hi), 0.5);
755
+ const subject = ra.in ? ra : rb;
756
+ const other = subject === ra ? rb : ra;
757
+ out.push({
758
+ check: "z_fight",
759
+ severity: z.count >= 2 ? "warn" : "look",
760
+ path: subject.p,
761
+ pos: v2(center),
762
+ why: `coplanar overlapping faces with ${other.p}: ${z.count} ray(s) see both within 3 mm (flicker)`,
763
+ ev: { other: other.p, rays: z.count, extent_m: v2(ext), normal: v2(z.n), nudge: nudgeAxes(subject, z.n) },
764
+ next: `summer_measure ${subject.p} vs ${other.p}; nudge it along world ${axisName(z.n)[1]} (the plane's normal; its local ${localAxisName(subject.b, z.n)[1]})`,
765
+ score: z.count,
766
+ frame: { focus: z.p, size: Math.max(1, length(ext)), dirs: z.clear > 0 ? [{ dir: normalize(z.n), clear: z.clear, pref: 1 }] : [] },
767
+ });
768
+ }
769
+ return out;
770
+ }
771
+ /** "on one plane" / "3.2 mm apart". */
772
+ function gapText(gap) {
773
+ return gap < 0.00005 ? "on the same plane" : `${Math.round(gap * 10000) / 10} mm apart`;
774
+ }
775
+ const mm = (m) => Math.round(m * 10000) / 10;
776
+ /** Which way to nudge a z-fighting face: the plane's normal as a world axis
777
+ * and as the piece's own local axis (what summer_set_prop moves). */
778
+ export function nudgeAxes(subject, normal) {
779
+ return { world: axisName(normal), local: localAxisName(subject.b, normal) };
780
+ }
781
+ /**
782
+ * z_fight from geometry: the kernel's planar face groups, compared between
783
+ * instances (any role: props, roofs, ledges, side walls, ceilings, inserts
784
+ * against hosts, decals and overlay cards) and within one mesh (two
785
+ * surfaces on one plane). A pair counts when its plane gap is under twice
786
+ * the 24-bit depth step at its view distance (the nearest walkable eye
787
+ * point, camera or bookmark; 30 m without one) for the main camera's near
788
+ * and far. Severity: warn over 0.05 m2 seen from a viewpoint; look
789
+ * otherwise, and look (never skipped) when the material says it may be
790
+ * intentional: render_priority, a depth or normal offset, a see-through
791
+ * (alpha) material, or no depth test / depth writes.
792
+ * Returns the issues and the instance pairs they cover (the ray samples do
793
+ * not report those again).
794
+ */
795
+ export function judgeZFightGeometry(geo, inst, duplicatePairs) {
796
+ const issues = [];
797
+ const pairs = new Set();
798
+ if (!geo)
799
+ return { issues, pairs };
800
+ const near = num(geo.near, DEFAULT_NEAR);
801
+ const far = num(geo.far, DEFAULT_FAR);
802
+ const strings = (v) => arr(v).map((x) => String(x)).filter(Boolean);
803
+ const grade = (area, seen, demoted) => (demoted.length ? "look" : area > 0.05 && seen ? "warn" : "look");
804
+ for (const raw of arr(geo.pairs)) {
805
+ if (!Array.isArray(raw))
806
+ continue;
807
+ const a = num(raw[0], -1);
808
+ const b = num(raw[1], -1);
809
+ const ra = inst[a];
810
+ const rb = inst[b];
811
+ if (!ra || !rb || (!ra.in && !rb.in))
812
+ continue;
813
+ const key = `${Math.min(a, b)}:${Math.max(a, b)}`;
814
+ if (duplicatePairs.has(key))
815
+ continue;
816
+ const area = num(raw[2]);
817
+ const gap = num(raw[3]);
818
+ const view = num(raw[6], TYPICAL_VIEW_M);
819
+ const tol = zFightTolerance(view, near, far);
820
+ if (gap > tol || area < 0.01)
821
+ continue;
822
+ pairs.add(key);
823
+ const seen = raw[8] === true;
824
+ const demoted = [...new Set([...strings(raw[9]), ...strings(raw[10])])];
825
+ const subject = ra.in ? ra : rb;
826
+ const other = subject === ra ? rb : ra;
827
+ const centre = vec(raw[4]);
828
+ const normal = vec(raw[5]);
829
+ const opposite = raw[13] === true;
830
+ const total = num(raw[14], area);
831
+ const groups = num(raw[15], 1);
832
+ const viewer = String(raw[7] ?? "");
833
+ const sameNode = ra.p === rb.p;
834
+ const otherName = sameNode ? `its own mesh ${String(subject === ra ? raw[17] : raw[16])}` : other.p;
835
+ issues.push({
836
+ check: "z_fight",
837
+ severity: grade(total, seen, demoted),
838
+ path: subject.p,
839
+ pos: v2(centre),
840
+ why: `coplanar overlapping faces with ${otherName}${opposite ? " (facing opposite ways, both double-sided)" : ""}: ${r2(total)} m2 ${gapText(gap)}, under ${mm(tol)} mm (2 depth steps at ${Math.round(view)} m from the ${viewer || "typical view"}); they flicker${demoted.length ? `. Look only: ${demoted.join("; ")}` : seen ? "" : ". Not seen from a viewpoint"}`,
841
+ ev: {
842
+ other: other.p,
843
+ area_m2: r3(total),
844
+ gap_mm: mm(gap),
845
+ tol_mm: mm(tol),
846
+ view_m: r2(view),
847
+ viewer: viewer || "typical view distance",
848
+ near,
849
+ far,
850
+ surfaces: [String(raw[11] ?? ""), String(raw[12] ?? "")],
851
+ seen,
852
+ normal: v2(normal),
853
+ nudge: nudgeAxes(subject, normal),
854
+ ...(groups > 1 ? { faces: groups } : {}),
855
+ ...(demoted.length ? { demoted } : {}),
856
+ },
857
+ next: `summer_zoom at ${fmt(centre)}; move ${subject.p} more than ${mm(tol)} mm along world ${axisName(normal)[1]} (the shared plane's normal ${fmt(normal)}; its local ${localAxisName(subject.b, normal)[1]}), not along the facade, or remove the doubled face`,
858
+ score: total,
859
+ frame: { focus: centre, size: Math.max(1, Math.sqrt(total) * 2), dirs: [{ dir: normalize(normal), clear: Math.min(6, Math.max(1.5, view)), pref: 1 }] },
860
+ });
861
+ }
862
+ for (const raw of arr(geo.in_mesh)) {
863
+ if (!Array.isArray(raw))
864
+ continue;
865
+ const shows = arr(raw[7]).map((x) => inst[num(x, -1)]).filter((r) => !!r);
866
+ const first = shows[0];
867
+ if (!first)
868
+ continue;
869
+ const area = num(raw[5]);
870
+ const gap = num(raw[6]);
871
+ const view = num(raw[11], TYPICAL_VIEW_M);
872
+ const tol = zFightTolerance(view, near, far);
873
+ if (gap > tol || area < 0.01)
874
+ continue;
875
+ const mesh = String(raw[0] ?? "its mesh").split("::")[0];
876
+ const seen = raw[13] === true;
877
+ const demoted = [...new Set([...strings(raw[14]), ...strings(raw[15])])];
878
+ const surfaces = [String(raw[3] ?? `surface ${num(raw[1])}`), String(raw[4] ?? `surface ${num(raw[2])}`)];
879
+ const centre = vec(raw[9]);
880
+ const users = num(raw[8], shows.length);
881
+ issues.push({
882
+ check: "z_fight",
883
+ severity: grade(area, seen, demoted),
884
+ path: first.p,
885
+ pos: v2(centre),
886
+ why: `${mesh} has coplanar faces of two surfaces (${surfaces.join(" / ")})${raw[16] === true ? " facing opposite ways" : ""}: ${r2(area)} m2 ${gapText(gap)}, under ${mm(tol)} mm at ${Math.round(view)} m; in the asset itself, so every instance flickers (${shows.length} shown of ${users})${demoted.length ? `. Look only: ${demoted.join("; ")}` : ""}`,
887
+ ev: { mesh, surfaces, area_m2: r3(area), gap_mm: mm(gap), tol_mm: mm(tol), view_m: r2(view), instances: shows.slice(0, 3).map((r) => r.p), users, seen, ...(demoted.length ? { demoted } : {}) },
888
+ next: `summer_zoom at ${fmt(centre)}; summer_inspect_asset ${first.s || first.k} (the overlay surface needs an offset or render_priority)`,
889
+ score: area,
890
+ frame: { focus: centre, size: Math.max(1, Math.sqrt(area) * 2), dirs: [{ dir: normalize(vec(raw[10])), clear: Math.min(6, Math.max(1.5, view)), pref: 1 }] },
891
+ });
892
+ }
893
+ return { issues, pairs };
894
+ }
895
+ // ---------------------------------------------------------------------------
896
+ // lights
897
+ // ---------------------------------------------------------------------------
898
+ export function judgeLights(lights, inst) {
899
+ if (!lights)
900
+ return [];
901
+ const out = [];
902
+ const renderer = String(lights.renderer ?? "");
903
+ const limit = num(lights.limit, 8);
904
+ const perObjectLimit = renderer === "gl_compatibility" || renderer === "mobile";
905
+ if (perObjectLimit) {
906
+ for (const raw of arr(lights.over)) {
907
+ if (!Array.isArray(raw))
908
+ continue;
909
+ const r = inst[num(raw[0], -1)];
910
+ if (!r)
911
+ continue;
912
+ const omni = num(raw[2]);
913
+ const spot = num(raw[3]);
914
+ out.push({
915
+ check: "lights",
916
+ severity: "warn",
917
+ path: r.p,
918
+ pos: v2(r.c),
919
+ why: `lit by ${omni} omni + ${spot} spot lights; ${renderer} keeps ${limit} of each per mesh, so light cuts off at its seams`,
920
+ ev: { omni, spot, limit, mesh: String(raw[1] ?? ""), first: arr(raw[4]).map(String) },
921
+ next: `split ${r.p} into smaller meshes or reduce overlapping light ranges`,
922
+ score: Math.max(omni, spot) - limit,
923
+ ...(instFrame(r) ? { frame: instFrame(r) } : {}),
924
+ });
925
+ }
926
+ }
927
+ for (const raw of arr(lights.hard_rim)) {
928
+ if (!Array.isArray(raw))
929
+ continue;
930
+ const pos = vec(raw[2]);
931
+ out.push({
932
+ check: "lights",
933
+ severity: "look",
934
+ path: String(raw[0] ?? ""),
935
+ pos: v2(pos),
936
+ why: `spot_angle_attenuation ${raw[1]} < 3: a hard rim at the cone edge`,
937
+ ev: { spot_angle_attenuation: num(raw[1]) },
938
+ next: `summer_set_prop ${String(raw[0] ?? "")} spot_angle_attenuation (3 or more for a soft edge)`,
939
+ score: 3 - num(raw[1]),
940
+ });
941
+ }
942
+ return out;
943
+ }
944
+ // ---------------------------------------------------------------------------
945
+ // transform
946
+ // ---------------------------------------------------------------------------
947
+ const PLACED_ROLES = new Set(["prop", "insert", "wall", "struct"]);
948
+ /**
949
+ * Pieces left at the scene origin, never placed: the global origin within
950
+ * 1 cm AND an identity LOCAL transform AND an identity parent, and not part
951
+ * of a structured layout (touching another piece, or one of a row of
952
+ * siblings along a line through the origin). A module placed so its corner is
953
+ * the world origin, or one placed by its parent, is not flagged.
954
+ */
955
+ export function unplacedAtOrigin(inst) {
956
+ const out = new Set();
957
+ const parentOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : ".");
958
+ const siblings = new Map();
959
+ for (const r of inst) {
960
+ const k = parentOf(r.p);
961
+ const list = siblings.get(k);
962
+ if (list)
963
+ list.push(r);
964
+ else
965
+ siblings.set(k, [r]);
966
+ }
967
+ for (const r of inst) {
968
+ if (!r.in || !PLACED_ROLES.has(r.r) || length(r.o) >= 0.01 || r.li !== true || r.pi === false)
969
+ continue;
970
+ const touching = inst.some((o) => o !== r && o.r !== "underlay" && o.r !== "floor" && boundsTouch(r, o, 0.02) && length(sub(o.o, r.o)) >= 0.01);
971
+ if (touching)
972
+ continue;
973
+ const sibs = (siblings.get(parentOf(r.p)) ?? []).filter((o) => o !== r && o.lo && length(o.lo) >= 0.01);
974
+ // A row through the origin: two or more siblings whose local positions
975
+ // are zero on two of three axes (a facade laid out from 0 along X).
976
+ const row = sibs.filter((o) => o.lo.filter((x) => Math.abs(x) < 0.01).length >= 2).length >= 2;
977
+ if (row)
978
+ continue;
979
+ out.add(r);
980
+ }
981
+ return out;
982
+ }
983
+ export function judgeTransforms(inst) {
984
+ const out = [];
985
+ const bounded = inst.filter((r) => r.r !== "underlay" && !r.nan);
986
+ const bounds = robustBounds(bounded.map((r) => r.c));
987
+ const medianDist = median(bounded.map((r) => length(r.o)));
988
+ const atOrigin = unplacedAtOrigin(inst);
989
+ for (const r of inst) {
990
+ if (!r.in)
991
+ continue;
992
+ const frame = instFrame(r);
993
+ const push = (severity, why, ev, next, score) => out.push({ check: "transform", severity, path: r.p, pos: v2(r.c), why, ev, next, score, ...(frame ? { frame } : {}) });
994
+ if (r.nan) {
995
+ push("error", "its transform contains NaN or infinity", {}, `summer_inspect_node ${r.p}`, 3);
996
+ continue;
997
+ }
998
+ if (r.det < 0)
999
+ push("warn", "negative scale: the piece is mirrored (normals and back-face culling flip)", { scale: v2(r.sc), det: r2(r.det) }, `summer_set_prop ${r.p} scale`, 1);
1000
+ const smin = Math.min(...r.sc);
1001
+ const smax = Math.max(...r.sc);
1002
+ if (smin > 0 && smax / smin > 1.01)
1003
+ push("look", `non-uniform scale ${v2(r.sc).join(" x ")}: textures and fitted openings stretch`, { scale: v2(r.sc) }, `summer_set_prop ${r.p} scale`, smax / smin - 1);
1004
+ if (atOrigin.has(r)) {
1005
+ const shared = atOrigin.size;
1006
+ if (shared >= 2) {
1007
+ push("warn", `${shared} pieces share the identity transform at the scene origin (local and parent transforms both identity, no neighbours): likely never placed`, { origin: v2(r.o), shared, others: [...atOrigin].filter((o) => o !== r).slice(0, 2).map((o) => o.p) }, `summer_inspect_node ${r.p}`, 1 + shared / 10);
1008
+ }
1009
+ else if (medianDist > 8) {
1010
+ push("look", "sits at the scene origin with an identity local transform under an identity parent, away from the rest of the scene and touching nothing: check that it was placed", { origin: v2(r.o), median_distance_m: r2(medianDist) }, `summer_inspect_node ${r.p}`, 0.5);
1011
+ }
1012
+ }
1013
+ const dist = length(sub(r.c, bounds.center));
1014
+ const extreme = Math.max(Math.abs(r.c[0]), Math.abs(r.c[1]), Math.abs(r.c[2]));
1015
+ if (extreme > 5000)
1016
+ push("error", `far out of bounds (${Math.round(extreme)} m from the origin)`, { center: v2(r.c) }, `summer_inspect_node ${r.p}`, 2);
1017
+ else if (bounded.length >= 8 && dist > bounds.limit)
1018
+ push("warn", `far out of bounds: ${Math.round(dist)} m from the scene's centre (95% of pieces are within ${Math.round(bounds.radius)} m)`, { distance_m: Math.round(dist), radius95_m: Math.round(bounds.radius) }, `summer_inspect_node ${r.p}`, dist / Math.max(1, bounds.limit));
1019
+ }
1020
+ return out;
1021
+ }
1022
+ // ---------------------------------------------------------------------------
1023
+ // resource
1024
+ // ---------------------------------------------------------------------------
1025
+ export function judgeResources(res, inst) {
1026
+ if (!res)
1027
+ return [];
1028
+ const out = [];
1029
+ for (const raw of arr(res.missing)) {
1030
+ if (!Array.isArray(raw))
1031
+ continue;
1032
+ const r = inst[num(raw[2], -1)];
1033
+ out.push({
1034
+ check: "resource",
1035
+ severity: "error",
1036
+ path: r?.p ?? ".",
1037
+ pos: v2(r?.c ?? [0, 0, 0]),
1038
+ why: `missing file ${String(raw[0])} (needed by ${String(raw[1])})`,
1039
+ ev: { missing: String(raw[0]), needed_by: String(raw[1]) },
1040
+ next: `summer_get_diagnostics; restore or re-import ${String(raw[0])}`,
1041
+ score: 3,
1042
+ ...(r && instFrame(r) ? { frame: instFrame(r) } : {}),
1043
+ });
1044
+ }
1045
+ for (const raw of arr(res.empty)) {
1046
+ if (!Array.isArray(raw))
1047
+ continue;
1048
+ out.push({
1049
+ check: "resource",
1050
+ severity: "warn",
1051
+ path: String(raw[0] ?? ""),
1052
+ pos: v2(inst[num(raw[1], -1)]?.c ?? [0, 0, 0]),
1053
+ why: "MeshInstance3D with no mesh (renders nothing)",
1054
+ ev: {},
1055
+ next: `summer_inspect_node ${String(raw[0] ?? "")}`,
1056
+ score: 1,
1057
+ });
1058
+ }
1059
+ for (const raw of arr(res.no_material)) {
1060
+ if (!Array.isArray(raw))
1061
+ continue;
1062
+ const r = inst[num(raw[0], -1)];
1063
+ if (!r)
1064
+ continue;
1065
+ out.push({
1066
+ check: "resource",
1067
+ severity: "warn",
1068
+ path: r.p,
1069
+ pos: v2(r.c),
1070
+ why: `${num(raw[1])} surface(s) have no material (default grey)`,
1071
+ ev: { surfaces: num(raw[1]) },
1072
+ next: `summer_inspect_resource on ${r.p}'s mesh`,
1073
+ score: 1,
1074
+ ...(instFrame(r) ? { frame: instFrame(r) } : {}),
1075
+ });
1076
+ }
1077
+ for (const raw of arr(res.shader_missing)) {
1078
+ if (!Array.isArray(raw))
1079
+ continue;
1080
+ const r = inst[num(raw[0], -1)];
1081
+ out.push({
1082
+ check: "resource",
1083
+ severity: "warn",
1084
+ path: String(raw[1] ?? r?.p ?? ""),
1085
+ pos: v2(r?.c ?? [0, 0, 0]),
1086
+ why: `ShaderMaterial on surface ${num(raw[2])} has no shader`,
1087
+ ev: { surface: num(raw[2]) },
1088
+ next: `summer_inspect_node ${String(raw[1] ?? "")}`,
1089
+ score: 1,
1090
+ });
1091
+ }
1092
+ return out;
1093
+ }
1094
+ // ---------------------------------------------------------------------------
1095
+ // Grouping: many identical findings in one group become one issue
1096
+ // ---------------------------------------------------------------------------
1097
+ /** Same check + severity + piece under the same parent: one issue that names
1098
+ * the count and the range (16 gutter sections 5.4-5.9 cm off the wall). */
1099
+ export function groupRepeats(issues, inst, min = 3) {
1100
+ const pieceOf = new Map(inst.map((r) => [r.p, r.k]));
1101
+ const keyOf = (i) => `${i.check}|${i.severity}|${pieceOf.get(i.path) ?? i.path}`;
1102
+ const groups = new Map();
1103
+ for (const i of issues) {
1104
+ if (i.check !== "orientation" && i.check !== "floating" && i.check !== "sunken" && i.check !== "exposed_edge" && i.check !== "depth_step") {
1105
+ groups.set(`${groups.size}#solo`, [i]);
1106
+ continue;
1107
+ }
1108
+ const k = keyOf(i);
1109
+ const g = groups.get(k);
1110
+ if (g)
1111
+ g.push(i);
1112
+ else
1113
+ groups.set(k, [i]);
1114
+ }
1115
+ const out = [];
1116
+ for (const g of groups.values()) {
1117
+ if (g.length < min) {
1118
+ out.push(...g);
1119
+ continue;
1120
+ }
1121
+ const first = g.reduce((a, b) => (b.score > a.score ? b : a));
1122
+ const piece = pieceOf.get(first.path) ?? "pieces";
1123
+ const groupsOf = [...new Set(g.map((i) => (i.path.includes("/") ? i.path.slice(0, i.path.lastIndexOf("/")) : ".")))];
1124
+ const nums = g.map((i) => i.ev.gap_m ?? i.ev.angle ?? i.ev.embed_m ?? i.ev.reveal_m ?? i.ev.depth_m).filter((x) => typeof x === "number");
1125
+ const range = nums.length && Math.min(...nums) !== Math.max(...nums) ? ` (${r3(Math.min(...nums))}..${r3(Math.max(...nums))})` : "";
1126
+ out.push({
1127
+ ...first,
1128
+ why: `${g.length} x ${piece}${range} in ${groupsOf.slice(0, 2).join(", ")}${groupsOf.length > 2 ? ` +${groupsOf.length - 2}` : ""}: ${first.why}`,
1129
+ ev: { ...first.ev, count: g.length, others: g.filter((i) => i !== first).slice(0, 2).map((i) => i.path) },
1130
+ score: first.score * Math.sqrt(g.length),
1131
+ });
1132
+ }
1133
+ return out;
1134
+ }