ArturoNereu commited on
Commit
cee5182
·
1 Parent(s): 1078429
README.md CHANGED
@@ -1,13 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: GameContextProtocol
3
- emoji: 🔥
4
- colorFrom: yellow
5
- colorTo: indigo
6
- sdk: gradio
7
- sdk_version: 6.0.1
8
- app_file: app.py
9
- pinned: false
10
- short_description: Focus on the creative side of making games
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ # GCP - Game Context Protocol
2
+
3
+ **Build 3D scenes and games with natural language**
4
+
5
+ ## What is it?
6
+
7
+ GCP (Game Context Protocol) is an AI-powered scene builder that lets you create interactive 3D game environments using simple commands. Built with Three.js and designed for LLM integration via MCP, it also works as a standalone HTTP service.
8
+
9
+ Simply describe what you want:
10
+
11
+ > "Add a red cube at 0,2,0"
12
+ > "Create a level 50 units wide"
13
+ > "Set lighting to night"
14
+
15
+ …and it instantly builds your 3D scene with:
16
+ - Real-time 3D rendering
17
+ - Interactive camera controls
18
+ - Dynamic object placement
19
+ - Lighting presets
20
+ - Primitive shapes (cubes, spheres, cylinders, etc.)
21
+
22
+ ---
23
+
24
+ ## Features
25
+
26
+ ### 🎨 Scene Building
27
+ - **6 primitive types**: cube, sphere, cylinder, plane, cone, torus
28
+ - **Flexible positioning**: Place objects anywhere in 3D space
29
+ - **Material system**: Colors, metalness, roughness, opacity
30
+ - **Dynamic scaling**: Custom size for each object
31
+
32
+ ### 💡 Lighting System
33
+ - **4 presets**: day, night, sunset, studio
34
+ - **Multiple light types**: ambient, directional, point, spot
35
+ - **Automatic shadows**: Realistic lighting effects
36
+
37
+ ### 🎮 FPS Controller
38
+ - **Physics-based movement**: Cannon.js integration with gravity, jumping, collisions
39
+ - **WASD controls**: Smooth keyboard-based movement
40
+ - **Mouse look**: Full 360° camera control with configurable sensitivity
41
+ - **Configurable feel**: Adjustable speed, jump force, FOV, air control
42
+ - **10x10 bounded world**: White floor and walls with collision detection
43
+
44
+ ### 👀 Interactive Viewer
45
+ - **Dual camera modes**: FPS (first-person) and Orbit (overview)
46
+ - **Real-time updates**: See changes instantly via postMessage API
47
+ - **Grid helper**: Optional floor grid for spatial reference
48
+ - **Auto-centering**: Camera automatically frames your scene in Orbit mode
49
+
50
+ ### 🤖 AI Integration
51
+ - **MCP protocol**: Works with Claude, GPT, and other AI assistants
52
+ - **Natural language**: Simple commands like "add a blue sphere" or "set speed to 10"
53
+ - **Context aware**: Builds on existing scenes
54
+ - **16 MCP tools**: Scene building (5) + Player controller (11)
55
+ - **No coding required**: Pure natural language scene building
56
+
57
+ ---
58
+
59
+ ## Quick Start
60
+
61
+ ### Installation
62
+
63
+ ```bash
64
+ # Clone the repository
65
+ git clone https://github.com/ArturoNereu/3DViz-MCP.git
66
+ cd 3DViz-MCP
67
+
68
+ # Install dependencies
69
+ pip install -r requirements.txt
70
+
71
+ # Run the application
72
+ python app.py
73
+ ```
74
+
75
+ The app will start two servers:
76
+ - **FastAPI** (port 8000): MCP server and scene API
77
+ - **Gradio** (port 7860): Chat interface
78
+
79
+ Open `http://localhost:7860` in your browser.
80
+
81
+ ### Example Commands
82
+
83
+ ```
84
+ Add a red cube at 0,2,0
85
+ Add a blue sphere at 5,1,5
86
+ Add a green cylinder at -3,1,0
87
+ Set lighting to night
88
+ Create a level 100 units wide
89
+ ```
90
+
91
+ ---
92
+
93
+ ## How It Works
94
+
95
+ ### For AI Assistants (MCP)
96
+
97
+ The MCP server exposes 16 tools that AI assistants can call:
98
+
99
+ **Scene Building (5 tools):**
100
+ - `create_scene_tool` - Create a new 3D scene/level
101
+ - `add_object_tool` - Add objects to the scene
102
+ - `remove_object_tool` - Remove objects from scene
103
+ - `set_lighting_tool` - Change lighting preset
104
+ - `get_scene_info_tool` - Get scene details
105
+
106
+ **Player Controller Phase 1 (5 tools):**
107
+ - `set_player_speed` - Movement speed
108
+ - `set_jump_force` - Jump height
109
+ - `set_mouse_sensitivity` - Mouse look sensitivity + Y-invert
110
+ - `set_gravity` - World gravity
111
+ - `set_player_dimensions` - Player size
112
+
113
+ **Player Controller Phase 2 (4 tools):**
114
+ - `set_movement_acceleration` - Movement feel
115
+ - `set_air_control` - Airborne control
116
+ - `set_camera_fov` - Field of view
117
+ - `set_vertical_look_limits` - Look angle limits
118
+
119
+ **Configuration (1 tool):**
120
+ - `get_player_config` - Get all player settings
121
+
122
+ ### For Developers (HTTP API)
123
+
124
+ ```python
125
+ import requests
126
+
127
+ # Create a new scene
128
+ response = requests.post("http://localhost:8000/api/scenes", json={
129
+ "name": "My Scene",
130
+ "world_width": 50.0,
131
+ "lighting_preset": "day"
132
+ })
133
+
134
+ scene_id = response.json()["scene_id"]
135
+
136
+ # Add an object
137
+ requests.post(f"http://localhost:8000/api/scenes/{scene_id}/objects", json={
138
+ "object_type": "cube",
139
+ "position": {"x": 0, "y": 1, "z": 0},
140
+ "material": {"color": "#ff0000"}
141
+ })
142
+
143
+ # View your scene
144
+ viewer_url = f"http://localhost:8000/view/scene/{scene_id}"
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Architecture
150
+
151
+ ```
152
+ GCP - Game Context Protocol
153
+ ├── app.py # Gradio chat interface
154
+ ├── backend/
155
+ │ ├── main.py # FastAPI + GCP server
156
+ │ ├── game_models.py # Scene, GameObject, Light models
157
+ │ ├── game_tools.py # GCP tool implementations
158
+ │ └── storage.py # In-memory scene storage
159
+ └── frontend/
160
+ └── game_viewer.html # Three.js 3D renderer
161
+ ```
162
+
163
+ ### Tech Stack
164
+ - **Backend**: FastAPI, FastMCP, Pydantic
165
+ - **Frontend**: Three.js, Gradio
166
+ - **3D Rendering**: Three.js with orbit controls
167
+ - **AI Integration**: MCP (Model Context Protocol)
168
+
169
+ ---
170
+
171
+ ## API Endpoints
172
+
173
+ ### Scenes
174
+ - `POST /api/scenes` - Create a new scene
175
+ - `GET /api/scenes/{scene_id}` - Get scene data
176
+ - `GET /view/scene/{scene_id}` - View scene in browser
177
+
178
+ ### MCP
179
+ - `GET /mcp` - MCP protocol endpoint
180
+ - `GET /docs` - API documentation
181
+
182
+ ---
183
+
184
+ ## Supported Objects
185
+
186
+ | Type | Description |
187
+ |------|-------------|
188
+ | `cube` | Box geometry |
189
+ | `sphere` | Spherical geometry |
190
+ | `cylinder` | Cylindrical geometry |
191
+ | `plane` | Flat surface (floor/wall) |
192
+ | `cone` | Conical geometry |
193
+ | `torus` | Donut shape |
194
+
195
+ ## Supported Colors
196
+
197
+ red, blue, green, yellow, purple, orange, pink, brown, black, white, or any hex code (#ff0000)
198
+
199
+ ## Lighting Presets
200
+
201
+ - **day**: Bright white directional light
202
+ - **night**: Dark blue moonlight
203
+ - **sunset**: Warm orange light
204
+ - **studio**: Neutral balanced lighting
205
+
206
  ---
207
+
208
+ ## Development
209
+
210
+ ### Project Structure
211
+
212
+ ```
213
+ backend/game_models.py # Data models (Scene, GameObject, etc.)
214
+ backend/game_tools.py # Tool implementations
215
+ backend/main.py # FastAPI routes + MCP tools
216
+ frontend/game_viewer.html # Three.js viewer
217
+ app.py # Gradio chat interface
218
+ ```
219
+
220
+ ### Adding New Object Types
221
+
222
+ 1. Add to `ObjectType` enum in `game_models.py`
223
+ 2. Add geometry case in `game_viewer.html` `renderGameObjects()`
224
+ 3. Update command parsing in `app.py` `chat_response()`
225
+
226
+ ---
227
+
228
+ ## Roadmap
229
+
230
+ ### ✅ Completed
231
+ - **Phase 1**: Player Controller - Core Controls (5 tools)
232
+ - **Phase 2**: Player Controller - Enhanced Feel (4 tools)
233
+ - Physics engine integration (Cannon.js)
234
+ - FPS controls (WASD + mouse look)
235
+
236
+ ### 🚧 Next Phase: Rendering & Lighting Tools
237
+ - Add/remove individual lights
238
+ - Update light properties (color, intensity, position)
239
+ - Change object materials (color, metalness, roughness)
240
+ - Set background color
241
+
242
+ ### 🔮 Phase 3: World Building
243
+ - glTF model loading (Kenney assets)
244
+ - Prefab system (props, buildings, terrain)
245
+ - Scene templates
246
+ - Export to Unity/Unreal
247
+
248
+ ### 💭 Future Ideas
249
+ - NPC system with behaviors
250
+ - Multiplayer support
251
+ - Procedural generation
252
+
253
+ ---
254
+
255
+ ## License
256
+
257
+ MIT License - feel free to use in your projects!
258
+
259
+ ## Contributing
260
+
261
+ Contributions welcome! Please open an issue or PR.
262
+
263
  ---
264
 
265
+ **Built with ❤️ for AI-powered game development**
app.py ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GCP - Game Context Protocol
3
+ Build 3D game scenes with natural language
4
+ """
5
+ import os
6
+ import json
7
+ import gradio as gr
8
+ import threading
9
+ import uvicorn
10
+ import time
11
+ import requests
12
+ from backend.main import app as fastapi_app
13
+
14
+ # Get base URLs from environment
15
+ # SPACE_URL is the public-facing URL (for Gradio)
16
+ # FastAPI runs on port 8000, Gradio on 7860
17
+ SPACE_URL = os.getenv("SPACE_URL", "http://localhost:7860")
18
+ # For local dev, FastAPI is on a different port; in HF Spaces, use same domain
19
+ FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000")
20
+ BASE_URL = SPACE_URL # For display in UI
21
+
22
+ # Global state for current scene
23
+ current_scene_id = None
24
+ current_scene_url = None
25
+ selected_object_id = None # Track currently looked-at object (FPS mode)
26
+
27
+
28
+ def add_cache_buster(url):
29
+ """Add timestamp to URL to force iframe reload"""
30
+ import time
31
+ timestamp = int(time.time() * 1000)
32
+ separator = "&" if "?" in url else "?"
33
+ return f"{url}{separator}t={timestamp}"
34
+
35
+
36
+ def wait_for_fastapi(max_retries=30, retry_interval=1):
37
+ """
38
+ Wait for FastAPI to be ready with health check.
39
+
40
+ Args:
41
+ max_retries: Maximum number of health check attempts
42
+ retry_interval: Seconds to wait between retries
43
+
44
+ Returns:
45
+ True if FastAPI is ready, False otherwise
46
+ """
47
+ print("\n" + "="*60)
48
+ print("⏳ Waiting for FastAPI/MCP server to be ready...")
49
+ print("="*60)
50
+
51
+ for i in range(max_retries):
52
+ try:
53
+ response = requests.get(f"{FASTAPI_URL}/health", timeout=2)
54
+ if response.status_code == 200:
55
+ data = response.json()
56
+ print(f"\n✅ FastAPI is ready! Service: {data.get('service', 'Unknown')}")
57
+ print(f" Version: {data.get('version', 'Unknown')}")
58
+ print(f" Status: {data.get('status', 'Unknown')}")
59
+ print("="*60 + "\n")
60
+ return True
61
+ except (requests.ConnectionError, requests.Timeout):
62
+ if i < max_retries - 1:
63
+ print(f" Attempt {i+1}/{max_retries}: FastAPI not ready yet, retrying in {retry_interval}s...")
64
+ time.sleep(retry_interval)
65
+ else:
66
+ print(f"\n⚠️ FastAPI health check failed after {max_retries} attempts")
67
+ print(" The server might still start, but there could be issues.")
68
+ print("="*60 + "\n")
69
+ return False
70
+ except Exception as e:
71
+ print(f" Unexpected error during health check: {e}")
72
+ time.sleep(retry_interval)
73
+
74
+ return False
75
+
76
+
77
+ # Start FastAPI/MCP server in background
78
+ def start_fastapi():
79
+ print("\n" + "="*60)
80
+ print("Starting FastAPI/MCP server on port 8000...")
81
+ print("="*60 + "\n")
82
+ uvicorn.run(fastapi_app, host="0.0.0.0", port=8000, log_level="info")
83
+
84
+ fastapi_thread = threading.Thread(target=start_fastapi, daemon=True)
85
+ fastapi_thread.start()
86
+
87
+ # Wait for FastAPI to be ready
88
+ wait_for_fastapi()
89
+
90
+
91
+ def create_default_scene():
92
+ """Use the clean default Welcome Scene created on server startup"""
93
+ global current_scene_id, current_scene_url
94
+
95
+ try:
96
+ # Use the pre-initialized "welcome" scene from storage
97
+ # (created in backend/storage.py on module load)
98
+ current_scene_id = "welcome"
99
+ current_scene_url = f"{FASTAPI_URL}/view/scene/welcome"
100
+
101
+ print(f"✅ Using default Welcome Scene")
102
+ print(f" Scene ID: {current_scene_id}")
103
+ print(f" Viewer URL: {current_scene_url}")
104
+ print(f" - Clean 10x10 FPS world with physics")
105
+ print(f" - Ground plane + walls (created by viewer)")
106
+ print(f" - Player starts at (0, 1, 0)")
107
+
108
+ return current_scene_url
109
+
110
+ except Exception as e:
111
+ import traceback
112
+ print(f"❌ Error loading default scene: {e}")
113
+ print(f" Full traceback:")
114
+ traceback.print_exc()
115
+ return None
116
+
117
+
118
+ # Initialize the GPT chat client
119
+ gpt_client = None
120
+
121
+ def get_gpt_client():
122
+ """Get or create the GPT chat client"""
123
+ global gpt_client, current_scene_id
124
+ if gpt_client is None or gpt_client.scene_id != current_scene_id:
125
+ from chat_client import GCPChatClient
126
+ gpt_client = GCPChatClient(scene_id=current_scene_id, base_url=FASTAPI_URL)
127
+ return gpt_client
128
+
129
+
130
+ def chat_response(message, history):
131
+ """Handle chat messages using GPT with tool calling"""
132
+ global current_scene_id, current_scene_url
133
+
134
+ # Handle help command locally (no need for LLM)
135
+ if message.lower().strip() == "help":
136
+ return """**GCP - Game Context Protocol**
137
+
138
+ I'm an AI assistant that can help you build 3D scenes using natural language.
139
+
140
+ **What I can do:**
141
+ - Add objects: "add a red cube at 2, 1, 0"
142
+ - Change lighting: "set lighting to night"
143
+ - Configure player: "set speed to 10" or "make the player move half as fast"
144
+ - Add lights: "add a point light above the cube"
145
+ - Update materials: "make it shiny and metallic"
146
+ - Set backgrounds: "gradient background from blue to orange"
147
+ - Add fog: "add some fog to the scene"
148
+ - Query state: "what's the current player speed?" or "show me the scene info"
149
+
150
+ **I understand context**, so you can say things like:
151
+ - "double the jump force"
152
+ - "make it twice as bright"
153
+ - "reduce gravity by half"
154
+
155
+ **Tips:**
156
+ - Press C in viewer to toggle FPS/Orbit camera
157
+ - WASD to move, Space to jump in FPS mode
158
+ - Click in viewer to enable mouse-look
159
+ """, None
160
+
161
+ try:
162
+ client = get_gpt_client()
163
+ response, action_data = client.chat(message)
164
+ return response, action_data
165
+ except Exception as e:
166
+ import traceback
167
+ traceback.print_exc()
168
+ return f"Error: {str(e)}", None
169
+
170
+
171
+ # Create default scene on startup
172
+ print("Creating default scene...")
173
+ default_viewer_url = create_default_scene()
174
+ print(f"Default viewer URL: {default_viewer_url}")
175
+ if not default_viewer_url:
176
+ print("⚠️ WARNING: Default scene creation failed! No viewer URL generated.")
177
+
178
+
179
+ # Minimal CSS - only essential styling, let Gradio handle layout
180
+ APP_CSS = """
181
+ /* Viewer iframe needs explicit sizing */
182
+ #viewer-container {
183
+ width: 100%;
184
+ height: 600px;
185
+ }
186
+
187
+ #viewer-container iframe {
188
+ width: 100%;
189
+ height: 100%;
190
+ border: none;
191
+ }
192
+
193
+ /* PostMessage Container - must be invisible */
194
+ #postmessage-container {
195
+ position: absolute;
196
+ width: 0;
197
+ height: 0;
198
+ overflow: hidden;
199
+ pointer-events: none;
200
+ }
201
+
202
+ /* Toast Notifications */
203
+ #toast-container {
204
+ position: fixed;
205
+ top: 20px;
206
+ right: 20px;
207
+ z-index: 200;
208
+ }
209
+
210
+ .toast {
211
+ background: rgba(0, 0, 0, 0.9);
212
+ color: white;
213
+ padding: 15px 20px;
214
+ border-radius: 8px;
215
+ margin-bottom: 10px;
216
+ border-left: 4px solid #2196f3;
217
+ }
218
+
219
+ .toast.success { border-left-color: #4caf50; }
220
+ .toast.error { border-left-color: #f44336; }
221
+
222
+ /* Hide the action data textbox but keep it in DOM for events to fire */
223
+ .hidden-action {
224
+ position: absolute !important;
225
+ width: 1px !important;
226
+ height: 1px !important;
227
+ padding: 0 !important;
228
+ margin: -1px !important;
229
+ overflow: hidden !important;
230
+ clip: rect(0, 0, 0, 0) !important;
231
+ white-space: nowrap !important;
232
+ border: 0 !important;
233
+ }
234
+ """
235
+
236
+ # Build immersive chat interface with overlay
237
+ with gr.Blocks(
238
+ title="GCP - Game Context Protocol",
239
+ ) as demo:
240
+ # Initialize JavaScript functionality (minimal essentials only)
241
+ gr.HTML("""
242
+ <script>
243
+ (function() {
244
+ // PostMessage API Helper - sends commands to the 3D viewer iframe
245
+ window.sendMessageToViewer = function(action, data) {
246
+ const iframe = document.querySelector('#viewer-container iframe');
247
+ if (iframe && iframe.contentWindow) {
248
+ iframe.contentWindow.postMessage({ action, data }, '*');
249
+ }
250
+ };
251
+
252
+ // Toast Notification Function
253
+ window.showToast = function(message, type = 'info') {
254
+ let toastContainer = document.getElementById('toast-container');
255
+ if (!toastContainer) {
256
+ toastContainer = document.createElement('div');
257
+ toastContainer.id = 'toast-container';
258
+ document.body.appendChild(toastContainer);
259
+ }
260
+ const toast = document.createElement('div');
261
+ toast.className = `toast ${type}`;
262
+ toast.textContent = message;
263
+ toastContainer.appendChild(toast);
264
+ setTimeout(() => toast.remove(), 3000);
265
+ };
266
+
267
+ // Loading Indicator Functions
268
+ window.showLoading = function() {
269
+ let loadingIndicator = document.getElementById('loading-indicator');
270
+ if (!loadingIndicator) {
271
+ loadingIndicator = document.createElement('div');
272
+ loadingIndicator.id = 'loading-indicator';
273
+ loadingIndicator.innerHTML = '<div class="spinner"></div>';
274
+ document.body.appendChild(loadingIndicator);
275
+ }
276
+ loadingIndicator.style.display = 'block';
277
+ };
278
+
279
+ window.hideLoading = function() {
280
+ const loadingIndicator = document.getElementById('loading-indicator');
281
+ if (loadingIndicator) loadingIndicator.style.display = 'none';
282
+ };
283
+
284
+ // Handle messages from iframe (screenshot, object selection)
285
+ window.addEventListener('message', function(event) {
286
+ if (event.data && event.data.action === 'screenshot') {
287
+ const { dataURL, sceneName, timestamp } = event.data.data;
288
+ const link = document.createElement('a');
289
+ link.href = dataURL;
290
+ link.download = `${sceneName}_${timestamp}.png`;
291
+ link.click();
292
+ if (window.showToast) {
293
+ window.showToast('Screenshot saved!', 'success');
294
+ }
295
+ }
296
+
297
+ if (event.data && event.data.action === 'objectInspect') {
298
+ const objInfo = event.data.data;
299
+ if (window.showToast) {
300
+ window.showToast(`Selected: ${objInfo.name}`, 'info');
301
+ }
302
+ }
303
+
304
+ if (event.data && event.data.action === 'objectSelected') {
305
+ const objData = event.data.data;
306
+ window.selectedObjectId = objData.object_id;
307
+ if (window.showToast) {
308
+ window.showToast(`Looking at: ${objData.object_type} (${objData.distance}m)`, 'info');
309
+ }
310
+ }
311
+
312
+ if (event.data && event.data.action === 'objectDeselected') {
313
+ window.selectedObjectId = null;
314
+ }
315
+ });
316
+
317
+ // Initialize toast container on load
318
+ setTimeout(function() {
319
+ if (!document.getElementById('toast-container')) {
320
+ const toastContainer = document.createElement('div');
321
+ toastContainer.id = 'toast-container';
322
+ document.body.appendChild(toastContainer);
323
+ }
324
+ }, 1000);
325
+
326
+ })();
327
+ </script>
328
+ """)
329
+
330
+ # State for file handling
331
+ file_state = gr.State([])
332
+
333
+ # Component for passing action data to JavaScript via .change() event
334
+ #
335
+ # IMPORTANT: DO NOT set visible=False!
336
+ # Gradio's visible=False removes the component from the DOM or renders it in a way
337
+ # that prevents .change() events from firing when the value is updated programmatically.
338
+ # Instead, we use visible=True and hide it with CSS (see .hidden-action in APP_CSS).
339
+ # This keeps the element in the DOM so events fire properly.
340
+ #
341
+ # The flow: bot() returns JSON → action_data updates → .change() fires → JS sends postMessage to iframe
342
+ action_data = gr.Textbox(value="", elem_id="action-data", visible=True, elem_classes=["hidden-action"])
343
+
344
+ # Main container - side by side layout: Chat (left) | Viewer (right)
345
+ with gr.Row(elem_id="main-container", equal_height=True):
346
+ # Left column: Chat interface (scale=1 = ~25% width)
347
+ with gr.Column(elem_id="chat-column", scale=1, min_width=350):
348
+ gr.Markdown("### 🎮 GCP - Game Context Protocol")
349
+ chatbot = gr.Chatbot(
350
+ height=500, # Taller to fill vertical space
351
+ show_label=False,
352
+ elem_id="chatbot",
353
+ # Gradio 6: type="messages" is now the default, removed
354
+ )
355
+ msg = gr.Textbox(
356
+ placeholder="'add a red cube' • 'set lighting to night' • 'help'",
357
+ show_label=False,
358
+ container=False,
359
+ elem_id="chat-input"
360
+ )
361
+
362
+ # Right column: 3D Viewer (scale=3 = ~75% width)
363
+ with gr.Column(elem_id="viewer-column", scale=3):
364
+ if default_viewer_url:
365
+ initial_viewer_html = f'<div id="viewer-container"><iframe src="{default_viewer_url}"></iframe></div>'
366
+ print(f"📊 Setting up viewer iframe with src: {default_viewer_url}")
367
+ else:
368
+ initial_viewer_html = '<div id="viewer-container" style="display: flex; align-items: center; justify-content: center; color: #666;"><p>⚠️ Scene failed to load. Check console logs.</p></div>'
369
+ print("⚠️ No viewer URL available - showing error message")
370
+
371
+ viewer = gr.HTML(
372
+ value=initial_viewer_html,
373
+ elem_id="viewer-fullscreen"
374
+ )
375
+
376
+ def user(user_message, history):
377
+ """Handle user input"""
378
+ history = history or []
379
+ history.append({"role": "user", "content": user_message})
380
+ return "", history
381
+
382
+ def bot(history):
383
+ """Generate bot response"""
384
+ # Gradio 6: content can be a string or list of content blocks
385
+ content = history[-1]["content"]
386
+ if isinstance(content, list):
387
+ # Extract text from content blocks
388
+ user_message = " ".join(
389
+ block.get("text", "") if isinstance(block, dict) else str(block)
390
+ for block in content
391
+ )
392
+ else:
393
+ user_message = content
394
+
395
+ # Process command (history not used by chat_response)
396
+ bot_message, action_result = chat_response(user_message, [])
397
+ history.append({"role": "assistant", "content": bot_message})
398
+
399
+ # Handle action_result
400
+ viewer_html = viewer.value # Default: keep current viewer
401
+ action_json = "" # Default: no action (empty string)
402
+
403
+ if action_result:
404
+ action_type = action_result.get("action")
405
+
406
+ if action_type == "reload":
407
+ # Full reload: update iframe src
408
+ viewer_html = f'<div id="viewer-container" style="width:100%; min-height:500px; height:70vh;"><iframe src="{action_result["url"]}" style="width:100%; height:100%; border:none;"></iframe></div>'
409
+
410
+ elif action_type in ["addObject", "setLighting", "setControlMode", "updateMaterial", "addLight", "removeLight", "updateLight", "setBackground", "setFog"]:
411
+ # Build action JSON for the JavaScript watcher
412
+ import json
413
+ import time
414
+
415
+ # Determine toast message based on action type
416
+ toast_message = ""
417
+ if action_type == "addObject":
418
+ obj_type = action_result["data"].get("type", "object")
419
+ toast_message = f"Added {obj_type} to scene"
420
+ elif action_type == "setLighting":
421
+ toast_message = "Lighting updated"
422
+ elif action_type == "setControlMode":
423
+ mode = action_result["data"].get("mode", "")
424
+ toast_message = f"Switched to {mode.upper()} mode"
425
+ elif action_type == "updateMaterial":
426
+ toast_message = "Material updated"
427
+ elif action_type == "addLight":
428
+ light_name = action_result["data"].get("name", "Light")
429
+ toast_message = f"Added light: {light_name}"
430
+ elif action_type == "removeLight":
431
+ toast_message = "Light removed"
432
+ elif action_type == "updateLight":
433
+ toast_message = "Light updated"
434
+ elif action_type == "setBackground":
435
+ toast_message = "Background updated"
436
+ elif action_type == "setFog":
437
+ toast_message = "Fog updated"
438
+
439
+ # Create JSON payload for the .then() JavaScript handler
440
+ action_json = json.dumps({
441
+ "action": action_result["action"],
442
+ "data": action_result["data"],
443
+ "toast": toast_message,
444
+ "toastType": "success"
445
+ })
446
+
447
+ return history, viewer_html, action_json
448
+
449
+ # When action_data changes, this handler sends postMessage to the iframe
450
+ def handle_action_change(action_json):
451
+ return action_json # Pass through for JS
452
+
453
+ action_data.change(
454
+ fn=handle_action_change,
455
+ inputs=[action_data],
456
+ outputs=[action_data],
457
+ js="""
458
+ (actionJson) => {
459
+ if (actionJson && actionJson.length > 2) {
460
+ try {
461
+ const actionData = JSON.parse(actionJson);
462
+ const iframe = document.querySelector('#viewer-container iframe');
463
+ if (iframe && iframe.contentWindow) {
464
+ iframe.contentWindow.postMessage({
465
+ action: actionData.action,
466
+ data: actionData.data
467
+ }, '*');
468
+ if (actionData.toast && window.showToast) {
469
+ window.showToast(actionData.toast, actionData.toastType || 'success');
470
+ }
471
+ }
472
+ } catch (e) {
473
+ // Silently ignore parse errors
474
+ }
475
+ }
476
+ return actionJson;
477
+ }
478
+ """
479
+ )
480
+
481
+ msg.submit(
482
+ user,
483
+ [msg, chatbot],
484
+ [msg, chatbot],
485
+ queue=False
486
+ ).then(
487
+ bot,
488
+ [chatbot],
489
+ [chatbot, viewer, action_data]
490
+ )
491
+
492
+
493
+ if __name__ == "__main__":
494
+ # Enable queue for handling multiple concurrent users (important for HF Spaces)
495
+ demo.queue()
496
+ # Gradio 6: theme and css moved from gr.Blocks() to launch()
497
+ demo.launch(server_name="0.0.0.0", server_port=7860, theme=gr.themes.Soft(), css=APP_CSS)
backend/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """
2
+ 3DViz MCP Server Backend Package
3
+ """
4
+ __version__ = "1.0.0"
backend/game_models.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for GCP - Game Context Protocol
3
+ Using plain dictionaries for simplicity and clarity.
4
+ """
5
+ from typing import Optional, Literal, Dict, Any, List
6
+ from datetime import datetime
7
+ import uuid
8
+
9
+
10
+ # Game Types
11
+ ObjectType = Literal["cube", "sphere", "cylinder", "plane", "cone", "torus", "model"]
12
+ LightType = Literal["ambient", "directional", "point", "spot"]
13
+ LightingPreset = Literal["day", "night", "sunset", "studio"]
14
+ CameraMode = Literal["fps", "orbit", "top_down", "free"]
15
+ MaterialType = Literal["standard", "basic", "phong", "toon"]
16
+
17
+
18
+ # Factory functions for creating default data structures
19
+
20
+ def create_vector3(x: float = 0.0, y: float = 0.0, z: float = 0.0) -> Dict[str, float]:
21
+ """Create a 3D vector for position, rotation, or scale."""
22
+ return {"x": x, "y": y, "z": z}
23
+
24
+
25
+ def create_material(
26
+ type: MaterialType = "standard",
27
+ color: str = "#ffffff",
28
+ metalness: float = 0.5,
29
+ roughness: float = 0.5,
30
+ opacity: float = 1.0,
31
+ wireframe: bool = False
32
+ ) -> Dict[str, Any]:
33
+ """Create material properties."""
34
+ return {
35
+ "type": type,
36
+ "color": color,
37
+ "metalness": metalness,
38
+ "roughness": roughness,
39
+ "opacity": opacity,
40
+ "wireframe": wireframe
41
+ }
42
+
43
+
44
+ def create_game_object(
45
+ object_type: ObjectType = "cube",
46
+ name: Optional[str] = None,
47
+ position: Optional[Dict[str, float]] = None,
48
+ rotation: Optional[Dict[str, float]] = None,
49
+ scale: Optional[Dict[str, float]] = None,
50
+ material: Optional[Dict[str, Any]] = None,
51
+ model_path: Optional[str] = None,
52
+ metadata: Optional[Dict[str, Any]] = None
53
+ ) -> Dict[str, Any]:
54
+ """Create a 3D game object."""
55
+ return {
56
+ "id": str(uuid.uuid4()),
57
+ "name": name,
58
+ "type": object_type,
59
+ "position": position or create_vector3(),
60
+ "rotation": rotation or create_vector3(),
61
+ "scale": scale or create_vector3(1, 1, 1),
62
+ "material": material or create_material(),
63
+ "model_path": model_path,
64
+ "metadata": metadata or {},
65
+ "created_at": datetime.utcnow().isoformat()
66
+ }
67
+
68
+
69
+ def create_light(
70
+ light_type: LightType = "directional",
71
+ name: Optional[str] = None,
72
+ color: str = "#ffffff",
73
+ intensity: float = 1.0,
74
+ position: Optional[Dict[str, float]] = None,
75
+ target: Optional[Dict[str, float]] = None,
76
+ cast_shadow: bool = True
77
+ ) -> Dict[str, Any]:
78
+ """Create a light source."""
79
+ return {
80
+ "id": str(uuid.uuid4()),
81
+ "name": name,
82
+ "type": light_type,
83
+ "color": color,
84
+ "intensity": intensity,
85
+ "position": position or create_vector3(10, 10, 10),
86
+ "target": target,
87
+ "cast_shadow": cast_shadow
88
+ }
89
+
90
+
91
+ def create_environment(
92
+ background_color: str = "#87CEEB",
93
+ fog_enabled: bool = False,
94
+ fog_color: str = "#ffffff",
95
+ fog_near: float = 10.0,
96
+ fog_far: float = 100.0,
97
+ ambient_light_intensity: float = 0.5,
98
+ lighting_preset: LightingPreset = "day"
99
+ ) -> Dict[str, Any]:
100
+ """Create environment settings."""
101
+ return {
102
+ "background_color": background_color,
103
+ "fog_enabled": fog_enabled,
104
+ "fog_color": fog_color,
105
+ "fog_near": fog_near,
106
+ "fog_far": fog_far,
107
+ "ambient_light_intensity": ambient_light_intensity,
108
+ "lighting_preset": lighting_preset
109
+ }
110
+
111
+
112
+ def create_player(
113
+ position: Optional[Dict[str, float]] = None,
114
+ rotation: Optional[Dict[str, float]] = None,
115
+ camera_mode: CameraMode = "orbit",
116
+ movement_speed: float = 5.0,
117
+ look_sensitivity: float = 0.002
118
+ ) -> Dict[str, Any]:
119
+ """Create player/camera configuration."""
120
+ return {
121
+ "position": position or create_vector3(0, 5, 10),
122
+ "rotation": rotation or create_vector3(),
123
+ "camera_mode": camera_mode,
124
+ "movement_speed": movement_speed,
125
+ "look_sensitivity": look_sensitivity
126
+ }
127
+
128
+
129
+ def create_player_config(
130
+ move_speed: float = 5.0,
131
+ jump_force: float = 5.0,
132
+ mouse_sensitivity: float = 0.002,
133
+ invert_y: bool = False,
134
+ gravity: float = -9.82,
135
+ player_height: float = 1.7,
136
+ player_radius: float = 0.3,
137
+ eye_height: float = 1.6,
138
+ player_mass: float = 80.0,
139
+ linear_damping: float = 0.9,
140
+ movement_acceleration: float = 0.0,
141
+ air_control: float = 1.0,
142
+ camera_fov: float = 75.0,
143
+ min_pitch: float = -89.0,
144
+ max_pitch: float = 89.0
145
+ ) -> Dict[str, Any]:
146
+ """
147
+ Create player controller configuration for FPS mode.
148
+
149
+ Args:
150
+ move_speed: Horizontal movement speed in units/second
151
+ jump_force: Initial upward velocity for jumps in m/s
152
+ mouse_sensitivity: Mouse look sensitivity multiplier
153
+ invert_y: Whether to invert vertical mouse look
154
+ gravity: World gravity in m/s² (negative = downward)
155
+ player_height: Player collision capsule height in meters
156
+ player_radius: Player collision capsule radius in meters
157
+ eye_height: Camera height from player feet in meters
158
+ player_mass: Player body mass in kg
159
+ linear_damping: Air resistance (0.0-1.0, higher = more friction)
160
+ movement_acceleration: Acceleration time (0.0=instant, higher=slower)
161
+ air_control: Movement control while airborne (0.0-1.0)
162
+ camera_fov: Field of view in degrees (typical: 60-90)
163
+ min_pitch: Minimum vertical look angle in degrees (looking down)
164
+ max_pitch: Maximum vertical look angle in degrees (looking up)
165
+
166
+ Returns:
167
+ Dictionary with player controller configuration
168
+ """
169
+ return {
170
+ "move_speed": move_speed,
171
+ "jump_force": jump_force,
172
+ "mouse_sensitivity": mouse_sensitivity,
173
+ "invert_y": invert_y,
174
+ "gravity": gravity,
175
+ "player_height": player_height,
176
+ "player_radius": player_radius,
177
+ "eye_height": eye_height,
178
+ "player_mass": player_mass,
179
+ "linear_damping": linear_damping,
180
+ "movement_acceleration": movement_acceleration,
181
+ "air_control": air_control,
182
+ "camera_fov": camera_fov,
183
+ "min_pitch": min_pitch,
184
+ "max_pitch": max_pitch
185
+ }
186
+
187
+
188
+ def create_scene(
189
+ name: str = "Untitled Scene",
190
+ description: Optional[str] = None,
191
+ world_width: float = 100.0,
192
+ world_height: float = 100.0,
193
+ world_depth: float = 100.0,
194
+ objects: Optional[List[Dict[str, Any]]] = None,
195
+ lights: Optional[List[Dict[str, Any]]] = None,
196
+ environment: Optional[Dict[str, Any]] = None,
197
+ player: Optional[Dict[str, Any]] = None,
198
+ show_grid: bool = True,
199
+ grid_size: float = 100.0,
200
+ grid_divisions: int = 20,
201
+ tags: Optional[List[str]] = None
202
+ ) -> Dict[str, Any]:
203
+ """Create a complete 3D scene."""
204
+ now = datetime.utcnow().isoformat()
205
+ return {
206
+ "scene_id": str(uuid.uuid4()),
207
+ "name": name,
208
+ "description": description,
209
+ "world_width": world_width,
210
+ "world_height": world_height,
211
+ "world_depth": world_depth,
212
+ "objects": objects or [],
213
+ "lights": lights or [],
214
+ "environment": environment or create_environment(),
215
+ "player": player or create_player(),
216
+ "show_grid": show_grid,
217
+ "grid_size": grid_size,
218
+ "grid_divisions": grid_divisions,
219
+ "tags": tags or [],
220
+ "created_at": now,
221
+ "updated_at": now
222
+ }
223
+
224
+
225
+ # Validation helpers (optional, for type checking)
226
+
227
+ def validate_vector3(v: Dict[str, float]) -> bool:
228
+ """Check if a dict is a valid Vector3."""
229
+ return isinstance(v, dict) and all(k in v for k in ["x", "y", "z"])
230
+
231
+
232
+ def validate_lighting_preset(preset: str) -> bool:
233
+ """Check if a string is a valid lighting preset."""
234
+ return preset in ["day", "night", "sunset", "studio"]
235
+
236
+
237
+ def validate_object_type(obj_type: str) -> bool:
238
+ """Check if a string is a valid object type."""
239
+ return obj_type in ["cube", "sphere", "cylinder", "plane", "cone", "torus", "model"]
backend/main.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GCP - Game Context Protocol Server
3
+ FastAPI server for the 3D scene viewer HTTP endpoints.
4
+ MCP tools are defined in mcp_server.py
5
+ """
6
+ import os
7
+ from fastapi import FastAPI, HTTPException
8
+ from fastapi.responses import HTMLResponse, JSONResponse
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+
11
+ from backend.storage import storage
12
+
13
+ # Create FastAPI app
14
+ app = FastAPI(
15
+ title="GCP - Game Context Protocol",
16
+ description="3D scene building server. Use MCP tools via Claude/AI assistants, or view scenes via HTTP.",
17
+ version="2.0.0",
18
+ )
19
+
20
+ # Add CORS middleware
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=["*"],
24
+ allow_credentials=True,
25
+ allow_methods=["*"],
26
+ allow_headers=["*"],
27
+ )
28
+
29
+
30
+ # =============================================================================
31
+ # HTTP Endpoints (for viewer and health checks)
32
+ # =============================================================================
33
+
34
+ @app.get("/health")
35
+ async def health_check():
36
+ """Health check endpoint for startup verification."""
37
+ return {
38
+ "status": "healthy",
39
+ "service": "GCP - Game Context Protocol",
40
+ "version": "2.0.0",
41
+ }
42
+
43
+
44
+ @app.get("/")
45
+ async def root():
46
+ """Root endpoint with API information."""
47
+ return {
48
+ "name": "GCP - Game Context Protocol",
49
+ "version": "2.0.0",
50
+ "description": "MCP server for building 3D scenes with AI assistants",
51
+ "endpoints": {
52
+ "viewer": "/view/scene/{scene_id}",
53
+ "scene_data": "/api/scenes/{scene_id}",
54
+ "health": "/health",
55
+ },
56
+ "mcp": "Connect via stdio transport using: python -m backend.mcp_server",
57
+ }
58
+
59
+
60
+ @app.get("/api/scenes/{scene_id}")
61
+ async def get_scene_api(scene_id: str):
62
+ """Get scene configuration and data as JSON."""
63
+ scene = storage.get(scene_id)
64
+ if not scene:
65
+ raise HTTPException(status_code=404, detail=f"Scene '{scene_id}' not found")
66
+ return scene
67
+
68
+
69
+ @app.get("/view/scene/{scene_id}")
70
+ async def view_scene(scene_id: str):
71
+ """Serve the Three.js viewer for a scene."""
72
+ scene = storage.get(scene_id)
73
+ if not scene:
74
+ raise HTTPException(status_code=404, detail=f"Scene '{scene_id}' not found")
75
+
76
+ # Serve the game viewer HTML
77
+ try:
78
+ viewer_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "game_viewer.html")
79
+ with open(viewer_path, 'r') as f:
80
+ html_content = f.read()
81
+ return HTMLResponse(content=html_content)
82
+ except FileNotFoundError:
83
+ raise HTTPException(status_code=500, detail="Game viewer HTML not found")
84
+
85
+
86
+ @app.get("/manifest.json")
87
+ async def get_manifest():
88
+ """Serve PWA manifest to avoid 404 errors."""
89
+ try:
90
+ manifest_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "manifest.json")
91
+ with open(manifest_path, 'r') as f:
92
+ import json
93
+ manifest_content = json.load(f)
94
+ return JSONResponse(content=manifest_content)
95
+ except FileNotFoundError:
96
+ return JSONResponse(content={"name": "GCP", "short_name": "GCP"})
97
+
98
+
99
+ # =============================================================================
100
+ # Run server
101
+ # =============================================================================
102
+
103
+ if __name__ == "__main__":
104
+ import uvicorn
105
+ uvicorn.run(app, host="0.0.0.0", port=8000)
backend/mcp_server.py ADDED
@@ -0,0 +1,1119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GCP - Game Context Protocol
3
+ MCP Server using the official Anthropic MCP SDK
4
+
5
+ This module defines all MCP tools for building 3D scenes with AI assistants.
6
+ """
7
+ import os
8
+ from typing import Any
9
+ from mcp.server import Server
10
+ from mcp.types import Tool, TextContent
11
+
12
+ # Import tool implementations
13
+ from backend.tools.scene_tools import (
14
+ create_game_scene,
15
+ add_game_object,
16
+ remove_game_object,
17
+ set_scene_lighting,
18
+ get_scene_info,
19
+ )
20
+ from backend.tools.player_tools import (
21
+ set_player_speed,
22
+ set_jump_force,
23
+ set_mouse_sensitivity,
24
+ set_gravity,
25
+ set_player_dimensions,
26
+ set_movement_acceleration,
27
+ set_air_control,
28
+ set_camera_fov,
29
+ set_vertical_look_limits,
30
+ get_player_config,
31
+ )
32
+ from backend.tools.rendering_tools import (
33
+ add_light,
34
+ remove_light,
35
+ update_light,
36
+ get_lights,
37
+ update_object_material,
38
+ set_background_color,
39
+ set_fog,
40
+ # Post-processing
41
+ set_bloom,
42
+ set_ssao,
43
+ set_color_grading,
44
+ set_vignette,
45
+ get_post_processing,
46
+ # Camera effects
47
+ set_depth_of_field,
48
+ set_motion_blur,
49
+ set_chromatic_aberration,
50
+ get_camera_effects,
51
+ )
52
+ from backend.game_models import create_vector3, create_material
53
+
54
+ # Base URL for viewer links
55
+ BASE_URL = os.getenv("SPACE_URL", "http://localhost:8000")
56
+
57
+ # Create MCP server instance
58
+ server = Server("gcp-server")
59
+
60
+
61
+ # =============================================================================
62
+ # Tool Definitions
63
+ # =============================================================================
64
+
65
+ SCENE_TOOLS = [
66
+ Tool(
67
+ name="create_scene",
68
+ description="""Create a new 3D scene/level.
69
+
70
+ Args:
71
+ name: Name of the scene (default: "New Scene")
72
+ description: Optional description
73
+ world_width: Width of the world in units (default: 100.0)
74
+ world_height: Height of the world in units (default: 100.0)
75
+ world_depth: Depth of the world in units (default: 100.0)
76
+ lighting_preset: "day", "night", "sunset", or "studio" (default: "day")
77
+
78
+ Returns: scene_id, viewer_url, and confirmation message""",
79
+ inputSchema={
80
+ "type": "object",
81
+ "properties": {
82
+ "name": {"type": "string", "default": "New Scene"},
83
+ "description": {"type": "string"},
84
+ "world_width": {"type": "number", "default": 100.0},
85
+ "world_height": {"type": "number", "default": 100.0},
86
+ "world_depth": {"type": "number", "default": 100.0},
87
+ "lighting_preset": {
88
+ "type": "string",
89
+ "enum": ["day", "night", "sunset", "studio"],
90
+ "default": "day"
91
+ },
92
+ },
93
+ },
94
+ ),
95
+ Tool(
96
+ name="add_object",
97
+ description="""Add a 3D object to the scene.
98
+
99
+ Args:
100
+ scene_id: ID of the scene to add the object to (required)
101
+ object_type: "cube", "sphere", "cylinder", "plane", "cone", or "torus" (default: "cube")
102
+ name: Optional name for the object
103
+ x, y, z: Position coordinates (default: 0, 0, 0)
104
+ rotation_x, rotation_y, rotation_z: Rotation in degrees (default: 0, 0, 0)
105
+ scale_x, scale_y, scale_z: Scale factors (default: 1, 1, 1)
106
+ color: Hex color code like "#ff0000" for red (default: "#ffffff")
107
+
108
+ Returns: object_id, scene_id, viewer_url, and confirmation message""",
109
+ inputSchema={
110
+ "type": "object",
111
+ "properties": {
112
+ "scene_id": {"type": "string"},
113
+ "object_type": {
114
+ "type": "string",
115
+ "enum": ["cube", "sphere", "cylinder", "plane", "cone", "torus"],
116
+ "default": "cube"
117
+ },
118
+ "name": {"type": "string"},
119
+ "x": {"type": "number", "default": 0.0},
120
+ "y": {"type": "number", "default": 0.0},
121
+ "z": {"type": "number", "default": 0.0},
122
+ "rotation_x": {"type": "number", "default": 0.0},
123
+ "rotation_y": {"type": "number", "default": 0.0},
124
+ "rotation_z": {"type": "number", "default": 0.0},
125
+ "scale_x": {"type": "number", "default": 1.0},
126
+ "scale_y": {"type": "number", "default": 1.0},
127
+ "scale_z": {"type": "number", "default": 1.0},
128
+ "color": {"type": "string", "default": "#ffffff"},
129
+ },
130
+ "required": ["scene_id"],
131
+ },
132
+ ),
133
+ Tool(
134
+ name="remove_object",
135
+ description="""Remove an object from the scene.
136
+
137
+ Args:
138
+ scene_id: ID of the scene (required)
139
+ object_id: ID of the object to remove (required)
140
+
141
+ Returns: scene_id, viewer_url, and confirmation message""",
142
+ inputSchema={
143
+ "type": "object",
144
+ "properties": {
145
+ "scene_id": {"type": "string"},
146
+ "object_id": {"type": "string"},
147
+ },
148
+ "required": ["scene_id", "object_id"],
149
+ },
150
+ ),
151
+ Tool(
152
+ name="set_lighting",
153
+ description="""Set the lighting preset for the scene.
154
+
155
+ Args:
156
+ scene_id: ID of the scene (required)
157
+ preset: "day", "night", "sunset", or "studio" (default: "day")
158
+
159
+ Returns: scene_id, viewer_url, and confirmation message""",
160
+ inputSchema={
161
+ "type": "object",
162
+ "properties": {
163
+ "scene_id": {"type": "string"},
164
+ "preset": {
165
+ "type": "string",
166
+ "enum": ["day", "night", "sunset", "studio"],
167
+ "default": "day"
168
+ },
169
+ },
170
+ "required": ["scene_id"],
171
+ },
172
+ ),
173
+ Tool(
174
+ name="get_scene_info",
175
+ description="""Get detailed information about a scene.
176
+
177
+ Args:
178
+ scene_id: ID of the scene to retrieve (required)
179
+
180
+ Returns: scene details including name, objects, lights, and viewer_url""",
181
+ inputSchema={
182
+ "type": "object",
183
+ "properties": {
184
+ "scene_id": {"type": "string"},
185
+ },
186
+ "required": ["scene_id"],
187
+ },
188
+ ),
189
+ ]
190
+
191
+ PLAYER_TOOLS = [
192
+ Tool(
193
+ name="set_player_speed",
194
+ description="""Set the player's movement speed.
195
+
196
+ Args:
197
+ scene_id: ID of the scene (required)
198
+ walk_speed: Movement speed in units/second (default: 5.0)
199
+
200
+ Example: set_player_speed(scene_id, walk_speed=8.0) for faster movement""",
201
+ inputSchema={
202
+ "type": "object",
203
+ "properties": {
204
+ "scene_id": {"type": "string"},
205
+ "walk_speed": {"type": "number", "default": 5.0},
206
+ },
207
+ "required": ["scene_id"],
208
+ },
209
+ ),
210
+ Tool(
211
+ name="set_jump_force",
212
+ description="""Configure jump height by setting initial upward velocity.
213
+
214
+ Args:
215
+ scene_id: ID of the scene (required)
216
+ jump_force: Initial jump velocity in m/s (default: 5.0, higher = higher jumps)
217
+
218
+ Example: set_jump_force(scene_id, jump_force=7.0) for higher jumps""",
219
+ inputSchema={
220
+ "type": "object",
221
+ "properties": {
222
+ "scene_id": {"type": "string"},
223
+ "jump_force": {"type": "number", "default": 5.0},
224
+ },
225
+ "required": ["scene_id"],
226
+ },
227
+ ),
228
+ Tool(
229
+ name="set_mouse_sensitivity",
230
+ description="""Configure mouse look sensitivity and Y-axis inversion.
231
+
232
+ Args:
233
+ scene_id: ID of the scene (required)
234
+ sensitivity: Mouse sensitivity multiplier (default: 0.002, lower = more precise)
235
+ invert_y: Invert vertical look, flight-sim style (default: false)
236
+
237
+ Example: set_mouse_sensitivity(scene_id, sensitivity=0.001) for precise aiming""",
238
+ inputSchema={
239
+ "type": "object",
240
+ "properties": {
241
+ "scene_id": {"type": "string"},
242
+ "sensitivity": {"type": "number", "default": 0.002},
243
+ "invert_y": {"type": "boolean", "default": False},
244
+ },
245
+ "required": ["scene_id"],
246
+ },
247
+ ),
248
+ Tool(
249
+ name="set_gravity",
250
+ description="""Set the world's gravity strength.
251
+
252
+ Args:
253
+ scene_id: ID of the scene (required)
254
+ gravity: Gravity in m/s² (default: -9.82 = Earth, negative = downward)
255
+
256
+ Examples:
257
+ -9.82 = Earth gravity (default)
258
+ -1.62 = Moon gravity (floaty)
259
+ -3.7 = Mars gravity
260
+ -20.0 = Heavy gravity""",
261
+ inputSchema={
262
+ "type": "object",
263
+ "properties": {
264
+ "scene_id": {"type": "string"},
265
+ "gravity": {"type": "number", "default": -9.82},
266
+ },
267
+ "required": ["scene_id"],
268
+ },
269
+ ),
270
+ Tool(
271
+ name="set_player_dimensions",
272
+ description="""Configure player collision capsule dimensions.
273
+
274
+ Args:
275
+ scene_id: ID of the scene (required)
276
+ height: Player height in meters (default: 1.7)
277
+ radius: Player radius in meters (default: 0.3)
278
+ eye_height: Camera height from feet (default: height - 0.1)
279
+
280
+ Example: set_player_dimensions(scene_id, height=1.2, radius=0.25) for child-sized""",
281
+ inputSchema={
282
+ "type": "object",
283
+ "properties": {
284
+ "scene_id": {"type": "string"},
285
+ "height": {"type": "number", "default": 1.7},
286
+ "radius": {"type": "number", "default": 0.3},
287
+ "eye_height": {"type": "number"},
288
+ },
289
+ "required": ["scene_id"],
290
+ },
291
+ ),
292
+ Tool(
293
+ name="set_movement_acceleration",
294
+ description="""Configure how quickly player reaches max speed and movement friction.
295
+
296
+ Args:
297
+ scene_id: ID of the scene (required)
298
+ acceleration: Time to reach max speed (0.0=instant, 0.5=snappy, 1.0=sliding)
299
+ damping: Linear damping/friction (0.0-1.0, higher=more friction, default: 0.9)
300
+
301
+ Example: set_movement_acceleration(scene_id, acceleration=0.3, damping=0.5) for sliding feel""",
302
+ inputSchema={
303
+ "type": "object",
304
+ "properties": {
305
+ "scene_id": {"type": "string"},
306
+ "acceleration": {"type": "number", "default": 0.0},
307
+ "damping": {"type": "number", "default": 0.9},
308
+ },
309
+ "required": ["scene_id"],
310
+ },
311
+ ),
312
+ Tool(
313
+ name="set_air_control",
314
+ description="""Configure movement control while airborne (jumping/falling).
315
+
316
+ Args:
317
+ scene_id: ID of the scene (required)
318
+ air_control_factor: Control while airborne 0.0-1.0 (default: 1.0)
319
+ 1.0 = full control (typical FPS)
320
+ 0.0 = no air steering (realistic)
321
+
322
+ Example: set_air_control(scene_id, air_control_factor=0.3) for limited air control""",
323
+ inputSchema={
324
+ "type": "object",
325
+ "properties": {
326
+ "scene_id": {"type": "string"},
327
+ "air_control_factor": {"type": "number", "default": 1.0},
328
+ },
329
+ "required": ["scene_id"],
330
+ },
331
+ ),
332
+ Tool(
333
+ name="set_camera_fov",
334
+ description="""Set the camera field of view.
335
+
336
+ Args:
337
+ scene_id: ID of the scene (required)
338
+ fov: Field of view in degrees (default: 75)
339
+ 60-70 = narrow/zoomed (competitive shooters)
340
+ 75-85 = normal (most games)
341
+ 90-120 = wide/"quake pro" style
342
+
343
+ Example: set_camera_fov(scene_id, fov=90.0) for wide angle""",
344
+ inputSchema={
345
+ "type": "object",
346
+ "properties": {
347
+ "scene_id": {"type": "string"},
348
+ "fov": {"type": "number", "default": 75.0},
349
+ },
350
+ "required": ["scene_id"],
351
+ },
352
+ ),
353
+ Tool(
354
+ name="set_vertical_look_limits",
355
+ description="""Configure how far up and down the player can look.
356
+
357
+ Args:
358
+ scene_id: ID of the scene (required)
359
+ min_pitch: Min angle in degrees, looking down (default: -89)
360
+ max_pitch: Max angle in degrees, looking up (default: 89)
361
+
362
+ Example: set_vertical_look_limits(scene_id, min_pitch=-45, max_pitch=45) for restricted""",
363
+ inputSchema={
364
+ "type": "object",
365
+ "properties": {
366
+ "scene_id": {"type": "string"},
367
+ "min_pitch": {"type": "number", "default": -89.0},
368
+ "max_pitch": {"type": "number", "default": 89.0},
369
+ },
370
+ "required": ["scene_id"],
371
+ },
372
+ ),
373
+ Tool(
374
+ name="get_player_config",
375
+ description="""Get the current player controller configuration.
376
+
377
+ Args:
378
+ scene_id: ID of the scene (required)
379
+
380
+ Returns: All player settings (speed, jump, gravity, dimensions, etc.)""",
381
+ inputSchema={
382
+ "type": "object",
383
+ "properties": {
384
+ "scene_id": {"type": "string"},
385
+ },
386
+ "required": ["scene_id"],
387
+ },
388
+ ),
389
+ ]
390
+
391
+ RENDERING_TOOLS = [
392
+ Tool(
393
+ name="add_light",
394
+ description="""Add a new light source to the scene.
395
+
396
+ Args:
397
+ scene_id: Scene to modify (required)
398
+ light_type: "ambient", "directional", "point", or "spot" (required)
399
+ name: Unique identifier like "Torch1", "MainLight" (required)
400
+ color: Hex color code (default: "#ffffff")
401
+ intensity: Brightness 0.0-2.0 (default: 1.0)
402
+ position: {x, y, z} for directional/point/spot lights
403
+ target: {x, y, z} where directional/spot lights point
404
+ cast_shadow: Enable shadows (default: false)
405
+ spot_angle: Cone angle in degrees for spot lights (default: 45)
406
+
407
+ Examples:
408
+ add_light(scene_id, "point", "Torch", "#ff6600", 1.5, {x:2, y:3, z:0})
409
+ add_light(scene_id, "ambient", "Fill", "#aaaaaa", 0.3)""",
410
+ inputSchema={
411
+ "type": "object",
412
+ "properties": {
413
+ "scene_id": {"type": "string"},
414
+ "light_type": {
415
+ "type": "string",
416
+ "enum": ["ambient", "directional", "point", "spot"]
417
+ },
418
+ "name": {"type": "string"},
419
+ "color": {"type": "string", "default": "#ffffff"},
420
+ "intensity": {"type": "number", "default": 1.0},
421
+ "position": {
422
+ "type": "object",
423
+ "properties": {
424
+ "x": {"type": "number"},
425
+ "y": {"type": "number"},
426
+ "z": {"type": "number"},
427
+ }
428
+ },
429
+ "target": {
430
+ "type": "object",
431
+ "properties": {
432
+ "x": {"type": "number"},
433
+ "y": {"type": "number"},
434
+ "z": {"type": "number"},
435
+ }
436
+ },
437
+ "cast_shadow": {"type": "boolean", "default": False},
438
+ "spot_angle": {"type": "number", "default": 45.0},
439
+ },
440
+ "required": ["scene_id", "light_type", "name"],
441
+ },
442
+ ),
443
+ Tool(
444
+ name="remove_light",
445
+ description="""Remove a light from the scene.
446
+
447
+ Args:
448
+ scene_id: Scene to modify (required)
449
+ light_name: Name of the light to remove (required)
450
+
451
+ Example: remove_light(scene_id, "Torch1")""",
452
+ inputSchema={
453
+ "type": "object",
454
+ "properties": {
455
+ "scene_id": {"type": "string"},
456
+ "light_name": {"type": "string"},
457
+ },
458
+ "required": ["scene_id", "light_name"],
459
+ },
460
+ ),
461
+ Tool(
462
+ name="update_light",
463
+ description="""Update existing light properties.
464
+
465
+ Args:
466
+ scene_id: Scene to modify (required)
467
+ light_name: Name of light to update (required)
468
+ color: New hex color (optional)
469
+ intensity: New brightness 0.0-2.0 (optional)
470
+ position: New {x, y, z} position (optional)
471
+ cast_shadow: Enable/disable shadows (optional)
472
+
473
+ Example: update_light(scene_id, "Sun", color="#ffaa00", intensity=0.8)""",
474
+ inputSchema={
475
+ "type": "object",
476
+ "properties": {
477
+ "scene_id": {"type": "string"},
478
+ "light_name": {"type": "string"},
479
+ "color": {"type": "string"},
480
+ "intensity": {"type": "number"},
481
+ "position": {
482
+ "type": "object",
483
+ "properties": {
484
+ "x": {"type": "number"},
485
+ "y": {"type": "number"},
486
+ "z": {"type": "number"},
487
+ }
488
+ },
489
+ "cast_shadow": {"type": "boolean"},
490
+ },
491
+ "required": ["scene_id", "light_name"],
492
+ },
493
+ ),
494
+ Tool(
495
+ name="get_lights",
496
+ description="""Get all lights in the scene.
497
+
498
+ Args:
499
+ scene_id: Scene to query (required)
500
+
501
+ Returns: List of all lights with their properties""",
502
+ inputSchema={
503
+ "type": "object",
504
+ "properties": {
505
+ "scene_id": {"type": "string"},
506
+ },
507
+ "required": ["scene_id"],
508
+ },
509
+ ),
510
+ Tool(
511
+ name="update_object_material",
512
+ description="""Update an object's material properties.
513
+
514
+ Args:
515
+ scene_id: Scene to modify (required)
516
+ object_id: ID of object to update (required)
517
+ color: Hex color code (optional)
518
+ metalness: 0.0 (matte) to 1.0 (metal) (optional)
519
+ roughness: 0.0 (shiny) to 1.0 (rough) (optional)
520
+ opacity: 0.0 (invisible) to 1.0 (solid) (optional)
521
+ emissive: Hex color for self-glow (optional)
522
+ emissive_intensity: Glow brightness 0.0-1.0 (optional)
523
+
524
+ Examples:
525
+ update_object_material(scene_id, object_id, color="#ff0000")
526
+ update_object_material(scene_id, object_id, metalness=0.9, roughness=0.1)
527
+ update_object_material(scene_id, object_id, emissive="#00ffff", emissive_intensity=0.8)""",
528
+ inputSchema={
529
+ "type": "object",
530
+ "properties": {
531
+ "scene_id": {"type": "string"},
532
+ "object_id": {"type": "string"},
533
+ "color": {"type": "string"},
534
+ "metalness": {"type": "number"},
535
+ "roughness": {"type": "number"},
536
+ "opacity": {"type": "number"},
537
+ "emissive": {"type": "string"},
538
+ "emissive_intensity": {"type": "number"},
539
+ },
540
+ "required": ["scene_id", "object_id"],
541
+ },
542
+ ),
543
+ Tool(
544
+ name="set_background_color",
545
+ description="""Set scene background color or gradient.
546
+
547
+ Args:
548
+ scene_id: Scene to modify (required)
549
+ color: Hex color for solid background
550
+ bg_type: "solid" or "gradient" (default: "solid")
551
+ gradient_top: Top hex color for gradient
552
+ gradient_bottom: Bottom hex color for gradient
553
+
554
+ Examples:
555
+ set_background_color(scene_id, color="#000000") # Black
556
+ set_background_color(scene_id, bg_type="gradient", gradient_top="#87CEEB", gradient_bottom="#FFE4B5")""",
557
+ inputSchema={
558
+ "type": "object",
559
+ "properties": {
560
+ "scene_id": {"type": "string"},
561
+ "color": {"type": "string"},
562
+ "bg_type": {
563
+ "type": "string",
564
+ "enum": ["solid", "gradient"],
565
+ "default": "solid"
566
+ },
567
+ "gradient_top": {"type": "string"},
568
+ "gradient_bottom": {"type": "string"},
569
+ },
570
+ "required": ["scene_id"],
571
+ },
572
+ ),
573
+ Tool(
574
+ name="set_fog",
575
+ description="""Add atmospheric fog to the scene.
576
+
577
+ Args:
578
+ scene_id: Scene to modify (required)
579
+ enabled: Enable or disable fog (required)
580
+ color: Hex color of fog (default: "#aaaaaa")
581
+ near: Start distance for linear fog
582
+ far: End distance for linear fog
583
+ density: Density for exponential fog (overrides near/far)
584
+
585
+ Examples:
586
+ set_fog(scene_id, enabled=True, color="#aaaaaa", near=10, far=50)
587
+ set_fog(scene_id, enabled=True, density=0.05) # Exponential
588
+ set_fog(scene_id, enabled=False) # Disable""",
589
+ inputSchema={
590
+ "type": "object",
591
+ "properties": {
592
+ "scene_id": {"type": "string"},
593
+ "enabled": {"type": "boolean"},
594
+ "color": {"type": "string", "default": "#aaaaaa"},
595
+ "near": {"type": "number"},
596
+ "far": {"type": "number"},
597
+ "density": {"type": "number"},
598
+ },
599
+ "required": ["scene_id", "enabled"],
600
+ },
601
+ ),
602
+ ]
603
+
604
+ POST_PROCESSING_TOOLS = [
605
+ Tool(
606
+ name="set_bloom",
607
+ description="""Configure bloom (glow) post-processing effect.
608
+
609
+ Args:
610
+ scene_id: Scene to modify (required)
611
+ enabled: Enable/disable bloom (required)
612
+ strength: Bloom intensity 0.0-3.0 (default: 1.0)
613
+ radius: Bloom spread/blur radius 0.0-1.0 (default: 0.4)
614
+ threshold: Brightness threshold to trigger bloom 0.0-1.0 (default: 0.8)
615
+
616
+ Example: set_bloom(scene_id, enabled=True, strength=1.5, threshold=0.6)""",
617
+ inputSchema={
618
+ "type": "object",
619
+ "properties": {
620
+ "scene_id": {"type": "string"},
621
+ "enabled": {"type": "boolean"},
622
+ "strength": {"type": "number", "default": 1.0},
623
+ "radius": {"type": "number", "default": 0.4},
624
+ "threshold": {"type": "number", "default": 0.8},
625
+ },
626
+ "required": ["scene_id", "enabled"],
627
+ },
628
+ ),
629
+ Tool(
630
+ name="set_ssao",
631
+ description="""Configure Screen Space Ambient Occlusion (SSAO).
632
+
633
+ Adds soft shadows in corners and crevices for depth and realism.
634
+
635
+ Args:
636
+ scene_id: Scene to modify (required)
637
+ enabled: Enable/disable SSAO (required)
638
+ radius: Sample radius in world units 0.1-2.0 (default: 0.5)
639
+ intensity: Shadow intensity 0.0-2.0 (default: 1.0)
640
+ bias: Depth bias to prevent self-occlusion 0.001-0.1 (default: 0.025)
641
+
642
+ Example: set_ssao(scene_id, enabled=True, intensity=1.5)""",
643
+ inputSchema={
644
+ "type": "object",
645
+ "properties": {
646
+ "scene_id": {"type": "string"},
647
+ "enabled": {"type": "boolean"},
648
+ "radius": {"type": "number", "default": 0.5},
649
+ "intensity": {"type": "number", "default": 1.0},
650
+ "bias": {"type": "number", "default": 0.025},
651
+ },
652
+ "required": ["scene_id", "enabled"],
653
+ },
654
+ ),
655
+ Tool(
656
+ name="set_color_grading",
657
+ description="""Configure color grading post-processing.
658
+
659
+ Adjust overall image colors for cinematic looks or stylized effects.
660
+
661
+ Args:
662
+ scene_id: Scene to modify (required)
663
+ enabled: Enable/disable color grading (required)
664
+ brightness: Brightness adjustment -1.0 to 1.0 (default: 0.0)
665
+ contrast: Contrast multiplier 0.0-2.0 (default: 1.0)
666
+ saturation: Color saturation 0.0-2.0 (default: 1.0)
667
+ hue: Hue shift in degrees -180 to 180 (default: 0)
668
+ exposure: Exposure adjustment 0.0-3.0 (default: 1.0)
669
+ gamma: Gamma correction 0.5-2.5 (default: 1.0)
670
+
671
+ Example: set_color_grading(scene_id, enabled=True, saturation=0.5) for desaturated look""",
672
+ inputSchema={
673
+ "type": "object",
674
+ "properties": {
675
+ "scene_id": {"type": "string"},
676
+ "enabled": {"type": "boolean"},
677
+ "brightness": {"type": "number", "default": 0.0},
678
+ "contrast": {"type": "number", "default": 1.0},
679
+ "saturation": {"type": "number", "default": 1.0},
680
+ "hue": {"type": "number", "default": 0.0},
681
+ "exposure": {"type": "number", "default": 1.0},
682
+ "gamma": {"type": "number", "default": 1.0},
683
+ },
684
+ "required": ["scene_id", "enabled"],
685
+ },
686
+ ),
687
+ Tool(
688
+ name="set_vignette",
689
+ description="""Configure vignette effect (darkened edges).
690
+
691
+ Darkens corners and edges of the screen, drawing focus to the center.
692
+
693
+ Args:
694
+ scene_id: Scene to modify (required)
695
+ enabled: Enable/disable vignette (required)
696
+ intensity: Darkness of the vignette 0.0-1.0 (default: 0.5)
697
+ smoothness: Softness of the vignette edge 0.0-1.0 (default: 0.5)
698
+
699
+ Example: set_vignette(scene_id, enabled=True, intensity=0.7)""",
700
+ inputSchema={
701
+ "type": "object",
702
+ "properties": {
703
+ "scene_id": {"type": "string"},
704
+ "enabled": {"type": "boolean"},
705
+ "intensity": {"type": "number", "default": 0.5},
706
+ "smoothness": {"type": "number", "default": 0.5},
707
+ },
708
+ "required": ["scene_id", "enabled"],
709
+ },
710
+ ),
711
+ Tool(
712
+ name="get_post_processing",
713
+ description="""Get all post-processing settings for the scene.
714
+
715
+ Args:
716
+ scene_id: Scene to query (required)
717
+
718
+ Returns: All post-processing settings (bloom, SSAO, color grading, vignette)""",
719
+ inputSchema={
720
+ "type": "object",
721
+ "properties": {
722
+ "scene_id": {"type": "string"},
723
+ },
724
+ "required": ["scene_id"],
725
+ },
726
+ ),
727
+ ]
728
+
729
+ CAMERA_EFFECTS_TOOLS = [
730
+ Tool(
731
+ name="set_depth_of_field",
732
+ description="""Configure depth of field (DoF) camera effect.
733
+
734
+ Blurs objects that are not at the focus distance, simulating real camera lenses.
735
+
736
+ Args:
737
+ scene_id: Scene to modify (required)
738
+ enabled: Enable/disable depth of field (required)
739
+ focus_distance: Distance to the focal plane in units (default: 10.0)
740
+ aperture: Aperture size, affects blur amount 0.001-0.1 (default: 0.025)
741
+ max_blur: Maximum blur strength 0.0-0.05 (default: 0.01)
742
+
743
+ Example: set_depth_of_field(scene_id, enabled=True, focus_distance=5.0)""",
744
+ inputSchema={
745
+ "type": "object",
746
+ "properties": {
747
+ "scene_id": {"type": "string"},
748
+ "enabled": {"type": "boolean"},
749
+ "focus_distance": {"type": "number", "default": 10.0},
750
+ "aperture": {"type": "number", "default": 0.025},
751
+ "max_blur": {"type": "number", "default": 0.01},
752
+ },
753
+ "required": ["scene_id", "enabled"],
754
+ },
755
+ ),
756
+ Tool(
757
+ name="set_motion_blur",
758
+ description="""Configure motion blur camera effect.
759
+
760
+ Adds blur in the direction of camera or object movement.
761
+
762
+ Args:
763
+ scene_id: Scene to modify (required)
764
+ enabled: Enable/disable motion blur (required)
765
+ intensity: Blur intensity 0.0-2.0 (default: 0.5)
766
+ samples: Quality samples for blur 4-32 (default: 8)
767
+
768
+ Example: set_motion_blur(scene_id, enabled=True, intensity=0.8)""",
769
+ inputSchema={
770
+ "type": "object",
771
+ "properties": {
772
+ "scene_id": {"type": "string"},
773
+ "enabled": {"type": "boolean"},
774
+ "intensity": {"type": "number", "default": 0.5},
775
+ "samples": {"type": "integer", "default": 8},
776
+ },
777
+ "required": ["scene_id", "enabled"],
778
+ },
779
+ ),
780
+ Tool(
781
+ name="set_chromatic_aberration",
782
+ description="""Configure chromatic aberration effect.
783
+
784
+ Simulates lens imperfection by separating color channels at the edges.
785
+
786
+ Args:
787
+ scene_id: Scene to modify (required)
788
+ enabled: Enable/disable chromatic aberration (required)
789
+ intensity: Effect strength 0.0-0.05 (default: 0.005)
790
+
791
+ Example: set_chromatic_aberration(scene_id, enabled=True, intensity=0.01)""",
792
+ inputSchema={
793
+ "type": "object",
794
+ "properties": {
795
+ "scene_id": {"type": "string"},
796
+ "enabled": {"type": "boolean"},
797
+ "intensity": {"type": "number", "default": 0.005},
798
+ },
799
+ "required": ["scene_id", "enabled"],
800
+ },
801
+ ),
802
+ Tool(
803
+ name="get_camera_effects",
804
+ description="""Get all camera effects settings for the scene.
805
+
806
+ Args:
807
+ scene_id: Scene to query (required)
808
+
809
+ Returns: All camera effects settings (depth of field, motion blur, chromatic aberration)""",
810
+ inputSchema={
811
+ "type": "object",
812
+ "properties": {
813
+ "scene_id": {"type": "string"},
814
+ },
815
+ "required": ["scene_id"],
816
+ },
817
+ ),
818
+ ]
819
+
820
+ # Combine all tools
821
+ ALL_TOOLS = SCENE_TOOLS + PLAYER_TOOLS + RENDERING_TOOLS + POST_PROCESSING_TOOLS + CAMERA_EFFECTS_TOOLS
822
+
823
+
824
+ # =============================================================================
825
+ # MCP Handlers
826
+ # =============================================================================
827
+
828
+ @server.list_tools()
829
+ async def list_tools() -> list[Tool]:
830
+ """Return all available GCP tools."""
831
+ return ALL_TOOLS
832
+
833
+
834
+ @server.call_tool()
835
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
836
+ """Handle tool calls from MCP clients."""
837
+ try:
838
+ result = await _execute_tool(name, arguments)
839
+ return [TextContent(type="text", text=str(result))]
840
+ except Exception as e:
841
+ return [TextContent(type="text", text=f"Error: {str(e)}")]
842
+
843
+
844
+ async def _execute_tool(name: str, args: dict) -> Any:
845
+ """Route tool calls to their implementations."""
846
+
847
+ # Scene tools
848
+ if name == "create_scene":
849
+ return create_game_scene(
850
+ name=args.get("name", "New Scene"),
851
+ description=args.get("description"),
852
+ world_width=args.get("world_width", 100.0),
853
+ world_height=args.get("world_height", 100.0),
854
+ world_depth=args.get("world_depth", 100.0),
855
+ lighting_preset=args.get("lighting_preset", "day"),
856
+ base_url=BASE_URL,
857
+ )
858
+
859
+ elif name == "add_object":
860
+ return add_game_object(
861
+ scene_id=args["scene_id"],
862
+ object_type=args.get("object_type", "cube"),
863
+ name=args.get("name"),
864
+ position=create_vector3(
865
+ args.get("x", 0.0),
866
+ args.get("y", 0.0),
867
+ args.get("z", 0.0)
868
+ ),
869
+ rotation=create_vector3(
870
+ args.get("rotation_x", 0.0),
871
+ args.get("rotation_y", 0.0),
872
+ args.get("rotation_z", 0.0)
873
+ ),
874
+ scale=create_vector3(
875
+ args.get("scale_x", 1.0),
876
+ args.get("scale_y", 1.0),
877
+ args.get("scale_z", 1.0)
878
+ ),
879
+ material=create_material(color=args.get("color", "#ffffff")),
880
+ base_url=BASE_URL,
881
+ )
882
+
883
+ elif name == "remove_object":
884
+ return remove_game_object(
885
+ scene_id=args["scene_id"],
886
+ object_id=args["object_id"],
887
+ base_url=BASE_URL,
888
+ )
889
+
890
+ elif name == "set_lighting":
891
+ return set_scene_lighting(
892
+ scene_id=args["scene_id"],
893
+ preset=args.get("preset", "day"),
894
+ base_url=BASE_URL,
895
+ )
896
+
897
+ elif name == "get_scene_info":
898
+ return get_scene_info(args["scene_id"], BASE_URL)
899
+
900
+ # Player tools
901
+ elif name == "set_player_speed":
902
+ return set_player_speed(
903
+ args["scene_id"],
904
+ args.get("walk_speed", 5.0)
905
+ )
906
+
907
+ elif name == "set_jump_force":
908
+ return set_jump_force(
909
+ args["scene_id"],
910
+ args.get("jump_force", 5.0)
911
+ )
912
+
913
+ elif name == "set_mouse_sensitivity":
914
+ return set_mouse_sensitivity(
915
+ args["scene_id"],
916
+ args.get("sensitivity", 0.002),
917
+ args.get("invert_y", False)
918
+ )
919
+
920
+ elif name == "set_gravity":
921
+ return set_gravity(
922
+ args["scene_id"],
923
+ args.get("gravity", -9.82)
924
+ )
925
+
926
+ elif name == "set_player_dimensions":
927
+ return set_player_dimensions(
928
+ args["scene_id"],
929
+ args.get("height", 1.7),
930
+ args.get("radius", 0.3),
931
+ args.get("eye_height")
932
+ )
933
+
934
+ elif name == "set_movement_acceleration":
935
+ return set_movement_acceleration(
936
+ args["scene_id"],
937
+ args.get("acceleration", 0.0),
938
+ args.get("damping", 0.9)
939
+ )
940
+
941
+ elif name == "set_air_control":
942
+ return set_air_control(
943
+ args["scene_id"],
944
+ args.get("air_control_factor", 1.0)
945
+ )
946
+
947
+ elif name == "set_camera_fov":
948
+ return set_camera_fov(
949
+ args["scene_id"],
950
+ args.get("fov", 75.0)
951
+ )
952
+
953
+ elif name == "set_vertical_look_limits":
954
+ return set_vertical_look_limits(
955
+ args["scene_id"],
956
+ args.get("min_pitch", -89.0),
957
+ args.get("max_pitch", 89.0)
958
+ )
959
+
960
+ elif name == "get_player_config":
961
+ return get_player_config(args["scene_id"])
962
+
963
+ # Rendering tools
964
+ elif name == "add_light":
965
+ return add_light(
966
+ args["scene_id"],
967
+ args["light_type"],
968
+ args["name"],
969
+ args.get("color", "#ffffff"),
970
+ args.get("intensity", 1.0),
971
+ args.get("position"),
972
+ args.get("target"),
973
+ args.get("cast_shadow", False),
974
+ args.get("spot_angle")
975
+ )
976
+
977
+ elif name == "remove_light":
978
+ return remove_light(
979
+ args["scene_id"],
980
+ args["light_name"]
981
+ )
982
+
983
+ elif name == "update_light":
984
+ return update_light(
985
+ args["scene_id"],
986
+ args["light_name"],
987
+ args.get("color"),
988
+ args.get("intensity"),
989
+ args.get("position"),
990
+ args.get("cast_shadow")
991
+ )
992
+
993
+ elif name == "get_lights":
994
+ return get_lights(args["scene_id"])
995
+
996
+ elif name == "update_object_material":
997
+ return update_object_material(
998
+ args["scene_id"],
999
+ args["object_id"],
1000
+ args.get("color"),
1001
+ args.get("metalness"),
1002
+ args.get("roughness"),
1003
+ args.get("opacity"),
1004
+ args.get("emissive"),
1005
+ args.get("emissive_intensity")
1006
+ )
1007
+
1008
+ elif name == "set_background_color":
1009
+ return set_background_color(
1010
+ args["scene_id"],
1011
+ args.get("color"),
1012
+ args.get("bg_type", "solid"),
1013
+ args.get("gradient_top"),
1014
+ args.get("gradient_bottom")
1015
+ )
1016
+
1017
+ elif name == "set_fog":
1018
+ return set_fog(
1019
+ args["scene_id"],
1020
+ args["enabled"],
1021
+ args.get("color"),
1022
+ args.get("near"),
1023
+ args.get("far"),
1024
+ args.get("density")
1025
+ )
1026
+
1027
+ # Post-processing tools
1028
+ elif name == "set_bloom":
1029
+ return set_bloom(
1030
+ args["scene_id"],
1031
+ args["enabled"],
1032
+ args.get("strength", 1.0),
1033
+ args.get("radius", 0.4),
1034
+ args.get("threshold", 0.8)
1035
+ )
1036
+
1037
+ elif name == "set_ssao":
1038
+ return set_ssao(
1039
+ args["scene_id"],
1040
+ args["enabled"],
1041
+ args.get("radius", 0.5),
1042
+ args.get("intensity", 1.0),
1043
+ args.get("bias", 0.025)
1044
+ )
1045
+
1046
+ elif name == "set_color_grading":
1047
+ return set_color_grading(
1048
+ args["scene_id"],
1049
+ args["enabled"],
1050
+ args.get("brightness", 0.0),
1051
+ args.get("contrast", 1.0),
1052
+ args.get("saturation", 1.0),
1053
+ args.get("hue", 0.0),
1054
+ args.get("exposure", 1.0),
1055
+ args.get("gamma", 1.0)
1056
+ )
1057
+
1058
+ elif name == "set_vignette":
1059
+ return set_vignette(
1060
+ args["scene_id"],
1061
+ args["enabled"],
1062
+ args.get("intensity", 0.5),
1063
+ args.get("smoothness", 0.5)
1064
+ )
1065
+
1066
+ elif name == "get_post_processing":
1067
+ return get_post_processing(args["scene_id"])
1068
+
1069
+ # Camera effects tools
1070
+ elif name == "set_depth_of_field":
1071
+ return set_depth_of_field(
1072
+ args["scene_id"],
1073
+ args["enabled"],
1074
+ args.get("focus_distance", 10.0),
1075
+ args.get("aperture", 0.025),
1076
+ args.get("max_blur", 0.01)
1077
+ )
1078
+
1079
+ elif name == "set_motion_blur":
1080
+ return set_motion_blur(
1081
+ args["scene_id"],
1082
+ args["enabled"],
1083
+ args.get("intensity", 0.5),
1084
+ args.get("samples", 8)
1085
+ )
1086
+
1087
+ elif name == "set_chromatic_aberration":
1088
+ return set_chromatic_aberration(
1089
+ args["scene_id"],
1090
+ args["enabled"],
1091
+ args.get("intensity", 0.005)
1092
+ )
1093
+
1094
+ elif name == "get_camera_effects":
1095
+ return get_camera_effects(args["scene_id"])
1096
+
1097
+ else:
1098
+ raise ValueError(f"Unknown tool: {name}")
1099
+
1100
+
1101
+ # =============================================================================
1102
+ # Server runner (for standalone MCP mode)
1103
+ # =============================================================================
1104
+
1105
+ async def run_stdio():
1106
+ """Run the MCP server using stdio transport."""
1107
+ from mcp.server.stdio import stdio_server
1108
+
1109
+ async with stdio_server() as (read_stream, write_stream):
1110
+ await server.run(
1111
+ read_stream,
1112
+ write_stream,
1113
+ server.create_initialization_options()
1114
+ )
1115
+
1116
+
1117
+ if __name__ == "__main__":
1118
+ import asyncio
1119
+ asyncio.run(run_stdio())
backend/storage.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ In-memory storage for game scenes
3
+ """
4
+ from typing import Dict, Optional, Any
5
+ from datetime import datetime
6
+
7
+
8
+ class Storage:
9
+ """Simple in-memory storage for game scenes using plain dictionaries"""
10
+
11
+ def __init__(self):
12
+ self._scenes: Dict[str, Dict[str, Any]] = {}
13
+
14
+ def save(self, scene: Dict[str, Any]) -> Dict[str, Any]:
15
+ """Save or update a scene"""
16
+ if not isinstance(scene, dict):
17
+ raise ValueError("Scene must be a dictionary")
18
+
19
+ if "scene_id" not in scene:
20
+ raise ValueError("Scene must have a 'scene_id' key")
21
+
22
+ # Update timestamp
23
+ scene["updated_at"] = datetime.utcnow().isoformat()
24
+
25
+ self._scenes[scene["scene_id"]] = scene
26
+ return scene
27
+
28
+ def get(self, scene_id: str) -> Optional[Dict[str, Any]]:
29
+ """Retrieve a scene by ID"""
30
+ return self._scenes.get(scene_id)
31
+
32
+ def delete(self, scene_id: str) -> bool:
33
+ """Delete a scene"""
34
+ if scene_id in self._scenes:
35
+ del self._scenes[scene_id]
36
+ return True
37
+ return False
38
+
39
+ def list_all(self) -> list[Dict[str, Any]]:
40
+ """List all scenes"""
41
+ return list(self._scenes.values())
42
+
43
+ def exists(self, scene_id: str) -> bool:
44
+ """Check if scene exists"""
45
+ return scene_id in self._scenes
46
+
47
+
48
+ # Global storage instance
49
+ storage = Storage()
50
+
51
+
52
+ # Initialize with a clean default Welcome Scene for HuggingFace Spaces
53
+ def initialize_default_scene():
54
+ """Create a clean default Welcome Scene on startup"""
55
+ from backend.game_models import create_scene, create_light, create_environment, create_vector3
56
+
57
+ # Create lights for day preset
58
+ lights = [
59
+ create_light(
60
+ name="Sun",
61
+ light_type="directional",
62
+ color="#ffffff",
63
+ intensity=1.0,
64
+ position=create_vector3(50, 50, 50),
65
+ ),
66
+ create_light(
67
+ name="Ambient",
68
+ light_type="ambient",
69
+ color="#ffffff",
70
+ intensity=0.5,
71
+ ),
72
+ ]
73
+
74
+ # Create environment
75
+ env = create_environment(lighting_preset="day")
76
+
77
+ # Create clean scene with NO default objects
78
+ # Ground plane and walls are created by physics system in viewer
79
+ scene = create_scene(
80
+ name="Welcome Scene",
81
+ description="Clean 10x10 FPS world with physics - Ready to explore!",
82
+ world_width=10,
83
+ world_height=10,
84
+ world_depth=10,
85
+ lights=lights,
86
+ environment=env,
87
+ )
88
+
89
+ # Use a predictable scene ID for easy linking
90
+ scene["scene_id"] = "welcome"
91
+
92
+ # Save to storage
93
+ storage.save(scene)
94
+ print(f"✓ Initialized default Welcome Scene (ID: welcome)")
95
+ print(f" - 10x10 world with FPS physics controller")
96
+ print(f" - Ground plane + boundary walls (created by viewer)")
97
+ print(f" - Player spawn at (0, 1, 0)")
98
+
99
+
100
+ # Initialize on module load
101
+ initialize_default_scene()
backend/tools/__init__.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GCP Tools - Game Context Protocol Tool Implementations
3
+
4
+ Organized by domain:
5
+ - scene_tools: Create, modify, and query 3D scenes
6
+ - player_tools: Configure FPS player controller
7
+ - rendering_tools: Lights, materials, backgrounds, fog
8
+ """
9
+
10
+ from backend.tools.scene_tools import (
11
+ create_game_scene,
12
+ add_game_object,
13
+ remove_game_object,
14
+ set_scene_lighting,
15
+ get_scene_info,
16
+ )
17
+
18
+ from backend.tools.player_tools import (
19
+ set_player_speed,
20
+ set_jump_force,
21
+ set_mouse_sensitivity,
22
+ set_gravity,
23
+ set_player_dimensions,
24
+ set_movement_acceleration,
25
+ set_air_control,
26
+ set_camera_fov,
27
+ set_vertical_look_limits,
28
+ get_player_config,
29
+ )
30
+
31
+ from backend.tools.rendering_tools import (
32
+ add_light,
33
+ remove_light,
34
+ update_light,
35
+ get_lights,
36
+ update_object_material,
37
+ set_background_color,
38
+ set_fog,
39
+ # Post-processing
40
+ set_bloom,
41
+ set_ssao,
42
+ set_color_grading,
43
+ set_vignette,
44
+ get_post_processing,
45
+ # Camera effects
46
+ set_depth_of_field,
47
+ set_motion_blur,
48
+ set_chromatic_aberration,
49
+ get_camera_effects,
50
+ )
51
+
52
+ __all__ = [
53
+ # Scene tools
54
+ "create_game_scene",
55
+ "add_game_object",
56
+ "remove_game_object",
57
+ "set_scene_lighting",
58
+ "get_scene_info",
59
+ # Player tools
60
+ "set_player_speed",
61
+ "set_jump_force",
62
+ "set_mouse_sensitivity",
63
+ "set_gravity",
64
+ "set_player_dimensions",
65
+ "set_movement_acceleration",
66
+ "set_air_control",
67
+ "set_camera_fov",
68
+ "set_vertical_look_limits",
69
+ "get_player_config",
70
+ # Rendering tools
71
+ "add_light",
72
+ "remove_light",
73
+ "update_light",
74
+ "get_lights",
75
+ "update_object_material",
76
+ "set_background_color",
77
+ "set_fog",
78
+ # Post-processing tools
79
+ "set_bloom",
80
+ "set_ssao",
81
+ "set_color_grading",
82
+ "set_vignette",
83
+ "get_post_processing",
84
+ # Camera effects tools
85
+ "set_depth_of_field",
86
+ "set_motion_blur",
87
+ "set_chromatic_aberration",
88
+ "get_camera_effects",
89
+ ]
backend/tools/player_tools.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Player Controller Tools
3
+ Configure first-person character movement, physics, and camera settings
4
+ """
5
+ from typing import Dict, Any, Optional
6
+ from backend.storage import storage
7
+
8
+
9
+ def set_player_speed(scene_id: str, walk_speed: float) -> Dict[str, Any]:
10
+ """
11
+ Implementation: Set player walking/movement speed
12
+
13
+ Args:
14
+ scene_id: ID of the scene
15
+ walk_speed: Movement speed in units/second
16
+
17
+ Returns:
18
+ Dictionary with updated speed and message
19
+ """
20
+ scene = storage.get(scene_id)
21
+ if not scene:
22
+ raise ValueError(f"Scene '{scene_id}' not found")
23
+
24
+ # Initialize player_config if it doesn't exist
25
+ if "player_config" not in scene:
26
+ scene["player_config"] = {}
27
+
28
+ scene["player_config"]["move_speed"] = walk_speed
29
+ storage.save(scene)
30
+
31
+ return {
32
+ "scene_id": scene_id,
33
+ "message": f"Set player movement speed to {walk_speed} units/sec",
34
+ "move_speed": walk_speed,
35
+ }
36
+
37
+
38
+ def set_jump_force(scene_id: str, jump_force: float) -> Dict[str, Any]:
39
+ """
40
+ Implementation: Configure jump height via initial upward velocity
41
+
42
+ Args:
43
+ scene_id: ID of the scene
44
+ jump_force: Initial jump velocity in m/s (higher = higher jumps)
45
+
46
+ Returns:
47
+ Dictionary with updated jump force and message
48
+ """
49
+ scene = storage.get(scene_id)
50
+ if not scene:
51
+ raise ValueError(f"Scene '{scene_id}' not found")
52
+
53
+ if "player_config" not in scene:
54
+ scene["player_config"] = {}
55
+
56
+ scene["player_config"]["jump_force"] = jump_force
57
+ storage.save(scene)
58
+
59
+ return {
60
+ "scene_id": scene_id,
61
+ "message": f"Set jump force to {jump_force} m/s",
62
+ "jump_force": jump_force,
63
+ }
64
+
65
+
66
+ def set_mouse_sensitivity(
67
+ scene_id: str,
68
+ sensitivity: float,
69
+ invert_y: bool
70
+ ) -> Dict[str, Any]:
71
+ """
72
+ Implementation: Configure mouse look sensitivity and Y-axis inversion
73
+
74
+ Args:
75
+ scene_id: ID of the scene
76
+ sensitivity: Mouse sensitivity multiplier (lower = slower/more precise)
77
+ invert_y: Whether to invert vertical look (flight sim style)
78
+
79
+ Returns:
80
+ Dictionary with updated settings and message
81
+ """
82
+ scene = storage.get(scene_id)
83
+ if not scene:
84
+ raise ValueError(f"Scene '{scene_id}' not found")
85
+
86
+ if "player_config" not in scene:
87
+ scene["player_config"] = {}
88
+
89
+ scene["player_config"]["mouse_sensitivity"] = sensitivity
90
+ scene["player_config"]["invert_y"] = invert_y
91
+ storage.save(scene)
92
+
93
+ invert_msg = "enabled" if invert_y else "disabled"
94
+ return {
95
+ "scene_id": scene_id,
96
+ "message": f"Set mouse sensitivity to {sensitivity}, Y-invert {invert_msg}",
97
+ "mouse_sensitivity": sensitivity,
98
+ "invert_y": invert_y,
99
+ }
100
+
101
+
102
+ def set_gravity(scene_id: str, gravity: float) -> Dict[str, Any]:
103
+ """
104
+ Implementation: Set world gravity strength
105
+
106
+ Args:
107
+ scene_id: ID of the scene
108
+ gravity: Gravity acceleration in m/s² (negative = downward, -9.82 = Earth)
109
+
110
+ Returns:
111
+ Dictionary with updated gravity and message
112
+ """
113
+ scene = storage.get(scene_id)
114
+ if not scene:
115
+ raise ValueError(f"Scene '{scene_id}' not found")
116
+
117
+ if "player_config" not in scene:
118
+ scene["player_config"] = {}
119
+
120
+ scene["player_config"]["gravity"] = gravity
121
+ storage.save(scene)
122
+
123
+ gravity_desc = "Earth-like" if abs(gravity + 9.82) < 0.1 else "custom"
124
+ return {
125
+ "scene_id": scene_id,
126
+ "message": f"Set gravity to {gravity} m/s² ({gravity_desc})",
127
+ "gravity": gravity,
128
+ }
129
+
130
+
131
+ def set_player_dimensions(
132
+ scene_id: str,
133
+ height: float,
134
+ radius: float,
135
+ eye_height: Optional[float] = None
136
+ ) -> Dict[str, Any]:
137
+ """
138
+ Implementation: Configure player collision capsule dimensions
139
+
140
+ Args:
141
+ scene_id: ID of the scene
142
+ height: Player height in meters (affects collision capsule)
143
+ radius: Player radius in meters (affects collision capsule width)
144
+ eye_height: Camera height from feet (defaults to height - 0.1m if not specified)
145
+
146
+ Returns:
147
+ Dictionary with updated dimensions and message
148
+ """
149
+ scene = storage.get(scene_id)
150
+ if not scene:
151
+ raise ValueError(f"Scene '{scene_id}' not found")
152
+
153
+ if "player_config" not in scene:
154
+ scene["player_config"] = {}
155
+
156
+ # Default eye height to slightly below top of head
157
+ if eye_height is None:
158
+ eye_height = height - 0.1
159
+
160
+ scene["player_config"]["player_height"] = height
161
+ scene["player_config"]["player_radius"] = radius
162
+ scene["player_config"]["eye_height"] = eye_height
163
+ storage.save(scene)
164
+
165
+ return {
166
+ "scene_id": scene_id,
167
+ "message": f"Set player dimensions: height={height}m, radius={radius}m, eye_height={eye_height}m",
168
+ "player_height": height,
169
+ "player_radius": radius,
170
+ "eye_height": eye_height,
171
+ }
172
+
173
+
174
+ def set_movement_acceleration(
175
+ scene_id: str,
176
+ acceleration: float,
177
+ damping: float
178
+ ) -> Dict[str, Any]:
179
+ """
180
+ Implementation: Configure movement acceleration and damping
181
+
182
+ Args:
183
+ scene_id: ID of the scene
184
+ acceleration: How quickly player reaches max speed (0.0=instant, higher=slower)
185
+ damping: Linear damping/air resistance (0.0-1.0, higher=more friction)
186
+
187
+ Returns:
188
+ Dictionary with updated settings and message
189
+ """
190
+ scene = storage.get(scene_id)
191
+ if not scene:
192
+ raise ValueError(f"Scene '{scene_id}' not found")
193
+
194
+ if "player_config" not in scene:
195
+ scene["player_config"] = {}
196
+
197
+ scene["player_config"]["movement_acceleration"] = acceleration
198
+ scene["player_config"]["linear_damping"] = damping
199
+ storage.save(scene)
200
+
201
+ feel = "instant" if acceleration < 0.1 else ("snappy" if acceleration < 0.5 else "sliding")
202
+ return {
203
+ "scene_id": scene_id,
204
+ "message": f"Set movement acceleration={acceleration}, damping={damping} ({feel} feel)",
205
+ "movement_acceleration": acceleration,
206
+ "linear_damping": damping,
207
+ }
208
+
209
+
210
+ def set_air_control(scene_id: str, air_control_factor: float) -> Dict[str, Any]:
211
+ """
212
+ Implementation: Configure movement control while airborne
213
+
214
+ Args:
215
+ scene_id: ID of the scene
216
+ air_control_factor: Movement control while airborne (0.0-1.0)
217
+ 1.0 = full control, 0.0 = no air steering
218
+
219
+ Returns:
220
+ Dictionary with updated setting and message
221
+ """
222
+ scene = storage.get(scene_id)
223
+ if not scene:
224
+ raise ValueError(f"Scene '{scene_id}' not found")
225
+
226
+ if "player_config" not in scene:
227
+ scene["player_config"] = {}
228
+
229
+ # Clamp between 0 and 1
230
+ air_control_factor = max(0.0, min(1.0, air_control_factor))
231
+
232
+ scene["player_config"]["air_control"] = air_control_factor
233
+ storage.save(scene)
234
+
235
+ control_desc = "full" if air_control_factor >= 0.9 else ("limited" if air_control_factor >= 0.3 else "none")
236
+ return {
237
+ "scene_id": scene_id,
238
+ "message": f"Set air control to {air_control_factor} ({control_desc} control)",
239
+ "air_control": air_control_factor,
240
+ }
241
+
242
+
243
+ def set_camera_fov(scene_id: str, fov: float) -> Dict[str, Any]:
244
+ """
245
+ Implementation: Set camera field of view
246
+
247
+ Args:
248
+ scene_id: ID of the scene
249
+ fov: Field of view in degrees (typical: 60-90, default: 75)
250
+
251
+ Returns:
252
+ Dictionary with updated FOV and message
253
+ """
254
+ scene = storage.get(scene_id)
255
+ if not scene:
256
+ raise ValueError(f"Scene '{scene_id}' not found")
257
+
258
+ if "player_config" not in scene:
259
+ scene["player_config"] = {}
260
+
261
+ # Clamp FOV to reasonable range
262
+ fov = max(30.0, min(120.0, fov))
263
+
264
+ scene["player_config"]["camera_fov"] = fov
265
+ storage.save(scene)
266
+
267
+ style = "narrow" if fov < 70 else ("normal" if fov < 90 else "wide")
268
+ return {
269
+ "scene_id": scene_id,
270
+ "message": f"Set camera FOV to {fov}° ({style})",
271
+ "camera_fov": fov,
272
+ }
273
+
274
+
275
+ def set_vertical_look_limits(
276
+ scene_id: str,
277
+ min_pitch: float,
278
+ max_pitch: float
279
+ ) -> Dict[str, Any]:
280
+ """
281
+ Implementation: Configure vertical look angle limits
282
+
283
+ Args:
284
+ scene_id: ID of the scene
285
+ min_pitch: Minimum pitch angle in degrees (looking down, negative)
286
+ max_pitch: Maximum pitch angle in degrees (looking up, positive)
287
+
288
+ Returns:
289
+ Dictionary with updated limits and message
290
+ """
291
+ scene = storage.get(scene_id)
292
+ if not scene:
293
+ raise ValueError(f"Scene '{scene_id}' not found")
294
+
295
+ if "player_config" not in scene:
296
+ scene["player_config"] = {}
297
+
298
+ scene["player_config"]["min_pitch"] = min_pitch
299
+ scene["player_config"]["max_pitch"] = max_pitch
300
+ storage.save(scene)
301
+
302
+ return {
303
+ "scene_id": scene_id,
304
+ "message": f"Set vertical look limits: {min_pitch}° to {max_pitch}°",
305
+ "min_pitch": min_pitch,
306
+ "max_pitch": max_pitch,
307
+ }
308
+
309
+
310
+ def get_player_config(scene_id: str) -> Dict[str, Any]:
311
+ """
312
+ Implementation: Get current player configuration
313
+
314
+ Args:
315
+ scene_id: ID of the scene
316
+
317
+ Returns:
318
+ Dictionary with all player config values
319
+ """
320
+ scene = storage.get(scene_id)
321
+ if not scene:
322
+ raise ValueError(f"Scene '{scene_id}' not found")
323
+
324
+ player_config = scene.get("player_config", {})
325
+
326
+ # Return with defaults if not set
327
+ return {
328
+ "scene_id": scene_id,
329
+ "player_config": {
330
+ # Phase 1
331
+ "move_speed": player_config.get("move_speed", 5.0),
332
+ "jump_force": player_config.get("jump_force", 5.0),
333
+ "mouse_sensitivity": player_config.get("mouse_sensitivity", 0.002),
334
+ "invert_y": player_config.get("invert_y", False),
335
+ "gravity": player_config.get("gravity", -9.82),
336
+ "player_height": player_config.get("player_height", 1.7),
337
+ "player_radius": player_config.get("player_radius", 0.3),
338
+ "eye_height": player_config.get("eye_height", 1.6),
339
+ # Phase 2
340
+ "movement_acceleration": player_config.get("movement_acceleration", 0.0),
341
+ "linear_damping": player_config.get("linear_damping", 0.9),
342
+ "air_control": player_config.get("air_control", 1.0),
343
+ "camera_fov": player_config.get("camera_fov", 75.0),
344
+ "min_pitch": player_config.get("min_pitch", -89.0),
345
+ "max_pitch": player_config.get("max_pitch", 89.0),
346
+ }
347
+ }
backend/tools/rendering_tools.py ADDED
@@ -0,0 +1,848 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rendering & Lighting Tools
3
+ Fine-grained control over scene lighting, materials, background, fog,
4
+ post-processing effects, and camera effects.
5
+ """
6
+ from typing import Dict, Any, Optional
7
+ from backend.storage import storage
8
+
9
+
10
+ def add_light(
11
+ scene_id: str,
12
+ light_type: str,
13
+ name: str,
14
+ color: str = "#ffffff",
15
+ intensity: float = 1.0,
16
+ position: Optional[Dict[str, float]] = None,
17
+ target: Optional[Dict[str, float]] = None,
18
+ cast_shadow: bool = False,
19
+ spot_angle: Optional[float] = None
20
+ ) -> Dict[str, Any]:
21
+ """
22
+ Implementation: Add a new light source to the scene
23
+
24
+ Args:
25
+ scene_id: ID of the scene
26
+ light_type: "ambient" | "directional" | "point" | "spot"
27
+ name: Light identifier (e.g., "Torch1", "MainLight")
28
+ color: Hex color (default: "#ffffff")
29
+ intensity: Brightness 0.0-2.0 (default: 1.0)
30
+ position: Position for directional/point/spot lights
31
+ target: Target position for directional/spot lights
32
+ cast_shadow: Enable shadows (default: False)
33
+ spot_angle: Cone angle in degrees (spot lights only)
34
+
35
+ Returns:
36
+ Dictionary with light details and message
37
+ """
38
+ scene = storage.get(scene_id)
39
+ if not scene:
40
+ raise ValueError(f"Scene '{scene_id}' not found")
41
+
42
+ # Validate light type
43
+ valid_types = ["ambient", "directional", "point", "spot"]
44
+ if light_type not in valid_types:
45
+ raise ValueError(f"Invalid light_type '{light_type}'. Must be one of: {valid_types}")
46
+
47
+ # Check for duplicate name
48
+ if "lights" not in scene:
49
+ scene["lights"] = []
50
+
51
+ for light in scene["lights"]:
52
+ if light.get("name") == name:
53
+ raise ValueError(f"Light with name '{name}' already exists. Use update_light() to modify it.")
54
+
55
+ # Create light object
56
+ light_obj = {
57
+ "name": name,
58
+ "light_type": light_type,
59
+ "color": color,
60
+ "intensity": intensity,
61
+ "cast_shadow": cast_shadow
62
+ }
63
+
64
+ # Add position for non-ambient lights
65
+ if light_type != "ambient":
66
+ if position:
67
+ light_obj["position"] = position
68
+ else:
69
+ # Default positions
70
+ defaults = {
71
+ "directional": {"x": 50, "y": 50, "z": 50},
72
+ "point": {"x": 0, "y": 5, "z": 0},
73
+ "spot": {"x": 0, "y": 5, "z": 0}
74
+ }
75
+ light_obj["position"] = defaults.get(light_type, {"x": 0, "y": 5, "z": 0})
76
+
77
+ # Add target for directional/spot lights
78
+ if light_type in ["directional", "spot"] and target:
79
+ light_obj["target"] = target
80
+
81
+ # Add spot angle for spot lights
82
+ if light_type == "spot":
83
+ light_obj["spot_angle"] = spot_angle if spot_angle else 45.0
84
+
85
+ scene["lights"].append(light_obj)
86
+ storage.save(scene)
87
+
88
+ return {
89
+ "scene_id": scene_id,
90
+ "message": f"Added {light_type} light '{name}' to scene",
91
+ "light": light_obj
92
+ }
93
+
94
+
95
+ def remove_light(scene_id: str, light_name: str) -> Dict[str, Any]:
96
+ """
97
+ Implementation: Remove a light from the scene
98
+
99
+ Args:
100
+ scene_id: ID of the scene
101
+ light_name: Name of light to remove
102
+
103
+ Returns:
104
+ Dictionary with confirmation message
105
+ """
106
+ scene = storage.get(scene_id)
107
+ if not scene:
108
+ raise ValueError(f"Scene '{scene_id}' not found")
109
+
110
+ if "lights" not in scene or not scene["lights"]:
111
+ raise ValueError(f"Scene has no lights to remove")
112
+
113
+ # Find and remove light
114
+ original_count = len(scene["lights"])
115
+ scene["lights"] = [light for light in scene["lights"] if light.get("name") != light_name]
116
+
117
+ if len(scene["lights"]) == original_count:
118
+ raise ValueError(f"Light '{light_name}' not found in scene")
119
+
120
+ storage.save(scene)
121
+
122
+ return {
123
+ "scene_id": scene_id,
124
+ "message": f"Removed light '{light_name}' from scene",
125
+ "light_name": light_name
126
+ }
127
+
128
+
129
+ def update_light(
130
+ scene_id: str,
131
+ light_name: str,
132
+ color: Optional[str] = None,
133
+ intensity: Optional[float] = None,
134
+ position: Optional[Dict[str, float]] = None,
135
+ cast_shadow: Optional[bool] = None
136
+ ) -> Dict[str, Any]:
137
+ """
138
+ Implementation: Update existing light properties
139
+
140
+ Args:
141
+ scene_id: ID of the scene
142
+ light_name: Name of light to update
143
+ color: New color (optional)
144
+ intensity: New intensity (optional)
145
+ position: New position (optional)
146
+ cast_shadow: Enable/disable shadows (optional)
147
+
148
+ Returns:
149
+ Dictionary with updated light and message
150
+ """
151
+ scene = storage.get(scene_id)
152
+ if not scene:
153
+ raise ValueError(f"Scene '{scene_id}' not found")
154
+
155
+ if "lights" not in scene or not scene["lights"]:
156
+ raise ValueError(f"Scene has no lights")
157
+
158
+ # Find light
159
+ light = None
160
+ for l in scene["lights"]:
161
+ if l.get("name") == light_name:
162
+ light = l
163
+ break
164
+
165
+ if not light:
166
+ raise ValueError(f"Light '{light_name}' not found in scene")
167
+
168
+ # Update properties
169
+ updated_props = []
170
+ if color is not None:
171
+ light["color"] = color
172
+ updated_props.append(f"color={color}")
173
+
174
+ if intensity is not None:
175
+ light["intensity"] = intensity
176
+ updated_props.append(f"intensity={intensity}")
177
+
178
+ if position is not None:
179
+ light["position"] = position
180
+ updated_props.append(f"position={position}")
181
+
182
+ if cast_shadow is not None:
183
+ light["cast_shadow"] = cast_shadow
184
+ updated_props.append(f"shadows={'on' if cast_shadow else 'off'}")
185
+
186
+ storage.save(scene)
187
+
188
+ return {
189
+ "scene_id": scene_id,
190
+ "message": f"Updated light '{light_name}': {', '.join(updated_props)}",
191
+ "light": light
192
+ }
193
+
194
+
195
+ def get_lights(scene_id: str) -> Dict[str, Any]:
196
+ """
197
+ Implementation: Get all lights in the scene
198
+
199
+ Args:
200
+ scene_id: ID of the scene
201
+
202
+ Returns:
203
+ Dictionary with list of all lights
204
+ """
205
+ scene = storage.get(scene_id)
206
+ if not scene:
207
+ raise ValueError(f"Scene '{scene_id}' not found")
208
+
209
+ lights = scene.get("lights", [])
210
+
211
+ return {
212
+ "scene_id": scene_id,
213
+ "lights": lights,
214
+ "count": len(lights)
215
+ }
216
+
217
+
218
+ def update_object_material(
219
+ scene_id: str,
220
+ object_id: str,
221
+ color: Optional[str] = None,
222
+ metalness: Optional[float] = None,
223
+ roughness: Optional[float] = None,
224
+ opacity: Optional[float] = None,
225
+ emissive: Optional[str] = None,
226
+ emissive_intensity: Optional[float] = None
227
+ ) -> Dict[str, Any]:
228
+ """
229
+ Implementation: Update an object's material properties
230
+
231
+ Args:
232
+ scene_id: ID of the scene
233
+ object_id: Object to update
234
+ color: Hex color (optional)
235
+ metalness: 0.0-1.0 (optional)
236
+ roughness: 0.0-1.0 (optional)
237
+ opacity: 0.0-1.0 (optional)
238
+ emissive: Emissive color for glow (optional)
239
+ emissive_intensity: Glow strength (optional)
240
+
241
+ Returns:
242
+ Dictionary with updated material and message
243
+ """
244
+ scene = storage.get(scene_id)
245
+ if not scene:
246
+ raise ValueError(f"Scene '{scene_id}' not found")
247
+
248
+ if "objects" not in scene or not scene["objects"]:
249
+ raise ValueError(f"Scene has no objects")
250
+
251
+ # Find object
252
+ obj = None
253
+ for o in scene["objects"]:
254
+ if o.get("object_id") == object_id:
255
+ obj = o
256
+ break
257
+
258
+ if not obj:
259
+ raise ValueError(f"Object '{object_id}' not found in scene")
260
+
261
+ # Ensure material exists
262
+ if "material" not in obj:
263
+ obj["material"] = {}
264
+
265
+ # Update material properties
266
+ updated_props = []
267
+
268
+ if color is not None:
269
+ obj["material"]["color"] = color
270
+ updated_props.append(f"color={color}")
271
+
272
+ if metalness is not None:
273
+ obj["material"]["metalness"] = max(0.0, min(1.0, metalness))
274
+ updated_props.append(f"metalness={metalness}")
275
+
276
+ if roughness is not None:
277
+ obj["material"]["roughness"] = max(0.0, min(1.0, roughness))
278
+ updated_props.append(f"roughness={roughness}")
279
+
280
+ if opacity is not None:
281
+ obj["material"]["opacity"] = max(0.0, min(1.0, opacity))
282
+ updated_props.append(f"opacity={opacity}")
283
+
284
+ if emissive is not None:
285
+ obj["material"]["emissive"] = emissive
286
+ updated_props.append(f"emissive={emissive}")
287
+
288
+ if emissive_intensity is not None:
289
+ obj["material"]["emissive_intensity"] = emissive_intensity
290
+ updated_props.append(f"emissive_intensity={emissive_intensity}")
291
+
292
+ storage.save(scene)
293
+
294
+ return {
295
+ "scene_id": scene_id,
296
+ "object_id": object_id,
297
+ "message": f"Updated material: {', '.join(updated_props)}",
298
+ "material": obj["material"]
299
+ }
300
+
301
+
302
+ def set_background_color(
303
+ scene_id: str,
304
+ color: Optional[str] = None,
305
+ bg_type: str = "solid",
306
+ gradient_top: Optional[str] = None,
307
+ gradient_bottom: Optional[str] = None
308
+ ) -> Dict[str, Any]:
309
+ """
310
+ Implementation: Set scene background color
311
+
312
+ Args:
313
+ scene_id: ID of the scene
314
+ color: Hex color for solid background
315
+ bg_type: "solid" | "gradient"
316
+ gradient_top: Top color for gradient
317
+ gradient_bottom: Bottom color for gradient
318
+
319
+ Returns:
320
+ Dictionary with background settings and message
321
+ """
322
+ scene = storage.get(scene_id)
323
+ if not scene:
324
+ raise ValueError(f"Scene '{scene_id}' not found")
325
+
326
+ if "environment" not in scene:
327
+ scene["environment"] = {}
328
+
329
+ if bg_type == "gradient":
330
+ if not gradient_top or not gradient_bottom:
331
+ raise ValueError("gradient_top and gradient_bottom are required for gradient backgrounds")
332
+
333
+ scene["environment"]["background_type"] = "gradient"
334
+ scene["environment"]["background_gradient_top"] = gradient_top
335
+ scene["environment"]["background_gradient_bottom"] = gradient_bottom
336
+
337
+ message = f"Set background to gradient: {gradient_top} → {gradient_bottom}"
338
+ else:
339
+ if not color:
340
+ raise ValueError("color is required for solid backgrounds")
341
+
342
+ scene["environment"]["background_type"] = "solid"
343
+ scene["environment"]["background_color"] = color
344
+
345
+ message = f"Set background to {color}"
346
+
347
+ storage.save(scene)
348
+
349
+ return {
350
+ "scene_id": scene_id,
351
+ "message": message,
352
+ "background": scene["environment"]
353
+ }
354
+
355
+
356
+ def set_fog(
357
+ scene_id: str,
358
+ enabled: bool,
359
+ color: Optional[str] = None,
360
+ near: Optional[float] = None,
361
+ far: Optional[float] = None,
362
+ density: Optional[float] = None
363
+ ) -> Dict[str, Any]:
364
+ """
365
+ Implementation: Set atmospheric fog
366
+
367
+ Args:
368
+ scene_id: ID of the scene
369
+ enabled: Enable/disable fog
370
+ color: Fog color (default: "#aaaaaa")
371
+ near: Start distance for linear fog
372
+ far: End distance for linear fog
373
+ density: Density for exponential fog
374
+
375
+ Returns:
376
+ Dictionary with fog settings and message
377
+ """
378
+ scene = storage.get(scene_id)
379
+ if not scene:
380
+ raise ValueError(f"Scene '{scene_id}' not found")
381
+
382
+ if "environment" not in scene:
383
+ scene["environment"] = {}
384
+
385
+ if "fog" not in scene["environment"]:
386
+ scene["environment"]["fog"] = {}
387
+
388
+ fog = scene["environment"]["fog"]
389
+ fog["enabled"] = enabled
390
+
391
+ if enabled:
392
+ fog["color"] = color if color else "#aaaaaa"
393
+
394
+ # Determine fog type
395
+ if density is not None:
396
+ fog["type"] = "exponential"
397
+ fog["density"] = density
398
+ message = f"Enabled exponential fog (density={density}, color={fog['color']})"
399
+ elif near is not None and far is not None:
400
+ fog["type"] = "linear"
401
+ fog["near"] = near
402
+ fog["far"] = far
403
+ message = f"Enabled linear fog (near={near}, far={far}, color={fog['color']})"
404
+ else:
405
+ # Default linear fog
406
+ fog["type"] = "linear"
407
+ fog["near"] = 10
408
+ fog["far"] = 50
409
+ message = f"Enabled linear fog (near=10, far=50, color={fog['color']})"
410
+ else:
411
+ message = "Disabled fog"
412
+
413
+ storage.save(scene)
414
+
415
+ return {
416
+ "scene_id": scene_id,
417
+ "message": message,
418
+ "fog": fog
419
+ }
420
+
421
+
422
+ # =============================================================================
423
+ # Post-Processing Tools
424
+ # =============================================================================
425
+
426
+ def set_bloom(
427
+ scene_id: str,
428
+ enabled: bool,
429
+ strength: float = 1.0,
430
+ radius: float = 0.4,
431
+ threshold: float = 0.8
432
+ ) -> Dict[str, Any]:
433
+ """
434
+ Configure bloom (glow) post-processing effect.
435
+
436
+ Bloom creates a glow effect around bright areas of the scene,
437
+ simulating how cameras capture bright light sources.
438
+
439
+ Args:
440
+ scene_id: ID of the scene
441
+ enabled: Enable/disable bloom
442
+ strength: Bloom intensity (0.0-3.0, default: 1.0)
443
+ radius: Bloom spread/blur radius (0.0-1.0, default: 0.4)
444
+ threshold: Brightness threshold to trigger bloom (0.0-1.0, default: 0.8)
445
+
446
+ Returns:
447
+ Dictionary with bloom settings and message
448
+ """
449
+ scene = storage.get(scene_id)
450
+ if not scene:
451
+ raise ValueError(f"Scene '{scene_id}' not found")
452
+
453
+ if "post_processing" not in scene:
454
+ scene["post_processing"] = {}
455
+
456
+ bloom = {
457
+ "enabled": enabled,
458
+ "strength": max(0.0, min(3.0, strength)),
459
+ "radius": max(0.0, min(1.0, radius)),
460
+ "threshold": max(0.0, min(1.0, threshold))
461
+ }
462
+ scene["post_processing"]["bloom"] = bloom
463
+
464
+ storage.save(scene)
465
+
466
+ if enabled:
467
+ message = f"Enabled bloom (strength={strength}, radius={radius}, threshold={threshold})"
468
+ else:
469
+ message = "Disabled bloom"
470
+
471
+ return {
472
+ "scene_id": scene_id,
473
+ "message": message,
474
+ "bloom": bloom
475
+ }
476
+
477
+
478
+ def set_ssao(
479
+ scene_id: str,
480
+ enabled: bool,
481
+ radius: float = 0.5,
482
+ intensity: float = 1.0,
483
+ bias: float = 0.025
484
+ ) -> Dict[str, Any]:
485
+ """
486
+ Configure Screen Space Ambient Occlusion (SSAO).
487
+
488
+ SSAO adds soft shadows in corners and crevices where ambient light
489
+ would naturally be occluded, adding depth and realism.
490
+
491
+ Args:
492
+ scene_id: ID of the scene
493
+ enabled: Enable/disable SSAO
494
+ radius: Sample radius in world units (0.1-2.0, default: 0.5)
495
+ intensity: Shadow intensity (0.0-2.0, default: 1.0)
496
+ bias: Depth bias to prevent self-occlusion (0.001-0.1, default: 0.025)
497
+
498
+ Returns:
499
+ Dictionary with SSAO settings and message
500
+ """
501
+ scene = storage.get(scene_id)
502
+ if not scene:
503
+ raise ValueError(f"Scene '{scene_id}' not found")
504
+
505
+ if "post_processing" not in scene:
506
+ scene["post_processing"] = {}
507
+
508
+ ssao = {
509
+ "enabled": enabled,
510
+ "radius": max(0.1, min(2.0, radius)),
511
+ "intensity": max(0.0, min(2.0, intensity)),
512
+ "bias": max(0.001, min(0.1, bias))
513
+ }
514
+ scene["post_processing"]["ssao"] = ssao
515
+
516
+ storage.save(scene)
517
+
518
+ if enabled:
519
+ message = f"Enabled SSAO (radius={radius}, intensity={intensity})"
520
+ else:
521
+ message = "Disabled SSAO"
522
+
523
+ return {
524
+ "scene_id": scene_id,
525
+ "message": message,
526
+ "ssao": ssao
527
+ }
528
+
529
+
530
+ def set_color_grading(
531
+ scene_id: str,
532
+ enabled: bool,
533
+ brightness: float = 0.0,
534
+ contrast: float = 1.0,
535
+ saturation: float = 1.0,
536
+ hue: float = 0.0,
537
+ exposure: float = 1.0,
538
+ gamma: float = 1.0
539
+ ) -> Dict[str, Any]:
540
+ """
541
+ Configure color grading post-processing.
542
+
543
+ Adjust overall image colors for cinematic looks or stylized effects.
544
+
545
+ Args:
546
+ scene_id: ID of the scene
547
+ enabled: Enable/disable color grading
548
+ brightness: Brightness adjustment (-1.0 to 1.0, default: 0.0)
549
+ contrast: Contrast multiplier (0.0-2.0, default: 1.0)
550
+ saturation: Color saturation (0.0=grayscale, 1.0=normal, 2.0=vivid)
551
+ hue: Hue shift in degrees (-180 to 180, default: 0)
552
+ exposure: Exposure adjustment (0.0-3.0, default: 1.0)
553
+ gamma: Gamma correction (0.5-2.5, default: 1.0)
554
+
555
+ Returns:
556
+ Dictionary with color grading settings and message
557
+ """
558
+ scene = storage.get(scene_id)
559
+ if not scene:
560
+ raise ValueError(f"Scene '{scene_id}' not found")
561
+
562
+ if "post_processing" not in scene:
563
+ scene["post_processing"] = {}
564
+
565
+ color_grading = {
566
+ "enabled": enabled,
567
+ "brightness": max(-1.0, min(1.0, brightness)),
568
+ "contrast": max(0.0, min(2.0, contrast)),
569
+ "saturation": max(0.0, min(2.0, saturation)),
570
+ "hue": max(-180, min(180, hue)),
571
+ "exposure": max(0.0, min(3.0, exposure)),
572
+ "gamma": max(0.5, min(2.5, gamma))
573
+ }
574
+ scene["post_processing"]["color_grading"] = color_grading
575
+
576
+ storage.save(scene)
577
+
578
+ if enabled:
579
+ adjustments = []
580
+ if brightness != 0.0:
581
+ adjustments.append(f"brightness={brightness}")
582
+ if contrast != 1.0:
583
+ adjustments.append(f"contrast={contrast}")
584
+ if saturation != 1.0:
585
+ adjustments.append(f"saturation={saturation}")
586
+ if hue != 0.0:
587
+ adjustments.append(f"hue={hue}°")
588
+ if exposure != 1.0:
589
+ adjustments.append(f"exposure={exposure}")
590
+ if gamma != 1.0:
591
+ adjustments.append(f"gamma={gamma}")
592
+
593
+ if adjustments:
594
+ message = f"Enabled color grading ({', '.join(adjustments)})"
595
+ else:
596
+ message = "Enabled color grading (default settings)"
597
+ else:
598
+ message = "Disabled color grading"
599
+
600
+ return {
601
+ "scene_id": scene_id,
602
+ "message": message,
603
+ "color_grading": color_grading
604
+ }
605
+
606
+
607
+ def set_vignette(
608
+ scene_id: str,
609
+ enabled: bool,
610
+ intensity: float = 0.5,
611
+ smoothness: float = 0.5
612
+ ) -> Dict[str, Any]:
613
+ """
614
+ Configure vignette effect (darkened edges).
615
+
616
+ Vignette darkens the corners and edges of the screen,
617
+ drawing focus to the center of the image.
618
+
619
+ Args:
620
+ scene_id: ID of the scene
621
+ enabled: Enable/disable vignette
622
+ intensity: Darkness of the vignette (0.0-1.0, default: 0.5)
623
+ smoothness: Softness of the vignette edge (0.0-1.0, default: 0.5)
624
+
625
+ Returns:
626
+ Dictionary with vignette settings and message
627
+ """
628
+ scene = storage.get(scene_id)
629
+ if not scene:
630
+ raise ValueError(f"Scene '{scene_id}' not found")
631
+
632
+ if "post_processing" not in scene:
633
+ scene["post_processing"] = {}
634
+
635
+ vignette = {
636
+ "enabled": enabled,
637
+ "intensity": max(0.0, min(1.0, intensity)),
638
+ "smoothness": max(0.0, min(1.0, smoothness))
639
+ }
640
+ scene["post_processing"]["vignette"] = vignette
641
+
642
+ storage.save(scene)
643
+
644
+ if enabled:
645
+ message = f"Enabled vignette (intensity={intensity}, smoothness={smoothness})"
646
+ else:
647
+ message = "Disabled vignette"
648
+
649
+ return {
650
+ "scene_id": scene_id,
651
+ "message": message,
652
+ "vignette": vignette
653
+ }
654
+
655
+
656
+ def get_post_processing(scene_id: str) -> Dict[str, Any]:
657
+ """
658
+ Get all post-processing settings for the scene.
659
+
660
+ Args:
661
+ scene_id: ID of the scene
662
+
663
+ Returns:
664
+ Dictionary with all post-processing settings
665
+ """
666
+ scene = storage.get(scene_id)
667
+ if not scene:
668
+ raise ValueError(f"Scene '{scene_id}' not found")
669
+
670
+ post_processing = scene.get("post_processing", {})
671
+
672
+ return {
673
+ "scene_id": scene_id,
674
+ "post_processing": post_processing
675
+ }
676
+
677
+
678
+ # =============================================================================
679
+ # Camera Effects Tools
680
+ # =============================================================================
681
+
682
+ def set_depth_of_field(
683
+ scene_id: str,
684
+ enabled: bool,
685
+ focus_distance: float = 10.0,
686
+ aperture: float = 0.025,
687
+ max_blur: float = 0.01
688
+ ) -> Dict[str, Any]:
689
+ """
690
+ Configure depth of field (DoF) camera effect.
691
+
692
+ Depth of field blurs objects that are not at the focus distance,
693
+ simulating how real camera lenses focus on a specific plane.
694
+
695
+ Args:
696
+ scene_id: ID of the scene
697
+ enabled: Enable/disable depth of field
698
+ focus_distance: Distance to the focal plane in units (default: 10.0)
699
+ aperture: Aperture size, affects blur amount (0.001-0.1, default: 0.025)
700
+ max_blur: Maximum blur strength (0.0-0.05, default: 0.01)
701
+
702
+ Returns:
703
+ Dictionary with DoF settings and message
704
+ """
705
+ scene = storage.get(scene_id)
706
+ if not scene:
707
+ raise ValueError(f"Scene '{scene_id}' not found")
708
+
709
+ if "camera_effects" not in scene:
710
+ scene["camera_effects"] = {}
711
+
712
+ dof = {
713
+ "enabled": enabled,
714
+ "focus_distance": max(0.1, focus_distance),
715
+ "aperture": max(0.001, min(0.1, aperture)),
716
+ "max_blur": max(0.0, min(0.05, max_blur))
717
+ }
718
+ scene["camera_effects"]["depth_of_field"] = dof
719
+
720
+ storage.save(scene)
721
+
722
+ if enabled:
723
+ message = f"Enabled depth of field (focus={focus_distance}m, aperture={aperture})"
724
+ else:
725
+ message = "Disabled depth of field"
726
+
727
+ return {
728
+ "scene_id": scene_id,
729
+ "message": message,
730
+ "depth_of_field": dof
731
+ }
732
+
733
+
734
+ def set_motion_blur(
735
+ scene_id: str,
736
+ enabled: bool,
737
+ intensity: float = 0.5,
738
+ samples: int = 8
739
+ ) -> Dict[str, Any]:
740
+ """
741
+ Configure motion blur camera effect.
742
+
743
+ Motion blur adds blur in the direction of camera or object movement,
744
+ creating a sense of speed and smooth motion.
745
+
746
+ Args:
747
+ scene_id: ID of the scene
748
+ enabled: Enable/disable motion blur
749
+ intensity: Blur intensity (0.0-2.0, default: 0.5)
750
+ samples: Quality samples for blur (4-32, default: 8)
751
+
752
+ Returns:
753
+ Dictionary with motion blur settings and message
754
+ """
755
+ scene = storage.get(scene_id)
756
+ if not scene:
757
+ raise ValueError(f"Scene '{scene_id}' not found")
758
+
759
+ if "camera_effects" not in scene:
760
+ scene["camera_effects"] = {}
761
+
762
+ motion_blur = {
763
+ "enabled": enabled,
764
+ "intensity": max(0.0, min(2.0, intensity)),
765
+ "samples": max(4, min(32, samples))
766
+ }
767
+ scene["camera_effects"]["motion_blur"] = motion_blur
768
+
769
+ storage.save(scene)
770
+
771
+ if enabled:
772
+ message = f"Enabled motion blur (intensity={intensity}, samples={samples})"
773
+ else:
774
+ message = "Disabled motion blur"
775
+
776
+ return {
777
+ "scene_id": scene_id,
778
+ "message": message,
779
+ "motion_blur": motion_blur
780
+ }
781
+
782
+
783
+ def set_chromatic_aberration(
784
+ scene_id: str,
785
+ enabled: bool,
786
+ intensity: float = 0.005
787
+ ) -> Dict[str, Any]:
788
+ """
789
+ Configure chromatic aberration effect.
790
+
791
+ Chromatic aberration simulates lens imperfection by separating
792
+ color channels at the edges of the screen.
793
+
794
+ Args:
795
+ scene_id: ID of the scene
796
+ enabled: Enable/disable chromatic aberration
797
+ intensity: Effect strength (0.0-0.05, default: 0.005)
798
+
799
+ Returns:
800
+ Dictionary with chromatic aberration settings and message
801
+ """
802
+ scene = storage.get(scene_id)
803
+ if not scene:
804
+ raise ValueError(f"Scene '{scene_id}' not found")
805
+
806
+ if "camera_effects" not in scene:
807
+ scene["camera_effects"] = {}
808
+
809
+ chromatic = {
810
+ "enabled": enabled,
811
+ "intensity": max(0.0, min(0.05, intensity))
812
+ }
813
+ scene["camera_effects"]["chromatic_aberration"] = chromatic
814
+
815
+ storage.save(scene)
816
+
817
+ if enabled:
818
+ message = f"Enabled chromatic aberration (intensity={intensity})"
819
+ else:
820
+ message = "Disabled chromatic aberration"
821
+
822
+ return {
823
+ "scene_id": scene_id,
824
+ "message": message,
825
+ "chromatic_aberration": chromatic
826
+ }
827
+
828
+
829
+ def get_camera_effects(scene_id: str) -> Dict[str, Any]:
830
+ """
831
+ Get all camera effects settings for the scene.
832
+
833
+ Args:
834
+ scene_id: ID of the scene
835
+
836
+ Returns:
837
+ Dictionary with all camera effects settings
838
+ """
839
+ scene = storage.get(scene_id)
840
+ if not scene:
841
+ raise ValueError(f"Scene '{scene_id}' not found")
842
+
843
+ camera_effects = scene.get("camera_effects", {})
844
+
845
+ return {
846
+ "scene_id": scene_id,
847
+ "camera_effects": camera_effects
848
+ }
backend/tools/scene_tools.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Scene Tools
3
+ Create, modify, and query 3D scenes
4
+ """
5
+ from typing import Optional, Dict, Any
6
+ from backend.game_models import (
7
+ create_scene,
8
+ create_game_object,
9
+ create_light,
10
+ create_environment,
11
+ create_vector3,
12
+ create_material,
13
+ )
14
+ from backend.storage import storage
15
+
16
+
17
+ def generate_viewer_url(scene_id: str, base_url: str = "http://localhost:8000") -> str:
18
+ """Generate viewer URL for a scene"""
19
+ return f"{base_url}/view/scene/{scene_id}"
20
+
21
+
22
+ def create_game_scene(
23
+ name: str = "New Scene",
24
+ description: Optional[str] = None,
25
+ world_width: float = 100.0,
26
+ world_height: float = 100.0,
27
+ world_depth: float = 100.0,
28
+ lighting_preset: str = "day",
29
+ base_url: str = "http://localhost:8000"
30
+ ) -> Dict[str, Any]:
31
+ """
32
+ Create a new 3D scene
33
+
34
+ Args:
35
+ name: Scene name
36
+ description: Scene description
37
+ world_width: Width of the world
38
+ world_height: Height of the world
39
+ world_depth: Depth of the world
40
+ lighting_preset: Lighting preset (day, night, sunset, studio)
41
+ base_url: Base URL for the deployed space
42
+
43
+ Returns:
44
+ Dict with scene_id, viewer_url, and message
45
+ """
46
+ # Create default lights based on preset
47
+ lights = []
48
+ if lighting_preset == "day":
49
+ lights = [
50
+ create_light(
51
+ name="Sun",
52
+ light_type="directional",
53
+ color="#ffffff",
54
+ intensity=1.0,
55
+ position=create_vector3(50, 50, 50),
56
+ ),
57
+ create_light(
58
+ name="Ambient",
59
+ light_type="ambient",
60
+ color="#ffffff",
61
+ intensity=0.5,
62
+ ),
63
+ ]
64
+ elif lighting_preset == "night":
65
+ lights = [
66
+ create_light(
67
+ name="Moon",
68
+ light_type="directional",
69
+ color="#6699cc",
70
+ intensity=0.3,
71
+ position=create_vector3(-50, 50, -50),
72
+ ),
73
+ create_light(
74
+ name="Ambient",
75
+ light_type="ambient",
76
+ color="#1a1a3a",
77
+ intensity=0.2,
78
+ ),
79
+ ]
80
+
81
+ # Create environment
82
+ env = create_environment(lighting_preset=lighting_preset)
83
+ if lighting_preset == "night":
84
+ env["background_color"] = "#0a0a1a"
85
+ elif lighting_preset == "sunset":
86
+ env["background_color"] = "#ff6b35"
87
+
88
+ # Create scene
89
+ scene = create_scene(
90
+ name=name,
91
+ description=description,
92
+ world_width=world_width,
93
+ world_height=world_height,
94
+ world_depth=world_depth,
95
+ lights=lights,
96
+ environment=env,
97
+ )
98
+
99
+ # Save to storage
100
+ storage.save(scene)
101
+
102
+ # Generate viewer URL
103
+ viewer_url = generate_viewer_url(scene["scene_id"], base_url)
104
+
105
+ return {
106
+ "scene_id": scene["scene_id"],
107
+ "viewer_url": viewer_url,
108
+ "message": f"Created scene '{scene['name']}' with 10x10 world (white ground plane and boundary walls)",
109
+ }
110
+
111
+
112
+ def add_game_object(
113
+ scene_id: str,
114
+ object_type: str = "cube",
115
+ name: Optional[str] = None,
116
+ position: Optional[Dict[str, float]] = None,
117
+ rotation: Optional[Dict[str, float]] = None,
118
+ scale: Optional[Dict[str, float]] = None,
119
+ material: Optional[Dict[str, Any]] = None,
120
+ model_path: Optional[str] = None,
121
+ base_url: str = "http://localhost:8000"
122
+ ) -> Dict[str, Any]:
123
+ """
124
+ Add an object to the scene
125
+
126
+ Args:
127
+ scene_id: ID of the scene
128
+ object_type: Type of object (cube, sphere, cylinder, etc.)
129
+ name: Object name
130
+ position: Position vector {x, y, z}
131
+ rotation: Rotation vector {x, y, z}
132
+ scale: Scale vector {x, y, z}
133
+ material: Material properties dict
134
+ model_path: Path to 3D model file
135
+ base_url: Base URL for the deployed space
136
+
137
+ Returns:
138
+ Dict with object_id, scene_id, viewer_url, and message
139
+ """
140
+ # Get existing scene
141
+ scene = storage.get(scene_id)
142
+ if not scene:
143
+ raise ValueError(f"Scene with ID '{scene_id}' not found")
144
+
145
+ # Validate position is within 10x10 world bounds (-5 to 5 in X and Z)
146
+ if position:
147
+ x = position.get('x', 0)
148
+ z = position.get('z', 0)
149
+ WORLD_HALF = 5.0
150
+ if abs(x) > WORLD_HALF or abs(z) > WORLD_HALF:
151
+ raise ValueError(
152
+ f"Object position ({x}, {z}) is outside the 10x10 world bounds. "
153
+ f"X and Z must be between -{WORLD_HALF} and {WORLD_HALF}."
154
+ )
155
+
156
+ # Create game object
157
+ obj = create_game_object(
158
+ object_type=object_type,
159
+ name=name or f"{object_type}_{len(scene['objects'])}",
160
+ position=position,
161
+ rotation=rotation,
162
+ scale=scale,
163
+ material=material,
164
+ model_path=model_path,
165
+ )
166
+
167
+ # Add to scene
168
+ scene["objects"].append(obj)
169
+
170
+ # Save updated scene
171
+ storage.save(scene)
172
+
173
+ # Generate viewer URL
174
+ viewer_url = generate_viewer_url(scene["scene_id"], base_url)
175
+
176
+ pos = obj["position"]
177
+ return {
178
+ "object_id": obj["id"],
179
+ "scene_id": scene["scene_id"],
180
+ "viewer_url": viewer_url,
181
+ "message": f"Added {obj['name']} ({object_type}) at position ({pos['x']}, {pos['y']}, {pos['z']})",
182
+ }
183
+
184
+
185
+ def remove_game_object(
186
+ scene_id: str,
187
+ object_id: str,
188
+ base_url: str = "http://localhost:8000"
189
+ ) -> Dict[str, Any]:
190
+ """
191
+ Remove an object from the scene
192
+
193
+ Args:
194
+ scene_id: ID of the scene
195
+ object_id: ID of the object to remove
196
+ base_url: Base URL for the deployed space
197
+
198
+ Returns:
199
+ Dict with scene_id, viewer_url, and message
200
+ """
201
+ # Get existing scene
202
+ scene = storage.get(scene_id)
203
+ if not scene:
204
+ raise ValueError(f"Scene with ID '{scene_id}' not found")
205
+
206
+ # Find and remove object
207
+ original_count = len(scene["objects"])
208
+ scene["objects"] = [obj for obj in scene["objects"] if obj["id"] != object_id]
209
+
210
+ if len(scene["objects"]) == original_count:
211
+ raise ValueError(f"Object with ID '{object_id}' not found in scene")
212
+
213
+ # Save updated scene
214
+ storage.save(scene)
215
+
216
+ # Generate viewer URL
217
+ viewer_url = generate_viewer_url(scene["scene_id"], base_url)
218
+
219
+ return {
220
+ "scene_id": scene["scene_id"],
221
+ "viewer_url": viewer_url,
222
+ "message": f"Removed object {object_id}",
223
+ }
224
+
225
+
226
+ def set_scene_lighting(
227
+ scene_id: str,
228
+ preset: str = "day",
229
+ base_url: str = "http://localhost:8000"
230
+ ) -> Dict[str, Any]:
231
+ """
232
+ Set lighting preset for the scene
233
+
234
+ Args:
235
+ scene_id: ID of the scene
236
+ preset: Lighting preset (day, night, sunset, studio)
237
+ base_url: Base URL for the deployed space
238
+
239
+ Returns:
240
+ Dict with scene_id, viewer_url, and message
241
+ """
242
+ # Get existing scene
243
+ scene = storage.get(scene_id)
244
+ if not scene:
245
+ raise ValueError(f"Scene with ID '{scene_id}' not found")
246
+
247
+ # Update lighting preset
248
+ scene["environment"]["lighting_preset"] = preset
249
+
250
+ # Update lights based on preset
251
+ if preset == "day":
252
+ scene["lights"] = [
253
+ create_light(
254
+ name="Sun",
255
+ light_type="directional",
256
+ color="#ffffff",
257
+ intensity=1.0,
258
+ position=create_vector3(50, 50, 50),
259
+ ),
260
+ create_light(
261
+ name="Ambient",
262
+ light_type="ambient",
263
+ color="#ffffff",
264
+ intensity=0.5,
265
+ ),
266
+ ]
267
+ scene["environment"]["background_color"] = "#87CEEB"
268
+ elif preset == "night":
269
+ scene["lights"] = [
270
+ create_light(
271
+ name="Moon",
272
+ light_type="directional",
273
+ color="#6699cc",
274
+ intensity=0.3,
275
+ position=create_vector3(-50, 50, -50),
276
+ ),
277
+ create_light(
278
+ name="Ambient",
279
+ light_type="ambient",
280
+ color="#1a1a3a",
281
+ intensity=0.2,
282
+ ),
283
+ ]
284
+ scene["environment"]["background_color"] = "#0a0a1a"
285
+ elif preset == "sunset":
286
+ scene["lights"] = [
287
+ create_light(
288
+ name="Sun",
289
+ light_type="directional",
290
+ color="#ff6b35",
291
+ intensity=0.8,
292
+ position=create_vector3(100, 20, 50),
293
+ ),
294
+ create_light(
295
+ name="Ambient",
296
+ light_type="ambient",
297
+ color="#ff9966",
298
+ intensity=0.4,
299
+ ),
300
+ ]
301
+ scene["environment"]["background_color"] = "#ff6b35"
302
+
303
+ # Save updated scene
304
+ storage.save(scene)
305
+
306
+ # Generate viewer URL
307
+ viewer_url = generate_viewer_url(scene["scene_id"], base_url)
308
+
309
+ return {
310
+ "scene_id": scene["scene_id"],
311
+ "viewer_url": viewer_url,
312
+ "message": f"Set lighting to {preset}",
313
+ }
314
+
315
+
316
+ def get_scene_info(
317
+ scene_id: str,
318
+ base_url: str = "http://localhost:8000"
319
+ ) -> Dict[str, Any]:
320
+ """
321
+ Get information about a scene
322
+
323
+ Args:
324
+ scene_id: ID of the scene to retrieve
325
+ base_url: Base URL for the deployed space
326
+
327
+ Returns:
328
+ Dict with scene details including objects and lights
329
+ """
330
+ # Get scene
331
+ scene = storage.get(scene_id)
332
+ if not scene:
333
+ raise ValueError(f"Scene with ID '{scene_id}' not found")
334
+
335
+ # Generate viewer URL
336
+ viewer_url = generate_viewer_url(scene["scene_id"], base_url)
337
+
338
+ # Format object list
339
+ objects_info = [
340
+ {
341
+ "id": obj["id"],
342
+ "name": obj["name"],
343
+ "type": obj["type"],
344
+ "position": obj["position"],
345
+ "color": obj["material"]["color"],
346
+ }
347
+ for obj in scene["objects"]
348
+ ]
349
+
350
+ return {
351
+ "scene_id": scene["scene_id"],
352
+ "name": scene["name"],
353
+ "viewer_url": viewer_url,
354
+ "object_count": len(scene["objects"]),
355
+ "light_count": len(scene["lights"]),
356
+ "world_bounds": {
357
+ "width": scene["world_width"],
358
+ "height": scene["world_height"],
359
+ "depth": scene["world_depth"],
360
+ },
361
+ "objects": objects_info,
362
+ }
chat_client.py ADDED
@@ -0,0 +1,918 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GPT-powered Chat Client for GCP (Game Context Protocol)
3
+
4
+ This module provides an intelligent chat interface that uses OpenAI's GPT
5
+ with function calling to interact with the GCP tools.
6
+ """
7
+ import os
8
+ import json
9
+ from typing import Optional, Dict, Any, List
10
+
11
+ # Load .env file if present
12
+ from dotenv import load_dotenv
13
+ load_dotenv()
14
+
15
+ from openai import OpenAI
16
+
17
+ # Import GCP tools
18
+ from backend.tools.scene_tools import (
19
+ create_game_scene,
20
+ add_game_object,
21
+ remove_game_object,
22
+ set_scene_lighting,
23
+ get_scene_info,
24
+ )
25
+ from backend.tools.player_tools import (
26
+ set_player_speed,
27
+ set_jump_force,
28
+ set_mouse_sensitivity,
29
+ set_gravity,
30
+ set_player_dimensions,
31
+ set_movement_acceleration,
32
+ set_air_control,
33
+ set_camera_fov,
34
+ set_vertical_look_limits,
35
+ get_player_config,
36
+ )
37
+ from backend.tools.rendering_tools import (
38
+ add_light,
39
+ remove_light,
40
+ update_light,
41
+ get_lights,
42
+ update_object_material,
43
+ set_background_color,
44
+ set_fog,
45
+ # Post-processing
46
+ set_bloom,
47
+ set_ssao,
48
+ set_color_grading,
49
+ set_vignette,
50
+ get_post_processing,
51
+ # Camera effects
52
+ set_depth_of_field,
53
+ set_motion_blur,
54
+ set_chromatic_aberration,
55
+ get_camera_effects,
56
+ )
57
+ from backend.game_models import create_vector3, create_material
58
+
59
+
60
+ # Tool definitions for OpenAI function calling
61
+ TOOLS = [
62
+ # Scene Tools
63
+ {
64
+ "type": "function",
65
+ "function": {
66
+ "name": "create_scene",
67
+ "description": "Create a new 3D scene/level",
68
+ "parameters": {
69
+ "type": "object",
70
+ "properties": {
71
+ "name": {"type": "string", "description": "Name of the scene"},
72
+ "description": {"type": "string", "description": "Description of the scene"},
73
+ "world_width": {"type": "number", "description": "Width of the world in units (default: 100)"},
74
+ "world_height": {"type": "number", "description": "Height of the world in units (default: 100)"},
75
+ "world_depth": {"type": "number", "description": "Depth of the world in units (default: 100)"},
76
+ "lighting_preset": {"type": "string", "enum": ["day", "night", "sunset", "studio"], "description": "Lighting preset"},
77
+ },
78
+ "required": []
79
+ }
80
+ }
81
+ },
82
+ {
83
+ "type": "function",
84
+ "function": {
85
+ "name": "add_object",
86
+ "description": "Add a 3D object to the scene",
87
+ "parameters": {
88
+ "type": "object",
89
+ "properties": {
90
+ "scene_id": {"type": "string", "description": "ID of the scene"},
91
+ "object_type": {"type": "string", "enum": ["cube", "sphere", "cylinder", "plane", "cone", "torus"], "description": "Type of object"},
92
+ "name": {"type": "string", "description": "Name for the object"},
93
+ "x": {"type": "number", "description": "X position"},
94
+ "y": {"type": "number", "description": "Y position"},
95
+ "z": {"type": "number", "description": "Z position"},
96
+ "scale_x": {"type": "number", "description": "X scale (default: 1)"},
97
+ "scale_y": {"type": "number", "description": "Y scale (default: 1)"},
98
+ "scale_z": {"type": "number", "description": "Z scale (default: 1)"},
99
+ "color": {"type": "string", "description": "Hex color code (e.g., #ff0000 for red)"},
100
+ },
101
+ "required": ["scene_id"]
102
+ }
103
+ }
104
+ },
105
+ {
106
+ "type": "function",
107
+ "function": {
108
+ "name": "remove_object",
109
+ "description": "Remove an object from the scene",
110
+ "parameters": {
111
+ "type": "object",
112
+ "properties": {
113
+ "scene_id": {"type": "string", "description": "ID of the scene"},
114
+ "object_id": {"type": "string", "description": "ID of the object to remove"},
115
+ },
116
+ "required": ["scene_id", "object_id"]
117
+ }
118
+ }
119
+ },
120
+ {
121
+ "type": "function",
122
+ "function": {
123
+ "name": "set_lighting",
124
+ "description": "Set the lighting preset for the scene",
125
+ "parameters": {
126
+ "type": "object",
127
+ "properties": {
128
+ "scene_id": {"type": "string", "description": "ID of the scene"},
129
+ "preset": {"type": "string", "enum": ["day", "night", "sunset", "studio"], "description": "Lighting preset"},
130
+ },
131
+ "required": ["scene_id"]
132
+ }
133
+ }
134
+ },
135
+ {
136
+ "type": "function",
137
+ "function": {
138
+ "name": "get_scene_info",
139
+ "description": "Get detailed information about a scene including all objects and settings",
140
+ "parameters": {
141
+ "type": "object",
142
+ "properties": {
143
+ "scene_id": {"type": "string", "description": "ID of the scene"},
144
+ },
145
+ "required": ["scene_id"]
146
+ }
147
+ }
148
+ },
149
+ # Player Tools
150
+ {
151
+ "type": "function",
152
+ "function": {
153
+ "name": "set_player_speed",
154
+ "description": "Set the player's movement speed in units per second",
155
+ "parameters": {
156
+ "type": "object",
157
+ "properties": {
158
+ "scene_id": {"type": "string", "description": "ID of the scene"},
159
+ "walk_speed": {"type": "number", "description": "Movement speed in units/second"},
160
+ },
161
+ "required": ["scene_id", "walk_speed"]
162
+ }
163
+ }
164
+ },
165
+ {
166
+ "type": "function",
167
+ "function": {
168
+ "name": "set_jump_force",
169
+ "description": "Set the player's jump force (initial upward velocity)",
170
+ "parameters": {
171
+ "type": "object",
172
+ "properties": {
173
+ "scene_id": {"type": "string", "description": "ID of the scene"},
174
+ "jump_force": {"type": "number", "description": "Jump force in m/s"},
175
+ },
176
+ "required": ["scene_id", "jump_force"]
177
+ }
178
+ }
179
+ },
180
+ {
181
+ "type": "function",
182
+ "function": {
183
+ "name": "set_mouse_sensitivity",
184
+ "description": "Set mouse look sensitivity and Y-axis inversion",
185
+ "parameters": {
186
+ "type": "object",
187
+ "properties": {
188
+ "scene_id": {"type": "string", "description": "ID of the scene"},
189
+ "sensitivity": {"type": "number", "description": "Mouse sensitivity multiplier"},
190
+ "invert_y": {"type": "boolean", "description": "Invert Y-axis"},
191
+ },
192
+ "required": ["scene_id"]
193
+ }
194
+ }
195
+ },
196
+ {
197
+ "type": "function",
198
+ "function": {
199
+ "name": "set_gravity",
200
+ "description": "Set the world's gravity strength",
201
+ "parameters": {
202
+ "type": "object",
203
+ "properties": {
204
+ "scene_id": {"type": "string", "description": "ID of the scene"},
205
+ "gravity": {"type": "number", "description": "Gravity in m/s² (negative = downward, e.g., -9.82 for Earth)"},
206
+ },
207
+ "required": ["scene_id", "gravity"]
208
+ }
209
+ }
210
+ },
211
+ {
212
+ "type": "function",
213
+ "function": {
214
+ "name": "set_player_dimensions",
215
+ "description": "Set player collision capsule dimensions",
216
+ "parameters": {
217
+ "type": "object",
218
+ "properties": {
219
+ "scene_id": {"type": "string", "description": "ID of the scene"},
220
+ "height": {"type": "number", "description": "Player height in meters"},
221
+ "radius": {"type": "number", "description": "Player radius in meters"},
222
+ "eye_height": {"type": "number", "description": "Camera height from feet"},
223
+ },
224
+ "required": ["scene_id"]
225
+ }
226
+ }
227
+ },
228
+ {
229
+ "type": "function",
230
+ "function": {
231
+ "name": "set_camera_fov",
232
+ "description": "Set camera field of view",
233
+ "parameters": {
234
+ "type": "object",
235
+ "properties": {
236
+ "scene_id": {"type": "string", "description": "ID of the scene"},
237
+ "fov": {"type": "number", "description": "Field of view in degrees (60-120)"},
238
+ },
239
+ "required": ["scene_id", "fov"]
240
+ }
241
+ }
242
+ },
243
+ {
244
+ "type": "function",
245
+ "function": {
246
+ "name": "get_player_config",
247
+ "description": "Get current player configuration including speed, jump force, gravity, dimensions, etc.",
248
+ "parameters": {
249
+ "type": "object",
250
+ "properties": {
251
+ "scene_id": {"type": "string", "description": "ID of the scene"},
252
+ },
253
+ "required": ["scene_id"]
254
+ }
255
+ }
256
+ },
257
+ # Rendering Tools
258
+ {
259
+ "type": "function",
260
+ "function": {
261
+ "name": "add_light",
262
+ "description": "Add a light source to the scene",
263
+ "parameters": {
264
+ "type": "object",
265
+ "properties": {
266
+ "scene_id": {"type": "string", "description": "ID of the scene"},
267
+ "light_type": {"type": "string", "enum": ["ambient", "directional", "point", "spot"], "description": "Type of light"},
268
+ "name": {"type": "string", "description": "Unique name for the light"},
269
+ "color": {"type": "string", "description": "Hex color code"},
270
+ "intensity": {"type": "number", "description": "Light intensity (0-2)"},
271
+ "x": {"type": "number", "description": "X position"},
272
+ "y": {"type": "number", "description": "Y position"},
273
+ "z": {"type": "number", "description": "Z position"},
274
+ "cast_shadow": {"type": "boolean", "description": "Enable shadows"},
275
+ },
276
+ "required": ["scene_id", "light_type", "name"]
277
+ }
278
+ }
279
+ },
280
+ {
281
+ "type": "function",
282
+ "function": {
283
+ "name": "remove_light",
284
+ "description": "Remove a light from the scene",
285
+ "parameters": {
286
+ "type": "object",
287
+ "properties": {
288
+ "scene_id": {"type": "string", "description": "ID of the scene"},
289
+ "light_name": {"type": "string", "description": "Name of the light to remove"},
290
+ },
291
+ "required": ["scene_id", "light_name"]
292
+ }
293
+ }
294
+ },
295
+ {
296
+ "type": "function",
297
+ "function": {
298
+ "name": "get_lights",
299
+ "description": "Get all lights in the scene",
300
+ "parameters": {
301
+ "type": "object",
302
+ "properties": {
303
+ "scene_id": {"type": "string", "description": "ID of the scene"},
304
+ },
305
+ "required": ["scene_id"]
306
+ }
307
+ }
308
+ },
309
+ {
310
+ "type": "function",
311
+ "function": {
312
+ "name": "update_object_material",
313
+ "description": "Update an object's material properties (color, metalness, roughness, opacity, emissive glow)",
314
+ "parameters": {
315
+ "type": "object",
316
+ "properties": {
317
+ "scene_id": {"type": "string", "description": "ID of the scene"},
318
+ "object_id": {"type": "string", "description": "ID of the object"},
319
+ "color": {"type": "string", "description": "Hex color code"},
320
+ "metalness": {"type": "number", "description": "Metalness (0=matte, 1=metal)"},
321
+ "roughness": {"type": "number", "description": "Roughness (0=shiny, 1=rough)"},
322
+ "opacity": {"type": "number", "description": "Opacity (0=invisible, 1=solid)"},
323
+ "emissive": {"type": "string", "description": "Emissive color for glow"},
324
+ "emissive_intensity": {"type": "number", "description": "Emissive intensity (0-1)"},
325
+ },
326
+ "required": ["scene_id", "object_id"]
327
+ }
328
+ }
329
+ },
330
+ {
331
+ "type": "function",
332
+ "function": {
333
+ "name": "set_background_color",
334
+ "description": "Set scene background color or gradient",
335
+ "parameters": {
336
+ "type": "object",
337
+ "properties": {
338
+ "scene_id": {"type": "string", "description": "ID of the scene"},
339
+ "color": {"type": "string", "description": "Hex color for solid background"},
340
+ "bg_type": {"type": "string", "enum": ["solid", "gradient"], "description": "Background type"},
341
+ "gradient_top": {"type": "string", "description": "Top color for gradient"},
342
+ "gradient_bottom": {"type": "string", "description": "Bottom color for gradient"},
343
+ },
344
+ "required": ["scene_id"]
345
+ }
346
+ }
347
+ },
348
+ {
349
+ "type": "function",
350
+ "function": {
351
+ "name": "set_fog",
352
+ "description": "Add or remove atmospheric fog",
353
+ "parameters": {
354
+ "type": "object",
355
+ "properties": {
356
+ "scene_id": {"type": "string", "description": "ID of the scene"},
357
+ "enabled": {"type": "boolean", "description": "Enable or disable fog"},
358
+ "color": {"type": "string", "description": "Fog color"},
359
+ "near": {"type": "number", "description": "Fog start distance"},
360
+ "far": {"type": "number", "description": "Fog end distance"},
361
+ "density": {"type": "number", "description": "Fog density (for exponential fog)"},
362
+ },
363
+ "required": ["scene_id", "enabled"]
364
+ }
365
+ }
366
+ },
367
+ # Post-processing Tools
368
+ {
369
+ "type": "function",
370
+ "function": {
371
+ "name": "set_bloom",
372
+ "description": "Configure bloom (glow) post-processing effect. Creates glow around bright areas.",
373
+ "parameters": {
374
+ "type": "object",
375
+ "properties": {
376
+ "scene_id": {"type": "string", "description": "ID of the scene"},
377
+ "enabled": {"type": "boolean", "description": "Enable or disable bloom"},
378
+ "strength": {"type": "number", "description": "Bloom intensity (0-3, default: 1)"},
379
+ "radius": {"type": "number", "description": "Bloom spread radius (0-1, default: 0.4)"},
380
+ "threshold": {"type": "number", "description": "Brightness threshold (0-1, default: 0.8)"},
381
+ },
382
+ "required": ["scene_id", "enabled"]
383
+ }
384
+ }
385
+ },
386
+ {
387
+ "type": "function",
388
+ "function": {
389
+ "name": "set_ssao",
390
+ "description": "Configure Screen Space Ambient Occlusion (SSAO). Adds soft shadows in corners for depth.",
391
+ "parameters": {
392
+ "type": "object",
393
+ "properties": {
394
+ "scene_id": {"type": "string", "description": "ID of the scene"},
395
+ "enabled": {"type": "boolean", "description": "Enable or disable SSAO"},
396
+ "radius": {"type": "number", "description": "Sample radius (0.1-2, default: 0.5)"},
397
+ "intensity": {"type": "number", "description": "Shadow intensity (0-2, default: 1)"},
398
+ "bias": {"type": "number", "description": "Depth bias (0.001-0.1, default: 0.025)"},
399
+ },
400
+ "required": ["scene_id", "enabled"]
401
+ }
402
+ }
403
+ },
404
+ {
405
+ "type": "function",
406
+ "function": {
407
+ "name": "set_color_grading",
408
+ "description": "Configure color grading for cinematic looks. Adjust brightness, contrast, saturation, etc.",
409
+ "parameters": {
410
+ "type": "object",
411
+ "properties": {
412
+ "scene_id": {"type": "string", "description": "ID of the scene"},
413
+ "enabled": {"type": "boolean", "description": "Enable or disable color grading"},
414
+ "brightness": {"type": "number", "description": "Brightness (-1 to 1, default: 0)"},
415
+ "contrast": {"type": "number", "description": "Contrast (0-2, default: 1)"},
416
+ "saturation": {"type": "number", "description": "Color saturation (0-2, default: 1)"},
417
+ "hue": {"type": "number", "description": "Hue shift in degrees (-180 to 180, default: 0)"},
418
+ "exposure": {"type": "number", "description": "Exposure (0-3, default: 1)"},
419
+ "gamma": {"type": "number", "description": "Gamma correction (0.5-2.5, default: 1)"},
420
+ },
421
+ "required": ["scene_id", "enabled"]
422
+ }
423
+ }
424
+ },
425
+ {
426
+ "type": "function",
427
+ "function": {
428
+ "name": "set_vignette",
429
+ "description": "Configure vignette effect (darkened edges) to focus attention on center.",
430
+ "parameters": {
431
+ "type": "object",
432
+ "properties": {
433
+ "scene_id": {"type": "string", "description": "ID of the scene"},
434
+ "enabled": {"type": "boolean", "description": "Enable or disable vignette"},
435
+ "intensity": {"type": "number", "description": "Darkness (0-1, default: 0.5)"},
436
+ "smoothness": {"type": "number", "description": "Edge softness (0-1, default: 0.5)"},
437
+ },
438
+ "required": ["scene_id", "enabled"]
439
+ }
440
+ }
441
+ },
442
+ {
443
+ "type": "function",
444
+ "function": {
445
+ "name": "get_post_processing",
446
+ "description": "Get all post-processing settings (bloom, SSAO, color grading, vignette)",
447
+ "parameters": {
448
+ "type": "object",
449
+ "properties": {
450
+ "scene_id": {"type": "string", "description": "ID of the scene"},
451
+ },
452
+ "required": ["scene_id"]
453
+ }
454
+ }
455
+ },
456
+ # Camera Effects Tools
457
+ {
458
+ "type": "function",
459
+ "function": {
460
+ "name": "set_depth_of_field",
461
+ "description": "Configure depth of field (DoF). Blurs objects not at the focus distance.",
462
+ "parameters": {
463
+ "type": "object",
464
+ "properties": {
465
+ "scene_id": {"type": "string", "description": "ID of the scene"},
466
+ "enabled": {"type": "boolean", "description": "Enable or disable DoF"},
467
+ "focus_distance": {"type": "number", "description": "Distance to focal plane (default: 10)"},
468
+ "aperture": {"type": "number", "description": "Aperture size (0.001-0.1, default: 0.025)"},
469
+ "max_blur": {"type": "number", "description": "Maximum blur (0-0.05, default: 0.01)"},
470
+ },
471
+ "required": ["scene_id", "enabled"]
472
+ }
473
+ }
474
+ },
475
+ {
476
+ "type": "function",
477
+ "function": {
478
+ "name": "set_motion_blur",
479
+ "description": "Configure motion blur effect for sense of speed and smooth motion.",
480
+ "parameters": {
481
+ "type": "object",
482
+ "properties": {
483
+ "scene_id": {"type": "string", "description": "ID of the scene"},
484
+ "enabled": {"type": "boolean", "description": "Enable or disable motion blur"},
485
+ "intensity": {"type": "number", "description": "Blur intensity (0-2, default: 0.5)"},
486
+ "samples": {"type": "integer", "description": "Quality samples (4-32, default: 8)"},
487
+ },
488
+ "required": ["scene_id", "enabled"]
489
+ }
490
+ }
491
+ },
492
+ {
493
+ "type": "function",
494
+ "function": {
495
+ "name": "set_chromatic_aberration",
496
+ "description": "Configure chromatic aberration. Simulates lens color separation at edges.",
497
+ "parameters": {
498
+ "type": "object",
499
+ "properties": {
500
+ "scene_id": {"type": "string", "description": "ID of the scene"},
501
+ "enabled": {"type": "boolean", "description": "Enable or disable effect"},
502
+ "intensity": {"type": "number", "description": "Effect strength (0-0.05, default: 0.005)"},
503
+ },
504
+ "required": ["scene_id", "enabled"]
505
+ }
506
+ }
507
+ },
508
+ {
509
+ "type": "function",
510
+ "function": {
511
+ "name": "get_camera_effects",
512
+ "description": "Get all camera effects settings (depth of field, motion blur, chromatic aberration)",
513
+ "parameters": {
514
+ "type": "object",
515
+ "properties": {
516
+ "scene_id": {"type": "string", "description": "ID of the scene"},
517
+ },
518
+ "required": ["scene_id"]
519
+ }
520
+ }
521
+ },
522
+ ]
523
+
524
+
525
+ class GCPChatClient:
526
+ """GPT-powered chat client for GCP"""
527
+
528
+ def __init__(self, scene_id: str, base_url: str = "http://localhost:8000"):
529
+ self.client = OpenAI() # Uses OPENAI_API_KEY env var
530
+ self.scene_id = scene_id
531
+ self.base_url = base_url
532
+ self.conversation_history: List[Dict[str, Any]] = []
533
+
534
+ # System prompt
535
+ self.system_prompt = f"""You are a helpful assistant for GCP (Game Context Protocol), a 3D scene building system.
536
+
537
+ You help users create and modify 3D scenes using natural language. You have access to tools for:
538
+ - Creating scenes and adding/removing objects (cubes, spheres, cylinders, etc.)
539
+ - Configuring player movement (speed, jump, gravity, dimensions)
540
+ - Managing lights (ambient, directional, point, spot)
541
+ - Updating materials (color, metalness, roughness, opacity, glow)
542
+ - Setting backgrounds and fog effects
543
+ - Post-processing effects (bloom, SSAO, color grading, vignette)
544
+ - Camera effects (depth of field, motion blur, chromatic aberration)
545
+
546
+ The current scene ID is: {scene_id}
547
+
548
+ When users ask to modify something relatively (like "half the speed" or "make it twice as big"),
549
+ ALWAYS first get the current state using the appropriate get_* function, then calculate the new value,
550
+ then apply it.
551
+
552
+ Be concise but helpful. After making changes, briefly confirm what was done."""
553
+
554
+ def execute_tool(self, name: str, args: Dict[str, Any]) -> Any:
555
+ """Execute a GCP tool and return the result"""
556
+
557
+ # Inject scene_id if not provided
558
+ if "scene_id" not in args:
559
+ args["scene_id"] = self.scene_id
560
+
561
+ # Scene tools
562
+ if name == "create_scene":
563
+ return create_game_scene(
564
+ name=args.get("name", "New Scene"),
565
+ description=args.get("description"),
566
+ world_width=args.get("world_width", 100.0),
567
+ world_height=args.get("world_height", 100.0),
568
+ world_depth=args.get("world_depth", 100.0),
569
+ lighting_preset=args.get("lighting_preset", "day"),
570
+ base_url=self.base_url,
571
+ )
572
+
573
+ elif name == "add_object":
574
+ position = create_vector3(
575
+ args.get("x", 0),
576
+ args.get("y", 0),
577
+ args.get("z", 0)
578
+ )
579
+ scale = create_vector3(
580
+ args.get("scale_x", 1),
581
+ args.get("scale_y", 1),
582
+ args.get("scale_z", 1)
583
+ )
584
+ material = create_material(color=args.get("color", "#ffffff"))
585
+ return add_game_object(
586
+ scene_id=args["scene_id"],
587
+ object_type=args.get("object_type", "cube"),
588
+ name=args.get("name"),
589
+ position=position,
590
+ scale=scale,
591
+ material=material,
592
+ base_url=self.base_url,
593
+ )
594
+
595
+ elif name == "remove_object":
596
+ return remove_game_object(
597
+ scene_id=args["scene_id"],
598
+ object_id=args["object_id"],
599
+ base_url=self.base_url,
600
+ )
601
+
602
+ elif name == "set_lighting":
603
+ return set_scene_lighting(
604
+ scene_id=args["scene_id"],
605
+ preset=args.get("preset", "day"),
606
+ base_url=self.base_url,
607
+ )
608
+
609
+ elif name == "get_scene_info":
610
+ return get_scene_info(args["scene_id"], self.base_url)
611
+
612
+ # Player tools
613
+ elif name == "set_player_speed":
614
+ return set_player_speed(args["scene_id"], args["walk_speed"])
615
+
616
+ elif name == "set_jump_force":
617
+ return set_jump_force(args["scene_id"], args["jump_force"])
618
+
619
+ elif name == "set_mouse_sensitivity":
620
+ return set_mouse_sensitivity(
621
+ args["scene_id"],
622
+ args.get("sensitivity", 0.002),
623
+ args.get("invert_y", False)
624
+ )
625
+
626
+ elif name == "set_gravity":
627
+ return set_gravity(args["scene_id"], args["gravity"])
628
+
629
+ elif name == "set_player_dimensions":
630
+ return set_player_dimensions(
631
+ args["scene_id"],
632
+ args.get("height", 1.7),
633
+ args.get("radius", 0.3),
634
+ args.get("eye_height")
635
+ )
636
+
637
+ elif name == "set_camera_fov":
638
+ return set_camera_fov(args["scene_id"], args["fov"])
639
+
640
+ elif name == "get_player_config":
641
+ return get_player_config(args["scene_id"])
642
+
643
+ # Rendering tools
644
+ elif name == "add_light":
645
+ position = None
646
+ if "x" in args or "y" in args or "z" in args:
647
+ position = {"x": args.get("x", 0), "y": args.get("y", 5), "z": args.get("z", 0)}
648
+ return add_light(
649
+ args["scene_id"],
650
+ args["light_type"],
651
+ args["name"],
652
+ args.get("color", "#ffffff"),
653
+ args.get("intensity", 1.0),
654
+ position,
655
+ None, # target
656
+ args.get("cast_shadow", False),
657
+ None # spot_angle
658
+ )
659
+
660
+ elif name == "remove_light":
661
+ return remove_light(args["scene_id"], args["light_name"])
662
+
663
+ elif name == "get_lights":
664
+ return get_lights(args["scene_id"])
665
+
666
+ elif name == "update_object_material":
667
+ return update_object_material(
668
+ args["scene_id"],
669
+ args["object_id"],
670
+ args.get("color"),
671
+ args.get("metalness"),
672
+ args.get("roughness"),
673
+ args.get("opacity"),
674
+ args.get("emissive"),
675
+ args.get("emissive_intensity")
676
+ )
677
+
678
+ elif name == "set_background_color":
679
+ return set_background_color(
680
+ args["scene_id"],
681
+ args.get("color"),
682
+ args.get("bg_type", "solid"),
683
+ args.get("gradient_top"),
684
+ args.get("gradient_bottom")
685
+ )
686
+
687
+ elif name == "set_fog":
688
+ return set_fog(
689
+ args["scene_id"],
690
+ args["enabled"],
691
+ args.get("color"),
692
+ args.get("near"),
693
+ args.get("far"),
694
+ args.get("density")
695
+ )
696
+
697
+ # Post-processing tools
698
+ elif name == "set_bloom":
699
+ return set_bloom(
700
+ args["scene_id"],
701
+ args["enabled"],
702
+ args.get("strength", 1.0),
703
+ args.get("radius", 0.4),
704
+ args.get("threshold", 0.8)
705
+ )
706
+
707
+ elif name == "set_ssao":
708
+ return set_ssao(
709
+ args["scene_id"],
710
+ args["enabled"],
711
+ args.get("radius", 0.5),
712
+ args.get("intensity", 1.0),
713
+ args.get("bias", 0.025)
714
+ )
715
+
716
+ elif name == "set_color_grading":
717
+ return set_color_grading(
718
+ args["scene_id"],
719
+ args["enabled"],
720
+ args.get("brightness", 0.0),
721
+ args.get("contrast", 1.0),
722
+ args.get("saturation", 1.0),
723
+ args.get("hue", 0.0),
724
+ args.get("exposure", 1.0),
725
+ args.get("gamma", 1.0)
726
+ )
727
+
728
+ elif name == "set_vignette":
729
+ return set_vignette(
730
+ args["scene_id"],
731
+ args["enabled"],
732
+ args.get("intensity", 0.5),
733
+ args.get("smoothness", 0.5)
734
+ )
735
+
736
+ elif name == "get_post_processing":
737
+ return get_post_processing(args["scene_id"])
738
+
739
+ # Camera effects tools
740
+ elif name == "set_depth_of_field":
741
+ return set_depth_of_field(
742
+ args["scene_id"],
743
+ args["enabled"],
744
+ args.get("focus_distance", 10.0),
745
+ args.get("aperture", 0.025),
746
+ args.get("max_blur", 0.01)
747
+ )
748
+
749
+ elif name == "set_motion_blur":
750
+ return set_motion_blur(
751
+ args["scene_id"],
752
+ args["enabled"],
753
+ args.get("intensity", 0.5),
754
+ args.get("samples", 8)
755
+ )
756
+
757
+ elif name == "set_chromatic_aberration":
758
+ return set_chromatic_aberration(
759
+ args["scene_id"],
760
+ args["enabled"],
761
+ args.get("intensity", 0.005)
762
+ )
763
+
764
+ elif name == "get_camera_effects":
765
+ return get_camera_effects(args["scene_id"])
766
+
767
+ else:
768
+ return {"error": f"Unknown tool: {name}"}
769
+
770
+ def chat(self, user_message: str) -> tuple[str, Optional[Dict[str, Any]]]:
771
+ """
772
+ Process a user message and return the response.
773
+
774
+ Returns:
775
+ tuple: (response_text, action_data)
776
+ - response_text: The assistant's response
777
+ - action_data: Optional dict with action info for the frontend
778
+ """
779
+ # Add user message to history
780
+ self.conversation_history.append({
781
+ "role": "user",
782
+ "content": user_message
783
+ })
784
+
785
+ # Build messages with system prompt
786
+ messages = [{"role": "system", "content": self.system_prompt}] + self.conversation_history
787
+
788
+ # Track actions for frontend
789
+ actions = []
790
+
791
+ # Call GPT with tools
792
+ while True:
793
+ response = self.client.chat.completions.create(
794
+ model="gpt-4o-mini", # or "gpt-4o" for better reasoning
795
+ messages=messages,
796
+ tools=TOOLS,
797
+ tool_choice="auto"
798
+ )
799
+
800
+ assistant_message = response.choices[0].message
801
+
802
+ # Check if there are tool calls
803
+ if assistant_message.tool_calls:
804
+ # Add assistant message with tool calls to history
805
+ messages.append({
806
+ "role": "assistant",
807
+ "content": assistant_message.content,
808
+ "tool_calls": [
809
+ {
810
+ "id": tc.id,
811
+ "type": "function",
812
+ "function": {
813
+ "name": tc.function.name,
814
+ "arguments": tc.function.arguments
815
+ }
816
+ }
817
+ for tc in assistant_message.tool_calls
818
+ ]
819
+ })
820
+
821
+ # Execute each tool call
822
+ for tool_call in assistant_message.tool_calls:
823
+ function_name = tool_call.function.name
824
+ function_args = json.loads(tool_call.function.arguments)
825
+
826
+ # Execute the tool
827
+ try:
828
+ result = self.execute_tool(function_name, function_args)
829
+ actions.append({
830
+ "tool": function_name,
831
+ "args": function_args,
832
+ "result": result
833
+ })
834
+ result_str = json.dumps(result)
835
+ except Exception as e:
836
+ result_str = json.dumps({"error": str(e)})
837
+
838
+ # Add tool result to messages
839
+ messages.append({
840
+ "role": "tool",
841
+ "tool_call_id": tool_call.id,
842
+ "content": result_str
843
+ })
844
+ else:
845
+ # No more tool calls, we have the final response
846
+ final_response = assistant_message.content or "Done!"
847
+
848
+ # Add to conversation history
849
+ self.conversation_history.append({
850
+ "role": "assistant",
851
+ "content": final_response
852
+ })
853
+
854
+ # Build action data for frontend
855
+ action_data = None
856
+ if actions:
857
+ # Return the last significant action for the frontend
858
+ last_action = actions[-1]
859
+ action_data = self._build_frontend_action(last_action)
860
+
861
+ return final_response, action_data
862
+
863
+ def _build_frontend_action(self, action: Dict[str, Any]) -> Optional[Dict[str, Any]]:
864
+ """Convert tool result to frontend action"""
865
+ tool = action["tool"]
866
+ result = action["result"]
867
+
868
+ # Map tool names to frontend actions
869
+ if tool == "add_object":
870
+ from backend.storage import storage
871
+ scene = storage.get(self.scene_id)
872
+ if scene and scene.get("objects"):
873
+ return {"action": "addObject", "data": scene["objects"][-1]}
874
+
875
+ elif tool == "set_lighting":
876
+ from backend.storage import storage
877
+ scene = storage.get(self.scene_id)
878
+ if scene and scene.get("lighting"):
879
+ return {"action": "setLighting", "data": scene["lighting"]}
880
+
881
+ elif tool in ["set_player_speed", "set_jump_force", "set_gravity",
882
+ "set_camera_fov", "set_player_dimensions"]:
883
+ return {"action": "reload", "url": f"{self.base_url}/view/scene/{self.scene_id}"}
884
+
885
+ elif tool == "add_light":
886
+ return {"action": "addLight", "data": result.get("light")}
887
+
888
+ elif tool == "remove_light":
889
+ return {"action": "removeLight", "data": {"light_name": action["args"].get("light_name")}}
890
+
891
+ elif tool == "update_object_material":
892
+ return {"action": "updateMaterial", "data": result}
893
+
894
+ elif tool == "set_background_color":
895
+ return {"action": "setBackground", "data": result.get("background")}
896
+
897
+ elif tool == "set_fog":
898
+ return {"action": "setFog", "data": result.get("fog")}
899
+
900
+ # Post-processing effects (require reload for now)
901
+ elif tool in ["set_bloom", "set_ssao", "set_color_grading", "set_vignette"]:
902
+ return {"action": "reload", "url": f"{self.base_url}/view/scene/{self.scene_id}"}
903
+
904
+ # Camera effects (require reload for now)
905
+ elif tool in ["set_depth_of_field", "set_motion_blur", "set_chromatic_aberration"]:
906
+ return {"action": "reload", "url": f"{self.base_url}/view/scene/{self.scene_id}"}
907
+
908
+ return None
909
+
910
+ def clear_history(self):
911
+ """Clear conversation history"""
912
+ self.conversation_history = []
913
+
914
+
915
+ # Convenience function for simple usage
916
+ def create_chat_client(scene_id: str = "welcome", base_url: str = "http://localhost:8000") -> GCPChatClient:
917
+ """Create a new GCP chat client"""
918
+ return GCPChatClient(scene_id, base_url)
frontend/game_viewer.html ADDED
@@ -0,0 +1,1515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>3D Game Viewer</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
16
+ overflow: hidden;
17
+ background: #0a0a0a;
18
+ }
19
+
20
+ #viewer-container {
21
+ width: 100vw;
22
+ height: 100vh;
23
+ position: relative;
24
+ }
25
+
26
+ /* Crosshair for FPS mode */
27
+ #crosshair {
28
+ position: absolute;
29
+ top: 50%;
30
+ left: 50%;
31
+ transform: translate(-50%, -50%);
32
+ width: 6px;
33
+ height: 6px;
34
+ background: #000000;
35
+ border-radius: 50%;
36
+ pointer-events: none;
37
+ display: none; /* Show only in FPS mode */
38
+ z-index: 100;
39
+ box-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
40
+ }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <div id="viewer-container">
45
+ <div id="crosshair"></div>
46
+ </div>
47
+
48
+ <script type="importmap">
49
+ {
50
+ "imports": {
51
+ "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
52
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
53
+ "cannon-es": "https://cdn.jsdelivr.net/npm/cannon-es@0.20.0/dist/cannon-es.js"
54
+ }
55
+ }
56
+ </script>
57
+
58
+ <!-- Stats library for FPS counter -->
59
+ <script src="https://cdn.jsdelivr.net/npm/stats.js@0.17.0/build/stats.min.js"></script>
60
+
61
+ <script type="module">
62
+ import * as THREE from 'three';
63
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
64
+ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
65
+ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
66
+ import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js';
67
+ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
68
+ import * as CANNON from 'cannon-es';
69
+
70
+ // Get scene ID from URL
71
+ const sceneId = window.location.pathname.split('/').pop();
72
+ const baseUrl = window.location.origin;
73
+
74
+ // Check for control mode in URL params
75
+ const urlParams = new URLSearchParams(window.location.search);
76
+ const initialMode = urlParams.get('mode') || 'fps'; // Default to FPS mode
77
+
78
+ console.log('3D Game Viewer - Initializing...');
79
+ console.log('Scene ID:', sceneId);
80
+ console.log('Fetch URL:', `${baseUrl}/api/scenes/${sceneId}`);
81
+ console.log('Initial control mode:', initialMode);
82
+
83
+ let scene, camera, renderer;
84
+ let orbitControls;
85
+ let controlMode = initialMode;
86
+ let sceneData = null;
87
+
88
+ // Postprocessing for outlines
89
+ let composer, outlinePass;
90
+
91
+ // Scene controls
92
+ let gridHelper = null;
93
+ let stats = null;
94
+ let wireframeEnabled = false;
95
+
96
+ // Object selection system (FPS mode)
97
+ let raycaster = new THREE.Raycaster();
98
+ let mouse = new THREE.Vector2();
99
+ let selectedObject = null;
100
+ let selectedObjectId = null;
101
+ const MAX_SELECT_DISTANCE = 10; // Max raycast distance for selection
102
+
103
+ // FPS movement and look variables (configurable via player_config)
104
+ let moveSpeed = 5.0;
105
+ const velocity = new THREE.Vector3();
106
+ let isMouseLocked = false;
107
+ let cameraRotationX = 0; // Pitch (up/down)
108
+ let cameraRotationY = 0; // Yaw (left/right)
109
+ let mouseSensitivity = 0.002;
110
+ let invertY = false;
111
+ let movementAcceleration = 0.0; // Phase 2
112
+ let airControl = 1.0; // Phase 2
113
+ let cameraFOV = 75.0; // Phase 2
114
+ let minPitch = -89.0; // Phase 2
115
+ let maxPitch = 89.0; // Phase 2
116
+
117
+ // Physics variables (configurable via player_config)
118
+ let physicsWorld;
119
+ let playerBody;
120
+ let groundBody;
121
+ let wallBodies = [];
122
+ let objectBodies = new Map(); // Maps object IDs to physics bodies
123
+ let PLAYER_HEIGHT = 1.7;
124
+ let PLAYER_RADIUS = 0.3;
125
+ let EYE_HEIGHT = 1.6; // Eye level for camera
126
+ let JUMP_FORCE = 5.0;
127
+ let GRAVITY = -9.82;
128
+ let PLAYER_MASS = 80.0;
129
+ let LINEAR_DAMPING = 0.9;
130
+ const WORLD_SIZE = 10; // 10x10 world (fixed)
131
+ const WORLD_HALF = WORLD_SIZE / 2; // -5 to 5 (fixed)
132
+ let isGrounded = false;
133
+ let canJump = true;
134
+
135
+ // Movement direction and keyboard state
136
+ const direction = new THREE.Vector3();
137
+ const moveForward = { value: false };
138
+ const moveBackward = { value: false };
139
+ const moveLeft = { value: false };
140
+ const moveRight = { value: false };
141
+ const moveUp = { value: false };
142
+ const moveDown = { value: false };
143
+ let prevTime = performance.now();
144
+
145
+ function applyPlayerConfig() {
146
+ /**
147
+ * Apply player configuration from scene data to runtime variables
148
+ * Allows MCP tools to customize player controller behavior
149
+ */
150
+ if (!sceneData || !sceneData.player_config) {
151
+ console.log('No player_config in scene data, using defaults');
152
+ return;
153
+ }
154
+
155
+ const config = sceneData.player_config;
156
+
157
+ // Apply movement settings
158
+ if (config.move_speed !== undefined) {
159
+ moveSpeed = config.move_speed;
160
+ console.log(`Applied player speed: ${moveSpeed} units/sec`);
161
+ }
162
+
163
+ if (config.jump_force !== undefined) {
164
+ JUMP_FORCE = config.jump_force;
165
+ console.log(`Applied jump force: ${JUMP_FORCE} m/s`);
166
+ }
167
+
168
+ // Apply camera settings
169
+ if (config.mouse_sensitivity !== undefined) {
170
+ mouseSensitivity = config.mouse_sensitivity;
171
+ console.log(`Applied mouse sensitivity: ${mouseSensitivity}`);
172
+ }
173
+
174
+ if (config.invert_y !== undefined) {
175
+ invertY = config.invert_y;
176
+ console.log(`Applied Y-axis inversion: ${invertY}`);
177
+ }
178
+
179
+ // Apply physics settings
180
+ if (config.gravity !== undefined) {
181
+ GRAVITY = config.gravity;
182
+ console.log(`Applied gravity: ${GRAVITY} m/s²`);
183
+ }
184
+
185
+ // Apply player dimensions
186
+ if (config.player_height !== undefined) {
187
+ PLAYER_HEIGHT = config.player_height;
188
+ console.log(`Applied player height: ${PLAYER_HEIGHT}m`);
189
+ }
190
+
191
+ if (config.player_radius !== undefined) {
192
+ PLAYER_RADIUS = config.player_radius;
193
+ console.log(`Applied player radius: ${PLAYER_RADIUS}m`);
194
+ }
195
+
196
+ if (config.eye_height !== undefined) {
197
+ EYE_HEIGHT = config.eye_height;
198
+ console.log(`Applied eye height: ${EYE_HEIGHT}m`);
199
+ }
200
+
201
+ if (config.player_mass !== undefined) {
202
+ PLAYER_MASS = config.player_mass;
203
+ console.log(`Applied player mass: ${PLAYER_MASS}kg`);
204
+ }
205
+
206
+ if (config.linear_damping !== undefined) {
207
+ LINEAR_DAMPING = config.linear_damping;
208
+ console.log(`Applied linear damping: ${LINEAR_DAMPING}`);
209
+ }
210
+
211
+ // Apply Phase 2 settings
212
+ if (config.movement_acceleration !== undefined) {
213
+ movementAcceleration = config.movement_acceleration;
214
+ console.log(`Applied movement acceleration: ${movementAcceleration}`);
215
+ }
216
+
217
+ if (config.air_control !== undefined) {
218
+ airControl = config.air_control;
219
+ console.log(`Applied air control: ${airControl}`);
220
+ }
221
+
222
+ if (config.camera_fov !== undefined) {
223
+ cameraFOV = config.camera_fov;
224
+ console.log(`Applied camera FOV: ${cameraFOV}°`);
225
+ }
226
+
227
+ if (config.min_pitch !== undefined) {
228
+ minPitch = config.min_pitch;
229
+ console.log(`Applied min pitch: ${minPitch}°`);
230
+ }
231
+
232
+ if (config.max_pitch !== undefined) {
233
+ maxPitch = config.max_pitch;
234
+ console.log(`Applied max pitch: ${maxPitch}°`);
235
+ }
236
+
237
+ console.log('✅ Player configuration applied successfully');
238
+ }
239
+
240
+ async function init() {
241
+ try {
242
+ // Fetch scene data
243
+ console.log('Fetching scene data...');
244
+ const response = await fetch(`${baseUrl}/api/scenes/${sceneId}`);
245
+ console.log('Response status:', response.status);
246
+
247
+ if (!response.ok) {
248
+ const errorText = await response.text();
249
+ console.error('Failed to fetch scene:', errorText);
250
+ throw new Error(`Scene not found (${response.status}): ${errorText}`);
251
+ }
252
+ sceneData = await response.json();
253
+ console.log('Scene data loaded:', sceneData);
254
+
255
+ // Apply player configuration from scene data
256
+ applyPlayerConfig();
257
+
258
+ // Setup Three.js scene
259
+ setupScene();
260
+
261
+ // Setup physics world
262
+ setupPhysics();
263
+
264
+ // Render all game objects
265
+ renderGameObjects();
266
+
267
+ // Start animation loop
268
+ animate();
269
+
270
+ } catch (error) {
271
+ console.error('Error initializing viewer:', error);
272
+ }
273
+ }
274
+
275
+ function setupScene() {
276
+ // Create scene
277
+ scene = new THREE.Scene();
278
+ const bgColor = sceneData.environment?.background_color || '#87CEEB';
279
+ scene.background = new THREE.Color(bgColor);
280
+
281
+ // Create camera (FOV from player_config)
282
+ camera = new THREE.PerspectiveCamera(
283
+ cameraFOV,
284
+ window.innerWidth / window.innerHeight,
285
+ 0.1,
286
+ 1000
287
+ );
288
+
289
+ // Position camera at player eye height (will be synced with physics in animate loop)
290
+ camera.position.set(0, EYE_HEIGHT, 0);
291
+
292
+ // Create renderer
293
+ renderer = new THREE.WebGLRenderer({ antialias: true });
294
+ renderer.setSize(window.innerWidth, window.innerHeight);
295
+ renderer.setPixelRatio(window.devicePixelRatio);
296
+ document.getElementById('viewer-container').appendChild(renderer.domElement);
297
+
298
+ // Setup postprocessing for object outlines
299
+ composer = new EffectComposer(renderer);
300
+
301
+ const renderPass = new RenderPass(scene, camera);
302
+ composer.addPass(renderPass);
303
+
304
+ outlinePass = new OutlinePass(new THREE.Vector2(window.innerWidth, window.innerHeight), scene, camera);
305
+ outlinePass.edgeStrength = 5.0; // Increased for better visibility
306
+ outlinePass.edgeGlow = 1.0; // Increased glow
307
+ outlinePass.edgeThickness = 3.0; // Thicker edge
308
+ outlinePass.pulsePeriod = 0; // No pulsing
309
+ outlinePass.visibleEdgeColor.set('#ff8800'); // Orange outline
310
+ outlinePass.hiddenEdgeColor.set('#ff4400'); // Darker orange for hidden edges
311
+ composer.addPass(outlinePass);
312
+
313
+ console.log('OutlinePass configured - orange outline ready');
314
+
315
+ const outputPass = new OutputPass();
316
+ composer.addPass(outputPass);
317
+
318
+ console.log('PostProcessing composer initialized with OutlinePass');
319
+
320
+ // Add lights from scene data
321
+ sceneData.lights.forEach(lightData => {
322
+ let light;
323
+
324
+ if (lightData.type === 'ambient') {
325
+ light = new THREE.AmbientLight(lightData.color, lightData.intensity);
326
+ } else if (lightData.type === 'directional') {
327
+ light = new THREE.DirectionalLight(lightData.color, lightData.intensity);
328
+ light.position.set(lightData.position.x, lightData.position.y, lightData.position.z);
329
+ // Directional lights need their target in the scene to work properly
330
+ light.target.position.set(0, 0, 0);
331
+ scene.add(light.target);
332
+ if (lightData.cast_shadow) {
333
+ light.castShadow = true;
334
+ }
335
+ } else if (lightData.type === 'point') {
336
+ light = new THREE.PointLight(lightData.color, lightData.intensity);
337
+ light.position.set(lightData.position.x, lightData.position.y, lightData.position.z);
338
+ }
339
+
340
+ if (light) {
341
+ scene.add(light);
342
+ console.log('Added light:', lightData.type, lightData.name, 'at', lightData.position || 'default');
343
+ }
344
+ });
345
+
346
+ // Add grid (initially hidden, can be toggled)
347
+ const gridSize = sceneData.grid_size || 100;
348
+ const divisions = sceneData.grid_divisions || 20;
349
+ gridHelper = new THREE.GridHelper(gridSize, divisions, 0x444444, 0x222222);
350
+ gridHelper.visible = sceneData.show_grid || false;
351
+ scene.add(gridHelper);
352
+
353
+ // Initialize stats (FPS counter) - initially hidden
354
+ if (typeof Stats !== 'undefined') {
355
+ stats = new Stats();
356
+ stats.dom.style.position = 'absolute';
357
+ stats.dom.style.top = '0px';
358
+ stats.dom.style.left = '0px';
359
+ stats.dom.style.display = 'none'; // Hidden by default
360
+ document.getElementById('viewer-container').appendChild(stats.dom);
361
+ }
362
+
363
+ // Setup Orbit controls
364
+ orbitControls = new OrbitControls(camera, renderer.domElement);
365
+ orbitControls.enableDamping = true;
366
+ orbitControls.dampingFactor = 0.05;
367
+
368
+ // Setup FPS mouse-look controls
369
+ setupFPSControls();
370
+
371
+ // Click handler for object inspection in orbit mode
372
+ renderer.domElement.addEventListener('click', (event) => {
373
+ // In orbit mode, allow object inspection
374
+ if (controlMode === 'orbit') {
375
+ onObjectClick(event);
376
+ }
377
+ });
378
+
379
+ // Keyboard controls for FPS movement
380
+ const onKeyDown = (event) => {
381
+ switch (event.code) {
382
+ case 'KeyW': moveForward.value = true; break;
383
+ case 'KeyA': moveLeft.value = true; break;
384
+ case 'KeyS': moveBackward.value = true; break;
385
+ case 'KeyD': moveRight.value = true; break;
386
+ case 'Space':
387
+ event.preventDefault(); // Prevent page scroll
388
+ if (canJump && isGrounded && playerBody) {
389
+ playerBody.velocity.y = JUMP_FORCE;
390
+ canJump = false;
391
+ isGrounded = false;
392
+ }
393
+ break;
394
+ case 'KeyC': toggleControlMode(); break;
395
+ }
396
+ };
397
+
398
+ const onKeyUp = (event) => {
399
+ switch (event.code) {
400
+ case 'KeyW': moveForward.value = false; break;
401
+ case 'KeyA': moveLeft.value = false; break;
402
+ case 'KeyS': moveBackward.value = false; break;
403
+ case 'KeyD': moveRight.value = false; break;
404
+ }
405
+ };
406
+
407
+ document.addEventListener('keydown', onKeyDown);
408
+ document.addEventListener('keyup', onKeyUp);
409
+
410
+ // Set initial control mode
411
+ setControlMode(controlMode);
412
+
413
+ // Handle window resize
414
+ window.addEventListener('resize', onWindowResize);
415
+ }
416
+
417
+ function setupFPSControls() {
418
+ // Mouse-look controls for FPS mode
419
+ renderer.domElement.addEventListener('mousedown', (event) => {
420
+ if (controlMode === 'fps' && event.button === 0) {
421
+ isMouseLocked = true;
422
+ renderer.domElement.requestPointerLock();
423
+ }
424
+ });
425
+
426
+ renderer.domElement.addEventListener('mouseup', () => {
427
+ // Keep mouse locked until Escape is pressed
428
+ });
429
+
430
+ // Mouse movement for camera rotation
431
+ document.addEventListener('mousemove', (event) => {
432
+ if (!isMouseLocked || controlMode !== 'fps') return;
433
+
434
+ const movementX = event.movementX || 0;
435
+ const movementY = event.movementY || 0;
436
+
437
+ // Update rotation (yaw and pitch)
438
+ cameraRotationY -= movementX * mouseSensitivity; // Yaw (left/right)
439
+ const pitchMultiplier = invertY ? 1 : -1; // Invert Y if configured
440
+ cameraRotationX += movementY * mouseSensitivity * pitchMultiplier; // Pitch (up/down)
441
+
442
+ // Clamp vertical rotation to configured limits
443
+ const minPitchRad = THREE.MathUtils.degToRad(minPitch);
444
+ const maxPitchRad = THREE.MathUtils.degToRad(maxPitch);
445
+ cameraRotationX = Math.max(minPitchRad, Math.min(maxPitchRad, cameraRotationX));
446
+ });
447
+
448
+ // Handle pointer lock changes
449
+ document.addEventListener('pointerlockchange', () => {
450
+ isMouseLocked = document.pointerLockElement === renderer.domElement;
451
+ });
452
+
453
+ document.addEventListener('pointerlockerror', () => {
454
+ console.error('Pointer lock failed');
455
+ isMouseLocked = false;
456
+ });
457
+
458
+ console.log('FPS controls initialized - click in viewer to enable mouse-look');
459
+ }
460
+
461
+ function setupPhysics() {
462
+ console.log('Setting up physics world...');
463
+
464
+ // Create physics world
465
+ physicsWorld = new CANNON.World();
466
+ physicsWorld.gravity.set(0, GRAVITY, 0);
467
+
468
+ // Set up collision materials for better physics response
469
+ const defaultMaterial = new CANNON.Material('default');
470
+ const defaultContactMaterial = new CANNON.ContactMaterial(
471
+ defaultMaterial,
472
+ defaultMaterial,
473
+ {
474
+ friction: 0.3,
475
+ restitution: 0.0, // No bounce
476
+ }
477
+ );
478
+ physicsWorld.addContactMaterial(defaultContactMaterial);
479
+ physicsWorld.defaultContactMaterial = defaultContactMaterial;
480
+
481
+ // Create ground plane (10x10 centered at origin)
482
+ const groundGeometry = new THREE.PlaneGeometry(WORLD_SIZE, WORLD_SIZE);
483
+ const groundMaterial = new THREE.MeshStandardMaterial({
484
+ color: 0xffffff,
485
+ roughness: 0.8,
486
+ metalness: 0.2
487
+ });
488
+ const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial);
489
+ groundMesh.rotation.x = -Math.PI / 2; // Rotate to be horizontal
490
+ groundMesh.position.y = 0;
491
+ groundMesh.receiveShadow = true;
492
+ groundMesh.userData = { isGround: true };
493
+ scene.add(groundMesh);
494
+
495
+ // Ground physics body
496
+ const groundShape = new CANNON.Plane();
497
+ groundBody = new CANNON.Body({ mass: 0, material: defaultMaterial });
498
+ groundBody.addShape(groundShape);
499
+ groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
500
+ physicsWorld.addBody(groundBody);
501
+
502
+ // Create 4 boundary walls (white cubes, 5 units high, 0.5 thick)
503
+ const wallHeight = 5;
504
+ const wallThickness = 0.5;
505
+ const wallMaterial = new THREE.MeshStandardMaterial({
506
+ color: 0xffffff,
507
+ roughness: 0.7,
508
+ metalness: 0.1
509
+ });
510
+
511
+ // North wall (z = 5)
512
+ const northWallGeometry = new THREE.BoxGeometry(WORLD_SIZE + wallThickness * 2, wallHeight, wallThickness);
513
+ const northWallMesh = new THREE.Mesh(northWallGeometry, wallMaterial);
514
+ northWallMesh.position.set(0, wallHeight / 2, WORLD_HALF);
515
+ northWallMesh.userData = { isWall: true };
516
+ scene.add(northWallMesh);
517
+
518
+ const northWallShape = new CANNON.Box(new CANNON.Vec3((WORLD_SIZE + wallThickness * 2) / 2, wallHeight / 2, wallThickness / 2));
519
+ const northWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial });
520
+ northWallBody.addShape(northWallShape);
521
+ northWallBody.position.copy(northWallMesh.position);
522
+ physicsWorld.addBody(northWallBody);
523
+ wallBodies.push(northWallBody);
524
+
525
+ // South wall (z = -5)
526
+ const southWallMesh = new THREE.Mesh(northWallGeometry, wallMaterial);
527
+ southWallMesh.position.set(0, wallHeight / 2, -WORLD_HALF);
528
+ southWallMesh.userData = { isWall: true };
529
+ scene.add(southWallMesh);
530
+
531
+ const southWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial });
532
+ southWallBody.addShape(northWallShape);
533
+ southWallBody.position.copy(southWallMesh.position);
534
+ physicsWorld.addBody(southWallBody);
535
+ wallBodies.push(southWallBody);
536
+
537
+ // East wall (x = 5)
538
+ const eastWallGeometry = new THREE.BoxGeometry(wallThickness, wallHeight, WORLD_SIZE);
539
+ const eastWallMesh = new THREE.Mesh(eastWallGeometry, wallMaterial);
540
+ eastWallMesh.position.set(WORLD_HALF, wallHeight / 2, 0);
541
+ eastWallMesh.userData = { isWall: true };
542
+ scene.add(eastWallMesh);
543
+
544
+ const eastWallShape = new CANNON.Box(new CANNON.Vec3(wallThickness / 2, wallHeight / 2, WORLD_SIZE / 2));
545
+ const eastWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial });
546
+ eastWallBody.addShape(eastWallShape);
547
+ eastWallBody.position.copy(eastWallMesh.position);
548
+ physicsWorld.addBody(eastWallBody);
549
+ wallBodies.push(eastWallBody);
550
+
551
+ // West wall (x = -5)
552
+ const westWallMesh = new THREE.Mesh(eastWallGeometry, wallMaterial);
553
+ westWallMesh.position.set(-WORLD_HALF, wallHeight / 2, 0);
554
+ westWallMesh.userData = { isWall: true };
555
+ scene.add(westWallMesh);
556
+
557
+ const westWallBody = new CANNON.Body({ mass: 0, material: defaultMaterial });
558
+ westWallBody.addShape(eastWallShape);
559
+ westWallBody.position.copy(westWallMesh.position);
560
+ physicsWorld.addBody(westWallBody);
561
+ wallBodies.push(westWallBody);
562
+
563
+ // Create player physics body (capsule approximated with cylinder)
564
+ const playerShape = new CANNON.Cylinder(PLAYER_RADIUS, PLAYER_RADIUS, PLAYER_HEIGHT, 8);
565
+ playerBody = new CANNON.Body({
566
+ mass: PLAYER_MASS, // kg (configurable via player_config)
567
+ material: defaultMaterial,
568
+ fixedRotation: true, // Prevent player from tipping over
569
+ linearDamping: LINEAR_DAMPING, // Air resistance for movement (configurable)
570
+ });
571
+ playerBody.addShape(playerShape);
572
+
573
+ // Set player starting position (0, 1, 0) - y=1 means feet at y=1, top at y=2.7
574
+ playerBody.position.set(0, 1 + PLAYER_HEIGHT / 2, 0);
575
+ physicsWorld.addBody(playerBody);
576
+
577
+ // Add collision detection for grounded check
578
+ playerBody.addEventListener('collide', (e) => {
579
+ // Check if colliding with ground
580
+ if (e.body === groundBody) {
581
+ isGrounded = true;
582
+ canJump = true;
583
+ }
584
+ });
585
+
586
+ console.log('Physics world created with ground, walls, and player');
587
+ }
588
+
589
+ function toggleControlMode() {
590
+ const newMode = controlMode === 'orbit' ? 'fps' : 'orbit';
591
+ setControlMode(newMode);
592
+ }
593
+
594
+ function setControlMode(mode) {
595
+ controlMode = mode;
596
+ const crosshair = document.getElementById('crosshair');
597
+
598
+ if (mode === 'fps') {
599
+ // Switch to FPS
600
+ orbitControls.enabled = false;
601
+ // Exit pointer lock if active
602
+ if (document.pointerLockElement) {
603
+ document.exitPointerLock();
604
+ }
605
+ isMouseLocked = false;
606
+
607
+ // Show crosshair
608
+ if (crosshair) crosshair.style.display = 'block';
609
+
610
+ console.log('Switched to FPS controls - click in viewer to enable mouse-look, WASD to move');
611
+ } else {
612
+ // Switch to Orbit
613
+ if (document.pointerLockElement) {
614
+ document.exitPointerLock();
615
+ }
616
+ isMouseLocked = false;
617
+ orbitControls.enabled = true;
618
+
619
+ // Hide crosshair
620
+ if (crosshair) crosshair.style.display = 'none';
621
+
622
+ // Clear selection
623
+ if (outlinePass) outlinePass.selectedObjects = [];
624
+ selectedObject = null;
625
+ selectedObjectId = null;
626
+
627
+ console.log('Switched to Orbit controls');
628
+ }
629
+ }
630
+
631
+ function createPhysicsShape(objType, scale) {
632
+ // Create Cannon.js physics shape based on object type
633
+ switch (objType) {
634
+ case 'cube':
635
+ return new CANNON.Box(new CANNON.Vec3(scale.x / 2, scale.y / 2, scale.z / 2));
636
+ case 'sphere':
637
+ return new CANNON.Sphere(scale.x);
638
+ case 'cylinder':
639
+ return new CANNON.Cylinder(scale.x, scale.x, scale.y, 8);
640
+ case 'plane':
641
+ // For planes, create a thin box
642
+ return new CANNON.Box(new CANNON.Vec3(scale.x / 2, 0.01, scale.y / 2));
643
+ case 'cone':
644
+ // Approximate cone with cylinder (Cannon doesn't have cone shape)
645
+ return new CANNON.Cylinder(0, scale.x, scale.y, 8);
646
+ case 'torus':
647
+ // Approximate torus with sphere
648
+ return new CANNON.Sphere(scale.x);
649
+ default:
650
+ // Default to box
651
+ return new CANNON.Box(new CANNON.Vec3(scale.x / 2, scale.y / 2, scale.z / 2));
652
+ }
653
+ }
654
+
655
+ function renderGameObjects() {
656
+ console.log('Rendering', sceneData.objects.length, 'game objects...');
657
+
658
+ sceneData.objects.forEach(obj => {
659
+ // Validate object is within bounds
660
+ if (Math.abs(obj.position.x) > WORLD_HALF || Math.abs(obj.position.z) > WORLD_HALF) {
661
+ console.warn(`Object ${obj.name} at (${obj.position.x}, ${obj.position.z}) is outside 10x10 world bounds - skipping`);
662
+ return;
663
+ }
664
+ let geometry, mesh;
665
+
666
+ // Create geometry based on type
667
+ switch (obj.type) {
668
+ case 'cube':
669
+ geometry = new THREE.BoxGeometry(
670
+ obj.scale.x,
671
+ obj.scale.y,
672
+ obj.scale.z
673
+ );
674
+ break;
675
+ case 'sphere':
676
+ geometry = new THREE.SphereGeometry(obj.scale.x, 32, 32);
677
+ break;
678
+ case 'cylinder':
679
+ geometry = new THREE.CylinderGeometry(
680
+ obj.scale.x,
681
+ obj.scale.x,
682
+ obj.scale.y,
683
+ 32
684
+ );
685
+ break;
686
+ case 'plane':
687
+ geometry = new THREE.PlaneGeometry(obj.scale.x, obj.scale.y);
688
+ break;
689
+ case 'cone':
690
+ geometry = new THREE.ConeGeometry(obj.scale.x, obj.scale.y, 32);
691
+ break;
692
+ case 'torus':
693
+ geometry = new THREE.TorusGeometry(obj.scale.x, obj.scale.x * 0.4, 16, 100);
694
+ break;
695
+ default:
696
+ console.warn('Unknown object type:', obj.type);
697
+ return;
698
+ }
699
+
700
+ // Create material
701
+ const material = new THREE.MeshStandardMaterial({
702
+ color: obj.material.color,
703
+ metalness: obj.material.metalness || 0.5,
704
+ roughness: obj.material.roughness || 0.5,
705
+ opacity: obj.material.opacity || 1.0,
706
+ transparent: obj.material.opacity < 1.0,
707
+ wireframe: obj.material.wireframe || false,
708
+ });
709
+
710
+ // Create mesh
711
+ mesh = new THREE.Mesh(geometry, material);
712
+
713
+ // Set position
714
+ mesh.position.set(obj.position.x, obj.position.y, obj.position.z);
715
+
716
+ // Set rotation (convert degrees to radians)
717
+ mesh.rotation.set(
718
+ THREE.MathUtils.degToRad(obj.rotation.x),
719
+ THREE.MathUtils.degToRad(obj.rotation.y),
720
+ THREE.MathUtils.degToRad(obj.rotation.z)
721
+ );
722
+
723
+ // Store metadata
724
+ mesh.userData = {
725
+ id: obj.id,
726
+ name: obj.name,
727
+ type: obj.type,
728
+ isSceneObject: true,
729
+ };
730
+
731
+ scene.add(mesh);
732
+
733
+ // Create physics body for collision
734
+ const physicsShape = createPhysicsShape(obj.type, obj.scale);
735
+ const physicsBody = new CANNON.Body({
736
+ mass: 0, // Static objects
737
+ position: new CANNON.Vec3(obj.position.x, obj.position.y, obj.position.z),
738
+ });
739
+ physicsBody.addShape(physicsShape);
740
+
741
+ // Apply rotation to physics body
742
+ const quaternion = new CANNON.Quaternion();
743
+ quaternion.setFromEuler(
744
+ THREE.MathUtils.degToRad(obj.rotation.x),
745
+ THREE.MathUtils.degToRad(obj.rotation.y),
746
+ THREE.MathUtils.degToRad(obj.rotation.z),
747
+ 'XYZ'
748
+ );
749
+ physicsBody.quaternion.copy(quaternion);
750
+
751
+ physicsWorld.addBody(physicsBody);
752
+ objectBodies.set(obj.id, physicsBody);
753
+
754
+ console.log('Added object:', obj.name || obj.id, obj.type, 'with physics collider');
755
+ });
756
+
757
+ // Camera position is managed by physics system in FPS mode
758
+ console.log('Scene objects loaded. Camera controlled by player physics body.');
759
+ }
760
+
761
+ function onWindowResize() {
762
+ camera.aspect = window.innerWidth / window.innerHeight;
763
+ camera.updateProjectionMatrix();
764
+ renderer.setSize(window.innerWidth, window.innerHeight);
765
+
766
+ // Update composer
767
+ if (composer) {
768
+ composer.setSize(window.innerWidth, window.innerHeight);
769
+ }
770
+ }
771
+
772
+ function updateLookedAtObject() {
773
+ if (controlMode !== 'fps') {
774
+ if (selectedObject) {
775
+ outlinePass.selectedObjects = [];
776
+ selectedObject = null;
777
+ selectedObjectId = null;
778
+ }
779
+ return;
780
+ }
781
+
782
+ // Raycast from camera center (crosshair position)
783
+ const raycaster = new THREE.Raycaster();
784
+ raycaster.setFromCamera(new THREE.Vector2(0, 0), camera); // Center of screen
785
+
786
+ const selectableObjects = scene.children.filter(obj => obj.userData.isSceneObject && obj.userData.id);
787
+ const intersects = raycaster.intersectObjects(selectableObjects);
788
+
789
+ if (intersects.length > 0 && intersects[0].distance < MAX_SELECT_DISTANCE) {
790
+ const newSelected = intersects[0].object;
791
+ if (newSelected !== selectedObject) {
792
+ selectedObject = newSelected;
793
+ selectedObjectId = newSelected.userData.id;
794
+ outlinePass.selectedObjects = [selectedObject];
795
+
796
+ console.log('🎯 Object selected:', selectedObjectId, newSelected.userData.type,
797
+ `distance: ${intersects[0].distance.toFixed(2)}m`,
798
+ `outline array length: ${outlinePass.selectedObjects.length}`);
799
+
800
+ // Send selection to parent (for chat commands)
801
+ if (window.parent) {
802
+ window.parent.postMessage({
803
+ action: 'objectSelected',
804
+ data: {
805
+ object_id: selectedObjectId,
806
+ object_type: newSelected.userData.type,
807
+ distance: intersects[0].distance.toFixed(2)
808
+ }
809
+ }, '*');
810
+ }
811
+ }
812
+ } else {
813
+ // No object in view
814
+ if (selectedObject) {
815
+ console.log('🎯 Object deselected');
816
+ outlinePass.selectedObjects = [];
817
+ selectedObject = null;
818
+ selectedObjectId = null;
819
+
820
+ // Notify deselection
821
+ if (window.parent) {
822
+ window.parent.postMessage({
823
+ action: 'objectDeselected',
824
+ data: {}
825
+ }, '*');
826
+ }
827
+ }
828
+ }
829
+ }
830
+
831
+ function animate() {
832
+ requestAnimationFrame(animate);
833
+
834
+ // Update stats
835
+ if (stats) stats.begin();
836
+
837
+ const time = performance.now();
838
+ const delta = (time - prevTime) / 1000;
839
+
840
+ // Step physics simulation
841
+ if (physicsWorld) {
842
+ physicsWorld.step(1/60, delta, 3);
843
+ }
844
+
845
+ if (controlMode === 'fps' && playerBody) {
846
+ // Apply camera rotation from mouse-look
847
+ camera.rotation.order = 'YXZ'; // Ensure correct rotation order
848
+ camera.rotation.y = cameraRotationY;
849
+ camera.rotation.x = cameraRotationX;
850
+ camera.rotation.z = 0;
851
+
852
+ // Get input direction from keyboard
853
+ direction.z = Number(moveForward.value) - Number(moveBackward.value);
854
+ direction.x = Number(moveRight.value) - Number(moveLeft.value);
855
+ direction.y = 0; // No vertical movement from input
856
+ direction.normalize();
857
+
858
+ // Calculate movement direction relative to camera orientation
859
+ const forward = new THREE.Vector3(0, 0, -1);
860
+ const right = new THREE.Vector3(1, 0, 0);
861
+
862
+ // Apply camera rotation to get movement direction
863
+ forward.applyQuaternion(camera.quaternion);
864
+ right.applyQuaternion(camera.quaternion);
865
+
866
+ // Project to horizontal plane
867
+ forward.y = 0;
868
+ right.y = 0;
869
+ forward.normalize();
870
+ right.normalize();
871
+
872
+ // Apply movement to physics body (keep current Y velocity for gravity/jump)
873
+ // Reduce effectiveness when airborne based on air control setting
874
+ const controlFactor = isGrounded ? 1.0 : airControl;
875
+ const moveX = (direction.x * right.x + direction.z * forward.x) * moveSpeed * controlFactor;
876
+ const moveZ = (direction.x * right.z + direction.z * forward.z) * moveSpeed * controlFactor;
877
+
878
+ playerBody.velocity.x = moveX;
879
+ playerBody.velocity.z = moveZ;
880
+ // Don't modify playerBody.velocity.y - let physics handle gravity and jumping
881
+
882
+ // Check if grounded using a small raycast downward
883
+ const groundRaycaster = new THREE.Raycaster(
884
+ new THREE.Vector3(playerBody.position.x, playerBody.position.y, playerBody.position.z),
885
+ new THREE.Vector3(0, -1, 0),
886
+ 0,
887
+ PLAYER_HEIGHT / 2 + 0.1
888
+ );
889
+ const groundIntersects = groundRaycaster.intersectObjects(
890
+ scene.children.filter(obj => obj.userData.isGround || obj.userData.isWall || obj.userData.isSceneObject)
891
+ );
892
+ if (groundIntersects.length > 0) {
893
+ isGrounded = true;
894
+ canJump = true;
895
+ } else {
896
+ isGrounded = false;
897
+ }
898
+
899
+ // Sync camera position to physics body (at eye height)
900
+ camera.position.x = playerBody.position.x;
901
+ camera.position.y = playerBody.position.y - PLAYER_HEIGHT / 2 + EYE_HEIGHT;
902
+ camera.position.z = playerBody.position.z;
903
+
904
+ prevTime = time;
905
+ } else if (controlMode === 'orbit') {
906
+ // Orbit controls
907
+ orbitControls.update();
908
+ }
909
+
910
+ // Update looked-at object (FPS mode only)
911
+ updateLookedAtObject();
912
+
913
+ // Render using composer (for outlines) instead of direct renderer
914
+ if (composer) {
915
+ composer.render();
916
+ } else {
917
+ renderer.render(scene, camera);
918
+ }
919
+
920
+ // End stats measurement
921
+ if (stats) stats.end();
922
+ }
923
+
924
+ // ==================== Scene Control Functions ====================
925
+
926
+ /**
927
+ * Toggle grid helper visibility
928
+ */
929
+ function toggleGrid(enabled) {
930
+ if (gridHelper) {
931
+ gridHelper.visible = enabled;
932
+ console.log('Grid helper:', enabled ? 'enabled' : 'disabled');
933
+ }
934
+ }
935
+
936
+ /**
937
+ * Toggle wireframe mode for all objects
938
+ */
939
+ function toggleWireframe(enabled) {
940
+ wireframeEnabled = enabled;
941
+ scene.traverse((object) => {
942
+ if (object.isMesh && object.material) {
943
+ object.material.wireframe = enabled;
944
+ }
945
+ });
946
+ console.log('Wireframe mode:', enabled ? 'enabled' : 'disabled');
947
+ }
948
+
949
+ /**
950
+ * Toggle stats (FPS counter) display
951
+ */
952
+ function toggleStats(enabled) {
953
+ if (stats) {
954
+ stats.dom.style.display = enabled ? 'block' : 'none';
955
+ console.log('Stats:', enabled ? 'enabled' : 'disabled');
956
+ }
957
+ }
958
+
959
+ /**
960
+ * Capture screenshot of the current scene
961
+ */
962
+ function captureScreenshot() {
963
+ if (!renderer) {
964
+ console.error('Renderer not initialized');
965
+ return;
966
+ }
967
+
968
+ // Render one more frame to ensure we have the latest scene
969
+ renderer.render(scene, camera);
970
+
971
+ // Get the canvas data as PNG
972
+ const dataURL = renderer.domElement.toDataURL('image/png');
973
+
974
+ // Send screenshot data back to parent window
975
+ window.parent.postMessage({
976
+ action: 'screenshot',
977
+ data: {
978
+ dataURL: dataURL,
979
+ timestamp: Date.now(),
980
+ sceneName: sceneData?.name || 'scene'
981
+ }
982
+ }, '*');
983
+
984
+ console.log('Screenshot captured and sent to parent window');
985
+ }
986
+
987
+ /**
988
+ * Handle object click for inspection
989
+ */
990
+ function onObjectClick(event) {
991
+ // Calculate mouse position in normalized device coordinates (-1 to +1)
992
+ const rect = renderer.domElement.getBoundingClientRect();
993
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
994
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
995
+
996
+ // Update raycaster
997
+ raycaster.setFromCamera(mouse, camera);
998
+
999
+ // Find intersections (only check mesh objects, not lights or helpers)
1000
+ const intersects = raycaster.intersectObjects(scene.children.filter(obj => obj.isMesh));
1001
+
1002
+ if (intersects.length > 0) {
1003
+ const clickedObject = intersects[0].object;
1004
+
1005
+ // Deselect previous object
1006
+ if (selectedObject && selectedObject !== clickedObject) {
1007
+ if (selectedObject.userData.originalColor) {
1008
+ selectedObject.material.emissive.copy(selectedObject.userData.originalColor);
1009
+ selectedObject.material.emissiveIntensity = 0;
1010
+ }
1011
+ }
1012
+
1013
+ // Select new object
1014
+ selectedObject = clickedObject;
1015
+
1016
+ // Highlight selected object with persistent glow
1017
+ if (!selectedObject.userData.originalColor) {
1018
+ selectedObject.userData.originalColor = selectedObject.material.emissive.clone();
1019
+ }
1020
+ selectedObject.material.emissive = new THREE.Color(0x4444ff);
1021
+ selectedObject.material.emissiveIntensity = 0.3;
1022
+
1023
+ // Get object properties
1024
+ const objectInfo = {
1025
+ id: clickedObject.userData.id,
1026
+ name: clickedObject.userData.name || 'Unnamed Object',
1027
+ type: clickedObject.userData.type || 'unknown',
1028
+ position: {
1029
+ x: clickedObject.position.x.toFixed(2),
1030
+ y: clickedObject.position.y.toFixed(2),
1031
+ z: clickedObject.position.z.toFixed(2)
1032
+ },
1033
+ rotation: {
1034
+ x: THREE.MathUtils.radToDeg(clickedObject.rotation.x).toFixed(2),
1035
+ y: THREE.MathUtils.radToDeg(clickedObject.rotation.y).toFixed(2),
1036
+ z: THREE.MathUtils.radToDeg(clickedObject.rotation.z).toFixed(2)
1037
+ },
1038
+ scale: {
1039
+ x: clickedObject.scale.x.toFixed(2),
1040
+ y: clickedObject.scale.y.toFixed(2),
1041
+ z: clickedObject.scale.z.toFixed(2)
1042
+ },
1043
+ color: '#' + clickedObject.material.color.getHexString()
1044
+ };
1045
+
1046
+ // Send object info to parent window
1047
+ window.parent.postMessage({
1048
+ action: 'objectInspect',
1049
+ data: objectInfo
1050
+ }, '*');
1051
+
1052
+ console.log('Object selected:', objectInfo);
1053
+ }
1054
+ }
1055
+
1056
+ // ==================== PostMessage API for Dynamic Updates ====================
1057
+
1058
+ /**
1059
+ * Listen for messages from parent window (Gradio) to update scene dynamically
1060
+ * This eliminates the need for iframe reloads
1061
+ */
1062
+ window.addEventListener('message', (event) => {
1063
+ // Security: verify origin in production
1064
+ // if (event.origin !== window.location.origin) return;
1065
+
1066
+ const { action, data } = event.data;
1067
+ console.log('📨 Received postMessage:', action, data);
1068
+
1069
+ switch (action) {
1070
+ case 'addObject':
1071
+ handleAddObject(data);
1072
+ break;
1073
+ case 'removeObject':
1074
+ handleRemoveObject(data);
1075
+ break;
1076
+ case 'setLighting':
1077
+ handleSetLighting(data);
1078
+ break;
1079
+ case 'updateScene':
1080
+ handleUpdateScene(data);
1081
+ break;
1082
+ case 'setControlMode':
1083
+ setControlMode(data.mode);
1084
+ break;
1085
+ case 'toggleGrid':
1086
+ toggleGrid(data.enabled);
1087
+ break;
1088
+ case 'toggleWireframe':
1089
+ toggleWireframe(data.enabled);
1090
+ break;
1091
+ case 'toggleStats':
1092
+ toggleStats(data.enabled);
1093
+ break;
1094
+ case 'takeScreenshot':
1095
+ captureScreenshot();
1096
+ break;
1097
+ case 'addLight':
1098
+ addLightToScene(data);
1099
+ break;
1100
+ case 'removeLight':
1101
+ removeLightFromScene(data.light_name);
1102
+ break;
1103
+ case 'updateLight':
1104
+ updateSceneLight(data.light_name, data);
1105
+ break;
1106
+ case 'updateMaterial':
1107
+ updateObjectMaterial(data.object_id, data);
1108
+ break;
1109
+ case 'setBackground':
1110
+ setSceneBackground(data);
1111
+ break;
1112
+ case 'setFog':
1113
+ setSceneFog(data);
1114
+ break;
1115
+ default:
1116
+ console.warn('Unknown postMessage action:', action);
1117
+ }
1118
+ });
1119
+
1120
+ /**
1121
+ * Dynamically add an object to the scene
1122
+ */
1123
+ function handleAddObject(objData) {
1124
+ if (!scene || !sceneData) {
1125
+ console.error('Scene not initialized yet');
1126
+ return;
1127
+ }
1128
+
1129
+ // Validate object is within bounds
1130
+ if (Math.abs(objData.position.x) > WORLD_HALF || Math.abs(objData.position.z) > WORLD_HALF) {
1131
+ console.error(`Cannot add object at (${objData.position.x}, ${objData.position.z}) - outside 10x10 world bounds`);
1132
+ return;
1133
+ }
1134
+
1135
+ // Add to scene data
1136
+ sceneData.objects.push(objData);
1137
+
1138
+ // Create and add to Three.js scene
1139
+ let geometry;
1140
+ switch (objData.type) {
1141
+ case 'cube':
1142
+ geometry = new THREE.BoxGeometry(objData.scale.x, objData.scale.y, objData.scale.z);
1143
+ break;
1144
+ case 'sphere':
1145
+ geometry = new THREE.SphereGeometry(objData.scale.x, 32, 32);
1146
+ break;
1147
+ case 'cylinder':
1148
+ geometry = new THREE.CylinderGeometry(objData.scale.x, objData.scale.x, objData.scale.y, 32);
1149
+ break;
1150
+ case 'plane':
1151
+ geometry = new THREE.PlaneGeometry(objData.scale.x, objData.scale.y);
1152
+ break;
1153
+ case 'cone':
1154
+ geometry = new THREE.ConeGeometry(objData.scale.x, objData.scale.y, 32);
1155
+ break;
1156
+ case 'torus':
1157
+ geometry = new THREE.TorusGeometry(objData.scale.x, objData.scale.x * 0.4, 16, 100);
1158
+ break;
1159
+ default:
1160
+ console.warn('Unknown object type:', objData.type);
1161
+ return;
1162
+ }
1163
+
1164
+ const material = new THREE.MeshStandardMaterial({
1165
+ color: objData.material.color,
1166
+ metalness: objData.material.metalness || 0.5,
1167
+ roughness: objData.material.roughness || 0.5,
1168
+ opacity: objData.material.opacity || 1.0,
1169
+ transparent: objData.material.opacity < 1.0,
1170
+ wireframe: wireframeEnabled || objData.material.wireframe || false,
1171
+ });
1172
+
1173
+ const mesh = new THREE.Mesh(geometry, material);
1174
+ mesh.position.set(objData.position.x, objData.position.y, objData.position.z);
1175
+ mesh.rotation.set(
1176
+ THREE.MathUtils.degToRad(objData.rotation.x),
1177
+ THREE.MathUtils.degToRad(objData.rotation.y),
1178
+ THREE.MathUtils.degToRad(objData.rotation.z)
1179
+ );
1180
+ mesh.userData = {
1181
+ id: objData.id,
1182
+ name: objData.name,
1183
+ type: objData.type,
1184
+ isSceneObject: true,
1185
+ };
1186
+
1187
+ scene.add(mesh);
1188
+
1189
+ // Create physics body for collision
1190
+ const physicsShape = createPhysicsShape(objData.type, objData.scale);
1191
+ const physicsBody = new CANNON.Body({
1192
+ mass: 0, // Static objects
1193
+ position: new CANNON.Vec3(objData.position.x, objData.position.y, objData.position.z),
1194
+ });
1195
+ physicsBody.addShape(physicsShape);
1196
+
1197
+ // Apply rotation to physics body
1198
+ const quaternion = new CANNON.Quaternion();
1199
+ quaternion.setFromEuler(
1200
+ THREE.MathUtils.degToRad(objData.rotation.x),
1201
+ THREE.MathUtils.degToRad(objData.rotation.y),
1202
+ THREE.MathUtils.degToRad(objData.rotation.z),
1203
+ 'XYZ'
1204
+ );
1205
+ physicsBody.quaternion.copy(quaternion);
1206
+
1207
+ physicsWorld.addBody(physicsBody);
1208
+ objectBodies.set(objData.id, physicsBody);
1209
+
1210
+ // Add highlight effect
1211
+ animateObjectHighlight(mesh);
1212
+
1213
+ console.log('Added object dynamically:', objData.name || objData.id);
1214
+ }
1215
+
1216
+ /**
1217
+ * Dynamically remove an object from the scene
1218
+ */
1219
+ function handleRemoveObject(data) {
1220
+ if (!scene || !sceneData) {
1221
+ console.error('Scene not initialized yet');
1222
+ return;
1223
+ }
1224
+
1225
+ const { object_id } = data;
1226
+
1227
+ // Remove from Three.js scene
1228
+ const objectToRemove = scene.children.find(obj => obj.userData && obj.userData.id === object_id);
1229
+ if (objectToRemove) {
1230
+ scene.remove(objectToRemove);
1231
+ if (objectToRemove.geometry) objectToRemove.geometry.dispose();
1232
+ if (objectToRemove.material) objectToRemove.material.dispose();
1233
+ }
1234
+
1235
+ // Remove physics body
1236
+ const physicsBody = objectBodies.get(object_id);
1237
+ if (physicsBody) {
1238
+ physicsWorld.removeBody(physicsBody);
1239
+ objectBodies.delete(object_id);
1240
+ }
1241
+
1242
+ // Remove from scene data
1243
+ sceneData.objects = sceneData.objects.filter(obj => obj.id !== object_id);
1244
+
1245
+ console.log('Removed object dynamically:', object_id);
1246
+ }
1247
+
1248
+ /**
1249
+ * Dynamically update lighting
1250
+ */
1251
+ function handleSetLighting(data) {
1252
+ if (!scene || !sceneData) {
1253
+ console.error('Scene not initialized yet');
1254
+ return;
1255
+ }
1256
+
1257
+ const { lights } = data;
1258
+
1259
+ // Remove all existing lights
1260
+ const lightsToRemove = scene.children.filter(obj => obj.isLight);
1261
+ lightsToRemove.forEach(light => scene.remove(light));
1262
+
1263
+ // Add new lights
1264
+ lights.forEach(lightData => {
1265
+ let light;
1266
+
1267
+ if (lightData.type === 'ambient') {
1268
+ light = new THREE.AmbientLight(lightData.color, lightData.intensity);
1269
+ } else if (lightData.type === 'directional') {
1270
+ light = new THREE.DirectionalLight(lightData.color, lightData.intensity);
1271
+ light.position.set(lightData.position.x, lightData.position.y, lightData.position.z);
1272
+ light.target.position.set(0, 0, 0);
1273
+ scene.add(light.target);
1274
+ if (lightData.cast_shadow) {
1275
+ light.castShadow = true;
1276
+ }
1277
+ } else if (lightData.type === 'point') {
1278
+ light = new THREE.PointLight(lightData.color, lightData.intensity);
1279
+ light.position.set(lightData.position.x, lightData.position.y, lightData.position.z);
1280
+ }
1281
+
1282
+ if (light) {
1283
+ scene.add(light);
1284
+ }
1285
+ });
1286
+
1287
+ // Update scene data
1288
+ sceneData.lights = lights;
1289
+
1290
+ console.log('Updated lighting dynamically');
1291
+ }
1292
+
1293
+ /**
1294
+ * Fully reload the scene from new data
1295
+ */
1296
+ function handleUpdateScene(data) {
1297
+ // For major updates, we can reload the entire scene
1298
+ // This is a fallback for complex changes
1299
+ location.reload();
1300
+ }
1301
+
1302
+ /**
1303
+ * Animate object highlight with enhanced color pulse and scale effect
1304
+ */
1305
+ function animateObjectHighlight(mesh) {
1306
+ const originalColor = mesh.material.color.clone();
1307
+ const originalScale = mesh.scale.clone();
1308
+
1309
+ // Color pulse sequence: yellow -> cyan -> yellow
1310
+ const pulseColors = [
1311
+ new THREE.Color(0xffff00), // Yellow
1312
+ new THREE.Color(0x00ffff), // Cyan
1313
+ new THREE.Color(0xffff00), // Yellow
1314
+ ];
1315
+
1316
+ let progress = 0;
1317
+ const duration = 90; // frames (1.5 seconds at 60fps)
1318
+ const pulseIntensity = 0.6; // How much to mix highlight colors
1319
+
1320
+ function animateHighlight() {
1321
+ if (progress < duration) {
1322
+ // Calculate normalized progress (0 to 1)
1323
+ const t = progress / duration;
1324
+
1325
+ // Color animation - cycle through pulse colors
1326
+ const colorPhase = t * (pulseColors.length - 1);
1327
+ const colorIndex = Math.floor(colorPhase);
1328
+ const colorBlend = colorPhase - colorIndex;
1329
+
1330
+ if (colorIndex < pulseColors.length - 1) {
1331
+ const color1 = pulseColors[colorIndex];
1332
+ const color2 = pulseColors[colorIndex + 1];
1333
+ const blendedColor = color1.clone().lerp(color2, colorBlend);
1334
+
1335
+ // Mix with original color using sine wave for smooth pulse
1336
+ const pulseMix = Math.sin(t * Math.PI) * pulseIntensity;
1337
+ mesh.material.color.lerpColors(originalColor, blendedColor, pulseMix);
1338
+ }
1339
+
1340
+ // Scale animation - subtle "pop" effect
1341
+ const scaleAmount = 1.0 + Math.sin(t * Math.PI) * 0.15; // 15% scale increase
1342
+ mesh.scale.copy(originalScale).multiplyScalar(scaleAmount);
1343
+
1344
+ // Increase emissive for glow effect
1345
+ if (mesh.material.emissive) {
1346
+ const emissiveIntensity = Math.sin(t * Math.PI * 2) * 0.3;
1347
+ mesh.material.emissiveIntensity = emissiveIntensity;
1348
+ }
1349
+
1350
+ progress++;
1351
+ requestAnimationFrame(animateHighlight);
1352
+ } else {
1353
+ // Reset to original state
1354
+ mesh.material.color.copy(originalColor);
1355
+ mesh.scale.copy(originalScale);
1356
+ if (mesh.material.emissive) {
1357
+ mesh.material.emissiveIntensity = 0;
1358
+ }
1359
+ }
1360
+ }
1361
+
1362
+ // Enable emissive if not already set
1363
+ if (!mesh.material.emissive) {
1364
+ mesh.material.emissive = new THREE.Color(originalColor);
1365
+ mesh.material.emissiveIntensity = 0;
1366
+ }
1367
+
1368
+ animateHighlight();
1369
+ }
1370
+
1371
+ // ==================== Rendering & Lighting Handler Functions ====================
1372
+
1373
+ function addLightToScene(lightData) {
1374
+ console.log('💡 addLightToScene called with:', lightData);
1375
+ let light;
1376
+
1377
+ if (lightData.light_type === 'ambient') {
1378
+ light = new THREE.AmbientLight(lightData.color, lightData.intensity);
1379
+ } else if (lightData.light_type === 'directional') {
1380
+ light = new THREE.DirectionalLight(lightData.color, lightData.intensity);
1381
+ light.position.set(lightData.position.x, lightData.position.y, lightData.position.z);
1382
+ if (lightData.target) {
1383
+ light.target.position.set(lightData.target.x, lightData.target.y, lightData.target.z);
1384
+ scene.add(light.target);
1385
+ }
1386
+ } else if (lightData.light_type === 'point') {
1387
+ light = new THREE.PointLight(lightData.color, lightData.intensity);
1388
+ light.position.set(lightData.position.x, lightData.position.y, lightData.position.z);
1389
+ } else if (lightData.light_type === 'spot') {
1390
+ light = new THREE.SpotLight(lightData.color, lightData.intensity);
1391
+ light.position.set(lightData.position.x, lightData.position.y, lightData.position.z);
1392
+ light.angle = THREE.MathUtils.degToRad(lightData.spot_angle || 45);
1393
+ if (lightData.target) {
1394
+ light.target.position.set(lightData.target.x, lightData.target.y, lightData.target.z);
1395
+ scene.add(light.target);
1396
+ }
1397
+ }
1398
+
1399
+ if (light) {
1400
+ light.name = lightData.name;
1401
+ if (lightData.cast_shadow) light.castShadow = true;
1402
+ scene.add(light);
1403
+ console.log('💡 Light added to scene:', lightData.name, 'type:', lightData.light_type);
1404
+ } else {
1405
+ console.error('❌ Failed to create light:', lightData);
1406
+ }
1407
+ }
1408
+
1409
+ function removeLightFromScene(lightName) {
1410
+ const light = scene.getObjectByName(lightName);
1411
+ if (light) {
1412
+ scene.remove(light);
1413
+ console.log('Removed light:', lightName);
1414
+ }
1415
+ }
1416
+
1417
+ function updateSceneLight(lightName, updates) {
1418
+ const light = scene.getObjectByName(lightName);
1419
+ if (!light) return;
1420
+
1421
+ if (updates.color) light.color.set(updates.color);
1422
+ if (updates.intensity !== undefined) light.intensity = updates.intensity;
1423
+ if (updates.position) {
1424
+ light.position.set(updates.position.x, updates.position.y, updates.position.z);
1425
+ }
1426
+ if (updates.cast_shadow !== undefined) light.castShadow = updates.cast_shadow;
1427
+
1428
+ console.log('Updated light:', lightName);
1429
+ }
1430
+
1431
+ function updateObjectMaterial(objectId, materialData) {
1432
+ console.log('updateObjectMaterial called with:', objectId, materialData);
1433
+ const obj = scene.children.find(child => child.userData.id === objectId);
1434
+ if (!obj || !obj.material) {
1435
+ console.error('Object not found or has no material:', objectId);
1436
+ return;
1437
+ }
1438
+
1439
+ console.log('Found object:', obj.userData.name, 'Current color:', obj.material.color.getHexString());
1440
+
1441
+ if (materialData.color) {
1442
+ obj.material.color.set(materialData.color);
1443
+ console.log('Set color to:', materialData.color);
1444
+ }
1445
+ if (materialData.metalness !== undefined) {
1446
+ obj.material.metalness = materialData.metalness;
1447
+ console.log('Set metalness to:', materialData.metalness);
1448
+ }
1449
+ if (materialData.roughness !== undefined) {
1450
+ obj.material.roughness = materialData.roughness;
1451
+ console.log('Set roughness to:', materialData.roughness);
1452
+ }
1453
+ if (materialData.opacity !== undefined) {
1454
+ obj.material.opacity = materialData.opacity;
1455
+ obj.material.transparent = materialData.opacity < 1.0;
1456
+ console.log('Set opacity to:', materialData.opacity);
1457
+ }
1458
+ if (materialData.emissive) {
1459
+ obj.material.emissive = new THREE.Color(materialData.emissive);
1460
+ console.log('Set emissive to:', materialData.emissive);
1461
+ }
1462
+ if (materialData.emissive_intensity !== undefined) {
1463
+ obj.material.emissiveIntensity = materialData.emissive_intensity;
1464
+ console.log('Set emissive intensity to:', materialData.emissive_intensity);
1465
+ }
1466
+
1467
+ obj.material.needsUpdate = true;
1468
+ console.log('Material updated successfully! New color:', obj.material.color.getHexString());
1469
+ }
1470
+
1471
+ function setSceneBackground(bgData) {
1472
+ if (bgData.background_type === 'gradient') {
1473
+ // Create gradient canvas
1474
+ const canvas = document.createElement('canvas');
1475
+ canvas.width = 2;
1476
+ canvas.height = 256;
1477
+ const ctx = canvas.getContext('2d');
1478
+ const gradient = ctx.createLinearGradient(0, 0, 0, 256);
1479
+ gradient.addColorStop(0, bgData.background_gradient_top);
1480
+ gradient.addColorStop(1, bgData.background_gradient_bottom);
1481
+ ctx.fillStyle = gradient;
1482
+ ctx.fillRect(0, 0, 2, 256);
1483
+
1484
+ const texture = new THREE.CanvasTexture(canvas);
1485
+ scene.background = texture;
1486
+ } else {
1487
+ scene.background = new THREE.Color(bgData.background_color);
1488
+ }
1489
+
1490
+ console.log('Updated background');
1491
+ }
1492
+
1493
+ function setSceneFog(fogData) {
1494
+ if (!fogData.enabled) {
1495
+ scene.fog = null;
1496
+ console.log('Fog disabled');
1497
+ return;
1498
+ }
1499
+
1500
+ const color = new THREE.Color(fogData.color);
1501
+
1502
+ if (fogData.type === 'exponential') {
1503
+ scene.fog = new THREE.FogExp2(color, fogData.density);
1504
+ } else {
1505
+ scene.fog = new THREE.Fog(color, fogData.near, fogData.far);
1506
+ }
1507
+
1508
+ console.log('Fog enabled:', fogData.type);
1509
+ }
1510
+
1511
+ // Start the application
1512
+ init();
1513
+ </script>
1514
+ </body>
1515
+ </html>
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core Backend
2
+ fastapi
3
+ uvicorn[standard]
4
+ requests
5
+
6
+ # MCP Server (official Anthropic SDK)
7
+ mcp>=1.0.0
8
+
9
+ # Chat Interface
10
+ gradio
11
+
12
+ # LLM
13
+ openai
14
+ python-dotenv