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.
@@ -0,0 +1,529 @@
1
+ """Unity Prefab Normalizer.
2
+
3
+ Implements deterministic serialization for Unity YAML files by:
4
+ 1. Sorting documents by fileID
5
+ 2. Sorting m_Modifications arrays
6
+ 3. Normalizing floating-point values
7
+ 4. Normalizing quaternions (w >= 0)
8
+ 5. Reordering MonoBehaviour fields according to C# script declaration order
9
+ 6. Syncing fields with C# script (remove obsolete, add missing, merge renamed)
10
+ 7. Preserving original fileIDs for external reference compatibility
11
+
12
+ Note: Script-based operations (5, 6) require project_root to be available.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ import struct
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from unityflow.parser import UnityYAMLDocument, UnityYAMLObject
23
+
24
+ # Properties that contain quaternion values
25
+ QUATERNION_PROPERTIES = {
26
+ "m_LocalRotation",
27
+ "m_Rotation",
28
+ "localRotation",
29
+ "rotation",
30
+ }
31
+
32
+ # Properties that should use hex float format
33
+ FLOAT_PROPERTIES_HEX = {
34
+ "m_LocalPosition",
35
+ "m_LocalRotation",
36
+ "m_LocalScale",
37
+ "m_Position",
38
+ "m_Rotation",
39
+ "m_Scale",
40
+ "m_Center",
41
+ "m_Size",
42
+ "m_Offset",
43
+ }
44
+
45
+ # Properties that contain order-independent arrays of references
46
+ # NOTE: We no longer sort any of these arrays because:
47
+ # - m_Children: affects Hierarchy order (rendering order, UI overlays)
48
+ # - m_Component: affects Inspector display order and GetComponents() order
49
+ # - Both may be intentionally ordered by developers for readability
50
+ ORDER_INDEPENDENT_ARRAYS: set[str] = set() # Empty - preserve all array orders
51
+
52
+
53
+ class UnityPrefabNormalizer:
54
+ """Normalizes Unity prefab files for deterministic serialization."""
55
+
56
+ def __init__(
57
+ self,
58
+ use_hex_floats: bool = False, # Default to decimal for readability
59
+ float_precision: int = 6,
60
+ project_root: str | Path | None = None,
61
+ ):
62
+ """Initialize the normalizer.
63
+
64
+ Args:
65
+ use_hex_floats: Use IEEE 754 hex format for floats (lossless but less readable)
66
+ float_precision: Decimal places for float normalization (if not using hex)
67
+ project_root: Unity project root for script resolution (auto-detected if None)
68
+ """
69
+ self.use_hex_floats = use_hex_floats
70
+ self.float_precision = float_precision
71
+ self.project_root = Path(project_root) if project_root else None
72
+ self._script_cache: Any = None # Lazy initialized ScriptFieldCache
73
+ self._script_info_cache: dict[str, Any] = {} # Cache for ScriptInfo by GUID
74
+ self._guid_index: Any = None # Lazy initialized GUIDIndex
75
+
76
+ def normalize_file(self, input_path: str | Path, output_path: str | Path | None = None) -> str:
77
+ """Normalize a Unity YAML file.
78
+
79
+ Args:
80
+ input_path: Path to the input file
81
+ output_path: Path to save the normalized file (if None, returns content only)
82
+
83
+ Returns:
84
+ The normalized YAML content
85
+ """
86
+ input_path = Path(input_path)
87
+
88
+ # Auto-detect project root if not specified
89
+ if self.project_root is None:
90
+ from unityflow.asset_tracker import find_unity_project_root
91
+
92
+ self.project_root = find_unity_project_root(input_path)
93
+
94
+ doc = UnityYAMLDocument.load(input_path)
95
+ self.normalize_document(doc)
96
+
97
+ content = doc.dump()
98
+
99
+ if output_path:
100
+ Path(output_path).write_text(content, encoding="utf-8", newline="\n")
101
+
102
+ return content
103
+
104
+ def normalize_document(self, doc: UnityYAMLDocument) -> None:
105
+ """Normalize a UnityYAMLDocument in place.
106
+
107
+ Args:
108
+ doc: The document to normalize
109
+ """
110
+ # Normalize each object's data
111
+ for obj in doc.objects:
112
+ self._normalize_object(obj)
113
+
114
+ # Sort documents by fileID
115
+ doc.objects.sort(key=lambda o: o.file_id)
116
+
117
+ def _normalize_object(self, obj: UnityYAMLObject) -> None:
118
+ """Normalize a single Unity YAML object."""
119
+ content = obj.get_content()
120
+ if content is None:
121
+ return
122
+
123
+ # Sort m_Modifications if present
124
+ if "m_Modification" in content:
125
+ self._sort_modifications(content["m_Modification"])
126
+
127
+ # Process MonoBehaviour fields (requires project_root for script parsing)
128
+ if obj.class_id == 114 and self.project_root: # MonoBehaviour
129
+ # Sync fields with C# script (remove obsolete, add missing, merge renamed)
130
+ self._cleanup_obsolete_fields(obj)
131
+
132
+ # Reorder MonoBehaviour fields according to C# script declaration order
133
+ self._reorder_monobehaviour_fields(obj)
134
+
135
+ # Recursively normalize the data
136
+ self._normalize_value(obj.data, parent_key=None)
137
+
138
+ def _reorder_monobehaviour_fields(self, obj: UnityYAMLObject) -> None:
139
+ """Reorder MonoBehaviour fields according to C# script declaration order.
140
+
141
+ Args:
142
+ obj: The MonoBehaviour object to reorder
143
+ """
144
+ content = obj.get_content()
145
+ if content is None:
146
+ return
147
+
148
+ # Get script reference
149
+ script_ref = content.get("m_Script")
150
+ if not isinstance(script_ref, dict):
151
+ return
152
+
153
+ script_guid = script_ref.get("guid")
154
+ if not script_guid:
155
+ return
156
+
157
+ # Get field order from script
158
+ field_order = self._get_script_field_order(script_guid)
159
+ if not field_order:
160
+ return
161
+
162
+ # Reorder the content fields
163
+ from unityflow.script_parser import reorder_fields
164
+
165
+ reordered = reorder_fields(content, field_order, unity_fields_first=True)
166
+
167
+ # Replace content in place
168
+ content.clear()
169
+ content.update(reordered)
170
+
171
+ def _cleanup_obsolete_fields(self, obj: UnityYAMLObject) -> None:
172
+ """Remove obsolete fields and merge FormerlySerializedAs renamed fields.
173
+
174
+ Args:
175
+ obj: The MonoBehaviour object to clean up
176
+ """
177
+ content = obj.get_content()
178
+ if content is None:
179
+ return
180
+
181
+ # Get script reference
182
+ script_ref = content.get("m_Script")
183
+ if not isinstance(script_ref, dict):
184
+ return
185
+
186
+ script_guid = script_ref.get("guid")
187
+ if not script_guid:
188
+ return
189
+
190
+ # Get script info
191
+ script_info = self._get_script_info(script_guid)
192
+ if script_info is None:
193
+ return
194
+
195
+ # Get valid field names and rename mapping
196
+ valid_names = script_info.get_valid_field_names()
197
+ rename_mapping = script_info.get_rename_mapping()
198
+
199
+ # Unity standard fields that should never be removed
200
+ unity_standard_fields = {
201
+ "m_ObjectHideFlags",
202
+ "m_CorrespondingSourceObject",
203
+ "m_PrefabInstance",
204
+ "m_PrefabAsset",
205
+ "m_GameObject",
206
+ "m_Enabled",
207
+ "m_EditorHideFlags",
208
+ "m_Script",
209
+ "m_Name",
210
+ "m_EditorClassIdentifier",
211
+ }
212
+
213
+ # First pass: handle FormerlySerializedAs renames
214
+ # If old name exists and new name doesn't, copy old value to new name
215
+ for old_name, new_name in rename_mapping.items():
216
+ if old_name in content and new_name not in content:
217
+ content[new_name] = content[old_name]
218
+
219
+ # Second pass: collect fields to remove
220
+ fields_to_remove = []
221
+ for field_name in content.keys():
222
+ # Skip Unity standard fields
223
+ if field_name in unity_standard_fields:
224
+ continue
225
+
226
+ # Check if field is valid (in current script) or is an old renamed field
227
+ if field_name not in valid_names:
228
+ # It's either an obsolete field or a FormerlySerializedAs old name
229
+ fields_to_remove.append(field_name)
230
+
231
+ # Remove obsolete fields
232
+ for field_name in fields_to_remove:
233
+ del content[field_name]
234
+
235
+ # Third pass: add missing fields with default values
236
+ existing_names = set(content.keys())
237
+ missing_fields = script_info.get_missing_fields(existing_names)
238
+
239
+ for field in missing_fields:
240
+ # Only add if we have a valid default value
241
+ if field.default_value is not None:
242
+ content[field.unity_name] = field.default_value
243
+
244
+ def _get_script_info(self, script_guid: str):
245
+ """Get script info for a script by GUID (with caching).
246
+
247
+ Args:
248
+ script_guid: The GUID of the script
249
+
250
+ Returns:
251
+ ScriptInfo object or None if not found
252
+ """
253
+ # Check cache first
254
+ if script_guid in self._script_info_cache:
255
+ return self._script_info_cache[script_guid]
256
+
257
+ if self.project_root is None:
258
+ return None
259
+
260
+ # Lazy initialize GUID index
261
+ if self._guid_index is None:
262
+ from unityflow.asset_tracker import build_guid_index
263
+
264
+ self._guid_index = build_guid_index(self.project_root)
265
+
266
+ # Find script path
267
+ script_path = self._guid_index.get_path(script_guid)
268
+ if script_path is None:
269
+ self._script_info_cache[script_guid] = None
270
+ return None
271
+
272
+ # Resolve to absolute path
273
+ if not script_path.is_absolute():
274
+ script_path = self.project_root / script_path
275
+
276
+ # Check if it's a C# script
277
+ if script_path.suffix.lower() != ".cs":
278
+ self._script_info_cache[script_guid] = None
279
+ return None
280
+
281
+ # Parse script
282
+ from unityflow.script_parser import parse_script_file
283
+
284
+ result = parse_script_file(script_path)
285
+ self._script_info_cache[script_guid] = result
286
+ return result
287
+
288
+ def _get_script_field_order(self, script_guid: str) -> list[str] | None:
289
+ """Get field order for a script by GUID (with caching).
290
+
291
+ Args:
292
+ script_guid: The GUID of the script
293
+
294
+ Returns:
295
+ List of field names in declaration order, or None if not found
296
+ """
297
+ if self.project_root is None:
298
+ return None
299
+
300
+ # Lazy initialize cache
301
+ if self._script_cache is None:
302
+ from unityflow.asset_tracker import build_guid_index
303
+ from unityflow.script_parser import ScriptFieldCache
304
+
305
+ # Build GUID index if not already done
306
+ if self._guid_index is None:
307
+ self._guid_index = build_guid_index(self.project_root)
308
+
309
+ self._script_cache = ScriptFieldCache(
310
+ guid_index=self._guid_index,
311
+ project_root=self.project_root,
312
+ )
313
+
314
+ return self._script_cache.get_field_order(script_guid)
315
+
316
+ def _sort_modifications(self, modification: dict[str, Any]) -> None:
317
+ """Sort m_Modifications array for deterministic order."""
318
+ if not isinstance(modification, dict):
319
+ return
320
+
321
+ # Sort m_Modifications array
322
+ mods = modification.get("m_Modifications")
323
+ if isinstance(mods, list) and mods:
324
+ sorted_mods = self._sort_modification_list(list(mods))
325
+ # Replace contents in place
326
+ mods.clear()
327
+ mods.extend(sorted_mods)
328
+
329
+ # Sort m_RemovedComponents
330
+ removed = modification.get("m_RemovedComponents")
331
+ if isinstance(removed, list) and removed:
332
+ sorted_removed = sorted(
333
+ removed,
334
+ key=lambda r: self._get_modification_sort_key(r, "target"),
335
+ )
336
+ removed.clear()
337
+ removed.extend(sorted_removed)
338
+
339
+ # Sort m_AddedComponents
340
+ added = modification.get("m_AddedComponents")
341
+ if isinstance(added, list) and added:
342
+ sorted_added = sorted(
343
+ added,
344
+ key=lambda a: self._get_modification_sort_key(a, "targetCorrespondingSourceObject"),
345
+ )
346
+ added.clear()
347
+ added.extend(sorted_added)
348
+
349
+ def _sort_modification_list(self, mods: list[dict[str, Any]]) -> list[dict[str, Any]]:
350
+ """Sort a list of modifications by target.fileID and propertyPath."""
351
+ return sorted(mods, key=self._modification_sort_key)
352
+
353
+ def _modification_sort_key(self, mod: dict[str, Any]) -> tuple[int, str]:
354
+ """Generate sort key for a modification entry."""
355
+ target = mod.get("target", {})
356
+ file_id = target.get("fileID", 0) if isinstance(target, dict) else 0
357
+ property_path = mod.get("propertyPath", "")
358
+ return (file_id, property_path)
359
+
360
+ def _get_modification_sort_key(self, item: dict[str, Any], target_key: str) -> tuple[int, str, int]:
361
+ """Generate sort key for removed/added component entries."""
362
+ target = item.get(target_key, {})
363
+ file_id = target.get("fileID", 0) if isinstance(target, dict) else 0
364
+ guid = target.get("guid", "") if isinstance(target, dict) else ""
365
+ ref_type = target.get("type", 0) if isinstance(target, dict) else 0
366
+ return (file_id, guid, ref_type)
367
+
368
+ def _sort_reference_array(self, arr: list[Any]) -> None:
369
+ """Sort an array of references by fileID for deterministic order.
370
+
371
+ Handles arrays like m_Component and m_Children which contain
372
+ references in the format: {component: {fileID: X}} or {fileID: X}
373
+ """
374
+
375
+ def get_sort_key(item: Any) -> int:
376
+ if isinstance(item, dict):
377
+ # Handle {component: {fileID: X}} format (m_Component)
378
+ if "component" in item:
379
+ ref = item["component"]
380
+ if isinstance(ref, dict):
381
+ return ref.get("fileID", 0)
382
+ # Handle {fileID: X} format (m_Children)
383
+ if "fileID" in item:
384
+ return item.get("fileID", 0)
385
+ return 0
386
+
387
+ # Sort in place
388
+ sorted_items = sorted(arr, key=get_sort_key)
389
+ arr.clear()
390
+ arr.extend(sorted_items)
391
+
392
+ def _normalize_value(self, value: Any, parent_key: str | None = None, property_path: str = "") -> Any:
393
+ """Recursively normalize a value."""
394
+ if isinstance(value, dict):
395
+ # Check if this is a quaternion
396
+ if parent_key in QUATERNION_PROPERTIES:
397
+ if self._is_quaternion_dict(value):
398
+ return self._normalize_quaternion_dict(value)
399
+
400
+ # Check if this is a vector/position that should use hex floats
401
+ if self.use_hex_floats:
402
+ if parent_key in FLOAT_PROPERTIES_HEX and self._is_vector_dict(value):
403
+ return self._normalize_vector_to_hex(value)
404
+
405
+ # Recursively normalize dict values
406
+ for key in value:
407
+ value[key] = self._normalize_value(
408
+ value[key],
409
+ parent_key=key,
410
+ property_path=f"{property_path}.{key}" if property_path else key,
411
+ )
412
+ return value
413
+
414
+ elif isinstance(value, list):
415
+ # Sort order-independent arrays (like m_Component, m_Children)
416
+ if parent_key in ORDER_INDEPENDENT_ARRAYS and value:
417
+ self._sort_reference_array(value)
418
+
419
+ # Recursively normalize list items
420
+ for i, item in enumerate(value):
421
+ value[i] = self._normalize_value(
422
+ item,
423
+ parent_key=parent_key,
424
+ property_path=f"{property_path}[{i}]",
425
+ )
426
+ return value
427
+
428
+ elif isinstance(value, float):
429
+ return self._normalize_float(value)
430
+
431
+ return value
432
+
433
+ def _is_quaternion_dict(self, d: dict) -> bool:
434
+ """Check if a dict represents a quaternion (has x, y, z, w keys)."""
435
+ return all(k in d for k in ("x", "y", "z", "w"))
436
+
437
+ def _is_vector_dict(self, d: dict) -> bool:
438
+ """Check if a dict represents a vector (has x, y, z or x, y keys)."""
439
+ keys = set(d.keys())
440
+ return keys == {"x", "y", "z"} or keys == {"x", "y"} or keys == {"x", "y", "z", "w"}
441
+
442
+ def _normalize_quaternion_dict(self, q: dict) -> dict:
443
+ """Normalize a quaternion dict to ensure w >= 0."""
444
+ x = float(q.get("x", 0))
445
+ y = float(q.get("y", 0))
446
+ z = float(q.get("z", 0))
447
+ w = float(q.get("w", 1))
448
+
449
+ # Negate all components if w < 0
450
+ if w < 0:
451
+ x, y, z, w = -x, -y, -z, -w
452
+
453
+ # Normalize to unit length
454
+ length = math.sqrt(x * x + y * y + z * z + w * w)
455
+ if length > 0:
456
+ x /= length
457
+ y /= length
458
+ z /= length
459
+ w /= length
460
+
461
+ # Update in place
462
+ if self.use_hex_floats:
463
+ q["x"] = self._float_to_hex(x)
464
+ q["y"] = self._float_to_hex(y)
465
+ q["z"] = self._float_to_hex(z)
466
+ q["w"] = self._float_to_hex(w)
467
+ else:
468
+ q["x"] = self._normalize_float(x)
469
+ q["y"] = self._normalize_float(y)
470
+ q["z"] = self._normalize_float(z)
471
+ q["w"] = self._normalize_float(w)
472
+
473
+ return q
474
+
475
+ def _normalize_vector_to_hex(self, v: dict) -> dict:
476
+ """Convert vector components to hex float format."""
477
+ for key in v:
478
+ if key in ("x", "y", "z", "w") and isinstance(v[key], (int, float)):
479
+ v[key] = self._float_to_hex(float(v[key]))
480
+ return v
481
+
482
+ def _normalize_float(self, value: float) -> float:
483
+ """Normalize a float value to consistent representation."""
484
+ # Handle special cases
485
+ if math.isnan(value):
486
+ return float("nan")
487
+ if math.isinf(value):
488
+ return float("inf") if value > 0 else float("-inf")
489
+
490
+ # Round to specified precision
491
+ rounded = round(value, self.float_precision)
492
+
493
+ # Avoid -0.0
494
+ if rounded == 0.0:
495
+ return 0.0
496
+
497
+ return rounded
498
+
499
+ def _float_to_hex(self, value: float) -> str:
500
+ """Convert a float to IEEE 754 hex representation."""
501
+ # Pack as 32-bit float, then unpack as unsigned int
502
+ packed = struct.pack(">f", value)
503
+ int_val = struct.unpack(">I", packed)[0]
504
+ return f"0x{int_val:08x}"
505
+
506
+ def _hex_to_float(self, hex_str: str) -> float:
507
+ """Convert IEEE 754 hex representation back to float."""
508
+ int_val = int(hex_str, 16)
509
+ packed = struct.pack(">I", int_val)
510
+ return struct.unpack(">f", packed)[0]
511
+
512
+
513
+ def normalize_prefab(
514
+ input_path: str | Path,
515
+ output_path: str | Path | None = None,
516
+ **kwargs,
517
+ ) -> str:
518
+ """Convenience function to normalize a prefab file.
519
+
520
+ Args:
521
+ input_path: Path to the input prefab file
522
+ output_path: Optional path to save the normalized file
523
+ **kwargs: Additional arguments passed to UnityPrefabNormalizer
524
+
525
+ Returns:
526
+ The normalized YAML content
527
+ """
528
+ normalizer = UnityPrefabNormalizer(**kwargs)
529
+ return normalizer.normalize_file(input_path, output_path)