unityflow 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
unityflow/hierarchy.py ADDED
@@ -0,0 +1,1672 @@
1
+ """High-level Hierarchy API for Unity Prefabs and Scenes.
2
+
3
+ This module provides an abstraction layer for Unity's Nested Prefab structure,
4
+ including stripped objects and PrefabInstance relationships, allowing users to
5
+ work with hierarchies without understanding Unity's internal representation.
6
+
7
+ Key Concepts:
8
+ - Stripped objects: Placeholder references to objects inside nested prefabs
9
+ - PrefabInstance: Reference to an instantiated prefab with property overrides
10
+ - m_Modifications: Property overrides applied to nested prefab instances
11
+
12
+ Example:
13
+ >>> doc = UnityYAMLDocument.load("file.prefab")
14
+ >>> hierarchy = Hierarchy.build(doc)
15
+ >>> for node in hierarchy.root_objects:
16
+ ... print(node.name)
17
+ ... if node.is_prefab_instance:
18
+ ... print(f" Nested prefab: {node.source_guid}")
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Iterator
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from .parser import (
29
+ UnityYAMLObject,
30
+ generate_file_id,
31
+ )
32
+
33
+ if TYPE_CHECKING:
34
+ from .asset_tracker import GUIDIndex
35
+ from .parser import UnityYAMLDocument
36
+
37
+
38
+ @dataclass
39
+ class ComponentInfo:
40
+ """Information about a component attached to a GameObject.
41
+
42
+ For MonoBehaviour components, script_guid and script_name provide
43
+ the resolved script information when a GUIDIndex is available.
44
+
45
+ For components on PrefabInstance nodes, modifications contains the
46
+ property overrides from the PrefabInstance's m_Modifications that
47
+ target this component. Use get_effective_property() to get the
48
+ effective value with modifications applied.
49
+
50
+ Attributes:
51
+ file_id: The fileID of this component in the document
52
+ class_id: Unity's ClassID (e.g., 114 for MonoBehaviour)
53
+ class_name: Human-readable class name from ClassID
54
+ data: Full component data dictionary
55
+ is_on_stripped_object: Whether this component is on a stripped GameObject
56
+ script_guid: GUID of the script (only for MonoBehaviour, class_id=114)
57
+ script_name: Resolved script name (only when GUIDIndex provided)
58
+ modifications: Property overrides targeting this component (PrefabInstance only)
59
+
60
+ Example:
61
+ >>> comp = node.get_component("MonoBehaviour")
62
+ >>> print(comp.script_name) # "PlayerController"
63
+ >>> print(comp.script_guid) # "f4afdcb1cbadf954ba8b1cf465429e17"
64
+ >>> # For PrefabInstance components with modifications:
65
+ >>> value = comp.get_effective_property("m_Enabled")
66
+ """
67
+
68
+ file_id: int
69
+ class_id: int
70
+ class_name: str
71
+ data: dict[str, Any]
72
+ is_on_stripped_object: bool = False
73
+ script_guid: str | None = None
74
+ script_name: str | None = None
75
+ modifications: list[dict[str, Any]] | None = None
76
+
77
+ @property
78
+ def type_name(self) -> str:
79
+ """Get the component type name.
80
+
81
+ For MonoBehaviour components with resolved script names,
82
+ returns the script name. Otherwise returns the class_name.
83
+ """
84
+ if self.script_name:
85
+ return self.script_name
86
+ return self.class_name
87
+
88
+ def get_effective_property(self, property_path: str) -> Any | None:
89
+ """Get a property value with modifications applied.
90
+
91
+ For components with modifications (typically from PrefabInstance),
92
+ this returns the modified value if it exists, otherwise falls back
93
+ to the original data.
94
+
95
+ Args:
96
+ property_path: Property path like "m_Enabled" or "m_Color.r"
97
+
98
+ Returns:
99
+ The effective property value, or None if not found
100
+ """
101
+ # Check modifications first
102
+ if self.modifications:
103
+ for mod in self.modifications:
104
+ if mod.get("propertyPath") == property_path:
105
+ # If objectReference has a fileID, return that
106
+ obj_ref = mod.get("objectReference", {})
107
+ if isinstance(obj_ref, dict) and obj_ref.get("fileID", 0) != 0:
108
+ return obj_ref
109
+ return mod.get("value")
110
+
111
+ # Fall back to original data
112
+ parts = property_path.split(".")
113
+ value = self.data
114
+ for part in parts:
115
+ if isinstance(value, dict) and part in value:
116
+ value = value[part]
117
+ else:
118
+ return None
119
+ return value
120
+
121
+
122
+ @dataclass
123
+ class HierarchyNode:
124
+ """Represents a node in the GameObject hierarchy.
125
+
126
+ A HierarchyNode can represent either:
127
+ - A regular GameObject with its Transform and components
128
+ - A PrefabInstance (nested prefab) with its modifications
129
+
130
+ For PrefabInstance nodes, the source prefab content can be loaded using
131
+ load_source_prefab() to access the internal structure of the nested prefab.
132
+
133
+ Attributes:
134
+ file_id: The fileID of this node's primary object (GameObject or PrefabInstance)
135
+ name: The name of this object
136
+ transform_id: The fileID of the associated Transform/RectTransform
137
+ parent: Parent node (None for root objects)
138
+ children: List of child nodes
139
+ components: List of components attached to this object
140
+ is_prefab_instance: Whether this node represents a nested prefab
141
+ source_guid: GUID of the source prefab (only for PrefabInstance nodes)
142
+ is_stripped: Whether the underlying object is stripped
143
+ prefab_instance_id: For stripped objects, the PrefabInstance they belong to
144
+ is_from_nested_prefab: Whether this node was loaded from a nested prefab
145
+ nested_prefab_loaded: Whether nested prefab content has been loaded
146
+
147
+ Example:
148
+ >>> # Load nested prefab content for a PrefabInstance
149
+ >>> node = hierarchy.find("board_CoreUpgrade")
150
+ >>> if node.is_prefab_instance:
151
+ ... node.load_source_prefab(project_root="/path/to/project")
152
+ ... print(node.children) # Now shows internal structure
153
+ ... print(node.nested_prefab_loaded) # True
154
+ """
155
+
156
+ file_id: int
157
+ name: str
158
+ transform_id: int
159
+ is_ui: bool = False
160
+ parent: HierarchyNode | None = None
161
+ children: list[HierarchyNode] = field(default_factory=list)
162
+ components: list[ComponentInfo] = field(default_factory=list)
163
+ is_prefab_instance: bool = False
164
+ source_guid: str = ""
165
+ source_file_id: int = 0
166
+ is_stripped: bool = False
167
+ prefab_instance_id: int = 0
168
+ modifications: list[dict[str, Any]] = field(default_factory=list)
169
+ is_from_nested_prefab: bool = False
170
+ nested_prefab_loaded: bool = False
171
+ _document: UnityYAMLDocument | None = field(default=None, repr=False)
172
+ _hierarchy: Hierarchy | None = field(default=None, repr=False)
173
+
174
+ def find(self, path: str) -> HierarchyNode | None:
175
+ """Find a descendant node by path.
176
+
177
+ Args:
178
+ path: Path like "Panel/Button" (relative to this node)
179
+
180
+ Returns:
181
+ The found node, or None if not found
182
+ """
183
+ if not path:
184
+ return self
185
+
186
+ parts = path.split("/")
187
+ name = parts[0]
188
+ rest = "/".join(parts[1:]) if len(parts) > 1 else ""
189
+
190
+ # Handle index notation like "Button[1]"
191
+ index = 0
192
+ if "[" in name and name.endswith("]"):
193
+ bracket_pos = name.index("[")
194
+ index = int(name[bracket_pos + 1 : -1])
195
+ name = name[:bracket_pos]
196
+
197
+ # Find matching children
198
+ matches = [c for c in self.children if c.name == name]
199
+ if index < len(matches):
200
+ found = matches[index]
201
+ return found.find(rest) if rest else found
202
+
203
+ return None
204
+
205
+ def get_component(self, type_name: str, index: int = 0) -> ComponentInfo | None:
206
+ """Get a component by type name.
207
+
208
+ Args:
209
+ type_name: Component type like "MonoBehaviour", "Image", etc.
210
+ index: Index if multiple components of same type exist
211
+
212
+ Returns:
213
+ The component info, or None if not found
214
+ """
215
+ matches = [c for c in self.components if c.class_name == type_name]
216
+ return matches[index] if index < len(matches) else None
217
+
218
+ def get_components(self, type_name: str | None = None) -> list[ComponentInfo]:
219
+ """Get all components, optionally filtered by type.
220
+
221
+ Args:
222
+ type_name: Optional type name to filter by
223
+
224
+ Returns:
225
+ List of matching components
226
+ """
227
+ if type_name is None:
228
+ return list(self.components)
229
+ return [c for c in self.components if c.class_name == type_name]
230
+
231
+ @property
232
+ def path(self) -> str:
233
+ """Get the full path from root to this node."""
234
+ if self.parent is None:
235
+ return self.name
236
+ return f"{self.parent.path}/{self.name}"
237
+
238
+ def iter_descendants(self) -> Iterator[HierarchyNode]:
239
+ """Iterate over all descendant nodes (depth-first)."""
240
+ for child in self.children:
241
+ yield child
242
+ yield from child.iter_descendants()
243
+
244
+ def get_property(self, property_path: str) -> Any | None:
245
+ """Get a property value from this node's GameObject or Transform.
246
+
247
+ For PrefabInstance nodes, this returns the effective value by checking
248
+ modifications first. This ensures get_property() returns the same value
249
+ that was set via set_property(), providing API consistency.
250
+
251
+ Args:
252
+ property_path: Property path like "m_Name" or "m_LocalPosition.x"
253
+
254
+ Returns:
255
+ The property value, or None if not found
256
+ """
257
+ # For PrefabInstance, check modifications first for effective value
258
+ if self.is_prefab_instance and self.modifications:
259
+ for mod in self.modifications:
260
+ target = mod.get("target", {})
261
+ # Match by source_guid (same prefab) and propertyPath
262
+ # target.fileID is the fileID within the source prefab, not the prefab asset
263
+ if target.get("guid") == self.source_guid and mod.get("propertyPath") == property_path:
264
+ # If objectReference has a fileID, return that
265
+ obj_ref = mod.get("objectReference", {})
266
+ if isinstance(obj_ref, dict) and obj_ref.get("fileID", 0) != 0:
267
+ return obj_ref
268
+ # Otherwise return the value
269
+ return mod.get("value")
270
+
271
+ if self._document is None:
272
+ return None
273
+
274
+ # Try GameObject first
275
+ obj = self._document.get_by_file_id(self.file_id)
276
+ if obj is not None:
277
+ content = obj.get_content()
278
+ if content is not None:
279
+ # Navigate nested properties
280
+ parts = property_path.split(".")
281
+ value = content
282
+ found = True
283
+ for part in parts:
284
+ if isinstance(value, dict) and part in value:
285
+ value = value[part]
286
+ else:
287
+ found = False
288
+ break
289
+ if found:
290
+ return value
291
+
292
+ # Try Transform/RectTransform if not found in GameObject
293
+ if self.transform_id:
294
+ transform_obj = self._document.get_by_file_id(self.transform_id)
295
+ if transform_obj is not None:
296
+ content = transform_obj.get_content()
297
+ if content is not None:
298
+ parts = property_path.split(".")
299
+ value = content
300
+ for part in parts:
301
+ if isinstance(value, dict) and part in value:
302
+ value = value[part]
303
+ else:
304
+ return None
305
+ return value
306
+
307
+ return None
308
+
309
+ def set_property(self, property_path: str, value: Any) -> bool:
310
+ """Set a property value on this node's GameObject.
311
+
312
+ For PrefabInstance nodes, this adds an entry to m_Modifications.
313
+ Both the document and the node's modifications list are updated
314
+ to ensure get_property() returns the same value (API consistency).
315
+
316
+ Args:
317
+ property_path: Property path like "m_Name" or "m_LocalPosition.x"
318
+ value: The new value
319
+
320
+ Returns:
321
+ True if successful, False otherwise
322
+ """
323
+ if self._document is None:
324
+ return False
325
+
326
+ # For PrefabInstance nodes, use file_id as the PrefabInstance ID
327
+ # (prefab_instance_id is only set for stripped objects pointing to a PrefabInstance)
328
+ prefab_id = self.prefab_instance_id if self.prefab_instance_id else self.file_id
329
+ if self.is_prefab_instance:
330
+ # Add to m_Modifications in document
331
+ prefab_instance = self._document.get_by_file_id(prefab_id)
332
+ if prefab_instance is None:
333
+ return False
334
+
335
+ content = prefab_instance.get_content()
336
+ if content is None:
337
+ return False
338
+
339
+ modification = content.get("m_Modification", {})
340
+ modifications = modification.get("m_Modifications", [])
341
+
342
+ # Find or create modification entry in document
343
+ # Match by source_guid and propertyPath (fileID within source may vary)
344
+ target_found = False
345
+ for mod in modifications:
346
+ target = mod.get("target", {})
347
+ if target.get("guid") == self.source_guid and mod.get("propertyPath") == property_path:
348
+ mod["value"] = value
349
+ target_found = True
350
+ break
351
+
352
+ if not target_found:
353
+ new_mod = {
354
+ "target": {
355
+ "fileID": self.source_file_id,
356
+ "guid": self.source_guid,
357
+ },
358
+ "propertyPath": property_path,
359
+ "value": value,
360
+ "objectReference": {"fileID": 0},
361
+ }
362
+ modifications.append(new_mod)
363
+ modification["m_Modifications"] = modifications
364
+ content["m_Modification"] = modification
365
+
366
+ # Also update node's modifications list for get_property() consistency
367
+ node_target_found = False
368
+ for mod in self.modifications:
369
+ target = mod.get("target", {})
370
+ if target.get("guid") == self.source_guid and mod.get("propertyPath") == property_path:
371
+ mod["value"] = value
372
+ node_target_found = True
373
+ break
374
+
375
+ if not node_target_found:
376
+ self.modifications.append(
377
+ {
378
+ "target": {
379
+ "fileID": self.source_file_id,
380
+ "guid": self.source_guid,
381
+ },
382
+ "propertyPath": property_path,
383
+ "value": value,
384
+ "objectReference": {"fileID": 0},
385
+ }
386
+ )
387
+
388
+ return True
389
+
390
+ # Regular object - direct modification
391
+ obj = self._document.get_by_file_id(self.file_id)
392
+ if obj is None:
393
+ return False
394
+
395
+ content = obj.get_content()
396
+ if content is None:
397
+ return False
398
+
399
+ # Navigate to parent and set final property
400
+ parts = property_path.split(".")
401
+ target = content
402
+ for part in parts[:-1]:
403
+ if isinstance(target, dict):
404
+ if part not in target:
405
+ target[part] = {}
406
+ target = target[part]
407
+ else:
408
+ return False
409
+
410
+ if isinstance(target, dict):
411
+ target[parts[-1]] = value
412
+ return True
413
+ return False
414
+
415
+ def load_source_prefab(
416
+ self,
417
+ project_root: Path | str | None = None,
418
+ guid_index: GUIDIndex | None = None,
419
+ _loading_prefabs: set[str] | None = None,
420
+ ) -> bool:
421
+ """Load the source prefab content for a PrefabInstance node.
422
+
423
+ This method loads the internal structure of a nested prefab,
424
+ making children and components from the source prefab accessible.
425
+
426
+ Uses caching at the Hierarchy level to avoid re-loading and re-parsing
427
+ the same prefab when it's referenced by multiple PrefabInstance nodes.
428
+ For example, if 'board_Upgrade' is used 10 times, it's only loaded once.
429
+
430
+ Args:
431
+ project_root: Path to Unity project root. Required if guid_index
432
+ is not provided.
433
+ guid_index: Optional GUIDIndex for resolving GUIDs and script names.
434
+ If not provided, will try to get from _hierarchy or build a
435
+ minimal index from project_root.
436
+ _loading_prefabs: Internal set to prevent circular references.
437
+
438
+ Returns:
439
+ True if the prefab was loaded successfully, False otherwise.
440
+
441
+ Example:
442
+ >>> node = hierarchy.find("board_CoreUpgrade")
443
+ >>> if node.is_prefab_instance:
444
+ ... node.load_source_prefab("/path/to/unity/project")
445
+ ... for child in node.children:
446
+ ... print(child.name) # Shows internal structure
447
+ """
448
+ if not self.is_prefab_instance or not self.source_guid:
449
+ return False
450
+
451
+ if self.nested_prefab_loaded:
452
+ return True # Already loaded
453
+
454
+ # Initialize loading set for circular reference prevention
455
+ if _loading_prefabs is None:
456
+ _loading_prefabs = set()
457
+
458
+ # Check for circular reference
459
+ if self.source_guid in _loading_prefabs:
460
+ return False # Skip to prevent infinite recursion
461
+
462
+ _loading_prefabs.add(self.source_guid)
463
+
464
+ try:
465
+ # Resolve project_root if needed
466
+ if project_root is not None:
467
+ project_root = Path(project_root)
468
+
469
+ # Get or build guid_index
470
+ if guid_index is None and self._hierarchy is not None:
471
+ guid_index = self._hierarchy.guid_index
472
+
473
+ if guid_index is None and project_root is not None:
474
+ # Import here to avoid circular dependency
475
+ from .asset_tracker import build_guid_index
476
+
477
+ guid_index = build_guid_index(project_root)
478
+
479
+ if guid_index is None:
480
+ return False
481
+
482
+ # Resolve source prefab path
483
+ source_path = guid_index.get_path(self.source_guid)
484
+ if source_path is None:
485
+ return False
486
+
487
+ # Make path absolute if needed
488
+ resolved_project_root = project_root
489
+ if resolved_project_root is None and self._hierarchy is not None:
490
+ resolved_project_root = self._hierarchy.project_root
491
+
492
+ if resolved_project_root is not None and not source_path.is_absolute():
493
+ source_path = resolved_project_root / source_path
494
+
495
+ if not source_path.exists():
496
+ return False
497
+
498
+ # Get cached hierarchy or load and cache it
499
+ # This is the key optimization: same prefab used N times = 1 load + (N-1) cache hits
500
+ if self._hierarchy is not None:
501
+ source_hierarchy = self._hierarchy._get_or_load_nested_hierarchy(
502
+ self.source_guid,
503
+ source_path,
504
+ guid_index,
505
+ )
506
+ else:
507
+ # No parent hierarchy for caching, load directly
508
+ from .parser import UnityYAMLDocument
509
+
510
+ source_doc = UnityYAMLDocument.load_auto(source_path)
511
+ source_hierarchy = Hierarchy.build(source_doc, guid_index=guid_index)
512
+
513
+ if source_hierarchy is None:
514
+ return False
515
+
516
+ # Merge root objects from source as children of this node
517
+ # Note: Nodes are copied (not shared) so each PrefabInstance has its own tree
518
+ for source_root in source_hierarchy.root_objects:
519
+ self._merge_nested_node(source_root, guid_index, _loading_prefabs)
520
+
521
+ self.nested_prefab_loaded = True
522
+ return True
523
+
524
+ finally:
525
+ _loading_prefabs.discard(self.source_guid)
526
+
527
+ def _merge_nested_node(
528
+ self,
529
+ source_node: HierarchyNode,
530
+ guid_index: GUIDIndex | None,
531
+ loading_prefabs: set[str],
532
+ ) -> None:
533
+ """Merge a node from nested prefab into this node's children.
534
+
535
+ This method also applies PrefabInstance modifications to components
536
+ so that ComponentInfo.get_effective_property() returns correct values.
537
+
538
+ Args:
539
+ source_node: The node from the source prefab to merge
540
+ guid_index: GUIDIndex for script resolution
541
+ loading_prefabs: Set of GUIDs being loaded (for circular reference prevention)
542
+ """
543
+ # Group modifications by target fileID for component linking
544
+ mods_by_target: dict[int, list[dict[str, Any]]] = {}
545
+ for mod in self.modifications:
546
+ target = mod.get("target", {})
547
+ target_id = target.get("fileID", 0)
548
+ if target_id:
549
+ if target_id not in mods_by_target:
550
+ mods_by_target[target_id] = []
551
+ mods_by_target[target_id].append(mod)
552
+
553
+ # Copy components with modifications linked
554
+ merged_components = []
555
+ for comp in source_node.components:
556
+ comp_mods = mods_by_target.get(comp.file_id)
557
+ merged_components.append(
558
+ ComponentInfo(
559
+ file_id=comp.file_id,
560
+ class_id=comp.class_id,
561
+ class_name=comp.class_name,
562
+ data=comp.data,
563
+ is_on_stripped_object=comp.is_on_stripped_object,
564
+ script_guid=comp.script_guid,
565
+ script_name=comp.script_name,
566
+ modifications=comp_mods,
567
+ )
568
+ )
569
+
570
+ # Create a copy of the node marked as from nested prefab
571
+ merged_node = HierarchyNode(
572
+ file_id=source_node.file_id,
573
+ name=source_node.name,
574
+ transform_id=source_node.transform_id,
575
+ is_ui=source_node.is_ui,
576
+ parent=self,
577
+ children=[],
578
+ components=merged_components,
579
+ is_prefab_instance=source_node.is_prefab_instance,
580
+ source_guid=source_node.source_guid,
581
+ source_file_id=source_node.source_file_id,
582
+ is_stripped=source_node.is_stripped,
583
+ prefab_instance_id=source_node.prefab_instance_id,
584
+ modifications=list(source_node.modifications),
585
+ is_from_nested_prefab=True,
586
+ nested_prefab_loaded=source_node.nested_prefab_loaded,
587
+ _document=source_node._document,
588
+ _hierarchy=self._hierarchy,
589
+ )
590
+
591
+ self.children.append(merged_node)
592
+
593
+ # Recursively merge children with inherited modifications
594
+ for child in source_node.children:
595
+ merged_node._merge_nested_child(child, guid_index, loading_prefabs, mods_by_target)
596
+
597
+ def _merge_nested_child(
598
+ self,
599
+ source_child: HierarchyNode,
600
+ guid_index: GUIDIndex | None,
601
+ loading_prefabs: set[str],
602
+ mods_by_target: dict[int, list[dict[str, Any]]] | None = None,
603
+ ) -> None:
604
+ """Recursively merge child nodes from nested prefab.
605
+
606
+ Args:
607
+ source_child: The child node from source prefab
608
+ guid_index: GUIDIndex for script resolution
609
+ loading_prefabs: Set of GUIDs being loaded (for circular reference prevention)
610
+ mods_by_target: Modifications grouped by target fileID (from parent PrefabInstance)
611
+ """
612
+ # Copy components with modifications linked
613
+ merged_components = []
614
+ for comp in source_child.components:
615
+ comp_mods = mods_by_target.get(comp.file_id) if mods_by_target else None
616
+ merged_components.append(
617
+ ComponentInfo(
618
+ file_id=comp.file_id,
619
+ class_id=comp.class_id,
620
+ class_name=comp.class_name,
621
+ data=comp.data,
622
+ is_on_stripped_object=comp.is_on_stripped_object,
623
+ script_guid=comp.script_guid,
624
+ script_name=comp.script_name,
625
+ modifications=comp_mods,
626
+ )
627
+ )
628
+
629
+ merged_child = HierarchyNode(
630
+ file_id=source_child.file_id,
631
+ name=source_child.name,
632
+ transform_id=source_child.transform_id,
633
+ is_ui=source_child.is_ui,
634
+ parent=self,
635
+ children=[],
636
+ components=merged_components,
637
+ is_prefab_instance=source_child.is_prefab_instance,
638
+ source_guid=source_child.source_guid,
639
+ source_file_id=source_child.source_file_id,
640
+ is_stripped=source_child.is_stripped,
641
+ prefab_instance_id=source_child.prefab_instance_id,
642
+ modifications=list(source_child.modifications),
643
+ is_from_nested_prefab=True,
644
+ nested_prefab_loaded=source_child.nested_prefab_loaded,
645
+ _document=source_child._document,
646
+ _hierarchy=self._hierarchy,
647
+ )
648
+
649
+ self.children.append(merged_child)
650
+
651
+ # Recursively merge grandchildren
652
+ for grandchild in source_child.children:
653
+ merged_child._merge_nested_child(grandchild, guid_index, loading_prefabs, mods_by_target)
654
+
655
+
656
+ @dataclass
657
+ class Hierarchy:
658
+ """Represents the complete hierarchy of a Unity YAML document.
659
+
660
+ Provides methods for traversing, querying, and modifying the hierarchy
661
+ with automatic handling of stripped objects and PrefabInstance relationships.
662
+
663
+ Supports loading nested prefab content to make the internal structure of
664
+ PrefabInstances accessible for LLM-friendly navigation.
665
+
666
+ Attributes:
667
+ root_objects: List of root-level HierarchyNodes
668
+ guid_index: Optional GUIDIndex for resolving script names
669
+ project_root: Optional project root for loading nested prefabs
670
+
671
+ Example:
672
+ >>> from unityflow import build_guid_index, build_hierarchy
673
+ >>> guid_index = build_guid_index("/path/to/unity/project")
674
+ >>> hierarchy = build_hierarchy(
675
+ ... doc,
676
+ ... guid_index=guid_index,
677
+ ... project_root="/path/to/unity/project",
678
+ ... load_nested_prefabs=True, # Auto-load nested prefab content
679
+ ... )
680
+ >>> for node in hierarchy.iter_all():
681
+ ... for comp in node.components:
682
+ ... # MonoBehaviour now shows script name
683
+ ... print(comp.type_name) # "PlayerController" instead of "MonoBehaviour"
684
+ ... if node.is_prefab_instance:
685
+ ... # Nested prefab children are now accessible
686
+ ... for child in node.children:
687
+ ... print(f" Nested child: {child.name}")
688
+ """
689
+
690
+ root_objects: list[HierarchyNode] = field(default_factory=list)
691
+ guid_index: GUIDIndex | None = field(default=None, repr=False)
692
+ project_root: Path | None = field(default=None, repr=False)
693
+ _document: UnityYAMLDocument | None = field(default=None, repr=False)
694
+ _nodes_by_file_id: dict[int, HierarchyNode] = field(default_factory=dict, repr=False)
695
+ _stripped_transforms: dict[int, int] = field(default_factory=dict, repr=False)
696
+ _stripped_game_objects: dict[int, int] = field(default_factory=dict, repr=False)
697
+ _prefab_instances: dict[int, list[int]] = field(default_factory=dict, repr=False)
698
+ # Cache for loaded nested prefab hierarchies (guid -> Hierarchy)
699
+ # This prevents re-loading and re-parsing the same prefab multiple times
700
+ _nested_prefab_cache: dict[str, Hierarchy] = field(default_factory=dict, repr=False)
701
+
702
+ @classmethod
703
+ def build(
704
+ cls,
705
+ doc: UnityYAMLDocument,
706
+ guid_index: GUIDIndex | None = None,
707
+ project_root: Path | str | None = None,
708
+ load_nested_prefabs: bool = False,
709
+ ) -> Hierarchy:
710
+ """Build a hierarchy from a UnityYAMLDocument.
711
+
712
+ This method:
713
+ 1. Builds indexes for stripped objects and PrefabInstances
714
+ 2. Constructs the transform hierarchy (parent-child relationships)
715
+ 3. Links components to their GameObjects
716
+ 4. Resolves stripped object references to PrefabInstances
717
+ 5. Optionally resolves MonoBehaviour script names using guid_index
718
+ 6. Optionally loads nested prefab content
719
+
720
+ Args:
721
+ doc: The Unity YAML document to build hierarchy from
722
+ guid_index: Optional GUIDIndex for resolving script names.
723
+ When provided, MonoBehaviour components will have their
724
+ script_guid and script_name fields populated.
725
+ project_root: Optional path to Unity project root. Required for
726
+ loading nested prefabs if guid_index doesn't have project_root set.
727
+ load_nested_prefabs: If True, automatically loads the content of
728
+ all nested prefabs (PrefabInstances) so their internal structure
729
+ is accessible through the children property.
730
+
731
+ Returns:
732
+ A Hierarchy instance with the complete object tree
733
+
734
+ Example:
735
+ >>> guid_index = build_guid_index("/path/to/project")
736
+ >>> hierarchy = Hierarchy.build(
737
+ ... doc,
738
+ ... guid_index=guid_index,
739
+ ... load_nested_prefabs=True,
740
+ ... )
741
+ >>> # Access nested prefab content directly
742
+ >>> prefab_node = hierarchy.find("MyPrefabInstance")
743
+ >>> print(prefab_node.children) # Shows internal structure
744
+ """
745
+ # Resolve project_root
746
+ resolved_project_root: Path | None = None
747
+ if project_root is not None:
748
+ resolved_project_root = Path(project_root)
749
+ elif guid_index is not None and guid_index.project_root is not None:
750
+ resolved_project_root = guid_index.project_root
751
+
752
+ hierarchy = cls(
753
+ _document=doc,
754
+ guid_index=guid_index,
755
+ project_root=resolved_project_root,
756
+ )
757
+ hierarchy._build_indexes(doc)
758
+ hierarchy._build_nodes(doc)
759
+ hierarchy._link_hierarchy()
760
+ hierarchy._set_hierarchy_references()
761
+
762
+ # Batch resolve script names (O(1) query instead of O(N))
763
+ hierarchy._batch_resolve_script_names()
764
+
765
+ # Optionally load nested prefabs
766
+ if load_nested_prefabs:
767
+ hierarchy.load_all_nested_prefabs()
768
+
769
+ return hierarchy
770
+
771
+ def _set_hierarchy_references(self) -> None:
772
+ """Set _hierarchy reference on all nodes for nested prefab loading."""
773
+ for node in self.iter_all():
774
+ node._hierarchy = self
775
+
776
+ def _get_or_load_nested_hierarchy(
777
+ self,
778
+ source_guid: str,
779
+ source_path: Path,
780
+ guid_index: GUIDIndex | None,
781
+ ) -> Hierarchy | None:
782
+ """Get cached hierarchy or load and cache a nested prefab.
783
+
784
+ This method provides caching for nested prefab hierarchies to avoid
785
+ re-loading and re-parsing the same prefab multiple times when it's
786
+ referenced by multiple PrefabInstance nodes.
787
+
788
+ Args:
789
+ source_guid: GUID of the source prefab
790
+ source_path: Path to the source prefab file
791
+ guid_index: GUIDIndex for script name resolution
792
+
793
+ Returns:
794
+ Cached or newly loaded Hierarchy, or None if loading failed
795
+ """
796
+ # Check cache first
797
+ if source_guid in self._nested_prefab_cache:
798
+ return self._nested_prefab_cache[source_guid]
799
+
800
+ # Load and parse the source prefab
801
+ try:
802
+ from .parser import UnityYAMLDocument
803
+
804
+ source_doc = UnityYAMLDocument.load_auto(source_path)
805
+
806
+ # Build hierarchy for the source prefab
807
+ # Use same guid_index for consistent script name resolution
808
+ source_hierarchy = Hierarchy.build(source_doc, guid_index=guid_index)
809
+
810
+ # Cache the hierarchy
811
+ self._nested_prefab_cache[source_guid] = source_hierarchy
812
+ return source_hierarchy
813
+ except Exception:
814
+ return None
815
+
816
+ def _build_indexes(self, doc: UnityYAMLDocument) -> None:
817
+ """Build lookup indexes for efficient resolution."""
818
+ # Index stripped objects
819
+ for obj in doc.objects:
820
+ if obj.stripped:
821
+ content = obj.get_content()
822
+ if content is None:
823
+ continue
824
+
825
+ prefab_ref = content.get("m_PrefabInstance", {})
826
+ prefab_id = prefab_ref.get("fileID", 0) if isinstance(prefab_ref, dict) else 0
827
+
828
+ if prefab_id:
829
+ # Track stripped object -> PrefabInstance mapping
830
+ if obj.class_id in (4, 224): # Transform or RectTransform
831
+ self._stripped_transforms[obj.file_id] = prefab_id
832
+ elif obj.class_id == 1: # GameObject
833
+ self._stripped_game_objects[obj.file_id] = prefab_id
834
+
835
+ # Track PrefabInstance -> stripped objects mapping
836
+ if prefab_id not in self._prefab_instances:
837
+ self._prefab_instances[prefab_id] = []
838
+ self._prefab_instances[prefab_id].append(obj.file_id)
839
+
840
+ def _create_component_info(
841
+ self,
842
+ comp_obj: UnityYAMLObject,
843
+ comp_content: dict[str, Any],
844
+ is_on_stripped_object: bool = False,
845
+ ) -> ComponentInfo:
846
+ """Create a ComponentInfo, extracting script GUID for MonoBehaviour.
847
+
848
+ For MonoBehaviour components (class_id=114), extracts the script GUID
849
+ from m_Script. Script name resolution is deferred to _batch_resolve_script_names
850
+ for better performance (single batch query instead of N individual queries).
851
+
852
+ Args:
853
+ comp_obj: The component's UnityYAMLObject
854
+ comp_content: The component's data dictionary
855
+ is_on_stripped_object: Whether component is on a stripped object
856
+
857
+ Returns:
858
+ ComponentInfo with script_guid populated for MonoBehaviour
859
+ (script_name will be resolved later via batch_resolve_script_names)
860
+ """
861
+ script_guid: str | None = None
862
+
863
+ # For MonoBehaviour (class_id=114), extract script GUID
864
+ # Script name resolution is deferred to _batch_resolve_script_names
865
+ if comp_obj.class_id == 114:
866
+ script_ref = comp_content.get("m_Script", {})
867
+ if isinstance(script_ref, dict):
868
+ script_guid = script_ref.get("guid")
869
+
870
+ return ComponentInfo(
871
+ file_id=comp_obj.file_id,
872
+ class_id=comp_obj.class_id,
873
+ class_name=comp_obj.class_name,
874
+ data=comp_content,
875
+ is_on_stripped_object=is_on_stripped_object,
876
+ script_guid=script_guid,
877
+ script_name=None, # Resolved later via _batch_resolve_script_names
878
+ )
879
+
880
+ def _batch_resolve_script_names(self) -> None:
881
+ """Batch resolve all script GUIDs to names using a single query.
882
+
883
+ This method collects all script GUIDs from MonoBehaviour components
884
+ across all nodes and resolves them in a single batch query, which is
885
+ significantly faster than resolving each GUID individually.
886
+
887
+ Performance improvement: O(1) query instead of O(N) queries.
888
+ Typical: 1600ms -> 80ms for prefabs with 100+ MonoBehaviour components.
889
+ """
890
+ if not self.guid_index:
891
+ return
892
+
893
+ # Collect all script GUIDs from all components
894
+ all_guids: set[str] = set()
895
+ for node in self.iter_all():
896
+ for comp in node.components:
897
+ if comp.script_guid:
898
+ all_guids.add(comp.script_guid)
899
+
900
+ if not all_guids:
901
+ return
902
+
903
+ # Batch resolve all GUIDs at once
904
+ resolved_names = self.guid_index.batch_resolve_names(all_guids)
905
+
906
+ # Update component script_name fields
907
+ for node in self.iter_all():
908
+ for comp in node.components:
909
+ if comp.script_guid and comp.script_guid in resolved_names:
910
+ # ComponentInfo is a dataclass, need to use object.__setattr__
911
+ # if it's frozen, but it's not frozen, so direct assignment works
912
+ comp.script_name = resolved_names[comp.script_guid]
913
+
914
+ def _build_nodes(self, doc: UnityYAMLDocument) -> None:
915
+ """Build HierarchyNode objects for each GameObject and PrefabInstance."""
916
+ # Build transform -> GameObject mapping
917
+ transform_to_go: dict[int, int] = {}
918
+ go_to_transform: dict[int, int] = {}
919
+
920
+ for obj in doc.objects:
921
+ if obj.class_id in (4, 224) and not obj.stripped:
922
+ content = obj.get_content()
923
+ if content:
924
+ go_ref = content.get("m_GameObject", {})
925
+ go_id = go_ref.get("fileID", 0) if isinstance(go_ref, dict) else 0
926
+ if go_id:
927
+ transform_to_go[obj.file_id] = go_id
928
+ go_to_transform[go_id] = obj.file_id
929
+
930
+ # Create nodes for regular GameObjects
931
+ for obj in doc.objects:
932
+ if obj.class_id == 1 and not obj.stripped:
933
+ content = obj.get_content()
934
+ if content is None:
935
+ continue
936
+
937
+ name = content.get("m_Name", "")
938
+ transform_id = go_to_transform.get(obj.file_id, 0)
939
+
940
+ # Determine if UI
941
+ is_ui = False
942
+ if transform_id:
943
+ transform_obj = doc.get_by_file_id(transform_id)
944
+ if transform_obj and transform_obj.class_id == 224:
945
+ is_ui = True
946
+
947
+ node = HierarchyNode(
948
+ file_id=obj.file_id,
949
+ name=name,
950
+ transform_id=transform_id,
951
+ is_ui=is_ui,
952
+ _document=doc,
953
+ )
954
+ self._nodes_by_file_id[obj.file_id] = node
955
+
956
+ # Collect components
957
+ components = content.get("m_Component", [])
958
+ for comp_entry in components:
959
+ if isinstance(comp_entry, dict):
960
+ comp_ref = comp_entry.get("component", {})
961
+ comp_id = comp_ref.get("fileID", 0) if isinstance(comp_ref, dict) else 0
962
+ if comp_id and comp_id != transform_id:
963
+ comp_obj = doc.get_by_file_id(comp_id)
964
+ if comp_obj:
965
+ comp_content = comp_obj.get_content() or {}
966
+ node.components.append(self._create_component_info(comp_obj, comp_content))
967
+
968
+ # Create nodes for PrefabInstances
969
+ for obj in doc.objects:
970
+ if obj.class_id == 1001: # PrefabInstance
971
+ content = obj.get_content()
972
+ if content is None:
973
+ continue
974
+
975
+ # Get source prefab info
976
+ source = content.get("m_SourcePrefab", {})
977
+ source_guid = source.get("guid", "") if isinstance(source, dict) else ""
978
+ source_file_id = source.get("fileID", 0) if isinstance(source, dict) else 0
979
+
980
+ # Get name from modifications
981
+ modification = content.get("m_Modification", {})
982
+ modifications = modification.get("m_Modifications", [])
983
+
984
+ name = ""
985
+ for mod in modifications:
986
+ if mod.get("propertyPath") == "m_Name":
987
+ name = str(mod.get("value", ""))
988
+ break
989
+
990
+ if not name:
991
+ # Try to get name from root stripped object
992
+ name = f"PrefabInstance_{obj.file_id}"
993
+
994
+ # Find the root transform of this PrefabInstance
995
+ transform_id = 0
996
+ is_ui = False
997
+ stripped_ids = self._prefab_instances.get(obj.file_id, [])
998
+ for stripped_id in stripped_ids:
999
+ stripped_obj = doc.get_by_file_id(stripped_id)
1000
+ if stripped_obj and stripped_obj.class_id in (4, 224):
1001
+ # Check if this is the root (parent is outside the prefab)
1002
+ transform_id = stripped_id
1003
+ # RectTransform (224) means UI
1004
+ is_ui = stripped_obj.class_id == 224
1005
+ break
1006
+
1007
+ node = HierarchyNode(
1008
+ file_id=obj.file_id,
1009
+ name=name,
1010
+ transform_id=transform_id,
1011
+ is_ui=is_ui,
1012
+ is_prefab_instance=True,
1013
+ source_guid=source_guid,
1014
+ source_file_id=source_file_id,
1015
+ modifications=modifications,
1016
+ _document=doc,
1017
+ )
1018
+
1019
+ self._nodes_by_file_id[obj.file_id] = node
1020
+
1021
+ # Collect components on stripped GameObjects in this prefab
1022
+ for stripped_id in stripped_ids:
1023
+ if stripped_id in self._stripped_game_objects:
1024
+ # Find components referencing this stripped GameObject
1025
+ for comp_obj in doc.objects:
1026
+ if (
1027
+ comp_obj.class_id
1028
+ not in (
1029
+ 1,
1030
+ 4,
1031
+ 224,
1032
+ 1001,
1033
+ )
1034
+ and not comp_obj.stripped
1035
+ ):
1036
+ comp_content = comp_obj.get_content()
1037
+ if comp_content:
1038
+ go_ref = comp_content.get("m_GameObject", {})
1039
+ go_id = go_ref.get("fileID", 0) if isinstance(go_ref, dict) else 0
1040
+ if go_id == stripped_id:
1041
+ node.components.append(
1042
+ self._create_component_info(
1043
+ comp_obj,
1044
+ comp_content,
1045
+ is_on_stripped_object=True,
1046
+ )
1047
+ )
1048
+
1049
+ def _link_hierarchy(self) -> None:
1050
+ """Link parent-child relationships and identify root objects."""
1051
+ if self._document is None:
1052
+ return
1053
+
1054
+ doc = self._document
1055
+
1056
+ # Build transform parent-child map
1057
+ transform_parents: dict[int, int] = {} # child_transform -> parent_transform
1058
+
1059
+ for obj in doc.objects:
1060
+ if obj.class_id in (4, 224): # Transform or RectTransform
1061
+ content = obj.get_content()
1062
+ if content:
1063
+ father = content.get("m_Father", {})
1064
+ father_id = father.get("fileID", 0) if isinstance(father, dict) else 0
1065
+ if father_id:
1066
+ transform_parents[obj.file_id] = father_id
1067
+
1068
+ # Also check PrefabInstance m_TransformParent
1069
+ for obj in doc.objects:
1070
+ if obj.class_id == 1001:
1071
+ content = obj.get_content()
1072
+ if content:
1073
+ modification = content.get("m_Modification", {})
1074
+ parent_ref = modification.get("m_TransformParent", {})
1075
+ parent_id = parent_ref.get("fileID", 0) if isinstance(parent_ref, dict) else 0
1076
+
1077
+ # Find the root stripped transform for this PrefabInstance
1078
+ stripped_ids = self._prefab_instances.get(obj.file_id, [])
1079
+ for stripped_id in stripped_ids:
1080
+ if stripped_id in self._stripped_transforms:
1081
+ transform_parents[stripped_id] = parent_id
1082
+ break
1083
+
1084
+ # Build transform -> node mapping
1085
+ transform_to_node: dict[int, HierarchyNode] = {}
1086
+ for node in self._nodes_by_file_id.values():
1087
+ if node.transform_id:
1088
+ transform_to_node[node.transform_id] = node
1089
+
1090
+ # Link parent-child relationships
1091
+ for node in self._nodes_by_file_id.values():
1092
+ if node.transform_id and node.transform_id in transform_parents:
1093
+ parent_transform_id = transform_parents[node.transform_id]
1094
+ parent_node = transform_to_node.get(parent_transform_id)
1095
+ if parent_node:
1096
+ node.parent = parent_node
1097
+ parent_node.children.append(node)
1098
+
1099
+ # Sort children based on Transform's m_Children order
1100
+ self._sort_children_by_transform_order(doc)
1101
+
1102
+ # Collect root objects
1103
+ for node in self._nodes_by_file_id.values():
1104
+ if node.parent is None:
1105
+ self.root_objects.append(node)
1106
+
1107
+ def _sort_children_by_transform_order(self, doc: UnityYAMLDocument) -> None:
1108
+ """Sort children of each node based on Transform's m_Children order.
1109
+
1110
+ Unity Editor displays children in the order specified by the parent
1111
+ Transform's m_Children array. This method ensures HierarchyNode.children
1112
+ matches that order.
1113
+
1114
+ Args:
1115
+ doc: The Unity YAML document
1116
+ """
1117
+ for node in self._nodes_by_file_id.values():
1118
+ if not node.children or not node.transform_id:
1119
+ continue
1120
+
1121
+ transform_obj = doc.get_by_file_id(node.transform_id)
1122
+ if transform_obj is None:
1123
+ continue
1124
+
1125
+ content = transform_obj.get_content()
1126
+ if content is None:
1127
+ continue
1128
+
1129
+ m_children = content.get("m_Children", [])
1130
+ if not m_children:
1131
+ continue
1132
+
1133
+ # Build order map: child_transform_id -> index
1134
+ order_map: dict[int, int] = {}
1135
+ for idx, child_ref in enumerate(m_children):
1136
+ if isinstance(child_ref, dict):
1137
+ child_id = child_ref.get("fileID", 0)
1138
+ if child_id:
1139
+ order_map[child_id] = idx
1140
+
1141
+ # Sort children by their transform_id's position in m_Children
1142
+ # Nodes not in m_Children go to the end
1143
+ node.children.sort(key=lambda c: order_map.get(c.transform_id, len(m_children)))
1144
+
1145
+ def find(self, path: str) -> HierarchyNode | None:
1146
+ """Find a node by full path from root.
1147
+
1148
+ Args:
1149
+ path: Full path like "Canvas/Panel/Button"
1150
+
1151
+ Returns:
1152
+ The found node, or None if not found
1153
+ """
1154
+ if not path:
1155
+ return None
1156
+
1157
+ parts = path.split("/")
1158
+ root_name = parts[0]
1159
+ rest = "/".join(parts[1:]) if len(parts) > 1 else ""
1160
+
1161
+ # Handle index notation
1162
+ index = 0
1163
+ if "[" in root_name and root_name.endswith("]"):
1164
+ bracket_pos = root_name.index("[")
1165
+ index = int(root_name[bracket_pos + 1 : -1])
1166
+ root_name = root_name[:bracket_pos]
1167
+
1168
+ # Find matching root
1169
+ matches = [r for r in self.root_objects if r.name == root_name]
1170
+ if index < len(matches):
1171
+ root = matches[index]
1172
+ return root.find(rest) if rest else root
1173
+
1174
+ return None
1175
+
1176
+ def get_by_file_id(self, file_id: int) -> HierarchyNode | None:
1177
+ """Get a node by its fileID.
1178
+
1179
+ Args:
1180
+ file_id: The fileID to look up
1181
+
1182
+ Returns:
1183
+ The node, or None if not found
1184
+ """
1185
+ return self._nodes_by_file_id.get(file_id)
1186
+
1187
+ def iter_all(self) -> Iterator[HierarchyNode]:
1188
+ """Iterate over all nodes in the hierarchy."""
1189
+ for root in self.root_objects:
1190
+ yield root
1191
+ yield from root.iter_descendants()
1192
+
1193
+ def load_all_nested_prefabs(
1194
+ self,
1195
+ recursive: bool = True,
1196
+ ) -> int:
1197
+ """Load all nested prefab content in the hierarchy.
1198
+
1199
+ This method finds all PrefabInstance nodes and loads their source
1200
+ prefab content, making the internal structure accessible through
1201
+ the children property.
1202
+
1203
+ Args:
1204
+ recursive: If True (default), also loads nested prefabs within
1205
+ the loaded prefabs (up to circular reference detection).
1206
+
1207
+ Returns:
1208
+ The number of prefabs successfully loaded.
1209
+
1210
+ Example:
1211
+ >>> hierarchy = build_hierarchy(doc, guid_index=guid_index)
1212
+ >>> count = hierarchy.load_all_nested_prefabs()
1213
+ >>> print(f"Loaded {count} nested prefabs")
1214
+ >>>
1215
+ >>> # Now all PrefabInstance nodes have children populated
1216
+ >>> for node in hierarchy.iter_all():
1217
+ ... if node.is_prefab_instance and node.nested_prefab_loaded:
1218
+ ... print(f"{node.name}: {len(node.children)} children")
1219
+ """
1220
+ if self.guid_index is None and self.project_root is None:
1221
+ return 0
1222
+
1223
+ loaded_count = 0
1224
+ loading_prefabs: set[str] = set()
1225
+
1226
+ # Find all PrefabInstance nodes
1227
+ prefab_nodes = [node for node in self.iter_all() if node.is_prefab_instance and not node.nested_prefab_loaded]
1228
+
1229
+ for node in prefab_nodes:
1230
+ if node.load_source_prefab(
1231
+ project_root=self.project_root,
1232
+ guid_index=self.guid_index,
1233
+ _loading_prefabs=loading_prefabs,
1234
+ ):
1235
+ loaded_count += 1
1236
+
1237
+ # Recursively load nested prefabs in the newly loaded content
1238
+ if recursive:
1239
+ loaded_count += self._load_nested_in_children(node, loading_prefabs)
1240
+
1241
+ return loaded_count
1242
+
1243
+ def _load_nested_in_children(
1244
+ self,
1245
+ node: HierarchyNode,
1246
+ loading_prefabs: set[str],
1247
+ ) -> int:
1248
+ """Recursively load nested prefabs in children.
1249
+
1250
+ Args:
1251
+ node: The node whose children to check
1252
+ loading_prefabs: Set of GUIDs being loaded (for circular reference prevention)
1253
+
1254
+ Returns:
1255
+ Number of additional prefabs loaded
1256
+ """
1257
+ loaded_count = 0
1258
+
1259
+ for child in node.children:
1260
+ if child.is_prefab_instance and not child.nested_prefab_loaded:
1261
+ if child.load_source_prefab(
1262
+ project_root=self.project_root,
1263
+ guid_index=self.guid_index,
1264
+ _loading_prefabs=loading_prefabs,
1265
+ ):
1266
+ loaded_count += 1
1267
+ loaded_count += self._load_nested_in_children(child, loading_prefabs)
1268
+ elif child.children:
1269
+ loaded_count += self._load_nested_in_children(child, loading_prefabs)
1270
+
1271
+ return loaded_count
1272
+
1273
+ def get_prefab_instance_for(self, stripped_file_id: int) -> int:
1274
+ """Get the PrefabInstance ID for a stripped object.
1275
+
1276
+ Args:
1277
+ stripped_file_id: FileID of a stripped Transform or GameObject
1278
+
1279
+ Returns:
1280
+ FileID of the owning PrefabInstance, or 0 if not found
1281
+ """
1282
+ if stripped_file_id in self._stripped_transforms:
1283
+ return self._stripped_transforms[stripped_file_id]
1284
+ if stripped_file_id in self._stripped_game_objects:
1285
+ return self._stripped_game_objects[stripped_file_id]
1286
+ return 0
1287
+
1288
+ def get_stripped_objects_for(self, prefab_instance_id: int) -> list[int]:
1289
+ """Get all stripped object IDs belonging to a PrefabInstance.
1290
+
1291
+ Args:
1292
+ prefab_instance_id: FileID of the PrefabInstance
1293
+
1294
+ Returns:
1295
+ List of stripped object fileIDs
1296
+ """
1297
+ return self._prefab_instances.get(prefab_instance_id, [])
1298
+
1299
+ def resolve_game_object(self, file_id: int) -> HierarchyNode | None:
1300
+ """Resolve a fileID to its effective HierarchyNode.
1301
+
1302
+ For regular objects, returns the node directly.
1303
+ For stripped objects, returns the owning PrefabInstance node.
1304
+ For components on stripped objects, returns the PrefabInstance node.
1305
+
1306
+ Args:
1307
+ file_id: FileID of a GameObject, component, or stripped object
1308
+
1309
+ Returns:
1310
+ The resolved HierarchyNode, or None if not found
1311
+ """
1312
+ # Direct lookup
1313
+ if file_id in self._nodes_by_file_id:
1314
+ return self._nodes_by_file_id[file_id]
1315
+
1316
+ # Check if it's a stripped object
1317
+ if file_id in self._stripped_transforms:
1318
+ prefab_id = self._stripped_transforms[file_id]
1319
+ return self._nodes_by_file_id.get(prefab_id)
1320
+
1321
+ if file_id in self._stripped_game_objects:
1322
+ prefab_id = self._stripped_game_objects[file_id]
1323
+ return self._nodes_by_file_id.get(prefab_id)
1324
+
1325
+ # Check if it's a component
1326
+ if self._document:
1327
+ obj = self._document.get_by_file_id(file_id)
1328
+ if obj and obj.class_id not in (1, 4, 224, 1001):
1329
+ content = obj.get_content()
1330
+ if content:
1331
+ go_ref = content.get("m_GameObject", {})
1332
+ go_id = go_ref.get("fileID", 0) if isinstance(go_ref, dict) else 0
1333
+ if go_id:
1334
+ return self.resolve_game_object(go_id)
1335
+
1336
+ return None
1337
+
1338
+ def add_prefab_instance(
1339
+ self,
1340
+ source_guid: str,
1341
+ parent: HierarchyNode | None = None,
1342
+ name: str | None = None,
1343
+ position: tuple[float, float, float] = (0, 0, 0),
1344
+ source_root_transform_id: int = 0,
1345
+ source_root_go_id: int = 0,
1346
+ is_ui: bool = False,
1347
+ ) -> HierarchyNode | None:
1348
+ """Add a new PrefabInstance to the hierarchy.
1349
+
1350
+ This method creates:
1351
+ 1. A PrefabInstance entry with m_Modification
1352
+ 2. Stripped Transform/RectTransform entry
1353
+ 3. Stripped GameObject entry (if source IDs provided)
1354
+
1355
+ Args:
1356
+ source_guid: GUID of the source prefab
1357
+ parent: Parent node to attach to (None for root)
1358
+ name: Override name for the instance
1359
+ position: Local position (x, y, z)
1360
+ source_root_transform_id: FileID of root Transform in source prefab
1361
+ source_root_go_id: FileID of root GameObject in source prefab
1362
+ is_ui: Whether to use RectTransform
1363
+
1364
+ Returns:
1365
+ The created HierarchyNode, or None if failed
1366
+ """
1367
+ if self._document is None:
1368
+ return None
1369
+
1370
+ doc = self._document
1371
+
1372
+ # Generate fileIDs
1373
+ prefab_instance_id = generate_file_id()
1374
+ stripped_transform_id = generate_file_id()
1375
+ stripped_go_id = generate_file_id() if source_root_go_id else 0
1376
+
1377
+ # Get parent transform ID
1378
+ parent_transform_id = parent.transform_id if parent else 0
1379
+
1380
+ # Build modifications list
1381
+ modifications: list[dict[str, Any]] = []
1382
+
1383
+ # Position modification
1384
+ if source_root_transform_id:
1385
+ if position[0] != 0:
1386
+ modifications.append(
1387
+ {
1388
+ "target": {"fileID": source_root_transform_id, "guid": source_guid},
1389
+ "propertyPath": "m_LocalPosition.x",
1390
+ "value": position[0],
1391
+ "objectReference": {"fileID": 0},
1392
+ }
1393
+ )
1394
+ if position[1] != 0:
1395
+ modifications.append(
1396
+ {
1397
+ "target": {"fileID": source_root_transform_id, "guid": source_guid},
1398
+ "propertyPath": "m_LocalPosition.y",
1399
+ "value": position[1],
1400
+ "objectReference": {"fileID": 0},
1401
+ }
1402
+ )
1403
+ if position[2] != 0:
1404
+ modifications.append(
1405
+ {
1406
+ "target": {"fileID": source_root_transform_id, "guid": source_guid},
1407
+ "propertyPath": "m_LocalPosition.z",
1408
+ "value": position[2],
1409
+ "objectReference": {"fileID": 0},
1410
+ }
1411
+ )
1412
+
1413
+ # Name modification
1414
+ if name and source_root_go_id:
1415
+ modifications.append(
1416
+ {
1417
+ "target": {"fileID": source_root_go_id, "guid": source_guid},
1418
+ "propertyPath": "m_Name",
1419
+ "value": name,
1420
+ "objectReference": {"fileID": 0},
1421
+ }
1422
+ )
1423
+
1424
+ # Create PrefabInstance object
1425
+ prefab_instance_data = {
1426
+ "PrefabInstance": {
1427
+ "m_ObjectHideFlags": 0,
1428
+ "serializedVersion": 2,
1429
+ "m_Modification": {
1430
+ "serializedVersion": 3,
1431
+ "m_TransformParent": {"fileID": parent_transform_id},
1432
+ "m_Modifications": modifications,
1433
+ "m_RemovedComponents": [],
1434
+ "m_RemovedGameObjects": [],
1435
+ "m_AddedGameObjects": [],
1436
+ "m_AddedComponents": [],
1437
+ },
1438
+ "m_SourcePrefab": {
1439
+ "fileID": 100100000,
1440
+ "guid": source_guid,
1441
+ "type": 3,
1442
+ },
1443
+ }
1444
+ }
1445
+ prefab_instance_obj = UnityYAMLObject(
1446
+ class_id=1001,
1447
+ file_id=prefab_instance_id,
1448
+ data=prefab_instance_data,
1449
+ )
1450
+ doc.add_object(prefab_instance_obj)
1451
+
1452
+ # Create stripped Transform
1453
+ transform_class_id = 224 if is_ui else 4
1454
+ transform_root_key = "RectTransform" if is_ui else "Transform"
1455
+ stripped_transform_data = {
1456
+ transform_root_key: {
1457
+ "m_CorrespondingSourceObject": {
1458
+ "fileID": source_root_transform_id,
1459
+ "guid": source_guid,
1460
+ },
1461
+ "m_PrefabInstance": {"fileID": prefab_instance_id},
1462
+ }
1463
+ }
1464
+ stripped_transform_obj = UnityYAMLObject(
1465
+ class_id=transform_class_id,
1466
+ file_id=stripped_transform_id,
1467
+ data=stripped_transform_data,
1468
+ stripped=True,
1469
+ )
1470
+ doc.add_object(stripped_transform_obj)
1471
+
1472
+ # Create stripped GameObject if source ID provided
1473
+ if source_root_go_id:
1474
+ stripped_go_data = {
1475
+ "GameObject": {
1476
+ "m_CorrespondingSourceObject": {
1477
+ "fileID": source_root_go_id,
1478
+ "guid": source_guid,
1479
+ },
1480
+ "m_PrefabInstance": {"fileID": prefab_instance_id},
1481
+ }
1482
+ }
1483
+ stripped_go_obj = UnityYAMLObject(
1484
+ class_id=1,
1485
+ file_id=stripped_go_id,
1486
+ data=stripped_go_data,
1487
+ stripped=True,
1488
+ )
1489
+ doc.add_object(stripped_go_obj)
1490
+
1491
+ # Update parent's m_Children
1492
+ if parent_transform_id:
1493
+ parent_transform = doc.get_by_file_id(parent_transform_id)
1494
+ if parent_transform:
1495
+ content = parent_transform.get_content()
1496
+ if content:
1497
+ children = content.get("m_Children", [])
1498
+ children.append({"fileID": stripped_transform_id})
1499
+ content["m_Children"] = children
1500
+
1501
+ # Update indexes
1502
+ self._stripped_transforms[stripped_transform_id] = prefab_instance_id
1503
+ if source_root_go_id:
1504
+ self._stripped_game_objects[stripped_go_id] = prefab_instance_id
1505
+ self._prefab_instances[prefab_instance_id] = [stripped_transform_id]
1506
+ if source_root_go_id:
1507
+ self._prefab_instances[prefab_instance_id].append(stripped_go_id)
1508
+
1509
+ # Create and register node
1510
+ node = HierarchyNode(
1511
+ file_id=prefab_instance_id,
1512
+ name=name or f"PrefabInstance_{prefab_instance_id}",
1513
+ transform_id=stripped_transform_id,
1514
+ is_ui=is_ui,
1515
+ is_prefab_instance=True,
1516
+ source_guid=source_guid,
1517
+ source_file_id=100100000,
1518
+ modifications=modifications,
1519
+ parent=parent,
1520
+ _document=doc,
1521
+ )
1522
+
1523
+ if parent:
1524
+ parent.children.append(node)
1525
+ else:
1526
+ self.root_objects.append(node)
1527
+
1528
+ self._nodes_by_file_id[prefab_instance_id] = node
1529
+
1530
+ return node
1531
+
1532
+
1533
+ def build_hierarchy(
1534
+ doc: UnityYAMLDocument,
1535
+ guid_index: GUIDIndex | None = None,
1536
+ project_root: Path | str | None = None,
1537
+ load_nested_prefabs: bool = False,
1538
+ ) -> Hierarchy:
1539
+ """Build a hierarchy from a UnityYAMLDocument.
1540
+
1541
+ Convenience function that calls Hierarchy.build().
1542
+
1543
+ This is the main entry point for building LLM-friendly hierarchies with:
1544
+ - Automatic script name resolution for MonoBehaviour components
1545
+ - Optional nested prefab content loading
1546
+
1547
+ Args:
1548
+ doc: The Unity YAML document
1549
+ guid_index: Optional GUIDIndex for resolving script names.
1550
+ When provided, MonoBehaviour components will have their
1551
+ script_guid and script_name fields populated.
1552
+ project_root: Optional path to Unity project root. Required for
1553
+ loading nested prefabs if guid_index doesn't have project_root set.
1554
+ load_nested_prefabs: If True, automatically loads the content of
1555
+ all nested prefabs (PrefabInstances) so their internal structure
1556
+ is accessible through the children property.
1557
+
1558
+ Returns:
1559
+ A Hierarchy instance
1560
+
1561
+ Example:
1562
+ >>> from unityflow import build_guid_index, build_hierarchy, UnityYAMLDocument
1563
+ >>> guid_index = build_guid_index("/path/to/unity/project")
1564
+ >>> doc = UnityYAMLDocument.load("MyPrefab.prefab")
1565
+ >>>
1566
+ >>> # Basic usage with script name resolution
1567
+ >>> hierarchy = build_hierarchy(doc, guid_index=guid_index)
1568
+ >>> for node in hierarchy.iter_all():
1569
+ ... for comp in node.components:
1570
+ ... if comp.script_name:
1571
+ ... print(f"{node.name}: {comp.script_name}")
1572
+ >>>
1573
+ >>> # Full LLM-friendly usage with nested prefab loading
1574
+ >>> hierarchy = build_hierarchy(
1575
+ ... doc,
1576
+ ... guid_index=guid_index,
1577
+ ... load_nested_prefabs=True,
1578
+ ... )
1579
+ >>> # Now PrefabInstances show their internal structure
1580
+ >>> prefab = hierarchy.find("board_CoreUpgrade")
1581
+ >>> if prefab and prefab.is_prefab_instance:
1582
+ ... for child in prefab.children:
1583
+ ... print(f" {child.name}")
1584
+ """
1585
+ return Hierarchy.build(
1586
+ doc,
1587
+ guid_index=guid_index,
1588
+ project_root=project_root,
1589
+ load_nested_prefabs=load_nested_prefabs,
1590
+ )
1591
+
1592
+
1593
+ def resolve_game_object_for_component(doc: UnityYAMLDocument, component_file_id: int) -> int:
1594
+ """Resolve a component to its owning GameObject, handling stripped objects.
1595
+
1596
+ Args:
1597
+ doc: The Unity YAML document
1598
+ component_file_id: FileID of the component
1599
+
1600
+ Returns:
1601
+ FileID of the owning GameObject (or PrefabInstance if stripped)
1602
+ """
1603
+ comp = doc.get_by_file_id(component_file_id)
1604
+ if comp is None:
1605
+ return 0
1606
+
1607
+ content = comp.get_content()
1608
+ if content is None:
1609
+ return 0
1610
+
1611
+ go_ref = content.get("m_GameObject", {})
1612
+ go_id = go_ref.get("fileID", 0) if isinstance(go_ref, dict) else 0
1613
+
1614
+ if not go_id:
1615
+ return 0
1616
+
1617
+ # Check if the referenced GameObject is stripped
1618
+ go = doc.get_by_file_id(go_id)
1619
+ if go and go.stripped:
1620
+ # Return the PrefabInstance instead
1621
+ go_content = go.get_content()
1622
+ if go_content:
1623
+ prefab_ref = go_content.get("m_PrefabInstance", {})
1624
+ prefab_id = prefab_ref.get("fileID", 0) if isinstance(prefab_ref, dict) else 0
1625
+ if prefab_id:
1626
+ return prefab_id
1627
+
1628
+ return go_id
1629
+
1630
+
1631
+ def get_prefab_instance_for_stripped(doc: UnityYAMLDocument, file_id: int) -> int:
1632
+ """Get the PrefabInstance ID for a stripped object.
1633
+
1634
+ Args:
1635
+ doc: The Unity YAML document
1636
+ file_id: FileID of the stripped object
1637
+
1638
+ Returns:
1639
+ FileID of the owning PrefabInstance, or 0 if not stripped
1640
+ """
1641
+ obj = doc.get_by_file_id(file_id)
1642
+ if obj is None or not obj.stripped:
1643
+ return 0
1644
+
1645
+ content = obj.get_content()
1646
+ if content is None:
1647
+ return 0
1648
+
1649
+ prefab_ref = content.get("m_PrefabInstance", {})
1650
+ return prefab_ref.get("fileID", 0) if isinstance(prefab_ref, dict) else 0
1651
+
1652
+
1653
+ def get_stripped_objects_for_prefab(doc: UnityYAMLDocument, prefab_instance_id: int) -> list[int]:
1654
+ """Get all stripped objects belonging to a PrefabInstance.
1655
+
1656
+ Args:
1657
+ doc: The Unity YAML document
1658
+ prefab_instance_id: FileID of the PrefabInstance
1659
+
1660
+ Returns:
1661
+ List of stripped object fileIDs
1662
+ """
1663
+ result = []
1664
+ for obj in doc.objects:
1665
+ if obj.stripped:
1666
+ content = obj.get_content()
1667
+ if content:
1668
+ prefab_ref = content.get("m_PrefabInstance", {})
1669
+ if isinstance(prefab_ref, dict):
1670
+ if prefab_ref.get("fileID") == prefab_instance_id:
1671
+ result.append(obj.file_id)
1672
+ return result