unitytk 0.0.1__tar.gz

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.
unitytk-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 m3trik@live.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ include LICENSE
2
+ include docs/README.md
3
+ include pyproject.toml
4
+ recursive-include unitytk *.ui *.gif *.png *.json *.txt *.cs
unitytk-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: unitytk
3
+ Version: 0.0.1
4
+ Summary: A modular Python toolkit for Unity interaction.
5
+ Author-email: Ryan Simpson <m3trik@outlook.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/unitytk/unitytk
8
+ Project-URL: Repository, https://github.com/unitytk/unitytk
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: pythontk>=0.7.71
16
+ Dynamic: license-file
17
+
18
+ # unitytk
19
+
20
+ A modular Python toolkit for Unity interaction.
@@ -0,0 +1,3 @@
1
+ # unitytk
2
+
3
+ A modular Python toolkit for Unity interaction.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "unitytk"
7
+ dynamic = ["version", "readme"]
8
+ description = "A modular Python toolkit for Unity interaction."
9
+ authors = [{name = "Ryan Simpson", email = "m3trik@outlook.com"}]
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+ dependencies = ["pythontk>=0.7.71"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/unitytk/unitytk"
21
+ Repository = "https://github.com/unitytk/unitytk"
22
+
23
+ [tool.setuptools.dynamic]
24
+ version = {attr = "unitytk.__version__"}
25
+ readme = {file = "docs/README.md", content-type = "text/markdown"}
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["."]
29
+ include = ["unitytk*"]
30
+
31
+ [tool.setuptools.package-data]
32
+ "*" = ["*.ui", "*.json", "*.txt", "*.md", "*.cs"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,600 @@
1
+ # !/usr/bin/python
2
+ # coding=utf-8
3
+ """
4
+ Integration test: EventTriggers Maya module -> FBX -> Unity readback.
5
+
6
+ Validates the full pipeline using the EventTriggers module:
7
+ 1. Maya (mayapy): Create object, add event triggers via EventTriggers.create(),
8
+ key events with EventTriggers.set_key(), export FBX.
9
+ 2. Unity (batchmode): Import FBX, verify the event_trigger curve exists
10
+ and the event_manifest string property arrives intact.
11
+
12
+ Requires:
13
+ - Maya 2025 installed (mayapy.exe)
14
+ - Unity Hub with at least one editor installed
15
+ """
16
+ import os
17
+ import sys
18
+ import json
19
+ import shutil
20
+ import tempfile
21
+ import unittest
22
+ import subprocess
23
+ import logging
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ scripts_dir = r"O:\Cloud\Code\_scripts"
28
+ if scripts_dir not in sys.path:
29
+ sys.path.insert(0, scripts_dir)
30
+
31
+ from unitytk import UnityLauncher, UnityFinder
32
+
33
+ MAYAPY = r"C:\Program Files\Autodesk\Maya2025\bin\mayapy.exe"
34
+
35
+
36
+ def _maya_available() -> bool:
37
+ return os.path.exists(MAYAPY)
38
+
39
+
40
+ def _unity_available() -> bool:
41
+ return bool(UnityFinder.find_editors())
42
+
43
+
44
+ # ======================================================================
45
+ # Maya-side script — uses EventTriggers module
46
+ # ======================================================================
47
+
48
+ MAYA_EXPORT_SCRIPT = r'''
49
+ """Executed inside mayapy — sets up event triggers via the EventTriggers API."""
50
+ import sys, os, json
51
+
52
+ for pkg_dir in (r"O:\Cloud\Code\_scripts\mayatk", r"O:\Cloud\Code\_scripts\pythontk"):
53
+ if pkg_dir not in sys.path:
54
+ sys.path.insert(0, pkg_dir)
55
+
56
+ import maya.standalone
57
+ maya.standalone.initialize(name="python")
58
+ import maya.cmds as cmds
59
+ import pymel.core as pm
60
+
61
+ import mayatk
62
+ from mayatk import EventTriggers
63
+
64
+ output_dir = sys.argv[1]
65
+ fbx_path = os.path.join(output_dir, "audio_events_test.fbx")
66
+
67
+ cmds.file(new=True, force=True)
68
+ pm.playbackOptions(min=1, max=60)
69
+
70
+ cube = pm.polyCube(name="AudioCube")[0]
71
+ cube2 = pm.polyCube(name="VfxCube")[0]
72
+
73
+ # ================================================================
74
+ # Test ensure() — auto-detect create vs update
75
+ # ================================================================
76
+
77
+ # ensure() on a fresh object should create
78
+ result = EventTriggers.ensure(
79
+ objects=[cube],
80
+ category="audio",
81
+ events=["Footstep", "Jump", "Land"],
82
+ )
83
+ assert cube.hasAttr("audio_trigger"), "ensure(create): audio_trigger not created"
84
+ events = EventTriggers.get_events(cube, category="audio")
85
+ assert events == ["None", "Footstep", "Jump", "Land"], f"ensure(create) bad events: {events}"
86
+ print(f"[PASS] ensure(create): {events}")
87
+
88
+ # ensure() on an existing attr should append (not recreate)
89
+ result2 = EventTriggers.ensure(
90
+ objects=[cube],
91
+ category="audio",
92
+ events=["Slide"],
93
+ )
94
+ events2 = EventTriggers.get_events(cube, category="audio")
95
+ assert events2 == ["None", "Footstep", "Jump", "Land", "Slide"], f"ensure(update) bad events: {events2}"
96
+ print(f"[PASS] ensure(update): {events2}")
97
+
98
+ # ensure() with mixed objects (one new, one existing)
99
+ result3 = EventTriggers.ensure(
100
+ objects=[cube, cube2],
101
+ category="audio",
102
+ events=["Footstep", "Jump"],
103
+ )
104
+ assert cube2.hasAttr("audio_trigger"), "ensure(mixed): cube2 should have attr"
105
+ events_cube2 = EventTriggers.get_events(cube2, category="audio")
106
+ assert "Footstep" in events_cube2, f"ensure(mixed) cube2 missing Footstep: {events_cube2}"
107
+ print(f"[PASS] ensure(mixed): cube={EventTriggers.get_events(cube, category='audio')}, cube2={events_cube2}")
108
+
109
+ # ================================================================
110
+ # Test multi-category support
111
+ # ================================================================
112
+
113
+ EventTriggers.ensure(
114
+ objects=[cube],
115
+ category="vfx",
116
+ events=["Sparks", "Smoke"],
117
+ )
118
+ assert cube.hasAttr("vfx_trigger"), "vfx_trigger not created"
119
+ vfx_events = EventTriggers.get_events(cube, category="vfx")
120
+ assert vfx_events == ["None", "Sparks", "Smoke"], f"vfx bad events: {vfx_events}"
121
+ # audio_trigger should be unaffected
122
+ audio_events = EventTriggers.get_events(cube, category="audio")
123
+ assert "Footstep" in audio_events, f"audio events disturbed: {audio_events}"
124
+ print(f"[PASS] multi-category: audio={audio_events}, vfx={vfx_events}")
125
+
126
+ # ================================================================
127
+ # Key events on timeline (audio category on cube)
128
+ # ================================================================
129
+
130
+ EventTriggers.set_key(cube, event="Footstep", time=10, category="audio")
131
+ EventTriggers.set_key(cube, event="Jump", time=30, category="audio")
132
+ EventTriggers.set_key(cube, event="Land", time=45, category="audio")
133
+
134
+ key_count = pm.keyframe(cube, attribute="audio_trigger", q=True, keyframeCount=True)
135
+ assert key_count > 0, "No keyframes on audio_trigger"
136
+
137
+ val_at_10 = pm.keyframe(cube, attribute="audio_trigger", time=(10, 10), q=True, vc=True)
138
+ val_at_30 = pm.keyframe(cube, attribute="audio_trigger", time=(30, 30), q=True, vc=True)
139
+ val_at_45 = pm.keyframe(cube, attribute="audio_trigger", time=(45, 45), q=True, vc=True)
140
+ assert val_at_10 and val_at_10[0] == 1.0, f"Expected 1.0 at f10, got {val_at_10}"
141
+ assert val_at_30 and val_at_30[0] == 2.0, f"Expected 2.0 at f30, got {val_at_30}"
142
+ assert val_at_45 and val_at_45[0] == 3.0, f"Expected 3.0 at f45, got {val_at_45}"
143
+ print(f"[PASS] keyframes: f10={val_at_10[0]}, f30={val_at_30[0]}, f45={val_at_45[0]}")
144
+
145
+ # ================================================================
146
+ # Bake manifest (auto-bake already ran, but explicit call too)
147
+ # ================================================================
148
+
149
+ baked = EventTriggers.bake_manifest([cube], category="audio")
150
+ assert cube.hasAttr("audio_manifest"), "audio_manifest not created by bake"
151
+ manifest = cube.audio_manifest.get()
152
+ assert "10:Footstep" in manifest, f"Missing 10:Footstep in: {manifest}"
153
+ assert "30:Jump" in manifest, f"Missing 30:Jump in: {manifest}"
154
+ assert "45:Land" in manifest, f"Missing 45:Land in: {manifest}"
155
+ print(f"[PASS] baked manifest: {manifest}")
156
+
157
+ # VFX manifest (no keys yet — should be empty)
158
+ EventTriggers.bake_manifest([cube], category="vfx")
159
+ assert cube.hasAttr("vfx_manifest"), "vfx_manifest not created"
160
+ vfx_manifest = cube.vfx_manifest.get()
161
+ assert vfx_manifest == "", f"VFX manifest should be empty, got: {vfx_manifest}"
162
+ print(f"[PASS] vfx manifest (empty): '{vfx_manifest}'")
163
+
164
+ # Key a VFX event and re-bake
165
+ EventTriggers.set_key(cube, event="Sparks", time=15, category="vfx")
166
+ EventTriggers.bake_manifest([cube], category="vfx")
167
+ vfx_manifest = cube.vfx_manifest.get()
168
+ assert "15:Sparks" in vfx_manifest, f"Missing 15:Sparks in: {vfx_manifest}"
169
+ print(f"[PASS] vfx manifest after keying: {vfx_manifest}")
170
+
171
+ # ================================================================
172
+ # Test remove()
173
+ # ================================================================
174
+
175
+ # Remove VFX only — audio should survive
176
+ EventTriggers.remove([cube], category="vfx")
177
+ assert not cube.hasAttr("vfx_trigger"), "vfx_trigger should be removed"
178
+ assert not cube.hasAttr("vfx_manifest"), "vfx_manifest should be removed"
179
+ assert cube.hasAttr("audio_trigger"), "audio_trigger should survive vfx removal"
180
+ print(f"[PASS] remove(vfx): vfx gone, audio intact")
181
+
182
+ # Remove cube2 + all categories
183
+ EventTriggers.remove([cube2], category="*")
184
+ assert not cube2.hasAttr("audio_trigger"), "cube2 audio_trigger should be removed"
185
+ print(f"[PASS] remove(*, cube2): all triggers gone")
186
+
187
+ # ================================================================
188
+ # Carrier animation + FBX export
189
+ # ================================================================
190
+
191
+ pm.setKeyframe(cube, attribute="translateY", time=1, value=0)
192
+ pm.setKeyframe(cube, attribute="translateY", time=60, value=3)
193
+
194
+ pm.select(cube)
195
+ if not cmds.pluginInfo("fbxmaya", q=True, loaded=True):
196
+ cmds.loadPlugin("fbxmaya")
197
+ pm.mel.eval("FBXResetExport")
198
+ pm.mel.eval("FBXExportBakeComplexAnimation -v true")
199
+ pm.mel.eval("FBXExportBakeComplexStart -v 1")
200
+ pm.mel.eval("FBXExportBakeComplexEnd -v 60")
201
+ pm.mel.eval('FBXProperty "Export|AdvOptGrp|UI|ShowWarningsManager" -v 0')
202
+ fbx_mel = fbx_path.replace("\\", "/")
203
+ pm.mel.eval(f'FBXExport -f "{fbx_mel}" -s')
204
+
205
+ # ---- Collect results ----
206
+ all_keys = {}
207
+ for t, v in zip(
208
+ pm.keyframe(cube, at="audio_trigger", q=True, tc=True),
209
+ pm.keyframe(cube, at="audio_trigger", q=True, vc=True),
210
+ ):
211
+ all_keys[str(int(t))] = int(v)
212
+
213
+ results = {
214
+ "fbx_path": fbx_path,
215
+ "fbx_exists": os.path.exists(fbx_path),
216
+ "baked_manifest": manifest,
217
+ "events": list(EventTriggers.get_events(cube, category="audio")),
218
+ "keyframes": all_keys,
219
+ "ensure_tests_passed": True,
220
+ "multi_category_passed": True,
221
+ "remove_tests_passed": True,
222
+ }
223
+
224
+ with open(os.path.join(output_dir, "maya_results.json"), "w") as f:
225
+ json.dump(results, f, indent=2)
226
+
227
+ print("\n" + "=" * 60)
228
+ print("MAYA EXPORT COMPLETE — ALL TESTS PASSED")
229
+ print("=" * 60)
230
+ maya.standalone.uninitialize()
231
+ '''
232
+
233
+
234
+ # ======================================================================
235
+ # Unity-side C# scripts
236
+ # ======================================================================
237
+
238
+ UNITY_POSTPROCESSOR_CS = r"""
239
+ using UnityEngine;
240
+ using UnityEditor;
241
+ using System.IO;
242
+ using System.Collections.Generic;
243
+
244
+ public class AudioEventTestPostprocessor : AssetPostprocessor
245
+ {
246
+ const string RESULTS_DIR = @"__RESULTS_DIR__";
247
+
248
+ void OnPreprocessModel()
249
+ {
250
+ var importer = assetImporter as ModelImporter;
251
+ if (importer != null && !importer.importAnimatedCustomProperties)
252
+ importer.importAnimatedCustomProperties = true;
253
+ }
254
+
255
+ void OnPostprocessGameObjectWithUserProperties(
256
+ GameObject go, string[] propNames, object[] values)
257
+ {
258
+ var entries = new List<string>();
259
+ for (int i = 0; i < propNames.Length; i++)
260
+ {
261
+ string typeName = values[i] != null ? values[i].GetType().Name : "null";
262
+ string valStr = values[i] != null ? values[i].ToString() : "null";
263
+ entries.Add(" \"" + Escape(propNames[i]) + "\": {\"type\": \""
264
+ + typeName + "\", \"value\": \"" + Escape(valStr) + "\"}");
265
+ }
266
+
267
+ string outPath = Path.Combine(RESULTS_DIR, "postprocessor_results.json");
268
+ string json = "{\n \"gameObject\": \"" + go.name + "\",\n"
269
+ + string.Join(",\n", entries) + "\n}";
270
+ File.WriteAllText(outPath, json);
271
+
272
+ Debug.Log("[AudioEventTest] Captured " + propNames.Length
273
+ + " user properties on " + go.name);
274
+ }
275
+
276
+ static string Escape(string s)
277
+ {
278
+ return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n");
279
+ }
280
+ }
281
+ """
282
+
283
+ UNITY_VERIFIER_CS = r"""
284
+ using UnityEngine;
285
+ using UnityEditor;
286
+ using System.IO;
287
+ using System.Linq;
288
+ using System.Collections.Generic;
289
+
290
+ public class AudioEventTestVerifier
291
+ {
292
+ public static void Verify()
293
+ {
294
+ string resultsFile = @"__RESULTS_PATH__";
295
+ string fbxAssetPath = "Assets/audio_events_test.fbx";
296
+
297
+ // Reimport with custom properties enabled
298
+ var importer = AssetImporter.GetAtPath(fbxAssetPath) as ModelImporter;
299
+ if (importer != null)
300
+ {
301
+ importer.importAnimation = true;
302
+ importer.animationType = ModelImporterAnimationType.Generic;
303
+ importer.importAnimatedCustomProperties = true;
304
+ EditorUtility.SetDirty(importer);
305
+ AssetDatabase.WriteImportSettingsIfDirty(fbxAssetPath);
306
+ AssetDatabase.ImportAsset(fbxAssetPath,
307
+ ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
308
+ }
309
+ AssetDatabase.Refresh();
310
+
311
+ var results = new Dictionary<string, object>();
312
+
313
+ // Check animation curves
314
+ var clips = AssetDatabase.LoadAllAssetsAtPath(fbxAssetPath)
315
+ .OfType<AnimationClip>()
316
+ .Where(c => !c.name.StartsWith("__preview__"))
317
+ .ToArray();
318
+
319
+ bool hasAudioEventCurve = false;
320
+ int audioEventKeyCount = 0;
321
+ var allCurveNames = new List<string>();
322
+
323
+ if (clips.Length > 0)
324
+ {
325
+ var clip = clips[0];
326
+ var bindings = AnimationUtility.GetCurveBindings(clip);
327
+
328
+ foreach (var binding in bindings)
329
+ {
330
+ allCurveNames.Add(binding.propertyName);
331
+ if (binding.propertyName == "audio_trigger")
332
+ {
333
+ hasAudioEventCurve = true;
334
+ var curve = AnimationUtility.GetEditorCurve(clip, binding);
335
+ audioEventKeyCount = curve != null ? curve.length : 0;
336
+ }
337
+ }
338
+ }
339
+
340
+ results["clips_found"] = clips.Length;
341
+ results["has_audio_event_curve"] = hasAudioEventCurve;
342
+ results["audio_event_key_count"] = audioEventKeyCount;
343
+ results["all_curve_names"] = allCurveNames.ToArray();
344
+
345
+ // Read postprocessor results
346
+ string postprocPath = Path.Combine(
347
+ Path.GetDirectoryName(resultsFile), "postprocessor_results.json");
348
+ results["postprocessor_fired"] = File.Exists(postprocPath);
349
+
350
+ // Write results
351
+ File.WriteAllText(resultsFile, DictToJson(results));
352
+ Debug.Log("AUDIO EVENT TEST COMPLETE. Results at: " + resultsFile);
353
+
354
+ EditorApplication.Exit(0);
355
+ }
356
+
357
+ static string DictToJson(Dictionary<string, object> dict)
358
+ {
359
+ var parts = new List<string>();
360
+ foreach (var kv in dict)
361
+ {
362
+ string val;
363
+ if (kv.Value is string[] arr)
364
+ val = "[" + string.Join(",", System.Array.ConvertAll(arr,
365
+ s => "\"" + Escape(s) + "\"")) + "]";
366
+ else if (kv.Value is string s)
367
+ val = "\"" + Escape(s) + "\"";
368
+ else if (kv.Value is bool b)
369
+ val = b ? "true" : "false";
370
+ else if (kv.Value is int i)
371
+ val = i.ToString();
372
+ else
373
+ val = "\"" + Escape(kv.Value != null ? kv.Value.ToString() : "null") + "\"";
374
+ parts.Add("\"" + Escape(kv.Key) + "\": " + val);
375
+ }
376
+ return "{" + string.Join(",\n", parts) + "}";
377
+ }
378
+
379
+ static string Escape(string s)
380
+ {
381
+ return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n");
382
+ }
383
+ }
384
+ """
385
+
386
+
387
+ # ======================================================================
388
+ # Test class
389
+ # ======================================================================
390
+
391
+
392
+ class TestAudioEventsIntegration(unittest.TestCase):
393
+ """Full pipeline: EventTriggers module -> FBX -> Unity verification."""
394
+
395
+ @classmethod
396
+ def setUpClass(cls):
397
+ cls.temp_dir = tempfile.mkdtemp(prefix="audio_events_integ_")
398
+ cls.fbx_path = None
399
+ cls.maya_results = None
400
+ cls.unity_results = None
401
+ logger.info(f"Temp dir: {cls.temp_dir}")
402
+
403
+ @classmethod
404
+ def tearDownClass(cls):
405
+ logger.info(f"Results preserved in: {cls.temp_dir}")
406
+
407
+ # ------------------------------------------------------------------
408
+ # Step 1: Maya export using EventTriggers module
409
+ # ------------------------------------------------------------------
410
+
411
+ @unittest.skipUnless(_maya_available(), "Maya 2025 not installed")
412
+ def test_01_maya_export(self):
413
+ """Create event triggers via EventTriggers API, verify attrs, export FBX."""
414
+ script_path = os.path.join(self.temp_dir, "maya_export.py")
415
+ with open(script_path, "w", encoding="utf-8") as f:
416
+ f.write(MAYA_EXPORT_SCRIPT)
417
+
418
+ env = os.environ.copy()
419
+ env["PYTHONPATH"] = os.pathsep.join(
420
+ [
421
+ os.path.join(scripts_dir, "mayatk"),
422
+ os.path.join(scripts_dir, "pythontk"),
423
+ ]
424
+ )
425
+ env["PYTHONIOENCODING"] = "utf-8"
426
+
427
+ proc = subprocess.run(
428
+ [MAYAPY, script_path, self.temp_dir],
429
+ capture_output=True,
430
+ text=True,
431
+ timeout=120,
432
+ env=env,
433
+ )
434
+
435
+ if proc.stdout:
436
+ logger.info(f"MAYAPY stdout:\n{proc.stdout[-2000:]}")
437
+ if proc.stderr:
438
+ logger.warning(f"MAYAPY stderr:\n{proc.stderr[-2000:]}")
439
+
440
+ self.assertEqual(proc.returncode, 0, f"mayapy failed:\n{proc.stderr[-1000:]}")
441
+
442
+ results_path = os.path.join(self.temp_dir, "maya_results.json")
443
+ self.assertTrue(os.path.exists(results_path))
444
+
445
+ with open(results_path) as f:
446
+ self.__class__.maya_results = json.load(f)
447
+
448
+ r = self.__class__.maya_results
449
+ self.__class__.fbx_path = r["fbx_path"]
450
+ self.assertTrue(r["fbx_exists"])
451
+ self.assertIn("10:Footstep", r["baked_manifest"])
452
+ self.assertIn("30:Jump", r["baked_manifest"])
453
+ self.assertIn("45:Land", r["baked_manifest"])
454
+ self.assertEqual(r["keyframes"]["10"], 1) # Footstep
455
+ self.assertEqual(r["keyframes"]["30"], 2) # Jump
456
+ self.assertEqual(r["keyframes"]["45"], 3) # Land
457
+ self.assertTrue(r["ensure_tests_passed"])
458
+ self.assertTrue(r["multi_category_passed"])
459
+ self.assertTrue(r["remove_tests_passed"])
460
+
461
+ # ------------------------------------------------------------------
462
+ # Step 2: Unity import and readback
463
+ # ------------------------------------------------------------------
464
+
465
+ @unittest.skipUnless(_maya_available(), "Maya 2025 not installed")
466
+ @unittest.skipUnless(_unity_available(), "No Unity editor found")
467
+ def test_02_unity_import(self):
468
+ """Import FBX into Unity, verify curve and manifest survived."""
469
+ fbx_path = self.__class__.fbx_path
470
+ if not fbx_path or not os.path.exists(fbx_path):
471
+ self.skipTest("FBX not available — test_01 must run first")
472
+
473
+ launcher = UnityLauncher()
474
+ project_path = os.path.join(self.temp_dir, "UnityProject")
475
+
476
+ logger.info(f"Creating Unity project: {project_path}")
477
+ created = launcher.create_project(project_path, batch_mode=True)
478
+ self.assertTrue(created, "Failed to create Unity project")
479
+
480
+ assets_path = os.path.join(project_path, "Assets")
481
+ dest_fbx = os.path.join(assets_path, "audio_events_test.fbx")
482
+ shutil.copy2(fbx_path, dest_fbx)
483
+
484
+ editor_path = os.path.join(assets_path, "Editor")
485
+ os.makedirs(editor_path, exist_ok=True)
486
+
487
+ # Deploy postprocessor
488
+ results_dir_escaped = self.temp_dir.replace("\\", "\\\\")
489
+ postproc_cs = UNITY_POSTPROCESSOR_CS.replace(
490
+ "__RESULTS_DIR__", results_dir_escaped
491
+ )
492
+ with open(
493
+ os.path.join(editor_path, "AudioEventTestPostprocessor.cs"),
494
+ "w",
495
+ encoding="utf-8",
496
+ ) as f:
497
+ f.write(postproc_cs)
498
+
499
+ # Deploy verifier
500
+ results_path = os.path.join(self.temp_dir, "unity_results.json")
501
+ results_path_escaped = results_path.replace("\\", "\\\\")
502
+ verifier_cs = UNITY_VERIFIER_CS.replace(
503
+ "__RESULTS_PATH__", results_path_escaped
504
+ )
505
+ with open(
506
+ os.path.join(editor_path, "AudioEventTestVerifier.cs"),
507
+ "w",
508
+ encoding="utf-8",
509
+ ) as f:
510
+ f.write(verifier_cs)
511
+
512
+ log_path = os.path.join(self.temp_dir, "unity_log.txt")
513
+ launcher.project_path = project_path
514
+
515
+ proc = launcher.launch_editor(
516
+ batch_mode=True,
517
+ execute_method="AudioEventTestVerifier.Verify",
518
+ log_file=log_path,
519
+ extra_args=["-quit"],
520
+ detached=False,
521
+ )
522
+
523
+ if proc:
524
+ try:
525
+ proc.wait(timeout=300)
526
+ except subprocess.TimeoutExpired:
527
+ proc.kill()
528
+ raise TimeoutError("Unity timed out")
529
+
530
+ if os.path.exists(log_path):
531
+ with open(log_path) as f:
532
+ log_tail = f.read()[-3000:]
533
+ logger.info(f"Unity log tail:\n{log_tail}")
534
+
535
+ self.assertTrue(
536
+ os.path.exists(results_path),
537
+ f"unity_results.json not created — check {log_path}",
538
+ )
539
+ with open(results_path) as f:
540
+ self.__class__.unity_results = json.load(f)
541
+
542
+ logger.info(
543
+ f"Unity results:\n{json.dumps(self.__class__.unity_results, indent=2)}"
544
+ )
545
+
546
+ # ------------------------------------------------------------------
547
+ # Step 3: Analyze round-trip results
548
+ # ------------------------------------------------------------------
549
+
550
+ @unittest.skipUnless(_maya_available(), "Maya 2025 not installed")
551
+ @unittest.skipUnless(_unity_available(), "No Unity editor found")
552
+ def test_03_analyze(self):
553
+ """Verify the event_trigger float curve and manifest string survived."""
554
+ unity = self.__class__.unity_results
555
+ if not unity:
556
+ self.skipTest("Unity results not available")
557
+
558
+ # Read postprocessor output
559
+ postproc_path = os.path.join(self.temp_dir, "postprocessor_results.json")
560
+ postproc = {}
561
+ if os.path.exists(postproc_path):
562
+ with open(postproc_path) as f:
563
+ postproc = json.load(f)
564
+
565
+ # ---- Verdicts ----
566
+ print("\n" + "=" * 60)
567
+ print(" EVENT TRIGGERS INTEGRATION TEST RESULTS")
568
+ print("=" * 60)
569
+
570
+ # Baked manifest survived as user property?
571
+ has_manifest = "audio_manifest" in postproc
572
+ manifest_value = postproc.get("audio_manifest", {}).get("value", "")
573
+ print(f"\n audio_manifest: {'PASS' if has_manifest else 'FAIL'}")
574
+ if has_manifest:
575
+ print(f" Value: {manifest_value}")
576
+
577
+ # Postprocessor fired?
578
+ print(
579
+ f" Postprocessor fired: {'PASS' if unity.get('postprocessor_fired') else 'FAIL'}"
580
+ )
581
+
582
+ # All curves found (may include legacy float curve or enum curve)
583
+ print(f" All curves: {unity.get('all_curve_names', [])}")
584
+
585
+ print("=" * 60)
586
+ print(f" Temp dir: {self.temp_dir}")
587
+ print("=" * 60 + "\n")
588
+
589
+ # Assertions — the baked manifest string is the sole transport now
590
+ self.assertTrue(
591
+ has_manifest, "audio_manifest string did not arrive as user property"
592
+ )
593
+ self.assertIn("Footstep", manifest_value)
594
+ self.assertIn("Jump", manifest_value)
595
+ self.assertIn("Land", manifest_value)
596
+
597
+
598
+ if __name__ == "__main__":
599
+ logging.basicConfig(level=logging.INFO)
600
+ unittest.main()