diff --git a/src/godotiq/tools/spatial/__init__.py b/src/godotiq/tools/spatial/__init__.py
index 6695368..648dd2e 100644
--- a/src/godotiq/tools/spatial/__init__.py
+++ b/src/godotiq/tools/spatial/__init__.py
@@ -1 +1,283 @@
-"""Spatial Intelligence tools — scene maps, transforms, collisions."""
+"""Spatial Intelligence tools -- scene maps, transforms, spatial queries.
+
+These tools provide spatial understanding of Godot scenes.
+"""
+
+from __future__ import annotations
+
+import math
+
+from mcp.server.fastmcp import Context
+
+from godotiq.server import mcp
+from godotiq.session import GodotIQSession
+from godotiq.parsers.scene_resolver import (
+    WorldNode,
+    find_nodes_in_radius,
+)
+
+
+def _compute_distance(a: tuple, b: tuple) -> float:
+    """Euclidean distance between two 3D points."""
+    return math.sqrt(sum((ai - bi) ** 2 for ai, bi in zip(a, b)))
+
+
+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"
+
+
+def _compute_bounds(nodes: list[WorldNode]) -> dict:
+    """Compute axis-aligned bounding box for a list of world nodes."""
+    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)],
+    }
+
+
+def _node_to_brief(wn: WorldNode) -> dict:
+    """Build brief node representation."""
+    return {
+        "name": wn.node.name,
+        "type": wn.node.type,
+        "position": list(wn.world_position),
+        "depth": wn.depth,
+    }
+
+
+def _node_to_normal(wn: WorldNode) -> dict:
+    """Build normal-detail node representation."""
+    return {
+        "name": wn.node.name,
+        "type": wn.node.type,
+        "full_path": wn.full_path,
+        "position": list(wn.world_position),
+        "rotation_deg": [math.degrees(r) for r in wn.world_rotation],
+        "scale": list(wn.world_scale),
+        "depth": wn.depth,
+        "is_instance": wn.is_instance_root,
+        "instance_source": wn.instance_source,
+        "groups": list(wn.node.groups),
+        "script": wn.node.script,
+    }
+
+
+def _node_to_full(wn: WorldNode) -> dict:
+    """Build full-detail node representation."""
+    d = _node_to_normal(wn)
+    d["properties"] = dict(wn.node.properties)
+    d["metadata"] = dict(wn.node.metadata)
+    d["children_count"] = len(wn.node.children)
+    return d
+
+
+def _filter_by_include(
+    world_nodes: list[WorldNode], include: list[str]
+) -> list[WorldNode]:
+    """Filter nodes whose type or any group matches an include entry."""
+    include_set = set(include)
+    return [
+        wn
+        for wn in world_nodes
+        if wn.node.type in include_set
+        or bool(include_set & set(wn.node.groups))
+    ]
+
+
+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.
+    """
+    spatial = session.resolve_scene_spatial(scene_path)
+    if spatial is None:
+        return {"error": "Scene not found", "scene": scene_path}
+
+    resolved, world_nodes = spatial
+    detail = detail.lower()
+
+    # Apply include filter
+    if include:
+        world_nodes = _filter_by_include(world_nodes, include)
+
+    # Focus mode: find focus node and filter by radius
+    focus_node: WorldNode | None = None
+    if focus:
+        for wn in world_nodes:
+            if wn.node.name == focus or wn.full_path == focus:
+                focus_node = wn
+                break
+        if focus_node is not None:
+            world_nodes = find_nodes_in_radius(
+                world_nodes, focus_node.world_position, radius
+            )
+
+    # Build node representations based on detail level
+    if detail == "full":
+        node_dicts = [_node_to_full(wn) for wn in world_nodes]
+    elif detail == "brief":
+        node_dicts = [_node_to_brief(wn) for wn in world_nodes]
+    else:
+        node_dicts = [_node_to_normal(wn) for wn in world_nodes]
+
+    # Add focus-specific fields to each node
+    if focus_node is not None:
+        for nd, wn in zip(node_dicts, world_nodes):
+            dist = _compute_distance(focus_node.world_position, wn.world_position)
+            nd["distance_to_focus"] = round(dist, 4)
+            nd["direction_from_focus"] = _compute_direction(
+                focus_node.world_position, wn.world_position
+            )
+
+    # Build result
+    root_type = resolved.root.type if resolved.root else ""
+    result: dict = {
+        "scene": scene_path,
+        "root_type": root_type,
+        "total_nodes": resolved.total_nodes,
+        "nodes": node_dicts,
+    }
+
+    # Add focus metadata
+    if focus_node is not None:
+        result["focus_node"] = focus_node.full_path or focus_node.node.name
+        result["focus_position"] = list(focus_node.world_position)
+
+    # Normal+ fields
+    if detail in ("normal", "full"):
+        result["spatial_summary"] = {
+            "bounds": _compute_bounds(world_nodes),
+            "center": _compute_center(world_nodes),
+        }
+        result["groups_summary"] = _compute_groups_summary(world_nodes)
+
+    # Full-only fields
+    if detail == "full":
+        result["distance_matrix"] = _compute_distance_matrix(world_nodes)
+        result["type_summary"] = _compute_type_summary(world_nodes)
+
+    return result
+
+
+def _compute_center(nodes: list[WorldNode]) -> list[float]:
+    """Compute center point of all nodes."""
+    if not nodes:
+        return [0.0, 0.0, 0.0]
+    positions = [wn.world_position for wn in nodes]
+    n = len(positions)
+    return [sum(p[i] for p in positions) / n for i in range(3)]
+
+
+def _compute_groups_summary(nodes: list[WorldNode]) -> dict[str, int]:
+    """Count nodes per group."""
+    groups: dict[str, int] = {}
+    for wn in nodes:
+        for g in wn.node.groups:
+            groups[g] = groups.get(g, 0) + 1
+    return groups
+
+
+def _compute_type_summary(nodes: list[WorldNode]) -> dict[str, int]:
+    """Count nodes per type."""
+    types: dict[str, int] = {}
+    for wn in nodes:
+        t = wn.node.type or "Unknown"
+        types[t] = types.get(t, 0) + 1
+    return types
+
+
+def _compute_distance_matrix(nodes: list[WorldNode]) -> list[dict]:
+    """Compute pairwise distances between nodes with scripts or groups."""
+    # Only include "key" nodes: those with scripts, groups, or instance roots
+    key_nodes = [
+        wn
+        for wn in nodes
+        if wn.node.script or wn.node.groups or wn.is_instance_root
+    ]
+    matrix: list[dict] = []
+    for i, a in enumerate(key_nodes):
+        for b in key_nodes[i + 1 :]:
+            matrix.append(
+                {
+                    "from": a.full_path or a.node.name,
+                    "to": b.full_path or b.node.name,
+                    "distance": round(
+                        _compute_distance(a.world_position, b.world_position), 4
+                    ),
+                }
+            )
+    return matrix
+
+
+@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_context["session"]
+    if session is None:
+        return {"error": "No Godot project loaded"}
+    return _build_scene_map(session, scene, focus, radius, include, detail)
diff --git a/tests/test_tools/test_scene_map.py b/tests/test_tools/test_scene_map.py
new file mode 100644
index 0000000..044a0ac
--- /dev/null
+++ b/tests/test_tools/test_scene_map.py
@@ -0,0 +1,141 @@
+"""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 TestSceneMapInclude:
+    def test_include_by_type(self, session: GodotIQSession) -> None:
+        """Include filter by node type."""
+        result = _build_scene_map(
+            session,
+            "res://scenes/equipment/fdm_printer.tscn",
+            include=["Node3D"],
+        )
+        for node in result["nodes"]:
+            assert node["type"] == "Node3D"
+
+    def test_include_by_group(self, session: GodotIQSession) -> None:
+        """Include filter by group name."""
+        result = _build_scene_map(
+            session,
+            "res://scenes/equipment/fdm_printer.tscn",
+            include=["interactable"],
+        )
+        assert len(result["nodes"]) >= 1
+        assert result["nodes"][0]["name"] == "FDMPrinter"
+
+
+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
