# Sprint 5c Implementation Plan — Editor Screenshot + Camera Control

## Overview

GodotIQ is an MCP server for AI-assisted Godot 4 development. It has a Python MCP server (`src/godotiq/`) communicating via WebSocket with a Godot editor addon (`godot-addon/addons/godotiq/`). The addon's `godotiq_server.gd` dispatches requests to either editor-side handlers or forwards to the running game via EngineDebugger.

This sprint adds visual awareness: the AI agent can capture what the editor sees (3D viewport screenshot) and control the editor camera — all without running the game.

## Section 1: Modify screenshot.py — Editor Routing

### Current State

`src/godotiq/tools/bridge/screenshot.py` has a `screenshot()` function that currently accepts `viewport`, `scale`, `format` parameters. It uses `bridge.request_with_retry("screenshot", ...)` which forwards to the game via EngineDebugger.

### Changes

Add `camera_position: list[float] | None` and `camera_target: list[float] | None` parameters to the `screenshot()` function.

When `viewport == "editor"`:
- Build params dict with `scale`, `format`, and optionally `camera_position`/`camera_target`
- Use `bridge.request("editor_screenshot", params=params, timeout=10.0)` — single attempt, no retry (editor-side is deterministic)
- Return the result dict

When `viewport == "game"` (default):
- Existing behavior unchanged — `bridge.request_with_retry("screenshot", ...)` forwarded to game

The function signature becomes:
```python
async def screenshot(
    viewport: str = "game",
    scale: float = 0.5,
    format: str = "webp",
    camera_position: list[float] | None = None,
    camera_target: list[float] | None = None,
) -> dict:
```

Camera parameters are only forwarded when `viewport == "editor"` and the values are not None.

Validation:
- If `viewport` is not `"editor"` or `"game"`, return error dict immediately
- If `camera_position` is provided, validate it has exactly 3 elements; return error if not
- If `camera_target` is provided, validate it has exactly 3 elements; return error if not

## Section 2: Create camera.py — Camera Control Tool

### New File: `src/godotiq/tools/bridge/camera.py`

A new bridge tool module with a `camera()` async function.

Parameters:
- `action: str = "get_position"` — one of `get_position`, `look_at`, `focus_node`
- `position: list[float] | None = None` — for `look_at` action
- `target: list[float] | None = None` — for `look_at` action
- `node: str = ""` — for `focus_node` action

Client-side validation:
- `look_at` without `position` → return error dict immediately (no bridge call)
- `look_at` with `position` that has != 3 elements → return error dict
- `look_at` with `target` that has != 3 elements → return error dict
- `focus_node` without `node` → return error dict immediately

Otherwise, build params dict from action + relevant fields and call `bridge.request("camera", params=params, timeout=5.0)`.

## Section 3: MCP Registration Updates

### Update screenshot registration in `bridge/__init__.py`

The existing `godotiq_screenshot` registration needs the new `camera_position` and `camera_target` parameters added to the wrapper function signature. The wrapper delegates to `_screenshot(viewport=viewport, scale=scale, format=format, camera_position=camera_position, camera_target=camera_target)`.

For editor viewport, the error handling should catch `BridgeConnectionError`, `BridgeTimeoutError`, `BridgeError`, and `Exception` — same pattern as existing.

### Add camera registration in `bridge/__init__.py`

Register `godotiq_camera` with:
- Annotations: `readOnlyHint=False` (look_at/focus_node mutate state), `destructiveHint=False`, `idempotentHint=True`, `openWorldHint=False`
- Import `camera as _camera` from `godotiq.tools.bridge.camera`
- Wrapper delegates to `_camera(action=action, position=position, target=target, node=node)`
- Standard error handling pattern

## Section 4: GDScript — Editor Screenshot Handler

### Dispatch Entry

Add to `_dispatch()` in `godotiq_server.gd`:
```
"editor_screenshot" → _handle_editor_screenshot(peer_id, id, params)
```

### Handler: `_handle_editor_screenshot`

This is the FIRST async handler in the server. **Critical pattern**: The dispatch calls the handler WITHOUT `await` — in GDScript, calling an async function without `await` starts it as a fire-and-forget coroutine. The handler internally awaits frame signals and sends the response when done. `_dispatch()` remains synchronous — it simply initiates the coroutine and returns immediately.

Steps:
1. Extract `scale` (float, default 0.5), `format` (string, default "webp") from params
2. Get editor viewport: `EditorInterface.get_editor_viewport_3d(0)` → guard null
3. Get camera: `viewport_3d.get_camera_3d()` → guard null
4. If `camera_position` param exists with 3+ elements:
   - Save `original_transform = camera.global_transform`
   - Set `camera.global_position` to new Vector3
   - If `camera_target` also exists AND distance to target > 0.001: call `camera.look_at(target_vec3)` (guard against zero-length direction vector which crashes `look_at`)
   - Set `camera_moved = true`
5. Await `RenderingServer.frame_post_draw` twice (frame 1: transform applied, frame 2: texture updated)
6. Capture: `viewport_3d.get_texture().get_image()` → guard null
7. Resize if `scale < 1.0 and scale > 0.0`: `img.resize(int(w * scale), int(h * scale))`
8. Encode based on format: `save_webp_to_buffer()`, `save_png_to_buffer()`, `save_jpg_to_buffer()`
9. Base64 encode: `Marshalls.raw_to_base64(encoded)`
10. Restore camera if moved: `camera.global_transform = original_transform`
11. Send response with `image`, `width`, `height`, `format`, `viewport: "editor"`

Error cases:
- No viewport → `_send_error("NO_VIEWPORT", ...)`
- No camera → `_send_error("NO_CAMERA", ...)`
- Null image → `_send_error("CAPTURE_FAILED", ...)` (restore camera first)

## Section 5: GDScript — Camera Handler

### Dispatch Entry

Add to `_dispatch()`:
```
"camera" → _handle_camera(peer_id, id, params)
```

### Handler: `_handle_camera`

Not async — all camera operations are synchronous.

Common preamble: get viewport + camera, guard nulls.

#### Action: `get_position`
Read camera state:
- `camera.global_position` → array with `snapped(x, 0.01)` precision
- `camera.global_rotation_degrees` → same
- Forward vector: `-camera.global_transform.basis.z` → same
- `camera.fov`

#### Action: `look_at`
Requires `position` param (3+ elements). Set `camera.global_position`, optionally `camera.look_at(target)` with epsilon guard (skip if distance < 0.001). Return final position/rotation. Include note: "Camera position is temporary — editor interpolation may reset it on user interaction."

#### Action: `focus_node`
Requires `node` param. Find node using existing `_find_node_by_name_or_path()`. Clear editor selection, add the target node. Return node name, position (if Node3D), hint about using editor_screenshot with camera_position for visual capture, and note that previous editor selection was replaced.

## Section 6: Update MCP Prompt

Update `src/godotiq/prompts/godot_development.md`:

1. Update `godotiq_screenshot` description: move to EDIT section with note about `viewport="editor"` for editor captures, keep cross-reference in PLAY & DEBUG for `viewport="game"` (default)
2. Add `godotiq_camera` to the EDIT section (editor-side, no game needed)
3. Add a "Visual Debugging" workflow:
   ```
   camera(get_position) → editor_screenshot(editor, camera_position=...) → verify
   ```

## Section 7: Verification

### Tests to Create

**`tests/test_bridge/test_editor_screenshot.py`**:
- Editor viewport routes to `"editor_screenshot"` method
- Editor with camera params includes them in request
- Game viewport still routes to `"screenshot"` method (backward compat)
- Default viewport is `"game"`
- Editor without camera params excludes them

**`tests/test_bridge/test_camera.py`**:
- `get_position` action sends correct params
- `look_at` with position + target sends both
- `look_at` without position returns error (no bridge call)
- `focus_node` sends node name
- `focus_node` without node returns error
- Default action is `get_position`

### Verification Steps

1. `pytest tests/ -v` → all existing tests pass
2. `pytest tests/test_bridge/test_editor_screenshot.py tests/test_bridge/test_camera.py -v` → new tests pass
3. Import smoke: `from godotiq.tools.bridge.camera import camera`
4. GDScript audit: no ternaries, no snapf, no f-strings in new code
5. Dispatch check: `"editor_screenshot"` and `"camera"` in dispatch table
6. Live Godot test: editor screenshot captures viewport, camera get_position returns data
