# Sprint 1B+1C Implementation Plan — Scene Resolver & Project Index

## Context and Motivation

GodotIQ's Sprint 1A delivered a raw `.tscn` parser that can read a single scene file and produce a structured `TscnScene` object with nodes, resources, connections, and transforms. However, the MCP tools that GodotIQ needs to provide — scene mapping, spatial auditing, dependency graphs, asset registries — require two capabilities the raw parser doesn't offer:

1. **Resolved scene trees**: When `main.tscn` instances `fdm_printer.tscn`, the raw parser only stores the reference. To answer "what nodes are near position X?" or "show me the full scene tree," we need to expand those instances recursively and compute world-space coordinates.

2. **Project-wide cross-references**: To answer "which scenes use this script?" or "what assets are unused?", we need an index across all files in the Godot project.

This plan describes two independent modules that address these needs. The project targets **3D Godot 4.x games** — 2D node transforms are out of scope.

---

## Architecture Overview

```
src/godotiq/parsers/
├── value_parser.py          # Sprint 1A (minor addition: raw transform values)
├── tscn_parser.py           # Sprint 1A (unchanged)
├── scene_resolver.py        # NEW — Sprint 1B
└── project_index.py         # NEW — Sprint 1C
```

Both modules depend on Sprint 1A's `tscn_parser.py` and Sprint 0's `FileCache`.

**Sprint 1A modification:** The `decompose_transform3d()` function in `value_parser.py` currently discards the raw 12-float values after decomposition. A `"raw"` key will be added to the returned dict so that the scene resolver can reconstruct basis matrices without Euler round-tripping:
```python
return {"position": position, "rotation": rotation, "scale": scale, "raw": values}
```
This is a backward-compatible, additive change.

Data flow:
```
.tscn file → parse_tscn() → TscnScene → resolve_scene() → ResolvedScene → calculate_world_transforms() → list[WorldNode]
                                  ↑
project dir → scan_project() → ProjectIndex (contains parsed TscnScenes)
```

---

## Module 1B: Scene Resolver

### File: `src/godotiq/parsers/scene_resolver.py`

### Data Structures

**`ResolvedScene`** — The result of expanding all instances in a parsed scene:
- `source_path: str` — original .tscn file path
- `root: TscnNode` — root node with all instances expanded as children
- `total_nodes: int` — total node count after expansion
- `instance_sources: dict[str, str]` — maps node full_path to source .tscn res:// path
- `unresolved: list[str]` — res:// paths of scenes that couldn't be found on disk or failed to parse

**`WorldNode`** — A node with its computed world-space transform:
- `node: TscnNode` — reference to the original node
- `world_position: tuple[float, float, float]`
- `world_rotation: tuple[float, float, float]` — YXZ Euler angles in radians
- `world_scale: tuple[float, float, float]`
- `full_path: str` — slash-separated path from root (see path construction rules below)
- `depth: int` — nesting level (root=0)
- `is_instance_root: bool` — True if this node was created by expanding an instance
- `instance_source: str | None` — source .tscn res:// path if is_instance_root

### Path Construction Rules

The `full_path` field uses slash-separated node names:
- Root node: `full_path = ""` (empty string, matching Godot's convention)
- Direct child of root: `full_path = child.name` (e.g., `"Equipment"`)
- Deeper nodes: `full_path = parent_path + "/" + child.name` (e.g., `"Equipment/FDMPrinter_1/WorkPoint"`)
- Instance-expanded children: paths continue from the instance node, not from the instanced scene's root. If `Equipment/FDMPrinter_1` instances `fdm_printer.tscn` whose root has children `PrinterModel` and `WorkPoint`, their full paths are `"Equipment/FDMPrinter_1/PrinterModel"` and `"Equipment/FDMPrinter_1/WorkPoint"`.

### Core Algorithm: Instance Resolution

The `resolve_scene()` function takes a `TscnScene` (from `parse_tscn()`) and a `project_root` Path (directory containing `project.godot`).

**Step 1: Initialize tracking structures.** Create empty `instance_sources` dict, `unresolved` list, and a local parse cache dict to avoid re-parsing the same `.tscn` file multiple times.

**Step 2: Recursive resolution function.** Define an inner function that processes a single scene's nodes, taking the current scene's `TscnScene`, the `ext_resources` dict to use for lookups, the current depth, and the set of active res:// paths (for cycle detection).

**Step 3: For each node with an instance reference:**
1. Look up the ExtResource by ID in the **current scene's** `ext_resources` dict to get the res:// path. (Critical: ExtResource IDs are local to each .tscn file — never look up in a different scene's ext_resources.)
2. Check if the path ends with `.tscn`. If not (`.glb`, `.gltf`, `.tres`), record in `instance_sources` but don't expand.
3. **Cycle detection (primary):** If the res:// path is in the active path set, log a warning and add to `unresolved`. Skip expansion.
4. **Depth check (secondary safety net):** If `depth >= max_depth`, log a warning and skip expansion.
5. Convert the res:// path to a filesystem path: `project_root / path[6:]` (strip `res://` prefix).
6. If the file doesn't exist, add to `unresolved` and continue.
7. Parse the .tscn file (check local cache first, then parse and cache). **Wrap in try/except** — if parsing fails (malformed file), log the error, add to `unresolved`, and continue.
8. **Deep-copy** the instanced scene's root node to create an independent copy. Use a custom `_clone_node()` function (faster than `copy.deepcopy()` — creates new `TscnNode` with copied `properties` dict and recursively clones children).
9. **Apply property overrides** from the instance node onto the cloned root (see override semantics below).
10. **Copy type:** If the instance node's `type` is empty (which it always is for instance nodes), set it to the instanced scene root's type.
11. Set the instance node's `children` to the cloned root's `children`.
12. Record `full_path → res_path` in `instance_sources`.
13. **Recurse:** Process the cloned children for their own instance references, using the **instanced scene's** `ext_resources` dict, depth+1, and adding the current res:// path to the active set.

**Step 4: Count total nodes** by traversing the final tree.

### Property Override Semantics

When the parent scene lists properties on an instance node, those override the instanced scene root's properties:

```
[node name="FDMPrinter_1" parent="Equipment" instance=ExtResource("1_lquwl")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -36, 0, -2)
```

**Override procedure** (applied to the deep-copied instanced root):

1. Copy all entries from `instance_node.properties` into `cloned_root.properties`, overwriting existing keys.
2. For `transform`: also update `cloned_root.position`, `.rotation`, `.scale` from the override's decomposed values.
3. For `script`: if `instance_node.script is not None`, set `cloned_root.script = instance_node.script`.
4. For `groups`: extend, don't replace — `cloned_root.groups.extend(instance_node.groups)`.
5. For `metadata`: merge `instance_node.metadata` into `cloned_root.metadata`.

**ExtResource ID context:** Property override values that reference ExtResources (e.g., `script = ExtResource("id")`) contain IDs from the **parent** scene's ext_resources dict, not the instanced scene's. During resolution, these are already stored as string IDs and don't need cross-file resolution at this stage — they will be resolved when the MCP tools need to look up actual paths.

### res:// Path Resolution

```python
def _resolve_res_path(res_path: str, project_root: Path) -> Path:
    """Convert res://x/y/z.tscn to project_root/x/y/z.tscn.

    This is a direct 1:1 mapping — Godot's res:// always points to the
    project root (the directory containing project.godot).
    """
```

### World-Space Transform Calculation

The `calculate_world_transforms()` function walks the resolved tree top-down, accumulating parent transforms.

**Transform representation during composition.** Work with raw column-major 3×3 basis matrix + origin vector, NOT decomposed Euler angles. Euler angles suffer from gimbal lock during composition. Only decompose to Euler angles at the end for each output WorldNode.

**Internal representation:**
```python
# A transform during calculation is:
# basis: tuple of 3 column vectors, each (x, y, z)
# origin: (x, y, z)
# Identity: basis=((1,0,0),(0,1,0),(0,0,1)), origin=(0,0,0)
```

**Extracting local transform from TscnNode:** Use the `"raw"` key from `node.properties["transform"]` to get the 12 original float values. Extract columns directly:
- Column 0 (X axis): `(raw[0], raw[1], raw[2])`
- Column 1 (Y axis): `(raw[3], raw[4], raw[5])`
- Column 2 (Z axis): `(raw[6], raw[7], raw[8])`
- Origin: `(raw[9], raw[10], raw[11])`

If no `"transform"` property exists, use identity transform. If `"raw"` key is missing (shouldn't happen with the parser update, but as fallback), reconstruct from Euler angles.

**Composition formula** (matching Godot's `Transform3D::operator*=`):
```
child_world.origin = parent_world.basis.xform(child_local.origin) + parent_world.origin
child_world.basis  = parent_world.basis * child_local.basis
```

Where:
- `basis.xform(point)` = `col0*point.x + col1*point.y + col2*point.z`
- `basis * basis` is standard 3×3 column-major matrix multiplication

**Tree walk:** Depth-first traversal from root. At each node:
1. Get this node's local transform (from raw property or identity)
2. Compose with parent's world transform to get this node's world transform
3. Decompose world basis to position/rotation/scale using existing `decompose_transform3d()` logic for the rotation extraction
4. Build WorldNode with `is_instance_root` and `instance_source` populated by checking `full_path` against `resolved_scene.instance_sources`
5. Pass world transform to children

### Query Functions

Three utility functions operate on the flat `list[WorldNode]` returned by `calculate_world_transforms()`:

- **`find_nodes_by_type(world_nodes, node_type)`** — Filter where `node.type == node_type`
- **`find_nodes_by_group(world_nodes, group)`** — Filter where `group in node.groups`
- **`find_nodes_in_radius(world_nodes, center, radius)`** — Filter where Euclidean distance from `world_position` to `center` <= `radius`. Distance formula: `sqrt((wx-cx)^2 + (wy-cy)^2 + (wz-cz)^2)`

### Known Limitations
- **2D nodes:** Only 3D transforms (Transform3D) are supported. Node2D position/rotation/scale as separate properties are not handled.
- **Editable children:** The `[editable path="..."]` feature allowing deep child overrides in instanced scenes is not implemented. Child overrides within instanced scenes will not be applied.
- **Child ordering:** The `index` attribute on override nodes (controlling child order) is not processed.

---

## Module 1C: Project Index

### File: `src/godotiq/parsers/project_index.py`

### Data Structure

**`ProjectIndex`** — Complete index of a Godot project:
- `project_root: Path`
- `project_name: str` — from project.godot `[application]` config/name
- `scenes: dict[str, TscnScene]` — res://path → parsed scene
- `scripts: list[str]` — res://paths of .gd files (not parsed yet, Sprint 2)
- `assets: dict[str, dict]` — res://path → `{"type": str, "size_bytes": int, "used_by": list[str]}`
- `scene_instances: dict[str, list[str]]` — scene → scenes it instantiates
- `scene_instance_of: dict[str, list[str]]` — scene → scenes that instantiate it
- `script_to_scenes: dict[str, list[str]]` — script → scenes using it
- `resource_usage: dict[str, list[str]]` — resource → scenes referencing it
- `autoloads: dict[str, str]` — autoload name → res:// path
- `total_scenes: int`, `total_scripts: int`, `total_assets: int`, `scan_time_ms: float`

The `assets[path]["used_by"]` field is a derived convenience copy of `resource_usage[path]`, populated after cross-reference maps are built.

### Filesystem Scanning

The `scan_project()` function:

1. **Validate project root.** Check that `project_root / "project.godot"` exists. Raise `FileNotFoundError` if not.
2. **Walk filesystem** using `os.walk()`:
   - Skip directories: `.godot`, `addons`, any directory starting with `.`
   - Collect files by extension:
     - `.tscn` → scenes list
     - `.gd` → scripts list
     - `.glb`, `.gltf`, `.png`, `.jpg`, `.jpeg`, `.tres`, `.wav`, `.ogg`, `.mp3` → assets
3. **Convert to res:// paths.** For each file, compute relative path from project_root (using forward slashes regardless of OS) and prepend `res://`.
4. **Parse .tscn files.** For each scene, call `parse_tscn()` with the filesystem path. If a FileCache is provided, use filesystem paths as cache keys (required by `os.path.getmtime()`): check `cache.is_stale(fs_path)` first, use `cache.get(fs_path)` for hits, and `cache.put(fs_path, parsed_scene)` for misses.
5. **Build cross-reference maps** (see below).
6. **Parse project.godot** for autoloads and project name.
7. **Populate asset usage:** For each asset in the `assets` dict, set `used_by = resource_usage.get(asset_res_path, [])`.

### Cross-Reference Map Construction

After all scenes are parsed, iterate over each scene's ext_resources:

For each `ExtResource` in `scene.ext_resources.values()`:
- Get the `res://` path from `ext.path`
- Add `scene_res_path` to `resource_usage[ext.path]`
- If `ext.type == "PackedScene"` and path ends with `.tscn`:
  - Add `ext.path` to `scene_instances[scene_res_path]`
  - Add `scene_res_path` to `scene_instance_of[ext.path]`
- If `ext.type == "Script"`:
  - Add `scene_res_path` to `script_to_scenes[ext.path]`

### project.godot Parsing

A helper function `_parse_project_godot(path: Path) -> tuple[str, dict[str, str]]` that returns `(project_name, autoloads)`.

**Parsing approach:** Read line by line. Track current section name. For each key=value line within a recognized section, extract the value.

**[autoload] section:**
- Values are quoted strings: `GameManager="*res://scripts/managers/game_manager.gd"`
- Strip outer quotes
- If value starts with `*`, strip it (the `*` indicates global singleton registration)
- Result: `{"GameManager": "res://scripts/managers/game_manager.gd"}`

**[application] section:**
- Look for `config/name="Project Name"` → extract as project_name
- Strip outer quotes from value

### Query Functions

- **`find_asset_usage(index, asset_path)`** — Return `resource_usage.get(asset_path, [])`
- **`find_unused_assets(index)`** — Return asset res:// paths where `resource_usage.get(path, [])` is empty
- **`get_scene_dependency_chain(index, scene_path)`** — BFS through `scene_instances` starting from `scene_path`, collecting all transitively-instanced scene paths. Use a visited set to avoid cycles.

### Incremental Rescan

The `rescan_changed()` function returns a **new** `ProjectIndex`:
1. Walk filesystem again to detect new/removed files
2. For existing scenes, check `cache.is_stale()` — only re-parse stale files, reuse cached parses for unchanged ones
3. Rebuild all cross-reference maps from scratch (simpler and more correct than incremental updates)
4. Return new ProjectIndex

---

## Test Fixtures

### New: `tests/fixtures/sample_project/`

A minimal Godot project structure for both Scene Resolver and Project Index tests:

```
sample_project/
├── project.godot              # minimal with [application] and [autoload]
├── scenes/
│   ├── main.tscn              # instances fdm_printer.tscn with transform override
│   └── equipment/
│       └── fdm_printer.tscn   # has script ref, model ref, child nodes
├── scripts/
│   └── entities/
│       ├── printer.gd         # minimal GDScript file
│       └── worker.gd          # minimal GDScript file
└── assets/
    └── models/
        └── printers/
            ├── printer_standard.glb   # empty file (existence only)
            └── unused_model.glb       # never referenced → unused asset test
```

**project.godot contents:**
```ini
[application]

config/name="Sample Project"
run/main_scene="res://scenes/main.tscn"

[autoload]

GameManager="*res://scripts/entities/printer.gd"
```

The `sample_project/scenes/main.tscn` and `sample_project/scenes/equipment/fdm_printer.tscn` are **minimal .tscn files** (not copies of the large test fixtures). They should contain just enough structure to exercise:
- Instance resolution (main → fdm_printer)
- Transform override (main sets position on instanced fdm_printer)
- Script reference (fdm_printer → printer.gd)
- Model reference (fdm_printer → printer_standard.glb)
- At least 3 child nodes in fdm_printer for tree traversal testing

---

## Performance Considerations

1. **Scene parse caching during resolution.** Cache parsed TscnScene objects in a local dict during `resolve_scene()`. Key by filesystem path. This avoids re-parsing the same scene when it's instanced multiple times.

2. **Custom node cloning.** Use a custom `_clone_node()` function instead of `copy.deepcopy()`. Create new `TscnNode` with `dict(node.properties)` (shallow copy) and `list(node.groups)`, recursively clone children. Significantly faster than generic deepcopy.

3. **Project scan time.** With FileCache, incremental rescans skip unchanged files entirely. Full scan of 100 files at ~1ms per small file is well under 500ms target.

4. **World transform calculation.** O(N) tree traversal with O(1) matrix operations per node. Negligible.

---

## Error Handling

- Missing project.godot → raise `FileNotFoundError` with clear message
- Missing instanced .tscn → add to `unresolved`, log warning, continue
- Malformed instanced .tscn (parse error) → add to `unresolved`, log error, continue
- Malformed project.godot → return empty autoloads, log warning
- Circular instance references → cycle detection set prevents expansion, logged as warning
- Excessive depth → max_depth cap prevents runaway recursion
- Invalid res:// paths → log warning, skip
- Empty project (no .tscn files) → return valid empty ProjectIndex
