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/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
+ }