# Section 03: Scene Resolver Core

## Overview

This section implements `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/parsers/scene_resolver.py`, the core module for recursive scene instance resolution. It takes a parsed `TscnScene` (from Sprint 1A's `parse_tscn()`) and a project root path, then expands all instance references by parsing and inlining the referenced `.tscn` files. The result is a `ResolvedScene` containing a fully expanded node tree.

This section covers the data structures (`ResolvedScene`, `WorldNode`), the `resolve_scene()` function, helper functions (`_resolve_res_path`, `_clone_node`), property override application, instance type copying, cycle detection, and depth limiting. World transform calculation and query functions are covered in section 04.

## Dependencies

- **Section 01 (Value Parser Raw):** The `decompose_transform3d()` function must return a `"raw"` key containing the original 12-float tuple. This section's property override logic reads the decomposed transform dict which now includes `raw`.
- **Section 02 (Test Fixtures):** The `tests/fixtures/sample_project/` directory must exist with its `.tscn` files and the `sample_project_root` conftest fixture must be available. The fixture files are used by integration tests.

## File to Create

`/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/parsers/scene_resolver.py`

## Existing Types Used

The implementation depends on existing types from `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/parsers/tscn_parser.py`:

```python
@dataclass
class TscnNode:
    name: str = ""
    type: str = ""
    parent: str = ""
    children: list[TscnNode] = field(default_factory=list)
    instance: str | None = None
    unique_id: int | None = None
    groups: list[str] = field(default_factory=list)
    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] = field(default_factory=dict)
    metadata: dict[str, object] = field(default_factory=dict)

@dataclass
class TscnScene:
    header: SceneHeader
    ext_resources: dict[str, ExtResource]
    sub_resources: dict[str, SubResource]
    root: TscnNode | None
    nodes: list[TscnNode]
    connections: list[Connection]

@dataclass
class ExtResource:
    id: str = ""
    type: str = ""
    path: str = ""
    uid: str | None = None
```

The `parse_tscn(path: str) -> TscnScene` function parses a `.tscn` file from a filesystem path. Node instance references are stored as `node.instance = ext_resource_id` (a string ID into the scene's `ext_resources` dict). The `ext_resources[id].path` contains the `res://` path to the instanced scene.

## Tests First

### File: `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_parsers/test_scene_resolver.py`

This section provides the test stubs relevant to scene resolver core functionality. World transform and query function tests are in section 04/06.

#### Unit Tests -- res:// Path Resolution

```python
def test_resolve_res_path_basic(tmp_path):
    """res://scenes/main.tscn + project_root -> project_root/scenes/main.tscn"""

def test_resolve_res_path_nested(tmp_path):
    """res://scenes/equipment/fdm_printer.tscn -> correct nested path"""
```

These test the `_resolve_res_path()` helper. Since it is a module-private function, the tests either import it directly (using `from godotiq.parsers.scene_resolver import _resolve_res_path`) or test it indirectly through `resolve_scene()`. Direct import is simpler and acceptable for unit tests.

#### Unit Tests -- Node Cloning

```python
def test_clone_node_independent():
    """Cloned node modifications don't affect original.

    Create a TscnNode with properties and groups.
    Clone it. Modify the clone's properties dict and groups list.
    Verify the original is unchanged.
    """

def test_clone_node_deep():
    """Clone a node with 3 levels of children.

    Verify all levels are independent copies -- modifying a
    grandchild on the clone does not affect the original.
    """
```

#### Integration Tests -- Instance Resolution

```python
def test_resolve_no_instances(sample_project_root):
    """Parse fdm_printer.tscn (has no .tscn instances). Resolved scene has
    same node count as original. unresolved is empty. instance_sources
    only contains the .glb reference (non-tscn, not expanded)."""

def test_resolve_with_instance(sample_project_root):
    """Parse main.tscn which instances fdm_printer.tscn. After resolution:
    - Instance node has children from fdm_printer
    - instance_sources contains the mapping
    - total_nodes > original count (3 original + 3 children from fdm_printer = 6)"""

def test_property_override_transform(sample_project_root):
    """Main scene overrides transform on instanced FDMPrinter_1.
    After resolution, the instance node's position is (-36, 0, -2),
    matching the parent scene's override, not the instanced scene's
    original (0, 0, 0)."""

def test_instance_type_copied(sample_project_root):
    """Instance node in parent has type="". After resolution, type
    is copied from instanced scene's root -- should be "Node3D"."""

def test_unresolved_instances(sample_project_root):
    """Create a TscnScene in-memory with an instance pointing to a
    non-existent file. Resolver adds path to unresolved, doesn't crash.
    Node still present in tree (without expanded children)."""

def test_max_depth_limit(sample_project_root):
    """Set max_depth=1. Scene with 2-level instance nesting: first level
    expanded, second level NOT expanded (appears in tree but children
    not populated from its .tscn)."""

def test_non_tscn_instance_noted(sample_project_root):
    """Instance pointing to .glb file. Not expanded but recorded in
    instance_sources."""
```

### Test Notes

- The `sample_project_root` fixture (from section 02) returns `Path` to `tests/fixtures/sample_project/`.
- Tests should import `resolve_scene` from `godotiq.parsers.scene_resolver` and `parse_tscn` from `godotiq.parsers.tscn_parser`.
- The integration tests parse a `.tscn` file first with `parse_tscn()`, then pass the result to `resolve_scene()`.

## Data Structures

### `ResolvedScene` dataclass

```python
@dataclass
class ResolvedScene:
    """Result of expanding all instances in a parsed scene."""
    source_path: str
    """Original .tscn file path (res:// or filesystem)."""

    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 -> source res:// path for instanced nodes."""

    unresolved: list[str]
    """res:// paths of scenes that couldn't be found or failed to parse."""
```

### `WorldNode` dataclass

This dataclass is defined in this section but primarily used by section 04 (world transforms). It is included here because section 04 builds on top of this file.

```python
@dataclass
class 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."""

    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 res:// path if is_instance_root."""
```

## Implementation Details

### `_resolve_res_path(res_path: str, project_root: Path) -> Path`

Converts a `res://` path to a filesystem `Path`. The conversion is a direct 1:1 mapping -- Godot's `res://` always points to the project root (the directory containing `project.godot`).

```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."""
    # Strip the "res://" prefix (6 characters) and join with project_root
```

The implementation strips the first 6 characters (`res://`) from the path and joins the remainder with `project_root`. Forward slashes in the res:// path work correctly with `pathlib.Path` on all platforms.

### `_clone_node(node: TscnNode) -> TscnNode`

Creates a deep copy of a `TscnNode` and all its children. This is faster than `copy.deepcopy()` because it only copies the fields that matter.

The clone must create:
- A new `TscnNode` instance
- A new `dict(node.properties)` (shallow copy of the properties dict -- values are immutable tuples/strings/ints so shallow is sufficient)
- A new `list(node.groups)` copy
- A new `dict(node.metadata)` copy
- Recursively cloned `children` list

The `name`, `type`, `parent`, `instance`, `unique_id`, `script`, `position`, `rotation`, `scale` fields are all immutable (strings, tuples, ints, None) and can be copied directly.

### `resolve_scene(scene: TscnScene, project_root: Path, max_depth: int = 10) -> ResolvedScene`

The main public function. Takes a parsed `TscnScene` and resolves all instance references recursively.

**Parameters:**
- `scene` -- A `TscnScene` from `parse_tscn()`.
- `project_root` -- The directory containing `project.godot`. Used to resolve `res://` paths.
- `max_depth` -- Maximum recursion depth for nested instances (default 10). Prevents runaway recursion from deeply nested or accidentally cyclic references.

**Algorithm:**

1. **Initialize tracking structures.** Create empty `instance_sources: dict[str, str]`, `unresolved: list[str]`, and `parse_cache: dict[str, TscnScene]` (keyed by filesystem path string).

2. **Clone the root.** Deep-copy the scene's root node using `_clone_node()` so that the original `TscnScene` is not mutated.

3. **Define recursive resolution function.** An inner function `_resolve_nodes()` accepts:
   - `node: TscnNode` -- the current node to process
   - `ext_resources: dict[str, ExtResource]` -- the ext_resources dict for the current scene context (critical: each `.tscn` file has its own ExtResource ID namespace)
   - `depth: int` -- current recursion depth
   - `active_paths: set[str]` -- set of res:// paths currently being expanded (for cycle detection)
   - `parent_full_path: str` -- the full_path of the parent node, used to construct this node's full_path

4. **For each child node of `node`:** If the child has an `instance` attribute (not None):

   a. Look up `ext_resources[child.instance]` to get the `ExtResource`, then get its `.path` (the `res://` path).

   b. Construct the child's `full_path` from `parent_full_path` and `child.name`.

   c. Record `full_path -> res_path` in `instance_sources`.

   d. If the path does not end with `.tscn` (e.g., `.glb`, `.gltf`, `.tres`), skip expansion but keep the recording. Continue to next child.

   e. **Cycle detection:** If `res_path` is in `active_paths`, log a warning and add to `unresolved`. Skip expansion.

   f. **Depth check:** If `depth >= max_depth`, log a warning. Skip expansion.

   g. Convert `res_path` to filesystem path using `_resolve_res_path()`.

   h. If the file does not exist on disk, add `res_path` to `unresolved`. Continue.

   i. Check `parse_cache` for the filesystem path. If not cached, call `parse_tscn(str(fs_path))` and cache the result. Wrap in `try/except` -- if parsing fails, log the error, add to `unresolved`, continue.

   j. Clone the instanced scene's root node using `_clone_node()`.

   k. **Apply property overrides** from the instance child node onto the cloned root (see override procedure below).

   l. **Copy type:** If the instance child's `type` is empty (which it always is for instance nodes), set `child.type` to the instanced scene root's type.

   m. Set `child.children` to the cloned root's `children`. This grafts the instanced scene's children onto the instance node.

   n. **Recurse:** Call `_resolve_nodes()` on the child, using the **instanced scene's** `ext_resources` dict (not the parent scene's), `depth + 1`, and `active_paths | {res_path}`.

5. For children without instance references, still recurse to process any instances in their subtrees (using the same `ext_resources` and `depth`).

6. **Count total nodes** by traversing the final tree after all resolution is complete.

### Property Override Procedure

When the parent scene specifies properties on an instance node, those values override the instanced scene root's properties. This is how Godot allows per-instance customization (e.g., different positions for each instance of the same scene).

The override procedure operates on the deep-copied instanced root node:

1. **Copy all property entries:** For each key-value pair in `instance_node.properties`, set `cloned_root.properties[key] = value`. This overwrites existing keys and adds new ones.

2. **Handle `transform` specially:** If `"transform"` is in the instance node's properties, also update the cloned root's convenience fields:
   - `cloned_root.position = instance_node.position`
   - `cloned_root.rotation = instance_node.rotation`
   - `cloned_root.scale = instance_node.scale`

   These fields are already decomposed by the parser (via `decompose_transform3d`), so the override values are ready to use. The `properties["transform"]` dict (which includes the `"raw"` key from section 01) is also copied as part of step 1.

3. **Handle `script`:** If `instance_node.script is not None`, set `cloned_root.script = instance_node.script`.

4. **Handle `groups`:** Extend, do not replace: `cloned_root.groups.extend(instance_node.groups)`. Groups from both the instanced scene root and the parent's override are combined.

5. **Handle `metadata`:** Merge instance node's metadata into cloned root's metadata: `cloned_root.metadata.update(instance_node.metadata)`.

**ExtResource ID context note:** 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 stored as string IDs and do not need cross-file resolution at this stage.

### Path Construction Rules

The `full_path` field on instance_sources and later on WorldNode 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"`.

### Cycle Detection

Cycle detection uses a set of `res://` paths that are currently being expanded in the recursion stack (`active_paths`). Before expanding an instance, the resolver checks if the instance's `res://` path is already in this set. If so, it means the scene directly or indirectly instances itself, which would cause infinite recursion. The path is added to `unresolved` and expansion is skipped.

The `active_paths` set is passed by value (using set union `active_paths | {res_path}`) so that sibling branches of the tree do not interfere with each other's cycle detection. Two sibling nodes can both instance the same scene -- that is not a cycle. A cycle only occurs when A instances B which instances A (directly or transitively).

### Depth Limiting

The `max_depth` parameter (default 10) provides a secondary safety net. Even without cycles, deeply nested instances could cause performance issues. When `depth >= max_depth`, further instance expansion is skipped. The node remains in the tree but its children are not populated from the referenced `.tscn`.

### Parse Caching

During a single `resolve_scene()` call, the same `.tscn` file may be instanced multiple times (e.g., 10 copies of the same enemy scene). A local `parse_cache: dict[str, TscnScene]` avoids re-parsing the same file. The cache is keyed by filesystem path (not res:// path) since `parse_tscn()` takes filesystem paths.

Note: each cached `TscnScene` is shared, but `_clone_node()` creates independent copies of the root for each instance, so mutations from property overrides do not leak between instances.

## Error Handling

- **Missing instanced `.tscn` file:** Add `res://` path to `unresolved`, log a warning, continue processing other nodes. The instance node remains in the tree without expanded children.
- **Malformed instanced `.tscn` (parse error):** Wrap `parse_tscn()` in `try/except Exception`. On failure, add to `unresolved`, log the error, continue.
- **Missing ExtResource ID:** If `node.instance` references an ID not found in `ext_resources`, log a warning and skip. This should not happen with well-formed `.tscn` files but must be handled defensively.
- **Circular references:** Detected by `active_paths` set. Logged as warning, added to `unresolved`.
- **Excessive depth:** Logged as warning when `max_depth` is hit.

## 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. Only root-level property overrides on the instance node are applied.
- **Child ordering:** The `index` attribute on override nodes (controlling child order) is not processed.
- **Child overrides within instanced scenes:** Godot allows the parent scene to override properties on individual children within an instanced scene (not just the root). This is not implemented -- only properties listed on the instance node header itself are applied as overrides.

## Module Exports

The `__init__.py` for the parsers package should be updated to export the new public names, or consumers can import directly from `scene_resolver`:

```python
from godotiq.parsers.scene_resolver import resolve_scene, ResolvedScene, WorldNode
```

## Verification

After implementation, run the scene resolver tests:

```bash
cd /Users/salvatorefarruggio/Desktop/godotiq && python -m pytest tests/test_parsers/test_scene_resolver.py -v
```

Expected results:
- `test_resolve_res_path_basic` -- passes
- `test_resolve_res_path_nested` -- passes
- `test_clone_node_independent` -- passes
- `test_clone_node_deep` -- passes
- `test_resolve_no_instances` -- passes (fdm_printer.tscn has only .glb instance, not .tscn)
- `test_resolve_with_instance` -- passes (main.tscn instances fdm_printer.tscn, children expanded)
- `test_property_override_transform` -- passes (position overridden to (-36, 0, -2))
- `test_instance_type_copied` -- passes (empty type on instance node filled from instanced root's "Node3D")
- `test_unresolved_instances` -- passes (non-existent path added to unresolved)
- `test_max_depth_limit` -- passes (second-level expansion skipped at max_depth=1)
- `test_non_tscn_instance_noted` -- passes (.glb recorded but not expanded)
