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/__init__.py +167 -0
- unityflow/asset_resolver.py +636 -0
- unityflow/asset_tracker.py +1687 -0
- unityflow/cli.py +2317 -0
- unityflow/data/__init__.py +1 -0
- unityflow/data/class_ids.json +336 -0
- unityflow/diff.py +234 -0
- unityflow/fast_parser.py +676 -0
- unityflow/formats.py +1558 -0
- unityflow/git_utils.py +307 -0
- unityflow/hierarchy.py +1672 -0
- unityflow/merge.py +226 -0
- unityflow/meta_generator.py +1291 -0
- unityflow/normalizer.py +529 -0
- unityflow/parser.py +698 -0
- unityflow/query.py +406 -0
- unityflow/script_parser.py +717 -0
- unityflow/sprite.py +378 -0
- unityflow/validator.py +783 -0
- unityflow-0.3.4.dist-info/METADATA +293 -0
- unityflow-0.3.4.dist-info/RECORD +25 -0
- unityflow-0.3.4.dist-info/WHEEL +5 -0
- unityflow-0.3.4.dist-info/entry_points.txt +2 -0
- unityflow-0.3.4.dist-info/licenses/LICENSE +21 -0
- unityflow-0.3.4.dist-info/top_level.txt +1 -0
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
|