# Sprint 5f Research — Spatial Validation in node_ops

## Part 1: Codebase Research

### node_ops.py (Bridge Tool)

**Location:** `src/godotiq/tools/bridge/node_ops.py`

**Function signature:**
```python
async def node_ops(operations: list[dict]) -> dict:
```

- Max 50 operations per call (`MAX_OPERATIONS = 50`)
- Empty list raises `ValueError`
- All ops grouped as single undo action
- Delegates to WebSocket bridge: `bridge.request("node_ops", params={"operations": operations})`
- Returns `{"results": [{"op": "move", "node": "Player", "status": "ok"}, ...]}`

**MCP registration (in `tools/bridge/__init__.py`):**
```python
@mcp.tool(name="godotiq_node_ops", annotations={...})
async def godotiq_node_ops(operations: list[dict] | None = None, ctx: Context = None) -> dict:
    try:
        return await _node_ops(operations=operations or [])
    except BridgeConnectionError as e: ...
    except BridgeTimeoutError as e: ...
    except BridgeError as e: ...
    except ValueError as e: ...
    except Exception as e: ...
```

### scene_map Tool (Spatial Analysis)

**Location:** `src/godotiq/tools/spatial/__init__.py`

**Internal implementation:**
```python
def _build_scene_map(session: GodotIQSession, scene_path: str, focus=None, radius=5.0, include=None, detail="normal") -> dict:
```

**Session access pattern:**
```python
session = ctx.request_context.lifespan_context["session"]
```

**Return structure (without focus):**
```python
{
    "scene": "res://scenes/equipment/fdm_printer.tscn",
    "root_type": "Node3D",
    "total_nodes": 10,
    "nodes": [
        {
            "name": "FDMPrinter",
            "type": "Node3D",
            "position": [0.0, 0.0, 0.0],
            "rotation_deg": [0.0, 0.0, 0.0],
            "scale": [1.0, 1.0, 1.0],
            "depth": 0,
            "is_instance": False,
            "groups": ["interactable"],
            "script": "res://scripts/fdm_printer.gd",
            "subtree_count": 9
        }, ...
    ],
    "spatial_summary": {
        "bounds": {"min": [-1.0, 0.0, -1.0], "max": [1.0, 2.0, 1.0]},
        "center": [0.0, 1.0, 0.0]
    },
    "groups_summary": {"interactable": 3, "equipment": 2}
}
```

**Key detail:** Nodes are `WorldNode` objects flattened into dicts with world-space positions already computed.

### GodotIQSession (Project State)

**Location:** `src/godotiq/session.py`

```python
class GodotIQSession:
    def __init__(self, project_root: str | Path):
        self.project_root = Path(project_root)
        self.cache = FileCache()
        self.config: GodotIQConfig = GodotIQConfig()

    def resolve_scene_spatial(self, res_path: str) -> tuple[ResolvedScene, list[WorldNode]] | None:
        """Lazy resolve + cache scene spatial data."""

    def resolve_file_path(self, path: str) -> str | None:
        """Resolve a file identifier to a res:// path."""
```

**Project root discovery:** `discover_project_root()` checks `GODOTIQ_PROJECT_ROOT` env, then `.godotiq.json`, then walks up for `project.godot`.

### WorldNode / TscnNode (Data Structures)

```python
@dataclass
class WorldNode:
    node: TscnNode
    world_position: tuple[float, float, float]
    world_rotation: tuple[float, float, float]  # YXZ Euler radians
    world_scale: tuple[float, float, float]
    full_path: str
    depth: int
    is_instance_root: bool
    instance_source: str | None

@dataclass
class TscnNode:
    name: str = ""
    type: str = ""
    parent: str = ""
    children: list[TscnNode] = ...
    instance: str | None = None
    groups: list[str] = ...
    script: str | None = None
    position: tuple[float, float, float] | None = None
    rotation: tuple[float, float, float] | None = None
    scale: tuple[float, float, float] | None = None
    properties: dict[str, object] = ...
```

### Config (GodotIQConfig)

```python
@dataclass
class GodotIQConfig:
    rooms: dict[str, Any] = ...  # Room bounds could be defined here
    conventions: dict[str, Any] = ...
```

### Testing Patterns

**Bridge tool mocking:**
```python
mock_bridge = AsyncMock()
mock_bridge.request.return_value = {"results": [...]}
with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
    result = await node_ops(operations=ops)
```

**Spatial tool testing (uses fixtures):**
```python
def test_map_basic(self, session: GodotIQSession):
    result = _build_scene_map(session, "res://scenes/equipment/fdm_printer.tscn")
    assert result["total_nodes"] >= 4
```

**Conftest fixtures:**
```python
@pytest.fixture
def sample_root() -> Path:
    return Path(__file__).resolve().parent.parent / "fixtures" / "sample_project"

@pytest.fixture
def session(sample_root: Path) -> GodotIQSession:
    s = GodotIQSession(sample_root)
    s.load()
    return s
```

### Integration Strategy

The spatial_validator module should be **pure Python** — it takes scene_map output (dict with `nodes` list) and operation parameters, returns validation results. No session or bridge dependency.

For the node_ops integration, the challenge is getting scene data without MCP context. Two approaches:
1. **Call `_build_scene_map` directly** — needs a `GodotIQSession` object
2. **Accept scene data as an optional parameter** — let the MCP wrapper handle session access

---

## Part 2: Web Research — Spatial Validation Patterns

### Editor Approaches

**Unity:** `Physics.OverlapBox` creates ephemeral test volumes, returns all colliders touching them. Layer mask filtering selectively tests against specific physics layers.

**Unreal:** `UEditorValidatorBase` subclasses for data validation. For editor-time collision, developers use `OnConstruction()` + `GetOverlappingActors` as the physics engine only fires during gameplay.

**Blender:** BVH-Trees from `mathutils` for mesh intersection testing. No real-time placement collision prevention built in.

### Common Collision Detection Patterns

1. **AABB (Axis-Aligned Bounding Box):** Fastest — 6 comparisons per pair, O(1). Poor for rotated objects.
2. **Bounding Spheres:** Rotation-invariant. Compare squared distances (avoid sqrt).
3. **OBB + Separating Axis Theorem:** For rotated objects — 15 axes, ~200 FLOPs.
4. **Two-phase detection:** Broad-phase (AABB/spatial hash) + narrow-phase (exact shape tests).

**For GodotIQ's use case:** AABB/distance-based checks are sufficient for < 100 nodes. The spec's approach of Euclidean distance is appropriate.

### Wall/Boundary Detection in Godot

**Multi-signal heuristic (recommended):**
1. Node type = `StaticBody3D`
2. Child has `CollisionShape3D` with `BoxShape3D`
3. Name matching: case-insensitive `wall`, `floor`, `ceiling`, `ground`, `barrier`
4. Group membership: `"environment"`, `"world"`, `"static"`
5. Shape dimension ratios (thin in one axis = likely wall)

**Import hint suffixes (Blender→Godot):** `-col`, `-colonly`, `-convcol`, `-static`

**No official naming standard** — community patterns vary. Multiple signals needed for robust detection.

### TSCN Representation

```
[sub_resource type="BoxShape3D" id="BoxShape3D_abc"]
size = Vector3(20, 1, 0.5)

[node name="Wall" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.5, -10)

[node name="CollisionShape3D" type="CollisionShape3D" parent="Wall"]
shape = SubResource("BoxShape3D_abc")
```

### Recommendations for Sprint 5f

1. **Point-distance approach is correct** for v1 — no need for full AABB/collision shape parsing
2. **Wall detection heuristic** should combine type (`StaticBody3D`) + name patterns (`Wall`, `Floor`, etc.)
3. **Collision shape dimensions** are a future enhancement (v2) — would require reading SubResource data
4. **Pairwise distance** is fine for scene sizes < 100 nodes (typical Godot scenes)
5. **Sibling outlier detection** is a unique differentiator — no other tool does this
