# Section 07: Tool B01 -- Scene Map

## Overview

Implement `godotiq_scene_map` -- the spatial intelligence tool. This is the killer feature: complete spatial understanding of a scene with 3D positions, distances, directions, and spatial relationships. Uses the scene resolver and world transforms from Sprint 1B.

**File to create:** `src/godotiq/tools/spatial.py` (replace placeholder content)
**Test file to create:** `tests/test_tools/test_scene_map.py`

---

## Dependencies

- **Upstream:** Section 03 (server integration)
- **Downstream:** None (independent of sections 04, 05, 06)

---

## Existing API Reference (from scene_resolver.py)

```python
from godotiq.parsers.scene_resolver import (
    resolve_scene,            # TscnScene + Path -> ResolvedScene
    calculate_world_transforms,  # ResolvedScene -> list[WorldNode]
    find_nodes_by_type,       # list[WorldNode] + str -> list[WorldNode]
    find_nodes_by_group,      # list[WorldNode] + str -> list[WorldNode]
    find_nodes_in_radius,     # list[WorldNode] + center + radius -> list[WorldNode]
    ResolvedScene,
    WorldNode,
)
```

WorldNode fields:
- `node: TscnNode` (name, type, groups, script, properties)
- `world_position: tuple[float, float, float]`
- `world_rotation: tuple[float, float, float]` (YXZ Euler radians)
- `world_scale: tuple[float, float, float]`
- `full_path: str` (slash-separated from root)
- `depth: int`
- `is_instance_root: bool`
- `instance_source: str | None`

---

## Implementation Details

### `src/godotiq/tools/spatial.py`

```python
"""Spatial Intelligence tools -- scene maps, transforms, spatial queries.

These tools provide spatial understanding of Godot scenes.
"""

from __future__ import annotations

import math

from godotiq.session import GodotIQSession
from godotiq.parsers.scene_resolver import WorldNode


def _build_scene_map(
    session: GodotIQSession,
    scene_path: str,
    focus: str | None = None,
    radius: float = 5.0,
    include: list[str] | None = None,
    detail: str = "normal",
) -> dict:
    """Build spatial map of a scene.

    Args:
        session: Loaded GodotIQSession.
        scene_path: res:// path to .tscn file.
        focus: Optional node name/path to center the view on.
        radius: Distance filter around focus node (only with focus).
        include: Optional list of node types or groups to include.
        detail: "brief", "normal", or "full".

    Returns:
        Dictionary with spatial map data.
    """
    ...
```

### Output Structure

#### Brief Detail

```python
{
    "scene": str,                     # res:// path
    "root_type": str,
    "total_nodes": int,
    "nodes": [
        {
            "name": str,
            "type": str,
            "position": [float, float, float],
            "depth": int,
        }
    ],
}
```

#### Normal Detail

```python
{
    "scene": str,
    "root_type": str,
    "total_nodes": int,
    "nodes": [
        {
            "name": str,
            "type": str,
            "full_path": str,
            "position": [float, float, float],
            "rotation_deg": [float, float, float],  # converted to degrees
            "scale": [float, float, float],
            "depth": int,
            "is_instance": bool,
            "instance_source": str | None,
            "groups": [str],
            "script": str | None,
        }
    ],
    "spatial_summary": {
        "bounds": {
            "min": [float, float, float],
            "max": [float, float, float],
        },
        "center": [float, float, float],
    },
    "groups_summary": {               # group name -> count
        str: int,
    },
}
```

#### Full Detail

```python
{
    # ... all normal fields ...
    "nodes": [
        {
            # ... all normal node fields ...
            "properties": dict,       # all node properties
            "metadata": dict,         # node metadata
            "children_count": int,
        }
    ],
    "distance_matrix": [              # pairwise distances between key nodes
        {
            "from": str,              # full_path
            "to": str,
            "distance": float,
        }
    ],
    "type_summary": {                 # type -> count
        str: int,
    },
}
```

### Focus Node Behavior

When `focus` is specified:
1. Find the WorldNode matching the focus name/path
2. Use `find_nodes_in_radius()` to get all nodes within the radius
3. Add distance and direction to each node relative to focus

```python
# Additional fields when focus is active:
{
    "focus_node": str,                # the focus node path
    "focus_position": [float, float, float],
    "nodes": [
        {
            # ... all standard fields ...
            "distance_to_focus": float,
            "direction_from_focus": str,  # "north", "south", "east", "west", "above", "below", "nearby"
        }
    ],
}
```

### Direction Computation

```python
def _compute_direction(from_pos: tuple, to_pos: tuple) -> str:
    """Compute cardinal direction from one position to another.

    Uses Godot's coordinate system:
    - X+ = east, X- = west
    - Y+ = up, Y- = down
    - Z+ = south (toward camera in default view), Z- = north
    """
    dx = to_pos[0] - from_pos[0]
    dy = to_pos[1] - from_pos[1]
    dz = to_pos[2] - from_pos[2]

    # Check vertical first
    if abs(dy) > abs(dx) and abs(dy) > abs(dz):
        return "above" if dy > 0 else "below"

    # Check horizontal
    dist_xz = math.sqrt(dx * dx + dz * dz)
    if dist_xz < 0.5:
        return "nearby"

    # Determine primary horizontal direction
    if abs(dx) > abs(dz):
        return "east" if dx > 0 else "west"
    else:
        return "north" if dz < 0 else "south"
```

### Include Filter

When `include` is specified, filter nodes to only those whose:
- `node.type` matches an entry in include, OR
- Any of `node.groups` matches an entry in include

### Distance and Bounds Computation

```python
def _compute_distance(a: tuple, b: tuple) -> float:
    return math.sqrt(sum((ai - bi) ** 2 for ai, bi in zip(a, b)))

def _compute_bounds(nodes: list[WorldNode]) -> dict:
    if not nodes:
        return {"min": [0, 0, 0], "max": [0, 0, 0]}
    positions = [wn.world_position for wn in nodes]
    return {
        "min": [min(p[i] for p in positions) for i in range(3)],
        "max": [max(p[i] for p in positions) for i in range(3)],
    }
```

### Error Handling

```python
{
    "error": "Scene not found",
    "scene": str,
}
```

### MCP Tool Registration

```python
from mcp.server.fastmcp import Context
from godotiq.server import mcp


@mcp.tool(
    name="godotiq_scene_map",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_scene_map(
    scene: str,
    focus: str | None = None,
    radius: float = 5.0,
    include: list[str] | None = None,
    detail: str = "normal",
    ctx: Context = None,
) -> dict:
    """Complete spatial map of a scene with 3D positions, distances, and spatial relationships.

    Args:
        scene: Path to .tscn file (res:// or relative).
        focus: Optional node name or path to center the view on.
        radius: Distance filter around focus node (meters, default 5.0).
        include: Optional list of node types or groups to include.
        detail: Level of detail - "brief", "normal", or "full".
    """
    session = ctx.request_context.lifespan_state["session"]
    if session is None:
        return {"error": "No Godot project loaded"}
    return _build_scene_map(session, scene, focus, radius, include, detail)
```

---

## Tests: `tests/test_tools/test_scene_map.py`

```python
"""Tests for godotiq_scene_map tool."""

from __future__ import annotations

import pytest

from godotiq.session import GodotIQSession
from godotiq.tools.spatial import _build_scene_map


class TestSceneMapBasic:
    def test_map_basic(self, session: GodotIQSession) -> None:
        """Basic scene map returns expected structure."""
        result = _build_scene_map(session, "res://scenes/equipment/fdm_printer.tscn")
        assert result["scene"] == "res://scenes/equipment/fdm_printer.tscn"
        assert result["total_nodes"] >= 4
        assert len(result["nodes"]) >= 4

    def test_map_node_positions(self, session: GodotIQSession) -> None:
        """Nodes have world-space positions."""
        result = _build_scene_map(session, "res://scenes/equipment/fdm_printer.tscn")
        for node in result["nodes"]:
            assert "position" in node
            assert len(node["position"]) == 3

    def test_map_main_scene_with_instances(self, session: GodotIQSession) -> None:
        """Main scene resolves instances and computes world positions."""
        result = _build_scene_map(session, "res://scenes/main.tscn")
        assert result["total_nodes"] >= 2
        # Should include the instanced FDMPrinter nodes
        node_names = [n["name"] for n in result["nodes"]]
        assert "Main" in node_names or any("Equipment" in n for n in node_names)


class TestSceneMapFocus:
    def test_map_with_focus(self, session: GodotIQSession) -> None:
        """Focus on a node filters to nearby nodes."""
        result = _build_scene_map(
            session,
            "res://scenes/equipment/fdm_printer.tscn",
            focus="WorkPoint",
            radius=5.0,
        )
        assert "focus_node" in result
        # All nodes should have distance_to_focus
        for node in result["nodes"]:
            assert "distance_to_focus" in node

    def test_map_radius_filter(self, session: GodotIQSession) -> None:
        """Radius parameter limits returned nodes."""
        result_wide = _build_scene_map(
            session,
            "res://scenes/equipment/fdm_printer.tscn",
            focus="FDMPrinter",
            radius=100.0,
        )
        result_narrow = _build_scene_map(
            session,
            "res://scenes/equipment/fdm_printer.tscn",
            focus="FDMPrinter",
            radius=0.1,
        )
        assert len(result_wide["nodes"]) >= len(result_narrow["nodes"])

    def test_map_direction(self, session: GodotIQSession) -> None:
        """Direction from focus is computed."""
        result = _build_scene_map(
            session,
            "res://scenes/equipment/fdm_printer.tscn",
            focus="FDMPrinter",
            radius=100.0,
        )
        directions = set()
        for node in result["nodes"]:
            if "direction_from_focus" in node:
                directions.add(node["direction_from_focus"])
        # Should have at least one direction (nearby, above, etc.)
        assert len(directions) >= 1


class TestSceneMapDetail:
    def test_map_brief(self, session: GodotIQSession) -> None:
        """Brief detail returns minimal data."""
        result = _build_scene_map(
            session,
            "res://scenes/equipment/fdm_printer.tscn",
            detail="brief",
        )
        assert "scene" in result
        assert "nodes" in result
        # Brief should NOT have spatial_summary
        assert "spatial_summary" not in result

    def test_map_full(self, session: GodotIQSession) -> None:
        """Full detail returns all data including distance matrix."""
        result = _build_scene_map(
            session,
            "res://scenes/equipment/fdm_printer.tscn",
            detail="full",
        )
        assert "type_summary" in result

    def test_map_groups_summary(self, session: GodotIQSession) -> None:
        """Groups summary counts nodes per group."""
        result = _build_scene_map(
            session,
            "res://scenes/equipment/fdm_printer.tscn",
            detail="normal",
        )
        assert "groups_summary" in result
        # FDMPrinter root node is in "interactable" group
        assert "interactable" in result["groups_summary"]


class TestSceneMapErrors:
    def test_map_missing_scene(self, session: GodotIQSession) -> None:
        """Missing scene returns error dict."""
        result = _build_scene_map(session, "res://nonexistent.tscn")
        assert "error" in result
```

---

## Checklist

- [x] `src/godotiq/tools/spatial/__init__.py` created with `_build_scene_map` and MCP tool
- [x] Scene resolution via session.resolve_scene_spatial()
- [x] World-space positions computed for all nodes
- [x] Focus node filtering with radius (focus search before include filter)
- [x] Direction computation (north/south/east/west/above/below/nearby)
- [x] Detail levels: brief, normal, full
- [x] Groups summary computed
- [x] Distance matrix in full detail (capped at 50 pairs)
- [x] Spatial bounds computed
- [x] Error handling for missing scenes + warning for missing focus nodes
- [x] Include filter by node type or group
- [x] `tests/test_tools/test_scene_map.py` created with 12 tests
- [x] All 306 tests pass: `uv run pytest tests/ -v`

## Implementation Notes

- **File path deviation**: Plan specified `src/godotiq/tools/spatial.py` but implementation uses `src/godotiq/tools/spatial/__init__.py` (package), consistent with existing `memory/` and `code/` tool packages.
- **lifespan_context**: Plan specified `lifespan_state` but `lifespan_context` is the correct accessor matching all other tools in the codebase.
- **Code review fixes**: Focus node search moved before include filter to prevent silent loss; distance matrix capped at 50 pairs; warning added when focus node not found.
