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/normalizer.py
ADDED
|
@@ -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)
|