# Sprint 1B + 1C — Scene Resolver e Project Index

## Prerequisito
Sprint 1A (Raw .tscn Parser) completato e tutti i test green.

## Perché servono
Il raw parser legge UN file .tscn e restituisce la struttura. Ma:
- `scene_map` (B01) ha bisogno dell'albero ESPANSO (istanze risolte, posizioni world-space)
- `asset_registry` (F01) ha bisogno di sapere dove ogni risorsa è usata nel progetto
- `dependency_graph` (C01) ha bisogno della mappa script↔scene↔resource globale
- `spatial_audit` (B03) ha bisogno di bounding box reali (dentro le istanze)

Senza questi layer, i tool più importanti dell'MVP sono ciechi.

---

## Task 1B — Scene Resolver

### Obiettivo
Dato un .tscn parsato + il path del progetto Godot, risolvere ricorsivamente tutte le scene instanziate e produrre un albero completo con posizioni world-space.

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

### Cosa deve fare

#### 1. Risoluzione ricorsiva delle istanze
```
main.tscn ha:
  [node name="FDMPrinter_1" parent="Equipment" instance=ExtResource("1_lquwl")]
  → ext_resource "1_lquwl" punta a "res://scenes/equipment/fdm_printer.tscn"

Scene Resolver:
  1. Parsa fdm_printer.tscn con il raw parser
  2. Inserisce i nodi di fdm_printer come figli di FDMPrinter_1
  3. Se fdm_printer.tscn a sua volta istanzia altre scene → ricorsione
  4. Limite di profondità configurabile (default 10) per evitare loop
```

#### 2. Property Override
Quando main.tscn sovrascrive proprietà dell'istanza:
```
# In main.tscn:
[node name="FDMPrinter_1" parent="Equipment" instance=ExtResource("1_lquwl")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -36, 0, -2)

# In fdm_printer.tscn il root ha:
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)

# Risultato: il transform di main.tscn SOVRASCRIVE quello di fdm_printer.tscn
# I figli dentro fdm_printer mantengono i loro transform locali
```

#### 3. World-Space Transform Calculation
Calcolare la posizione assoluta di ogni nodo nel mondo:
```
FDMPrinter_1:        pos_local=(-36, 0, -2),     pos_world=(-36, 0, -2)
  └─ PrinterModel:   pos_local=(0, 0.326, 0),     pos_world=(-36, 0.326, -2)
  └─ FilamentSpool:  pos_local=(0.45, 0.55, 0),   pos_world=(-35.55, 0.55, -2)
  └─ WorkPoint:      pos_local=(0, 0, 1.2),        pos_world=(-36, 0, -0.8)
```

La formula per world position (senza rotazione): `world = parent_world + local`
Con rotazione: `world = parent_world + parent_rotation_matrix * local`

#### 4. API

```python
@dataclass
class ResolvedScene:
    """Fully resolved scene with all instances expanded."""
    source_path: str                    # original .tscn path
    root: TscnNode                      # root with expanded children
    total_nodes: int                    # total after expansion
    instance_sources: dict[str, str]    # node_path → source .tscn path
    unresolved: list[str]               # instances that couldn't be found

@dataclass
class WorldNode:
    """Node with world-space transform calculated."""
    node: TscnNode
    world_position: tuple[float, float, float]
    world_rotation: tuple[float, float, float]
    world_scale: tuple[float, float, float]
    full_path: str                      # "Equipment/FDMPrinter_1/WorkPoint"
    depth: int
    is_instance_root: bool              # true if this node came from resolving an instance
    instance_source: str | None         # source .tscn if is_instance_root

def resolve_scene(
    parsed: TscnScene,
    project_root: Path,
    max_depth: int = 10,
    resolve_instances: bool = True
) -> ResolvedScene:
    """Resolve all instances in a parsed scene.

    Args:
        parsed: Raw parsed TscnScene from parse_tscn()
        project_root: Path to Godot project root (contains project.godot)
        max_depth: Maximum instance nesting depth
        resolve_instances: If False, skip instance resolution (just build hierarchy)
    """

def calculate_world_transforms(resolved: ResolvedScene) -> list[WorldNode]:
    """Calculate world-space transforms for all nodes.

    Walks the tree top-down, accumulating parent transforms.
    Returns flat list of WorldNode for easy filtering/searching.
    """

def find_nodes_by_type(world_nodes: list[WorldNode], node_type: str) -> list[WorldNode]:
    """Filter nodes by Godot type (e.g., 'Marker3D', 'MeshInstance3D')."""

def find_nodes_by_group(world_nodes: list[WorldNode], group: str) -> list[WorldNode]:
    """Filter nodes that belong to a specific group."""

def find_nodes_in_radius(
    world_nodes: list[WorldNode],
    center: tuple[float, float, float],
    radius: float
) -> list[WorldNode]:
    """Find all nodes within radius of a world-space point."""
```

### Esempio reale atteso

```python
parsed = parse_tscn(Path("tests/fixtures/main.tscn"))
resolved = resolve_scene(parsed, project_root=Path("path/to/print-farm-tycoon"))
world_nodes = calculate_world_transforms(resolved)

# Trova tutti i printer slots
slots = find_nodes_by_group(world_nodes, "printer_slots")
# → 6 nodi, ciascuno con world_position reale

# Trova tutto entro 5m da printer_slot_1
nearby = find_nodes_in_radius(world_nodes, center=(-36.117, 0, -1.947), radius=5.0)
# → printer_slot_2, printer_slot_6, vicini vari
```

### Test

```
test_resolve_simple_no_instances       → worker.tscn (no instances) → resolved = original
test_resolve_with_instance             → main.tscn → FDMPrinter_1 espanso con figli di fdm_printer.tscn
test_property_override_transform       → main.tscn → FDMPrinter_1 usa il transform di main, non quello di fdm_printer
test_world_position_root               → root node → world_pos = local_pos
test_world_position_nested             → WorkPoint dentro FDMPrinter_1 → world_pos calcolata correttamente
test_world_position_deep_nesting       → nodo a 4+ livelli → accumulo transform corretto
test_find_nodes_by_group               → "printer_slots" → 6 risultati
test_find_nodes_by_type                → "Marker3D" → N risultati
test_find_nodes_in_radius              → centro su printer_slot_1, raggio 5.0 → risultati ragionevoli
test_unresolved_instances              → istanza che punta a file inesistente → listed in unresolved, no crash
test_max_depth_limit                   → profondità ricorsiva rispettata
```

### Edge cases
- File .tscn istanziato che non esiste su disco → aggiunto a `unresolved`, no crash
- Istanza circolare (A istanzia B che istanzia A) → limit depth lo protegge
- Path `res://` → convertire in path filesystem relativo a project_root
- `.glb`/`.gltf` come instance → NON li risolviamo (non sono .tscn), ma li notiamo

---

## Task 1C — Project Index

### Obiettivo
Scanner che indicizza l'intero progetto Godot, costruisce mappe globali di dipendenze e usa la cache per evitare re-parsing.

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

### Cosa deve fare

#### 1. Scansione filesystem
```python
project_root/
├── scenes/          → trova tutti i .tscn
├── scripts/         → trova tutti i .gd
├── assets/          → trova tutti i .glb, .gltf, .png, .jpg, .tres
└── project.godot    → parsa gli autoload
```

#### 2. Indici costruiti

```python
@dataclass
class ProjectIndex:
    """Complete index of a Godot project."""
    project_root: Path
    project_name: str                          # from project.godot

    # File inventories
    scenes: dict[str, TscnScene]               # res://path → parsed scene
    scripts: list[str]                         # list of .gd paths (parsed in Sprint 2)
    assets: dict[str, dict]                    # res://path → {type, size_bytes, used_by: []}

    # Cross-reference maps
    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 path → [scenes using it]
    resource_usage: dict[str, list[str]]       # resource path → [scenes/scripts referencing it]

    # Autoloads (from project.godot)
    autoloads: dict[str, str]                  # autoload_name → script_path

    # Stats
    total_scenes: int
    total_scripts: int
    total_assets: int
    scan_time_ms: float
```

#### 3. API

```python
def scan_project(project_root: Path, cache: FileCache | None = None) -> ProjectIndex:
    """Scan entire Godot project and build indices.

    Uses FileCache to skip unchanged files.
    Only parses .tscn files (Sprint 1). .gd parsing added in Sprint 2.

    Args:
        project_root: Path containing project.godot
        cache: Optional FileCache for incremental updates
    """

def rescan_changed(index: ProjectIndex, cache: FileCache) -> ProjectIndex:
    """Incremental rescan — only re-parse files that changed since last scan."""

def find_asset_usage(index: ProjectIndex, asset_path: str) -> list[str]:
    """Find all scenes/scripts that reference an asset."""

def find_unused_assets(index: ProjectIndex) -> list[str]:
    """Find assets in the project that are never referenced."""

def get_scene_dependency_chain(index: ProjectIndex, scene_path: str) -> list[str]:
    """Get all scenes that this scene depends on (recursive)."""
```

#### 4. project.godot Parsing (minimal)
Estrarre solo autoloads:
```ini
[autoload]
GameManager="*res://scripts/managers/game_manager.gd"
EconomyManager="*res://scripts/managers/economy_manager.gd"
Events="*res://scripts/autoloads/events.gd"
```
→ `{"GameManager": "res://scripts/managers/game_manager.gd", ...}`

Il `*` prefix indica autoload abilitato.

### Test

```
test_scan_finds_all_tscn             → fixture project con 4 .tscn → tutti trovati
test_scan_finds_all_scripts          → fixture project → .gd files listed (not parsed yet)
test_scene_instances_map             → main.tscn istanzia fdm_printer.tscn → relazione mappata
test_script_to_scenes_map            → printer.gd → usato in fdm_printer.tscn
test_resource_usage                  → printer_standard.glb → usato in fdm_printer.tscn
test_autoloads_parsed                → project.godot fixture → autoloads estratti
test_unused_assets                   → asset non referenziato in nessun .tscn → trovato
test_incremental_rescan              → modifica mtime di un file → solo quello riparsato
test_scan_skips_godot_dir            → .godot/ directory ignorata
test_scan_skips_addons               → addons/ directory ignorata (o configurabile)
```

### Fixture per test
Servono dei test in una mini struttura progetto:
```
tests/fixtures/sample_project/
├── project.godot              # minimal, con autoloads
├── scenes/
│   ├── main.tscn              # symlink o copia ridotta
│   └── equipment/
│       └── fdm_printer.tscn
├── scripts/
│   └── entities/
│       ├── printer.gd         # file vuoto o minimal
│       └── worker.gd
└── assets/
    └── models/
        └── printers/
            ├── printer_standard.glb   # file vuoto (solo per existence check)
            └── unused_model.glb       # non referenziato → unused asset test
```

---

## Ordine di esecuzione

```
1A (Raw Parser) ← in corso / completato
    │
    ├── 1B (Scene Resolver) ← deep plan su questo
    │       Dipende da: 1A completo, fixture .tscn disponibili
    │
    └── 1C (Project Index) ← plan normale (meno complesso)
            Dipende da: 1A completo, FileCache (Sprint 0)
            Nota: 1B e 1C sono indipendenti tra loro
```

1B e 1C si possono fare in parallelo o in sequenza. 1B è più critico per i tool spatial (B01, B03). 1C è più critico per i tool code/asset (C01, F01).

## Vincoli
- Zero dipendenze esterne
- Type hints ovunque, docstring su ogni funzione pubblica
- NON toccare il raw parser (1A) — solo aggiungere sopra
- NON toccare Sprint 0
- NON implementare tool MCP — solo parser/resolver/index
- Performance: scan di un progetto con 100 file < 500ms, resolve di main.tscn < 200ms

## Come dare a Claude Code

Per 1B:
> Deep plan il Scene Resolver. Il raw parser .tscn è completato in `src/godotiq/parsers/tscn_parser.py`. Le fixture .tscn sono in `tests/fixtures/`. Non toccare il raw parser né Sprint 0.

Per 1C:
> Plan il Project Index. Il raw parser .tscn è completato. Serve parsare anche project.godot per gli autoloads. Le fixture vanno in `tests/fixtures/sample_project/`. Non toccare il raw parser né Sprint 0.
