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/formats.py
ADDED
|
@@ -0,0 +1,1558 @@
|
|
|
1
|
+
"""LLM-friendly format conversion for Unity YAML files.
|
|
2
|
+
|
|
3
|
+
Provides JSON export/import for easier manipulation by LLMs and scripts,
|
|
4
|
+
with round-trip fidelity through _rawFields preservation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from unityflow.parser import CLASS_IDS, UnityYAMLDocument, UnityYAMLObject
|
|
15
|
+
|
|
16
|
+
# =============================================================================
|
|
17
|
+
# RectTransform Editor <-> File Format Conversion Utilities
|
|
18
|
+
# =============================================================================
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RectTransformEditorValues:
|
|
23
|
+
"""Values as shown in Unity Editor Inspector.
|
|
24
|
+
|
|
25
|
+
For stretch mode (anchorMin != anchorMax):
|
|
26
|
+
- left, right, top, bottom: offsets from anchor edges
|
|
27
|
+
For anchored mode (anchorMin == anchorMax):
|
|
28
|
+
- pos_x, pos_y: position relative to anchor
|
|
29
|
+
- width, height: size of the rect
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# Anchors (same for both modes)
|
|
33
|
+
anchor_min_x: float = 0.5
|
|
34
|
+
anchor_min_y: float = 0.5
|
|
35
|
+
anchor_max_x: float = 0.5
|
|
36
|
+
anchor_max_y: float = 0.5
|
|
37
|
+
|
|
38
|
+
# Pivot
|
|
39
|
+
pivot_x: float = 0.5
|
|
40
|
+
pivot_y: float = 0.5
|
|
41
|
+
|
|
42
|
+
# Position (Z is always stored directly)
|
|
43
|
+
pos_z: float = 0
|
|
44
|
+
|
|
45
|
+
# Stretch mode values (when anchorMin != anchorMax on that axis)
|
|
46
|
+
left: float | None = None
|
|
47
|
+
right: float | None = None
|
|
48
|
+
top: float | None = None
|
|
49
|
+
bottom: float | None = None
|
|
50
|
+
|
|
51
|
+
# Anchored mode values (when anchorMin == anchorMax on that axis)
|
|
52
|
+
pos_x: float | None = None
|
|
53
|
+
pos_y: float | None = None
|
|
54
|
+
width: float | None = None
|
|
55
|
+
height: float | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_stretch_horizontal(self) -> bool:
|
|
59
|
+
"""Check if horizontal axis is in stretch mode."""
|
|
60
|
+
return self.anchor_min_x != self.anchor_max_x
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def is_stretch_vertical(self) -> bool:
|
|
64
|
+
"""Check if vertical axis is in stretch mode."""
|
|
65
|
+
return self.anchor_min_y != self.anchor_max_y
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class RectTransformFileValues:
|
|
70
|
+
"""Values as stored in Unity YAML file."""
|
|
71
|
+
|
|
72
|
+
anchor_min: dict[str, float] # {x, y}
|
|
73
|
+
anchor_max: dict[str, float] # {x, y}
|
|
74
|
+
anchored_position: dict[str, float] # {x, y}
|
|
75
|
+
size_delta: dict[str, float] # {x, y}
|
|
76
|
+
pivot: dict[str, float] # {x, y}
|
|
77
|
+
local_position_z: float = 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def editor_to_file_values(editor: RectTransformEditorValues) -> RectTransformFileValues:
|
|
81
|
+
"""Convert Unity Editor values to file format values.
|
|
82
|
+
|
|
83
|
+
This handles the complex conversion between what you see in the Inspector
|
|
84
|
+
and what gets stored in the .prefab/.unity file.
|
|
85
|
+
|
|
86
|
+
The conversion formulas:
|
|
87
|
+
- For stretch mode (anchorMin != anchorMax):
|
|
88
|
+
offsetMin = (left, bottom)
|
|
89
|
+
offsetMax = (-right, -top)
|
|
90
|
+
anchoredPosition = (offsetMin + offsetMax) / 2
|
|
91
|
+
sizeDelta = offsetMax - offsetMin
|
|
92
|
+
|
|
93
|
+
- For anchored mode (anchorMin == anchorMax):
|
|
94
|
+
anchoredPosition = (pos_x, pos_y)
|
|
95
|
+
sizeDelta = (width, height)
|
|
96
|
+
"""
|
|
97
|
+
# Determine mode for each axis
|
|
98
|
+
stretch_h = editor.is_stretch_horizontal
|
|
99
|
+
stretch_v = editor.is_stretch_vertical
|
|
100
|
+
|
|
101
|
+
# Calculate anchored position and size delta
|
|
102
|
+
if stretch_h:
|
|
103
|
+
# Horizontal stretch mode
|
|
104
|
+
left = editor.left or 0
|
|
105
|
+
right = editor.right or 0
|
|
106
|
+
offset_min_x = left
|
|
107
|
+
offset_max_x = -right
|
|
108
|
+
anchored_x = (offset_min_x + offset_max_x) / 2
|
|
109
|
+
size_delta_x = offset_max_x - offset_min_x
|
|
110
|
+
else:
|
|
111
|
+
# Horizontal anchored mode
|
|
112
|
+
anchored_x = editor.pos_x or 0
|
|
113
|
+
size_delta_x = editor.width or 100
|
|
114
|
+
|
|
115
|
+
if stretch_v:
|
|
116
|
+
# Vertical stretch mode
|
|
117
|
+
bottom = editor.bottom or 0
|
|
118
|
+
top = editor.top or 0
|
|
119
|
+
offset_min_y = bottom
|
|
120
|
+
offset_max_y = -top
|
|
121
|
+
anchored_y = (offset_min_y + offset_max_y) / 2
|
|
122
|
+
size_delta_y = offset_max_y - offset_min_y
|
|
123
|
+
else:
|
|
124
|
+
# Vertical anchored mode
|
|
125
|
+
anchored_y = editor.pos_y or 0
|
|
126
|
+
size_delta_y = editor.height or 100
|
|
127
|
+
|
|
128
|
+
return RectTransformFileValues(
|
|
129
|
+
anchor_min={"x": editor.anchor_min_x, "y": editor.anchor_min_y},
|
|
130
|
+
anchor_max={"x": editor.anchor_max_x, "y": editor.anchor_max_y},
|
|
131
|
+
anchored_position={"x": anchored_x, "y": anchored_y},
|
|
132
|
+
size_delta={"x": size_delta_x, "y": size_delta_y},
|
|
133
|
+
pivot={"x": editor.pivot_x, "y": editor.pivot_y},
|
|
134
|
+
local_position_z=editor.pos_z,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def file_to_editor_values(file_vals: RectTransformFileValues) -> RectTransformEditorValues:
|
|
139
|
+
"""Convert file format values to Unity Editor values.
|
|
140
|
+
|
|
141
|
+
The conversion formulas:
|
|
142
|
+
- offsetMin = anchoredPosition - sizeDelta * pivot
|
|
143
|
+
- offsetMax = anchoredPosition + sizeDelta * (1 - pivot)
|
|
144
|
+
|
|
145
|
+
For stretch mode:
|
|
146
|
+
left = offsetMin.x
|
|
147
|
+
right = -offsetMax.x
|
|
148
|
+
bottom = offsetMin.y
|
|
149
|
+
top = -offsetMax.y
|
|
150
|
+
|
|
151
|
+
For anchored mode:
|
|
152
|
+
pos_x = anchoredPosition.x
|
|
153
|
+
pos_y = anchoredPosition.y
|
|
154
|
+
width = sizeDelta.x
|
|
155
|
+
height = sizeDelta.y
|
|
156
|
+
"""
|
|
157
|
+
anchor_min_x = file_vals.anchor_min.get("x", 0.5)
|
|
158
|
+
anchor_min_y = file_vals.anchor_min.get("y", 0.5)
|
|
159
|
+
anchor_max_x = file_vals.anchor_max.get("x", 0.5)
|
|
160
|
+
anchor_max_y = file_vals.anchor_max.get("y", 0.5)
|
|
161
|
+
|
|
162
|
+
pivot_x = file_vals.pivot.get("x", 0.5)
|
|
163
|
+
pivot_y = file_vals.pivot.get("y", 0.5)
|
|
164
|
+
|
|
165
|
+
anchored_x = file_vals.anchored_position.get("x", 0)
|
|
166
|
+
anchored_y = file_vals.anchored_position.get("y", 0)
|
|
167
|
+
|
|
168
|
+
size_delta_x = file_vals.size_delta.get("x", 100)
|
|
169
|
+
size_delta_y = file_vals.size_delta.get("y", 100)
|
|
170
|
+
|
|
171
|
+
# Calculate offset values
|
|
172
|
+
offset_min_x = anchored_x - size_delta_x * pivot_x
|
|
173
|
+
offset_max_x = anchored_x + size_delta_x * (1 - pivot_x)
|
|
174
|
+
offset_min_y = anchored_y - size_delta_y * pivot_y
|
|
175
|
+
offset_max_y = anchored_y + size_delta_y * (1 - pivot_y)
|
|
176
|
+
|
|
177
|
+
editor = RectTransformEditorValues(
|
|
178
|
+
anchor_min_x=anchor_min_x,
|
|
179
|
+
anchor_min_y=anchor_min_y,
|
|
180
|
+
anchor_max_x=anchor_max_x,
|
|
181
|
+
anchor_max_y=anchor_max_y,
|
|
182
|
+
pivot_x=pivot_x,
|
|
183
|
+
pivot_y=pivot_y,
|
|
184
|
+
pos_z=file_vals.local_position_z,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Determine mode and set appropriate values
|
|
188
|
+
stretch_h = anchor_min_x != anchor_max_x
|
|
189
|
+
stretch_v = anchor_min_y != anchor_max_y
|
|
190
|
+
|
|
191
|
+
if stretch_h:
|
|
192
|
+
editor.left = offset_min_x
|
|
193
|
+
editor.right = -offset_max_x
|
|
194
|
+
else:
|
|
195
|
+
editor.pos_x = anchored_x
|
|
196
|
+
editor.width = size_delta_x
|
|
197
|
+
|
|
198
|
+
if stretch_v:
|
|
199
|
+
editor.bottom = offset_min_y
|
|
200
|
+
editor.top = -offset_max_y
|
|
201
|
+
else:
|
|
202
|
+
editor.pos_y = anchored_y
|
|
203
|
+
editor.height = size_delta_y
|
|
204
|
+
|
|
205
|
+
return editor
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def create_rect_transform_file_values(
|
|
209
|
+
anchor_preset: str = "center",
|
|
210
|
+
pivot: tuple[float, float] = (0.5, 0.5),
|
|
211
|
+
pos_x: float = 0,
|
|
212
|
+
pos_y: float = 0,
|
|
213
|
+
pos_z: float = 0,
|
|
214
|
+
width: float = 100,
|
|
215
|
+
height: float = 100,
|
|
216
|
+
left: float = 0,
|
|
217
|
+
right: float = 0,
|
|
218
|
+
top: float = 0,
|
|
219
|
+
bottom: float = 0,
|
|
220
|
+
) -> RectTransformFileValues:
|
|
221
|
+
"""Create RectTransform file values from common parameters.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
anchor_preset: Preset name for anchor position:
|
|
225
|
+
- "center": anchors at center (0.5, 0.5)
|
|
226
|
+
- "top-left", "top-center", "top-right"
|
|
227
|
+
- "middle-left", "middle-center", "middle-right"
|
|
228
|
+
- "bottom-left", "bottom-center", "bottom-right"
|
|
229
|
+
- "stretch-top", "stretch-middle", "stretch-bottom" (horizontal stretch)
|
|
230
|
+
- "stretch-left", "stretch-center", "stretch-right" (vertical stretch)
|
|
231
|
+
- "stretch-all": full stretch (0,0) to (1,1)
|
|
232
|
+
pivot: Pivot point (x, y), default center
|
|
233
|
+
pos_x, pos_y, pos_z: Position (for anchored mode)
|
|
234
|
+
width, height: Size (for anchored mode)
|
|
235
|
+
left, right, top, bottom: Offsets (for stretch mode)
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
RectTransformFileValues ready for use
|
|
239
|
+
"""
|
|
240
|
+
# Anchor presets mapping
|
|
241
|
+
presets = {
|
|
242
|
+
# Single point anchors
|
|
243
|
+
"top-left": ((0, 1), (0, 1)),
|
|
244
|
+
"top-center": ((0.5, 1), (0.5, 1)),
|
|
245
|
+
"top-right": ((1, 1), (1, 1)),
|
|
246
|
+
"middle-left": ((0, 0.5), (0, 0.5)),
|
|
247
|
+
"center": ((0.5, 0.5), (0.5, 0.5)),
|
|
248
|
+
"middle-center": ((0.5, 0.5), (0.5, 0.5)),
|
|
249
|
+
"middle-right": ((1, 0.5), (1, 0.5)),
|
|
250
|
+
"bottom-left": ((0, 0), (0, 0)),
|
|
251
|
+
"bottom-center": ((0.5, 0), (0.5, 0)),
|
|
252
|
+
"bottom-right": ((1, 0), (1, 0)),
|
|
253
|
+
# Horizontal stretch
|
|
254
|
+
"stretch-top": ((0, 1), (1, 1)),
|
|
255
|
+
"stretch-middle": ((0, 0.5), (1, 0.5)),
|
|
256
|
+
"stretch-bottom": ((0, 0), (1, 0)),
|
|
257
|
+
# Vertical stretch
|
|
258
|
+
"stretch-left": ((0, 0), (0, 1)),
|
|
259
|
+
"stretch-center": ((0.5, 0), (0.5, 1)),
|
|
260
|
+
"stretch-right": ((1, 0), (1, 1)),
|
|
261
|
+
# Full stretch
|
|
262
|
+
"stretch-all": ((0, 0), (1, 1)),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
anchor_min, anchor_max = presets.get(anchor_preset, ((0.5, 0.5), (0.5, 0.5)))
|
|
266
|
+
|
|
267
|
+
editor = RectTransformEditorValues(
|
|
268
|
+
anchor_min_x=anchor_min[0],
|
|
269
|
+
anchor_min_y=anchor_min[1],
|
|
270
|
+
anchor_max_x=anchor_max[0],
|
|
271
|
+
anchor_max_y=anchor_max[1],
|
|
272
|
+
pivot_x=pivot[0],
|
|
273
|
+
pivot_y=pivot[1],
|
|
274
|
+
pos_z=pos_z,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Set values based on stretch mode
|
|
278
|
+
if editor.is_stretch_horizontal:
|
|
279
|
+
editor.left = left
|
|
280
|
+
editor.right = right
|
|
281
|
+
else:
|
|
282
|
+
editor.pos_x = pos_x
|
|
283
|
+
editor.width = width
|
|
284
|
+
|
|
285
|
+
if editor.is_stretch_vertical:
|
|
286
|
+
editor.top = top
|
|
287
|
+
editor.bottom = bottom
|
|
288
|
+
else:
|
|
289
|
+
editor.pos_y = pos_y
|
|
290
|
+
editor.height = height
|
|
291
|
+
|
|
292
|
+
return editor_to_file_values(editor)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# Reverse mapping: class name -> class ID
|
|
296
|
+
CLASS_NAME_TO_ID = {name: id for id, name in CLASS_IDS.items()}
|
|
297
|
+
|
|
298
|
+
# =============================================================================
|
|
299
|
+
# Layout-Driven Properties Detection
|
|
300
|
+
# =============================================================================
|
|
301
|
+
|
|
302
|
+
# Layout components that drive RectTransform properties on the SAME GameObject
|
|
303
|
+
# These components modify their own RectTransform's size
|
|
304
|
+
SELF_DRIVING_LAYOUT_GUIDS = {
|
|
305
|
+
# ContentSizeFitter - drives width/height based on content
|
|
306
|
+
"3245ec927659c4140ac4f8d17403cc18": "ContentSizeFitter",
|
|
307
|
+
# AspectRatioFitter - drives width or height to maintain aspect ratio
|
|
308
|
+
"306cc8c2b49d7114eaa3623786fc2126": "AspectRatioFitter",
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# Layout components that drive RectTransform properties on CHILD GameObjects
|
|
312
|
+
# These components modify their children's RectTransform position/size
|
|
313
|
+
CHILD_DRIVING_LAYOUT_GUIDS = {
|
|
314
|
+
# VerticalLayoutGroup - arranges children vertically
|
|
315
|
+
"59f8146938fff824cb5fd77236b75775": "VerticalLayoutGroup",
|
|
316
|
+
# HorizontalLayoutGroup - arranges children horizontally
|
|
317
|
+
"30649d3a9faa99c48a7b1166b86bf2a0": "HorizontalLayoutGroup",
|
|
318
|
+
# GridLayoutGroup - arranges children in a grid
|
|
319
|
+
"8a8695521f0d02e499659fee002a26c2": "GridLayoutGroup",
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# Combined for quick lookup
|
|
323
|
+
ALL_LAYOUT_GUIDS = {**SELF_DRIVING_LAYOUT_GUIDS, **CHILD_DRIVING_LAYOUT_GUIDS}
|
|
324
|
+
|
|
325
|
+
# Fields that are represented in the structured format (not raw)
|
|
326
|
+
STRUCTURED_FIELDS = {
|
|
327
|
+
# GameObject fields
|
|
328
|
+
"m_Name",
|
|
329
|
+
"m_Layer",
|
|
330
|
+
"m_TagString",
|
|
331
|
+
"m_IsActive",
|
|
332
|
+
"m_Component",
|
|
333
|
+
# Transform fields
|
|
334
|
+
"m_LocalPosition",
|
|
335
|
+
"m_LocalRotation",
|
|
336
|
+
"m_LocalScale",
|
|
337
|
+
"m_Children",
|
|
338
|
+
"m_Father",
|
|
339
|
+
"m_GameObject",
|
|
340
|
+
# MonoBehaviour fields
|
|
341
|
+
"m_Script",
|
|
342
|
+
"m_Enabled",
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass
|
|
347
|
+
class PrefabJSON:
|
|
348
|
+
"""JSON representation of a Unity prefab."""
|
|
349
|
+
|
|
350
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
351
|
+
game_objects: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
352
|
+
components: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
353
|
+
raw_fields: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
354
|
+
|
|
355
|
+
def to_dict(self) -> dict[str, Any]:
|
|
356
|
+
"""Convert to dictionary for JSON serialization."""
|
|
357
|
+
result = {
|
|
358
|
+
"metadata": self.metadata,
|
|
359
|
+
"gameObjects": self.game_objects,
|
|
360
|
+
"components": self.components,
|
|
361
|
+
}
|
|
362
|
+
if self.raw_fields:
|
|
363
|
+
result["_rawFields"] = self.raw_fields
|
|
364
|
+
return result
|
|
365
|
+
|
|
366
|
+
def to_json(self, indent: int = 2) -> str:
|
|
367
|
+
"""Convert to JSON string."""
|
|
368
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
369
|
+
|
|
370
|
+
@classmethod
|
|
371
|
+
def from_dict(cls, data: dict[str, Any]) -> PrefabJSON:
|
|
372
|
+
"""Create from dictionary."""
|
|
373
|
+
# Support both "metadata" and legacy "prefabMetadata" keys
|
|
374
|
+
metadata = data.get("metadata") or data.get("prefabMetadata", {})
|
|
375
|
+
return cls(
|
|
376
|
+
metadata=metadata,
|
|
377
|
+
game_objects=data.get("gameObjects", {}),
|
|
378
|
+
components=data.get("components", {}),
|
|
379
|
+
raw_fields=data.get("_rawFields", {}),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
@classmethod
|
|
383
|
+
def from_json(cls, json_str: str) -> PrefabJSON:
|
|
384
|
+
"""Create from JSON string."""
|
|
385
|
+
data = json.loads(json_str)
|
|
386
|
+
return cls.from_dict(data)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _analyze_layout_driven_properties(doc: UnityYAMLDocument) -> dict[str, dict[str, Any]]:
|
|
390
|
+
"""Analyze which RectTransforms have layout-driven properties.
|
|
391
|
+
|
|
392
|
+
Returns a dict mapping RectTransform fileID -> driven info:
|
|
393
|
+
{
|
|
394
|
+
"rectTransformFileId": {
|
|
395
|
+
"drivenBy": "ContentSizeFitter", # or other layout component
|
|
396
|
+
"drivenProperties": ["width", "height"], # which properties are driven
|
|
397
|
+
"driverComponentId": "123456", # fileID of the layout component
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
"""
|
|
401
|
+
driven_info: dict[str, dict[str, Any]] = {}
|
|
402
|
+
|
|
403
|
+
# Build lookup tables
|
|
404
|
+
# gameObjectId -> list of component fileIDs
|
|
405
|
+
go_components: dict[str, list[str]] = {}
|
|
406
|
+
# componentId -> component object
|
|
407
|
+
component_objs: dict[str, tuple[UnityYAMLObject, dict[str, Any]]] = {}
|
|
408
|
+
# gameObjectId -> RectTransform fileID
|
|
409
|
+
go_rect_transform: dict[str, str] = {}
|
|
410
|
+
# RectTransform fileID -> parent RectTransform fileID
|
|
411
|
+
rect_parent: dict[str, str] = {}
|
|
412
|
+
|
|
413
|
+
# First pass: collect all objects
|
|
414
|
+
for obj in doc.objects:
|
|
415
|
+
content = obj.get_content()
|
|
416
|
+
if content is None:
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
file_id = str(obj.file_id)
|
|
420
|
+
|
|
421
|
+
if obj.class_id == 1: # GameObject
|
|
422
|
+
components = content.get("m_Component", [])
|
|
423
|
+
go_components[file_id] = [
|
|
424
|
+
str(c.get("component", {}).get("fileID", 0))
|
|
425
|
+
for c in components
|
|
426
|
+
if isinstance(c, dict) and "component" in c
|
|
427
|
+
]
|
|
428
|
+
elif obj.class_id == 224: # RectTransform
|
|
429
|
+
component_objs[file_id] = (obj, content)
|
|
430
|
+
# Map GameObject -> RectTransform
|
|
431
|
+
go_ref = content.get("m_GameObject", {})
|
|
432
|
+
if isinstance(go_ref, dict) and "fileID" in go_ref:
|
|
433
|
+
go_rect_transform[str(go_ref["fileID"])] = file_id
|
|
434
|
+
# Track parent
|
|
435
|
+
father = content.get("m_Father", {})
|
|
436
|
+
if isinstance(father, dict) and father.get("fileID", 0) != 0:
|
|
437
|
+
rect_parent[file_id] = str(father["fileID"])
|
|
438
|
+
elif obj.class_id == 114: # MonoBehaviour
|
|
439
|
+
component_objs[file_id] = (obj, content)
|
|
440
|
+
|
|
441
|
+
# Second pass: find layout components and mark driven RectTransforms
|
|
442
|
+
for obj in doc.objects:
|
|
443
|
+
content = obj.get_content()
|
|
444
|
+
if content is None or obj.class_id != 114:
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
file_id = str(obj.file_id)
|
|
448
|
+
script = content.get("m_Script", {})
|
|
449
|
+
if not isinstance(script, dict):
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
guid = script.get("guid", "")
|
|
453
|
+
|
|
454
|
+
# Check if this is a self-driving layout component (ContentSizeFitter, AspectRatioFitter)
|
|
455
|
+
if guid in SELF_DRIVING_LAYOUT_GUIDS:
|
|
456
|
+
component_name = SELF_DRIVING_LAYOUT_GUIDS[guid]
|
|
457
|
+
go_ref = content.get("m_GameObject", {})
|
|
458
|
+
if isinstance(go_ref, dict) and "fileID" in go_ref:
|
|
459
|
+
go_id = str(go_ref["fileID"])
|
|
460
|
+
rect_id = go_rect_transform.get(go_id)
|
|
461
|
+
if rect_id:
|
|
462
|
+
driven_props = _get_driven_properties_for_component(component_name, content)
|
|
463
|
+
if driven_props:
|
|
464
|
+
driven_info[rect_id] = {
|
|
465
|
+
"drivenBy": component_name,
|
|
466
|
+
"drivenProperties": driven_props,
|
|
467
|
+
"driverComponentId": file_id,
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
# Check if this is a child-driving layout component (LayoutGroups)
|
|
471
|
+
elif guid in CHILD_DRIVING_LAYOUT_GUIDS:
|
|
472
|
+
component_name = CHILD_DRIVING_LAYOUT_GUIDS[guid]
|
|
473
|
+
go_ref = content.get("m_GameObject", {})
|
|
474
|
+
if isinstance(go_ref, dict) and "fileID" in go_ref:
|
|
475
|
+
go_id = str(go_ref["fileID"])
|
|
476
|
+
parent_rect_id = go_rect_transform.get(go_id)
|
|
477
|
+
if parent_rect_id:
|
|
478
|
+
# Find all children of this RectTransform
|
|
479
|
+
for child_rect_id, parent_id in rect_parent.items():
|
|
480
|
+
if parent_id == parent_rect_id:
|
|
481
|
+
driven_props = _get_driven_properties_for_layout_child(component_name, content)
|
|
482
|
+
if driven_props:
|
|
483
|
+
# Merge with existing driven info (a child could have both
|
|
484
|
+
# ContentSizeFitter and be in a LayoutGroup)
|
|
485
|
+
if child_rect_id in driven_info:
|
|
486
|
+
existing = driven_info[child_rect_id]
|
|
487
|
+
existing["drivenProperties"] = list(
|
|
488
|
+
set(existing["drivenProperties"] + driven_props)
|
|
489
|
+
)
|
|
490
|
+
existing["drivenBy"] = f"{existing['drivenBy']}, {component_name}"
|
|
491
|
+
else:
|
|
492
|
+
driven_info[child_rect_id] = {
|
|
493
|
+
"drivenBy": component_name,
|
|
494
|
+
"drivenProperties": driven_props,
|
|
495
|
+
"driverComponentId": file_id,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return driven_info
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _get_driven_properties_for_component(component_name: str, content: dict[str, Any]) -> list[str]:
|
|
502
|
+
"""Get which properties are driven by a self-driving layout component."""
|
|
503
|
+
driven = []
|
|
504
|
+
|
|
505
|
+
if component_name == "ContentSizeFitter":
|
|
506
|
+
# m_HorizontalFit: 0=Unconstrained, 1=MinSize, 2=PreferredSize
|
|
507
|
+
h_fit = content.get("m_HorizontalFit", 0)
|
|
508
|
+
v_fit = content.get("m_VerticalFit", 0)
|
|
509
|
+
if h_fit != 0:
|
|
510
|
+
driven.append("width")
|
|
511
|
+
if v_fit != 0:
|
|
512
|
+
driven.append("height")
|
|
513
|
+
|
|
514
|
+
elif component_name == "AspectRatioFitter":
|
|
515
|
+
# m_AspectMode: 0=None, 1=WidthControlsHeight, 2=HeightControlsWidth,
|
|
516
|
+
# 3=FitInParent, 4=EnvelopeParent
|
|
517
|
+
mode = content.get("m_AspectMode", 0)
|
|
518
|
+
if mode == 1:
|
|
519
|
+
driven.append("height")
|
|
520
|
+
elif mode == 2:
|
|
521
|
+
driven.append("width")
|
|
522
|
+
elif mode in (3, 4):
|
|
523
|
+
driven.extend(["width", "height"])
|
|
524
|
+
|
|
525
|
+
return driven
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _get_driven_properties_for_layout_child(component_name: str, content: dict[str, Any]) -> list[str]:
|
|
529
|
+
"""Get which properties are driven on children by a layout group."""
|
|
530
|
+
driven = []
|
|
531
|
+
|
|
532
|
+
if component_name in ("HorizontalLayoutGroup", "VerticalLayoutGroup"):
|
|
533
|
+
# Check childControlWidth/Height and childForceExpandWidth/Height
|
|
534
|
+
if content.get("m_ChildControlWidth", False):
|
|
535
|
+
driven.append("width")
|
|
536
|
+
if content.get("m_ChildControlHeight", False):
|
|
537
|
+
driven.append("height")
|
|
538
|
+
# Position is always driven in layout groups
|
|
539
|
+
driven.extend(["posX", "posY"])
|
|
540
|
+
|
|
541
|
+
elif component_name == "GridLayoutGroup":
|
|
542
|
+
# Grid always controls both size and position
|
|
543
|
+
driven.extend(["width", "height", "posX", "posY"])
|
|
544
|
+
|
|
545
|
+
return driven
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def export_to_json(doc: UnityYAMLDocument, include_raw: bool = True) -> PrefabJSON:
|
|
549
|
+
"""Export a Unity YAML document to JSON format.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
doc: The parsed Unity YAML document
|
|
553
|
+
include_raw: Whether to include _rawFields for round-trip fidelity
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
PrefabJSON object
|
|
557
|
+
"""
|
|
558
|
+
result = PrefabJSON()
|
|
559
|
+
|
|
560
|
+
# Metadata
|
|
561
|
+
result.metadata = {
|
|
562
|
+
"sourcePath": str(doc.source_path) if doc.source_path else None,
|
|
563
|
+
"objectCount": len(doc.objects),
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
# Analyze layout-driven properties
|
|
567
|
+
driven_info = _analyze_layout_driven_properties(doc)
|
|
568
|
+
|
|
569
|
+
# Process each object
|
|
570
|
+
for obj in doc.objects:
|
|
571
|
+
file_id = str(obj.file_id)
|
|
572
|
+
content = obj.get_content()
|
|
573
|
+
|
|
574
|
+
if content is None:
|
|
575
|
+
continue
|
|
576
|
+
|
|
577
|
+
if obj.class_id == 1: # GameObject
|
|
578
|
+
result.game_objects[file_id] = _export_game_object(obj, content)
|
|
579
|
+
if include_raw:
|
|
580
|
+
raw = _extract_raw_fields(content, {"m_Name", "m_Layer", "m_TagString", "m_IsActive", "m_Component"})
|
|
581
|
+
if raw:
|
|
582
|
+
result.raw_fields[file_id] = raw
|
|
583
|
+
|
|
584
|
+
else: # Component (Transform, MonoBehaviour, etc.)
|
|
585
|
+
# Pass driven info for RectTransforms
|
|
586
|
+
rect_driven = driven_info.get(file_id) if obj.class_id == 224 else None
|
|
587
|
+
result.components[file_id] = _export_component(obj, content, rect_driven)
|
|
588
|
+
if include_raw:
|
|
589
|
+
component_structured = _get_structured_fields_for_class(obj.class_id)
|
|
590
|
+
raw = _extract_raw_fields(content, component_structured)
|
|
591
|
+
if raw:
|
|
592
|
+
result.raw_fields[file_id] = raw
|
|
593
|
+
|
|
594
|
+
return result
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _export_game_object(obj: UnityYAMLObject, content: dict[str, Any]) -> dict[str, Any]:
|
|
598
|
+
"""Export a GameObject to JSON format."""
|
|
599
|
+
result: dict[str, Any] = {
|
|
600
|
+
"name": content.get("m_Name", ""),
|
|
601
|
+
"layer": content.get("m_Layer", 0),
|
|
602
|
+
"tag": content.get("m_TagString", "Untagged"),
|
|
603
|
+
"isActive": content.get("m_IsActive", 1) == 1,
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
# Extract component references
|
|
607
|
+
components = content.get("m_Component", [])
|
|
608
|
+
if components:
|
|
609
|
+
result["components"] = [
|
|
610
|
+
str(c.get("component", {}).get("fileID", 0)) for c in components if isinstance(c, dict) and "component" in c
|
|
611
|
+
]
|
|
612
|
+
|
|
613
|
+
return result
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _export_component(
|
|
617
|
+
obj: UnityYAMLObject,
|
|
618
|
+
content: dict[str, Any],
|
|
619
|
+
driven_info: dict[str, Any] | None = None,
|
|
620
|
+
) -> dict[str, Any]:
|
|
621
|
+
"""Export a component to JSON format.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
obj: The Unity YAML object
|
|
625
|
+
content: The object's content dict
|
|
626
|
+
driven_info: Optional layout-driven property info for RectTransforms
|
|
627
|
+
"""
|
|
628
|
+
result: dict[str, Any] = {
|
|
629
|
+
"type": obj.class_name,
|
|
630
|
+
"classId": obj.class_id,
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
# Preserve original root key for unknown types (important for round-trip fidelity)
|
|
634
|
+
original_root_key = obj.root_key
|
|
635
|
+
if original_root_key and original_root_key != obj.class_name:
|
|
636
|
+
result["_originalType"] = original_root_key
|
|
637
|
+
|
|
638
|
+
# GameObject reference
|
|
639
|
+
go_ref = content.get("m_GameObject", {})
|
|
640
|
+
if isinstance(go_ref, dict) and "fileID" in go_ref:
|
|
641
|
+
result["gameObject"] = str(go_ref["fileID"])
|
|
642
|
+
|
|
643
|
+
# Type-specific export
|
|
644
|
+
if obj.class_id == 4: # Transform
|
|
645
|
+
result.update(_export_transform(content))
|
|
646
|
+
elif obj.class_id == 224: # RectTransform
|
|
647
|
+
result.update(_export_rect_transform(content, driven_info))
|
|
648
|
+
elif obj.class_id == 114: # MonoBehaviour
|
|
649
|
+
result.update(_export_monobehaviour(content))
|
|
650
|
+
elif obj.class_id == 1001: # PrefabInstance
|
|
651
|
+
result.update(_export_prefab_instance(content))
|
|
652
|
+
else:
|
|
653
|
+
# Generic component - export known fields
|
|
654
|
+
result.update(_export_generic_component(content))
|
|
655
|
+
|
|
656
|
+
return result
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _export_transform(content: dict[str, Any]) -> dict[str, Any]:
|
|
660
|
+
"""Export Transform-specific fields."""
|
|
661
|
+
result: dict[str, Any] = {}
|
|
662
|
+
|
|
663
|
+
# Position, rotation, scale
|
|
664
|
+
if "m_LocalPosition" in content:
|
|
665
|
+
result["localPosition"] = _export_vector(content["m_LocalPosition"])
|
|
666
|
+
if "m_LocalRotation" in content:
|
|
667
|
+
result["localRotation"] = _export_quaternion(content["m_LocalRotation"])
|
|
668
|
+
if "m_LocalScale" in content:
|
|
669
|
+
result["localScale"] = _export_vector(content["m_LocalScale"])
|
|
670
|
+
|
|
671
|
+
# Parent reference
|
|
672
|
+
father = content.get("m_Father", {})
|
|
673
|
+
if isinstance(father, dict) and father.get("fileID", 0) != 0:
|
|
674
|
+
result["parent"] = str(father["fileID"])
|
|
675
|
+
|
|
676
|
+
# Children references
|
|
677
|
+
children = content.get("m_Children", [])
|
|
678
|
+
if children:
|
|
679
|
+
result["children"] = [
|
|
680
|
+
str(c.get("fileID", 0)) for c in children if isinstance(c, dict) and c.get("fileID", 0) != 0
|
|
681
|
+
]
|
|
682
|
+
|
|
683
|
+
return result
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _export_rect_transform(
|
|
687
|
+
content: dict[str, Any],
|
|
688
|
+
driven_info: dict[str, Any] | None = None,
|
|
689
|
+
) -> dict[str, Any]:
|
|
690
|
+
"""Export RectTransform-specific fields.
|
|
691
|
+
|
|
692
|
+
Exports values as shown in Unity Inspector (editor values) for intuitive
|
|
693
|
+
manipulation by LLMs. Each Unity field maps to exactly one JSON field.
|
|
694
|
+
|
|
695
|
+
Layout-driven properties are replaced with "<driven>" placeholder to:
|
|
696
|
+
1. Prevent LLMs from modifying values that will be overwritten at runtime
|
|
697
|
+
2. Ensure consistent output regardless of what Unity saved (no spurious diffs)
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
content: The RectTransform content dict
|
|
701
|
+
driven_info: Optional layout-driven property info containing:
|
|
702
|
+
- drivenBy: Name of the layout component driving properties
|
|
703
|
+
- drivenProperties: List of properties being driven (e.g., ["width", "height"])
|
|
704
|
+
- driverComponentId: fileID of the layout component
|
|
705
|
+
"""
|
|
706
|
+
# Start with Transform fields
|
|
707
|
+
result = _export_transform(content)
|
|
708
|
+
|
|
709
|
+
# Determine which properties are driven
|
|
710
|
+
driven_props = set(driven_info["drivenProperties"]) if driven_info else set()
|
|
711
|
+
|
|
712
|
+
# Extract RectTransform-specific fields
|
|
713
|
+
anchor_min = content.get("m_AnchorMin", {"x": 0.5, "y": 0.5})
|
|
714
|
+
anchor_max = content.get("m_AnchorMax", {"x": 0.5, "y": 0.5})
|
|
715
|
+
anchored_position = content.get("m_AnchoredPosition", {"x": 0, "y": 0})
|
|
716
|
+
size_delta = content.get("m_SizeDelta", {"x": 100, "y": 100})
|
|
717
|
+
pivot = content.get("m_Pivot", {"x": 0.5, "y": 0.5})
|
|
718
|
+
local_position = content.get("m_LocalPosition", {"x": 0, "y": 0, "z": 0})
|
|
719
|
+
|
|
720
|
+
# Convert to editor values (what you see in Unity Inspector)
|
|
721
|
+
file_vals = RectTransformFileValues(
|
|
722
|
+
anchor_min=anchor_min,
|
|
723
|
+
anchor_max=anchor_max,
|
|
724
|
+
anchored_position=anchored_position,
|
|
725
|
+
size_delta=size_delta,
|
|
726
|
+
pivot=pivot,
|
|
727
|
+
local_position_z=float(local_position.get("z", 0)),
|
|
728
|
+
)
|
|
729
|
+
editor_vals = file_to_editor_values(file_vals)
|
|
730
|
+
|
|
731
|
+
# Export as single "rectTransform" field with Inspector-style values
|
|
732
|
+
# Driven properties show "<driven>" placeholder instead of actual values
|
|
733
|
+
rt = result["rectTransform"] = {
|
|
734
|
+
"anchorMin": {"x": editor_vals.anchor_min_x, "y": editor_vals.anchor_min_y},
|
|
735
|
+
"anchorMax": {"x": editor_vals.anchor_max_x, "y": editor_vals.anchor_max_y},
|
|
736
|
+
"pivot": {"x": editor_vals.pivot_x, "y": editor_vals.pivot_y},
|
|
737
|
+
"posZ": editor_vals.pos_z,
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
# Add mode-specific values, replacing driven properties with placeholder
|
|
741
|
+
if editor_vals.is_stretch_horizontal:
|
|
742
|
+
rt["left"] = "<driven>" if "posX" in driven_props else editor_vals.left
|
|
743
|
+
rt["right"] = "<driven>" if "posX" in driven_props else editor_vals.right
|
|
744
|
+
else:
|
|
745
|
+
rt["posX"] = "<driven>" if "posX" in driven_props else editor_vals.pos_x
|
|
746
|
+
rt["width"] = "<driven>" if "width" in driven_props else editor_vals.width
|
|
747
|
+
|
|
748
|
+
if editor_vals.is_stretch_vertical:
|
|
749
|
+
rt["top"] = "<driven>" if "posY" in driven_props else editor_vals.top
|
|
750
|
+
rt["bottom"] = "<driven>" if "posY" in driven_props else editor_vals.bottom
|
|
751
|
+
else:
|
|
752
|
+
rt["posY"] = "<driven>" if "posY" in driven_props else editor_vals.pos_y
|
|
753
|
+
rt["height"] = "<driven>" if "height" in driven_props else editor_vals.height
|
|
754
|
+
|
|
755
|
+
# Add layout-driven metadata if present (for reference only)
|
|
756
|
+
if driven_info:
|
|
757
|
+
result["_layoutDriven"] = {
|
|
758
|
+
"drivenBy": driven_info["drivenBy"],
|
|
759
|
+
"drivenProperties": driven_info["drivenProperties"],
|
|
760
|
+
"driverComponentId": driven_info["driverComponentId"],
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return result
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _export_monobehaviour(content: dict[str, Any]) -> dict[str, Any]:
|
|
767
|
+
"""Export MonoBehaviour-specific fields."""
|
|
768
|
+
result: dict[str, Any] = {}
|
|
769
|
+
|
|
770
|
+
# Script reference
|
|
771
|
+
script = content.get("m_Script", {})
|
|
772
|
+
if isinstance(script, dict):
|
|
773
|
+
result["scriptRef"] = {
|
|
774
|
+
"fileID": script.get("fileID", 0),
|
|
775
|
+
"guid": script.get("guid", ""),
|
|
776
|
+
"type": script.get("type", 0),
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
# Enabled state
|
|
780
|
+
if "m_Enabled" in content:
|
|
781
|
+
result["enabled"] = content["m_Enabled"] == 1
|
|
782
|
+
|
|
783
|
+
# Custom properties (everything else)
|
|
784
|
+
properties: dict[str, Any] = {}
|
|
785
|
+
skip_keys = {
|
|
786
|
+
"m_ObjectHideFlags",
|
|
787
|
+
"m_CorrespondingSourceObject",
|
|
788
|
+
"m_PrefabInstance",
|
|
789
|
+
"m_PrefabAsset",
|
|
790
|
+
"m_GameObject",
|
|
791
|
+
"m_Enabled",
|
|
792
|
+
"m_Script",
|
|
793
|
+
"m_EditorHideFlags",
|
|
794
|
+
"m_EditorClassIdentifier",
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
for key, value in content.items():
|
|
798
|
+
if key not in skip_keys:
|
|
799
|
+
properties[key] = _export_value(value)
|
|
800
|
+
|
|
801
|
+
if properties:
|
|
802
|
+
result["properties"] = properties
|
|
803
|
+
|
|
804
|
+
return result
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _export_prefab_instance(content: dict[str, Any]) -> dict[str, Any]:
|
|
808
|
+
"""Export PrefabInstance-specific fields."""
|
|
809
|
+
result: dict[str, Any] = {}
|
|
810
|
+
|
|
811
|
+
# Source prefab
|
|
812
|
+
source = content.get("m_SourcePrefab", {})
|
|
813
|
+
if isinstance(source, dict):
|
|
814
|
+
result["sourcePrefab"] = {
|
|
815
|
+
"fileID": source.get("fileID", 0),
|
|
816
|
+
"guid": source.get("guid", ""),
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
# Modifications
|
|
820
|
+
modification = content.get("m_Modification", {})
|
|
821
|
+
if isinstance(modification, dict):
|
|
822
|
+
mods = modification.get("m_Modifications", [])
|
|
823
|
+
if mods:
|
|
824
|
+
result["modifications"] = [
|
|
825
|
+
{
|
|
826
|
+
"target": {
|
|
827
|
+
"fileID": m.get("target", {}).get("fileID", 0),
|
|
828
|
+
"guid": m.get("target", {}).get("guid", ""),
|
|
829
|
+
},
|
|
830
|
+
"propertyPath": m.get("propertyPath", ""),
|
|
831
|
+
"value": m.get("value"),
|
|
832
|
+
}
|
|
833
|
+
for m in mods
|
|
834
|
+
if isinstance(m, dict)
|
|
835
|
+
]
|
|
836
|
+
|
|
837
|
+
return result
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _export_generic_component(content: dict[str, Any]) -> dict[str, Any]:
|
|
841
|
+
"""Export a generic component's fields."""
|
|
842
|
+
result: dict[str, Any] = {}
|
|
843
|
+
|
|
844
|
+
skip_keys = {
|
|
845
|
+
"m_ObjectHideFlags",
|
|
846
|
+
"m_CorrespondingSourceObject",
|
|
847
|
+
"m_PrefabInstance",
|
|
848
|
+
"m_PrefabAsset",
|
|
849
|
+
"m_GameObject",
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
for key, value in content.items():
|
|
853
|
+
if key not in skip_keys:
|
|
854
|
+
# Convert m_FieldName to fieldName
|
|
855
|
+
json_key = key[2].lower() + key[3:] if key.startswith("m_") else key
|
|
856
|
+
result[json_key] = _export_value(value)
|
|
857
|
+
|
|
858
|
+
return result
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _export_vector(v: dict[str, Any]) -> dict[str, float]:
|
|
862
|
+
"""Export a vector to JSON."""
|
|
863
|
+
return {
|
|
864
|
+
"x": float(v.get("x", 0)),
|
|
865
|
+
"y": float(v.get("y", 0)),
|
|
866
|
+
"z": float(v.get("z", 0)),
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _export_quaternion(q: dict[str, Any]) -> dict[str, float]:
|
|
871
|
+
"""Export a quaternion to JSON."""
|
|
872
|
+
return {
|
|
873
|
+
"x": float(q.get("x", 0)),
|
|
874
|
+
"y": float(q.get("y", 0)),
|
|
875
|
+
"z": float(q.get("z", 0)),
|
|
876
|
+
"w": float(q.get("w", 1)),
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _export_value(value: Any) -> Any:
|
|
881
|
+
"""Export a generic value, converting Unity-specific types."""
|
|
882
|
+
if isinstance(value, dict):
|
|
883
|
+
# Check if it's a reference
|
|
884
|
+
if "fileID" in value:
|
|
885
|
+
return {
|
|
886
|
+
"fileID": value.get("fileID", 0),
|
|
887
|
+
"guid": value.get("guid"),
|
|
888
|
+
"type": value.get("type"),
|
|
889
|
+
}
|
|
890
|
+
# Check if it's a vector
|
|
891
|
+
if set(value.keys()) <= {"x", "y", "z", "w"}:
|
|
892
|
+
return {k: float(v) for k, v in value.items()}
|
|
893
|
+
# Recursive export
|
|
894
|
+
return {k: _export_value(v) for k, v in value.items()}
|
|
895
|
+
elif isinstance(value, list):
|
|
896
|
+
return [_export_value(item) for item in value]
|
|
897
|
+
else:
|
|
898
|
+
return value
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _extract_raw_fields(content: dict[str, Any], structured: set[str]) -> dict[str, Any]:
|
|
902
|
+
"""Extract fields that aren't in the structured representation."""
|
|
903
|
+
raw: dict[str, Any] = {}
|
|
904
|
+
|
|
905
|
+
for key, value in content.items():
|
|
906
|
+
if key not in structured and not key.startswith("m_Corresponding") and not key.startswith("m_Prefab"):
|
|
907
|
+
raw[key] = value
|
|
908
|
+
|
|
909
|
+
return raw
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _get_structured_fields_for_class(class_id: int) -> set[str]:
|
|
913
|
+
"""Get the set of structured fields for a class ID."""
|
|
914
|
+
if class_id == 4: # Transform
|
|
915
|
+
return {"m_LocalPosition", "m_LocalRotation", "m_LocalScale", "m_Children", "m_Father", "m_GameObject"}
|
|
916
|
+
elif class_id == 224: # RectTransform
|
|
917
|
+
return {
|
|
918
|
+
"m_LocalPosition",
|
|
919
|
+
"m_LocalRotation",
|
|
920
|
+
"m_LocalScale",
|
|
921
|
+
"m_Children",
|
|
922
|
+
"m_Father",
|
|
923
|
+
"m_GameObject",
|
|
924
|
+
"m_AnchorMin",
|
|
925
|
+
"m_AnchorMax",
|
|
926
|
+
"m_AnchoredPosition",
|
|
927
|
+
"m_SizeDelta",
|
|
928
|
+
"m_Pivot",
|
|
929
|
+
}
|
|
930
|
+
elif class_id == 114: # MonoBehaviour
|
|
931
|
+
return {"m_Script", "m_Enabled", "m_GameObject"}
|
|
932
|
+
elif class_id == 1001: # PrefabInstance
|
|
933
|
+
return {"m_SourcePrefab", "m_Modification"}
|
|
934
|
+
else:
|
|
935
|
+
return {"m_GameObject"}
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def export_file_to_json(
|
|
939
|
+
input_path: str | Path,
|
|
940
|
+
output_path: str | Path | None = None,
|
|
941
|
+
include_raw: bool = True,
|
|
942
|
+
indent: int = 2,
|
|
943
|
+
) -> str:
|
|
944
|
+
"""Export a Unity YAML file to JSON.
|
|
945
|
+
|
|
946
|
+
Args:
|
|
947
|
+
input_path: Path to the Unity YAML file
|
|
948
|
+
output_path: Optional path to save the JSON output
|
|
949
|
+
include_raw: Whether to include _rawFields
|
|
950
|
+
indent: JSON indentation level
|
|
951
|
+
|
|
952
|
+
Returns:
|
|
953
|
+
The JSON string
|
|
954
|
+
"""
|
|
955
|
+
doc = UnityYAMLDocument.load(input_path)
|
|
956
|
+
prefab_json = export_to_json(doc, include_raw=include_raw)
|
|
957
|
+
json_str = prefab_json.to_json(indent=indent)
|
|
958
|
+
|
|
959
|
+
if output_path:
|
|
960
|
+
Path(output_path).write_text(json_str, encoding="utf-8")
|
|
961
|
+
|
|
962
|
+
return json_str
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def import_from_json(
|
|
966
|
+
prefab_json: PrefabJSON,
|
|
967
|
+
auto_fix: bool = True,
|
|
968
|
+
) -> UnityYAMLDocument:
|
|
969
|
+
"""Import a PrefabJSON back to UnityYAMLDocument.
|
|
970
|
+
|
|
971
|
+
This enables round-trip conversion: YAML -> JSON -> YAML
|
|
972
|
+
LLMs can modify the JSON and this function converts it back to Unity YAML.
|
|
973
|
+
|
|
974
|
+
Args:
|
|
975
|
+
prefab_json: The PrefabJSON object to convert
|
|
976
|
+
auto_fix: If True, automatically fix common issues like invalid GUIDs
|
|
977
|
+
and missing SceneRoots entries (default: True)
|
|
978
|
+
|
|
979
|
+
Returns:
|
|
980
|
+
UnityYAMLDocument ready to be saved
|
|
981
|
+
"""
|
|
982
|
+
doc = UnityYAMLDocument()
|
|
983
|
+
|
|
984
|
+
# Import GameObjects (class_id = 1)
|
|
985
|
+
for file_id_str, go_data in prefab_json.game_objects.items():
|
|
986
|
+
file_id = int(file_id_str)
|
|
987
|
+
raw_fields = prefab_json.raw_fields.get(file_id_str, {})
|
|
988
|
+
obj = _import_game_object(file_id, go_data, raw_fields)
|
|
989
|
+
doc.objects.append(obj)
|
|
990
|
+
|
|
991
|
+
# Import Components
|
|
992
|
+
for file_id_str, comp_data in prefab_json.components.items():
|
|
993
|
+
file_id = int(file_id_str)
|
|
994
|
+
raw_fields = prefab_json.raw_fields.get(file_id_str, {})
|
|
995
|
+
obj = _import_component(file_id, comp_data, raw_fields)
|
|
996
|
+
doc.objects.append(obj)
|
|
997
|
+
|
|
998
|
+
# Sort by file_id for consistent output
|
|
999
|
+
doc.objects.sort(key=lambda o: o.file_id)
|
|
1000
|
+
|
|
1001
|
+
# Apply automatic fixes if requested
|
|
1002
|
+
if auto_fix:
|
|
1003
|
+
from unityflow.validator import fix_document
|
|
1004
|
+
|
|
1005
|
+
fix_document(doc)
|
|
1006
|
+
|
|
1007
|
+
return doc
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _import_game_object(file_id: int, data: dict[str, Any], raw_fields: dict[str, Any]) -> UnityYAMLObject:
|
|
1011
|
+
"""Import a GameObject from JSON format."""
|
|
1012
|
+
content: dict[str, Any] = {}
|
|
1013
|
+
|
|
1014
|
+
# Default Unity fields
|
|
1015
|
+
content["m_ObjectHideFlags"] = raw_fields.get("m_ObjectHideFlags", 0)
|
|
1016
|
+
content["m_CorrespondingSourceObject"] = {"fileID": 0}
|
|
1017
|
+
content["m_PrefabInstance"] = {"fileID": 0}
|
|
1018
|
+
content["m_PrefabAsset"] = {"fileID": 0}
|
|
1019
|
+
content["serializedVersion"] = raw_fields.get("serializedVersion", 6)
|
|
1020
|
+
|
|
1021
|
+
# Component references
|
|
1022
|
+
components = data.get("components", [])
|
|
1023
|
+
if components:
|
|
1024
|
+
content["m_Component"] = [{"component": {"fileID": int(c)}} for c in components]
|
|
1025
|
+
else:
|
|
1026
|
+
content["m_Component"] = []
|
|
1027
|
+
|
|
1028
|
+
# Core fields from JSON
|
|
1029
|
+
content["m_Layer"] = data.get("layer", 0)
|
|
1030
|
+
content["m_Name"] = data.get("name", "")
|
|
1031
|
+
content["m_TagString"] = data.get("tag", "Untagged")
|
|
1032
|
+
|
|
1033
|
+
# Restore raw fields that aren't structured
|
|
1034
|
+
for key in ["m_Icon", "m_NavMeshLayer", "m_StaticEditorFlags"]:
|
|
1035
|
+
if key in raw_fields:
|
|
1036
|
+
content[key] = raw_fields[key]
|
|
1037
|
+
else:
|
|
1038
|
+
# Default values
|
|
1039
|
+
if key == "m_Icon":
|
|
1040
|
+
content[key] = {"fileID": 0}
|
|
1041
|
+
else:
|
|
1042
|
+
content[key] = 0
|
|
1043
|
+
|
|
1044
|
+
# isActive: bool -> 1/0
|
|
1045
|
+
is_active = data.get("isActive", True)
|
|
1046
|
+
content["m_IsActive"] = 1 if is_active else 0
|
|
1047
|
+
|
|
1048
|
+
# Merge any additional raw fields
|
|
1049
|
+
for key, value in raw_fields.items():
|
|
1050
|
+
if key not in content:
|
|
1051
|
+
content[key] = value
|
|
1052
|
+
|
|
1053
|
+
return UnityYAMLObject(
|
|
1054
|
+
class_id=1,
|
|
1055
|
+
file_id=file_id,
|
|
1056
|
+
data={"GameObject": content},
|
|
1057
|
+
stripped=False,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _import_component(file_id: int, data: dict[str, Any], raw_fields: dict[str, Any]) -> UnityYAMLObject:
|
|
1062
|
+
"""Import a component from JSON format."""
|
|
1063
|
+
class_id = data.get("classId", 0)
|
|
1064
|
+
comp_type = data.get("type", "")
|
|
1065
|
+
|
|
1066
|
+
# Determine class_id if not provided
|
|
1067
|
+
if class_id == 0 and comp_type:
|
|
1068
|
+
class_id = CLASS_NAME_TO_ID.get(comp_type, 0)
|
|
1069
|
+
|
|
1070
|
+
# Get the root key (class name)
|
|
1071
|
+
# Priority: _originalType > CLASS_IDS > comp_type > fallback
|
|
1072
|
+
original_type = data.get("_originalType")
|
|
1073
|
+
if original_type:
|
|
1074
|
+
root_key = original_type
|
|
1075
|
+
elif class_id in CLASS_IDS:
|
|
1076
|
+
root_key = CLASS_IDS[class_id]
|
|
1077
|
+
elif comp_type and not comp_type.startswith("Unknown"):
|
|
1078
|
+
root_key = comp_type
|
|
1079
|
+
else:
|
|
1080
|
+
root_key = comp_type or f"Unknown{class_id}"
|
|
1081
|
+
|
|
1082
|
+
# Build content based on component type
|
|
1083
|
+
if class_id == 4: # Transform
|
|
1084
|
+
content = _import_transform(data, raw_fields)
|
|
1085
|
+
elif class_id == 224: # RectTransform
|
|
1086
|
+
content = _import_rect_transform(data, raw_fields)
|
|
1087
|
+
elif class_id == 114: # MonoBehaviour
|
|
1088
|
+
content = _import_monobehaviour(data, raw_fields)
|
|
1089
|
+
elif class_id == 1001: # PrefabInstance
|
|
1090
|
+
content = _import_prefab_instance(data, raw_fields)
|
|
1091
|
+
else:
|
|
1092
|
+
content = _import_generic_component(data, raw_fields)
|
|
1093
|
+
|
|
1094
|
+
return UnityYAMLObject(
|
|
1095
|
+
class_id=class_id,
|
|
1096
|
+
file_id=file_id,
|
|
1097
|
+
data={root_key: content},
|
|
1098
|
+
stripped=False,
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _import_transform(data: dict[str, Any], raw_fields: dict[str, Any]) -> dict[str, Any]:
|
|
1103
|
+
"""Import Transform-specific fields."""
|
|
1104
|
+
content: dict[str, Any] = {}
|
|
1105
|
+
|
|
1106
|
+
# Default Unity fields
|
|
1107
|
+
content["m_ObjectHideFlags"] = raw_fields.get("m_ObjectHideFlags", 0)
|
|
1108
|
+
content["m_CorrespondingSourceObject"] = {"fileID": 0}
|
|
1109
|
+
content["m_PrefabInstance"] = {"fileID": 0}
|
|
1110
|
+
content["m_PrefabAsset"] = {"fileID": 0}
|
|
1111
|
+
|
|
1112
|
+
# GameObject reference
|
|
1113
|
+
if "gameObject" in data:
|
|
1114
|
+
content["m_GameObject"] = {"fileID": int(data["gameObject"])}
|
|
1115
|
+
else:
|
|
1116
|
+
content["m_GameObject"] = {"fileID": 0}
|
|
1117
|
+
|
|
1118
|
+
content["serializedVersion"] = raw_fields.get("serializedVersion", 2)
|
|
1119
|
+
|
|
1120
|
+
# Transform properties
|
|
1121
|
+
if "localRotation" in data:
|
|
1122
|
+
content["m_LocalRotation"] = _import_vector(data["localRotation"], include_w=True)
|
|
1123
|
+
else:
|
|
1124
|
+
content["m_LocalRotation"] = {"x": 0, "y": 0, "z": 0, "w": 1}
|
|
1125
|
+
|
|
1126
|
+
if "localPosition" in data:
|
|
1127
|
+
content["m_LocalPosition"] = _import_vector(data["localPosition"])
|
|
1128
|
+
else:
|
|
1129
|
+
content["m_LocalPosition"] = {"x": 0, "y": 0, "z": 0}
|
|
1130
|
+
|
|
1131
|
+
if "localScale" in data:
|
|
1132
|
+
content["m_LocalScale"] = _import_vector(data["localScale"])
|
|
1133
|
+
else:
|
|
1134
|
+
content["m_LocalScale"] = {"x": 1, "y": 1, "z": 1}
|
|
1135
|
+
|
|
1136
|
+
# Raw fields like m_ConstrainProportionsScale
|
|
1137
|
+
if "m_ConstrainProportionsScale" in raw_fields:
|
|
1138
|
+
content["m_ConstrainProportionsScale"] = raw_fields["m_ConstrainProportionsScale"]
|
|
1139
|
+
else:
|
|
1140
|
+
content["m_ConstrainProportionsScale"] = 0
|
|
1141
|
+
|
|
1142
|
+
# Children references
|
|
1143
|
+
if "children" in data and data["children"]:
|
|
1144
|
+
content["m_Children"] = [{"fileID": int(c)} for c in data["children"]]
|
|
1145
|
+
else:
|
|
1146
|
+
content["m_Children"] = []
|
|
1147
|
+
|
|
1148
|
+
# Parent reference
|
|
1149
|
+
if "parent" in data and data["parent"]:
|
|
1150
|
+
content["m_Father"] = {"fileID": int(data["parent"])}
|
|
1151
|
+
else:
|
|
1152
|
+
content["m_Father"] = {"fileID": 0}
|
|
1153
|
+
|
|
1154
|
+
# Euler angles hint
|
|
1155
|
+
if "m_LocalEulerAnglesHint" in raw_fields:
|
|
1156
|
+
content["m_LocalEulerAnglesHint"] = raw_fields["m_LocalEulerAnglesHint"]
|
|
1157
|
+
else:
|
|
1158
|
+
content["m_LocalEulerAnglesHint"] = {"x": 0, "y": 0, "z": 0}
|
|
1159
|
+
|
|
1160
|
+
# Merge any additional raw fields
|
|
1161
|
+
for key, value in raw_fields.items():
|
|
1162
|
+
if key not in content:
|
|
1163
|
+
content[key] = value
|
|
1164
|
+
|
|
1165
|
+
return content
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _import_rect_transform(data: dict[str, Any], raw_fields: dict[str, Any]) -> dict[str, Any]:
|
|
1169
|
+
"""Import RectTransform-specific fields (extends Transform).
|
|
1170
|
+
|
|
1171
|
+
Supports two ways to specify RectTransform values:
|
|
1172
|
+
1. From rectTransform (Inspector-style values - posX/posY, width/height, etc.)
|
|
1173
|
+
2. From raw_fields (fallback for round-trip)
|
|
1174
|
+
|
|
1175
|
+
Layout-driven properties (marked as "<driven>" or listed in _layoutDriven)
|
|
1176
|
+
are normalized to 0, as they will be recalculated by Unity at runtime.
|
|
1177
|
+
"""
|
|
1178
|
+
# Start with Transform fields
|
|
1179
|
+
content = _import_transform(data, raw_fields)
|
|
1180
|
+
|
|
1181
|
+
# Get driven properties if present
|
|
1182
|
+
driven_props = set()
|
|
1183
|
+
if "_layoutDriven" in data:
|
|
1184
|
+
driven_props = set(data["_layoutDriven"].get("drivenProperties", []))
|
|
1185
|
+
|
|
1186
|
+
# Priority 1: Import from rectTransform (Inspector-style values)
|
|
1187
|
+
if "rectTransform" in data:
|
|
1188
|
+
rt = data["rectTransform"]
|
|
1189
|
+
|
|
1190
|
+
# Helper to get value, treating "<driven>" as None (will use default 0)
|
|
1191
|
+
def get_val(key: str, default: float | None = None) -> float | None:
|
|
1192
|
+
val = rt.get(key)
|
|
1193
|
+
if val == "<driven>":
|
|
1194
|
+
return None # Will be handled as driven
|
|
1195
|
+
return val if val is not None else default
|
|
1196
|
+
|
|
1197
|
+
# Build editor values object
|
|
1198
|
+
# Driven properties get None, which will result in 0 after conversion
|
|
1199
|
+
editor_vals = RectTransformEditorValues(
|
|
1200
|
+
anchor_min_x=rt.get("anchorMin", {}).get("x", 0.5),
|
|
1201
|
+
anchor_min_y=rt.get("anchorMin", {}).get("y", 0.5),
|
|
1202
|
+
anchor_max_x=rt.get("anchorMax", {}).get("x", 0.5),
|
|
1203
|
+
anchor_max_y=rt.get("anchorMax", {}).get("y", 0.5),
|
|
1204
|
+
pivot_x=rt.get("pivot", {}).get("x", 0.5),
|
|
1205
|
+
pivot_y=rt.get("pivot", {}).get("y", 0.5),
|
|
1206
|
+
pos_z=rt.get("posZ", 0),
|
|
1207
|
+
left=get_val("left"),
|
|
1208
|
+
right=get_val("right"),
|
|
1209
|
+
top=get_val("top"),
|
|
1210
|
+
bottom=get_val("bottom"),
|
|
1211
|
+
pos_x=get_val("posX"),
|
|
1212
|
+
pos_y=get_val("posY"),
|
|
1213
|
+
width=get_val("width"),
|
|
1214
|
+
height=get_val("height"),
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
# Convert to file values
|
|
1218
|
+
file_vals = editor_to_file_values(editor_vals)
|
|
1219
|
+
|
|
1220
|
+
content["m_AnchorMin"] = file_vals.anchor_min
|
|
1221
|
+
content["m_AnchorMax"] = file_vals.anchor_max
|
|
1222
|
+
content["m_AnchoredPosition"] = file_vals.anchored_position
|
|
1223
|
+
content["m_SizeDelta"] = file_vals.size_delta
|
|
1224
|
+
content["m_Pivot"] = file_vals.pivot
|
|
1225
|
+
content["m_LocalPosition"]["z"] = file_vals.local_position_z
|
|
1226
|
+
|
|
1227
|
+
# Normalize driven properties to 0
|
|
1228
|
+
if "posX" in driven_props or "posY" in driven_props:
|
|
1229
|
+
pos = content["m_AnchoredPosition"]
|
|
1230
|
+
if "posX" in driven_props:
|
|
1231
|
+
pos["x"] = 0
|
|
1232
|
+
if "posY" in driven_props:
|
|
1233
|
+
pos["y"] = 0
|
|
1234
|
+
if "width" in driven_props or "height" in driven_props:
|
|
1235
|
+
size = content["m_SizeDelta"]
|
|
1236
|
+
if "width" in driven_props:
|
|
1237
|
+
size["x"] = 0
|
|
1238
|
+
if "height" in driven_props:
|
|
1239
|
+
size["y"] = 0
|
|
1240
|
+
|
|
1241
|
+
# Priority 2: Fallback to raw_fields
|
|
1242
|
+
else:
|
|
1243
|
+
rect_fields = [
|
|
1244
|
+
("m_AnchorMin", {"x": 0.5, "y": 0.5}),
|
|
1245
|
+
("m_AnchorMax", {"x": 0.5, "y": 0.5}),
|
|
1246
|
+
("m_AnchoredPosition", {"x": 0, "y": 0}),
|
|
1247
|
+
("m_SizeDelta", {"x": 100, "y": 100}),
|
|
1248
|
+
("m_Pivot", {"x": 0.5, "y": 0.5}),
|
|
1249
|
+
]
|
|
1250
|
+
|
|
1251
|
+
for field, default in rect_fields:
|
|
1252
|
+
if field in raw_fields:
|
|
1253
|
+
content[field] = raw_fields[field]
|
|
1254
|
+
elif field not in content:
|
|
1255
|
+
content[field] = default
|
|
1256
|
+
|
|
1257
|
+
return content
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _import_monobehaviour(data: dict[str, Any], raw_fields: dict[str, Any]) -> dict[str, Any]:
|
|
1261
|
+
"""Import MonoBehaviour-specific fields."""
|
|
1262
|
+
content: dict[str, Any] = {}
|
|
1263
|
+
|
|
1264
|
+
# Default Unity fields
|
|
1265
|
+
content["m_ObjectHideFlags"] = raw_fields.get("m_ObjectHideFlags", 0)
|
|
1266
|
+
content["m_CorrespondingSourceObject"] = {"fileID": 0}
|
|
1267
|
+
content["m_PrefabInstance"] = {"fileID": 0}
|
|
1268
|
+
content["m_PrefabAsset"] = {"fileID": 0}
|
|
1269
|
+
|
|
1270
|
+
# GameObject reference
|
|
1271
|
+
if "gameObject" in data:
|
|
1272
|
+
content["m_GameObject"] = {"fileID": int(data["gameObject"])}
|
|
1273
|
+
else:
|
|
1274
|
+
content["m_GameObject"] = {"fileID": 0}
|
|
1275
|
+
|
|
1276
|
+
# Enabled state
|
|
1277
|
+
enabled = data.get("enabled", True)
|
|
1278
|
+
content["m_Enabled"] = 1 if enabled else 0
|
|
1279
|
+
|
|
1280
|
+
# Editor fields
|
|
1281
|
+
content["m_EditorHideFlags"] = raw_fields.get("m_EditorHideFlags", 0)
|
|
1282
|
+
content["m_EditorClassIdentifier"] = raw_fields.get("m_EditorClassIdentifier", "")
|
|
1283
|
+
|
|
1284
|
+
# Script reference
|
|
1285
|
+
if "scriptRef" in data:
|
|
1286
|
+
script_ref = data["scriptRef"]
|
|
1287
|
+
content["m_Script"] = {
|
|
1288
|
+
"fileID": script_ref.get("fileID", 0),
|
|
1289
|
+
"guid": script_ref.get("guid", ""),
|
|
1290
|
+
"type": script_ref.get("type", 0),
|
|
1291
|
+
}
|
|
1292
|
+
elif "m_Script" in raw_fields:
|
|
1293
|
+
content["m_Script"] = raw_fields["m_Script"]
|
|
1294
|
+
else:
|
|
1295
|
+
content["m_Script"] = {"fileID": 0}
|
|
1296
|
+
|
|
1297
|
+
# Custom properties
|
|
1298
|
+
properties = data.get("properties", {})
|
|
1299
|
+
for key, value in properties.items():
|
|
1300
|
+
content[key] = _import_value(value)
|
|
1301
|
+
|
|
1302
|
+
# Merge additional raw fields
|
|
1303
|
+
for key, value in raw_fields.items():
|
|
1304
|
+
if key not in content:
|
|
1305
|
+
content[key] = value
|
|
1306
|
+
|
|
1307
|
+
return content
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _import_prefab_instance(data: dict[str, Any], raw_fields: dict[str, Any]) -> dict[str, Any]:
|
|
1311
|
+
"""Import PrefabInstance-specific fields."""
|
|
1312
|
+
content: dict[str, Any] = {}
|
|
1313
|
+
|
|
1314
|
+
# Default Unity fields
|
|
1315
|
+
content["m_ObjectHideFlags"] = raw_fields.get("m_ObjectHideFlags", 0)
|
|
1316
|
+
content["m_CorrespondingSourceObject"] = {"fileID": 0}
|
|
1317
|
+
content["m_PrefabInstance"] = {"fileID": 0}
|
|
1318
|
+
content["m_PrefabAsset"] = {"fileID": 0}
|
|
1319
|
+
|
|
1320
|
+
# Source prefab
|
|
1321
|
+
if "sourcePrefab" in data:
|
|
1322
|
+
src = data["sourcePrefab"]
|
|
1323
|
+
content["m_SourcePrefab"] = {
|
|
1324
|
+
"fileID": src.get("fileID", 0),
|
|
1325
|
+
"guid": src.get("guid", ""),
|
|
1326
|
+
"type": src.get("type", 2),
|
|
1327
|
+
}
|
|
1328
|
+
elif "m_SourcePrefab" in raw_fields:
|
|
1329
|
+
content["m_SourcePrefab"] = raw_fields["m_SourcePrefab"]
|
|
1330
|
+
|
|
1331
|
+
# Modifications
|
|
1332
|
+
modification: dict[str, Any] = {}
|
|
1333
|
+
|
|
1334
|
+
# TransformParent
|
|
1335
|
+
if "m_Modification" in raw_fields and "m_TransformParent" in raw_fields["m_Modification"]:
|
|
1336
|
+
modification["m_TransformParent"] = raw_fields["m_Modification"]["m_TransformParent"]
|
|
1337
|
+
else:
|
|
1338
|
+
modification["m_TransformParent"] = {"fileID": 0}
|
|
1339
|
+
|
|
1340
|
+
# Modifications list
|
|
1341
|
+
if "modifications" in data:
|
|
1342
|
+
mods_list = []
|
|
1343
|
+
for mod in data["modifications"]:
|
|
1344
|
+
target = mod.get("target", {})
|
|
1345
|
+
mods_list.append(
|
|
1346
|
+
{
|
|
1347
|
+
"target": {
|
|
1348
|
+
"fileID": target.get("fileID", 0),
|
|
1349
|
+
"guid": target.get("guid", ""),
|
|
1350
|
+
},
|
|
1351
|
+
"propertyPath": mod.get("propertyPath", ""),
|
|
1352
|
+
"value": mod.get("value"),
|
|
1353
|
+
"objectReference": mod.get("objectReference", {"fileID": 0}),
|
|
1354
|
+
}
|
|
1355
|
+
)
|
|
1356
|
+
modification["m_Modifications"] = mods_list
|
|
1357
|
+
elif "m_Modification" in raw_fields and "m_Modifications" in raw_fields["m_Modification"]:
|
|
1358
|
+
modification["m_Modifications"] = raw_fields["m_Modification"]["m_Modifications"]
|
|
1359
|
+
else:
|
|
1360
|
+
modification["m_Modifications"] = []
|
|
1361
|
+
|
|
1362
|
+
# RemovedComponents and RemovedGameObjects
|
|
1363
|
+
if "m_Modification" in raw_fields:
|
|
1364
|
+
for key in ["m_RemovedComponents", "m_RemovedGameObjects", "m_AddedComponents", "m_AddedGameObjects"]:
|
|
1365
|
+
if key in raw_fields["m_Modification"]:
|
|
1366
|
+
modification[key] = raw_fields["m_Modification"][key]
|
|
1367
|
+
|
|
1368
|
+
if "m_RemovedComponents" not in modification:
|
|
1369
|
+
modification["m_RemovedComponents"] = []
|
|
1370
|
+
if "m_RemovedGameObjects" not in modification:
|
|
1371
|
+
modification["m_RemovedGameObjects"] = []
|
|
1372
|
+
|
|
1373
|
+
content["m_Modification"] = modification
|
|
1374
|
+
|
|
1375
|
+
# Merge additional raw fields
|
|
1376
|
+
for key, value in raw_fields.items():
|
|
1377
|
+
if key not in content and key != "m_Modification":
|
|
1378
|
+
content[key] = value
|
|
1379
|
+
|
|
1380
|
+
return content
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _import_generic_component(data: dict[str, Any], raw_fields: dict[str, Any]) -> dict[str, Any]:
|
|
1384
|
+
"""Import a generic component's fields.
|
|
1385
|
+
|
|
1386
|
+
For unknown/generic components, prioritize raw_fields to preserve
|
|
1387
|
+
the original data structure. Only add default Unity fields if they
|
|
1388
|
+
existed in the original data.
|
|
1389
|
+
"""
|
|
1390
|
+
content: dict[str, Any] = {}
|
|
1391
|
+
|
|
1392
|
+
# First, restore all raw fields (preserves original structure)
|
|
1393
|
+
for key, value in raw_fields.items():
|
|
1394
|
+
content[key] = value
|
|
1395
|
+
|
|
1396
|
+
# Only add default Unity fields if they existed in raw_fields
|
|
1397
|
+
# (Don't inject new fields that weren't in the original)
|
|
1398
|
+
if "m_ObjectHideFlags" not in content and "m_ObjectHideFlags" not in raw_fields:
|
|
1399
|
+
# Only add if data suggests it's needed
|
|
1400
|
+
pass # Don't add default
|
|
1401
|
+
|
|
1402
|
+
# GameObject reference - only if provided in data and not already in content
|
|
1403
|
+
if "gameObject" in data and "m_GameObject" not in content:
|
|
1404
|
+
content["m_GameObject"] = {"fileID": int(data["gameObject"])}
|
|
1405
|
+
|
|
1406
|
+
# Convert exported fields back to Unity format
|
|
1407
|
+
# Skip metadata keys and keys already handled
|
|
1408
|
+
skip_keys = {"type", "classId", "gameObject", "_originalType"}
|
|
1409
|
+
|
|
1410
|
+
for key, value in data.items():
|
|
1411
|
+
if key in skip_keys:
|
|
1412
|
+
continue
|
|
1413
|
+
|
|
1414
|
+
# Convert camelCase back to m_PascalCase
|
|
1415
|
+
if key[0].islower() and not key.startswith("m_"):
|
|
1416
|
+
unity_key = "m_" + key[0].upper() + key[1:]
|
|
1417
|
+
else:
|
|
1418
|
+
unity_key = key
|
|
1419
|
+
|
|
1420
|
+
# Skip if the original key (without m_ prefix) already exists in content
|
|
1421
|
+
# This prevents duplicates like serializedVersion and m_SerializedVersion
|
|
1422
|
+
if key in content:
|
|
1423
|
+
continue
|
|
1424
|
+
|
|
1425
|
+
# Only update if not already set from raw_fields
|
|
1426
|
+
if unity_key not in content:
|
|
1427
|
+
content[unity_key] = _import_value(value)
|
|
1428
|
+
|
|
1429
|
+
return content
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _import_vector(v: dict[str, Any], include_w: bool = False) -> dict[str, Any]:
|
|
1433
|
+
"""Import a vector from JSON."""
|
|
1434
|
+
result = {
|
|
1435
|
+
"x": v.get("x", 0),
|
|
1436
|
+
"y": v.get("y", 0),
|
|
1437
|
+
"z": v.get("z", 0),
|
|
1438
|
+
}
|
|
1439
|
+
if include_w or "w" in v:
|
|
1440
|
+
result["w"] = v.get("w", 1)
|
|
1441
|
+
return result
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _import_value(value: Any) -> Any:
|
|
1445
|
+
"""Import a generic value, converting JSON types back to Unity format."""
|
|
1446
|
+
if isinstance(value, dict):
|
|
1447
|
+
# Check if it's a reference
|
|
1448
|
+
if "fileID" in value:
|
|
1449
|
+
ref: dict[str, Any] = {"fileID": value["fileID"]}
|
|
1450
|
+
if value.get("guid"):
|
|
1451
|
+
ref["guid"] = value["guid"]
|
|
1452
|
+
if value.get("type") is not None:
|
|
1453
|
+
ref["type"] = value["type"]
|
|
1454
|
+
return ref
|
|
1455
|
+
# Recursive import
|
|
1456
|
+
return {k: _import_value(v) for k, v in value.items()}
|
|
1457
|
+
elif isinstance(value, list):
|
|
1458
|
+
return [_import_value(item) for item in value]
|
|
1459
|
+
else:
|
|
1460
|
+
return value
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def import_file_from_json(
|
|
1464
|
+
input_path: str | Path,
|
|
1465
|
+
output_path: str | Path | None = None,
|
|
1466
|
+
auto_fix: bool = True,
|
|
1467
|
+
) -> UnityYAMLDocument:
|
|
1468
|
+
"""Import a JSON file back to Unity YAML format.
|
|
1469
|
+
|
|
1470
|
+
Args:
|
|
1471
|
+
input_path: Path to the JSON file
|
|
1472
|
+
output_path: Optional path to save the Unity YAML output
|
|
1473
|
+
auto_fix: If True, automatically fix common issues like invalid GUIDs
|
|
1474
|
+
and missing SceneRoots entries (default: True)
|
|
1475
|
+
|
|
1476
|
+
Returns:
|
|
1477
|
+
UnityYAMLDocument object
|
|
1478
|
+
"""
|
|
1479
|
+
input_path = Path(input_path)
|
|
1480
|
+
json_str = input_path.read_text(encoding="utf-8")
|
|
1481
|
+
prefab_json = PrefabJSON.from_json(json_str)
|
|
1482
|
+
doc = import_from_json(prefab_json, auto_fix=auto_fix)
|
|
1483
|
+
|
|
1484
|
+
if output_path:
|
|
1485
|
+
doc.save(output_path)
|
|
1486
|
+
|
|
1487
|
+
return doc
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def get_summary(doc: UnityYAMLDocument) -> dict[str, Any]:
|
|
1491
|
+
"""Get a summary of a Unity YAML document for context management.
|
|
1492
|
+
|
|
1493
|
+
Useful for providing LLMs with an overview before sending full details.
|
|
1494
|
+
"""
|
|
1495
|
+
# Count by type
|
|
1496
|
+
type_counts: dict[str, int] = {}
|
|
1497
|
+
for obj in doc.objects:
|
|
1498
|
+
type_counts[obj.class_name] = type_counts.get(obj.class_name, 0) + 1
|
|
1499
|
+
|
|
1500
|
+
# Build hierarchy
|
|
1501
|
+
hierarchy: list[str] = []
|
|
1502
|
+
transforms: dict[int, dict[str, Any]] = {}
|
|
1503
|
+
|
|
1504
|
+
# First pass: collect all transforms
|
|
1505
|
+
for obj in doc.objects:
|
|
1506
|
+
if obj.class_id == 4: # Transform
|
|
1507
|
+
content = obj.get_content()
|
|
1508
|
+
if content:
|
|
1509
|
+
go_ref = content.get("m_GameObject", {})
|
|
1510
|
+
go_id = go_ref.get("fileID", 0) if isinstance(go_ref, dict) else 0
|
|
1511
|
+
father = content.get("m_Father", {})
|
|
1512
|
+
father_id = father.get("fileID", 0) if isinstance(father, dict) else 0
|
|
1513
|
+
transforms[obj.file_id] = {
|
|
1514
|
+
"gameObject": go_id,
|
|
1515
|
+
"parent": father_id,
|
|
1516
|
+
"children": [],
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
# Second pass: find names
|
|
1520
|
+
go_names: dict[int, str] = {}
|
|
1521
|
+
for obj in doc.objects:
|
|
1522
|
+
if obj.class_id == 1: # GameObject
|
|
1523
|
+
content = obj.get_content()
|
|
1524
|
+
if content:
|
|
1525
|
+
go_names[obj.file_id] = content.get("m_Name", "<unnamed>")
|
|
1526
|
+
|
|
1527
|
+
# Build hierarchy strings
|
|
1528
|
+
def build_path(transform_id: int, visited: set[int]) -> str:
|
|
1529
|
+
if transform_id in visited or transform_id not in transforms:
|
|
1530
|
+
return ""
|
|
1531
|
+
visited.add(transform_id)
|
|
1532
|
+
|
|
1533
|
+
t = transforms[transform_id]
|
|
1534
|
+
name = go_names.get(t["gameObject"], "<unnamed>")
|
|
1535
|
+
|
|
1536
|
+
if t["parent"] == 0:
|
|
1537
|
+
return name
|
|
1538
|
+
else:
|
|
1539
|
+
parent_path = build_path(t["parent"], visited)
|
|
1540
|
+
if parent_path:
|
|
1541
|
+
return f"{parent_path}/{name}"
|
|
1542
|
+
return name
|
|
1543
|
+
|
|
1544
|
+
# Find roots and build paths
|
|
1545
|
+
for tid, t in transforms.items():
|
|
1546
|
+
if t["parent"] == 0:
|
|
1547
|
+
path = build_path(tid, set())
|
|
1548
|
+
if path:
|
|
1549
|
+
hierarchy.append(path)
|
|
1550
|
+
|
|
1551
|
+
return {
|
|
1552
|
+
"summary": {
|
|
1553
|
+
"totalGameObjects": type_counts.get("GameObject", 0),
|
|
1554
|
+
"totalComponents": len(doc.objects) - type_counts.get("GameObject", 0),
|
|
1555
|
+
"typeCounts": type_counts,
|
|
1556
|
+
"hierarchy": sorted(hierarchy),
|
|
1557
|
+
}
|
|
1558
|
+
}
|