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