unitytk 0.2.0__tar.gz → 0.2.2__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.
Files changed (98) hide show
  1. {unitytk-0.2.0/unitytk.egg-info → unitytk-0.2.2}/PKG-INFO +1 -1
  2. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_asset_delivery.py +51 -8
  3. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_audio_events_integration.py +167 -16
  4. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_embedded_dependencies_integration.py +183 -1
  5. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_run_tests.py +5 -2
  6. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/__init__.py +1 -1
  7. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/asset_delivery.py +20 -4
  8. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulationRecord.g.cs +5 -2
  9. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/AudioEvent/AudioEventController.cs +48 -19
  10. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/README.md +1 -1
  11. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/UnitytkSettings.cs +60 -8
  12. {unitytk-0.2.0 → unitytk-0.2.2/unitytk.egg-info}/PKG-INFO +1 -1
  13. {unitytk-0.2.0 → unitytk-0.2.2}/LICENSE +0 -0
  14. {unitytk-0.2.0 → unitytk-0.2.2}/MANIFEST.in +0 -0
  15. {unitytk-0.2.0 → unitytk-0.2.2}/docs/README.md +0 -0
  16. {unitytk-0.2.0 → unitytk-0.2.2}/pyproject.toml +0 -0
  17. {unitytk-0.2.0 → unitytk-0.2.2}/setup.cfg +0 -0
  18. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_articulated_rig_runtime.py +0 -0
  19. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_base_test.py +0 -0
  20. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_blender_render_effects_e2e.py +0 -0
  21. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_blender_shadow_plane_integration.py +0 -0
  22. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_blender_shot_metadata_integration.py +0 -0
  23. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_bool_to_fade_curve.py +0 -0
  24. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_clip_name_contract_integration.py +0 -0
  25. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_controller_runtime_integration.py +0 -0
  26. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_deploy_templates.py +0 -0
  27. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_emissive_groups_integration.py +0 -0
  28. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_fade_window_preservation.py +0 -0
  29. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_keyed_visibility.py +0 -0
  30. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_launcher.py +0 -0
  31. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_lightmap_metadata_integration.py +0 -0
  32. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_maya_surface_materials_integration.py +0 -0
  33. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_multi_section_audio_integration.py +0 -0
  34. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_render_effects_e2e.py +0 -0
  35. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_render_opacity_controller.py +0 -0
  36. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_render_opacity_e2e.py +0 -0
  37. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_render_opacity_integration.py +0 -0
  38. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_scene_builder.py +0 -0
  39. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_shadow_plane_integration.py +0 -0
  40. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_shadow_plane_runtime_integration.py +0 -0
  41. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_shared_material_standalone.py +0 -0
  42. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_shot_metadata_integration.py +0 -0
  43. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_shots_audio_sidebyside_integration.py +0 -0
  44. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_standalone_enduser.py +0 -0
  45. {unitytk-0.2.0 → unitytk-0.2.2}/test/test_temp_projects.py +0 -0
  46. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/launcher.py +0 -0
  47. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/scene_builder.py +0 -0
  48. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/template_deployer.py +0 -0
  49. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulatedRigController.cs +0 -0
  50. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulatedRigController.cs.meta +0 -0
  51. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulatedRigPointerGrab.cs +0 -0
  52. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulatedRigPointerGrab.cs.meta +0 -0
  53. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulationRecord.g.cs.meta +0 -0
  54. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRGrab.cs +0 -0
  55. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRGrab.cs.meta +0 -0
  56. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRSetup.cs +0 -0
  57. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRSetup.cs.meta +0 -0
  58. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/M3trik.Unitytk.XR.asmdef +0 -0
  59. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/M3trik.Unitytk.XR.asmdef.meta +0 -0
  60. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR.meta +0 -0
  61. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig.meta +0 -0
  62. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/AudioEvent/AudioEventController.cs.meta +0 -0
  63. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/AudioEvent.meta +0 -0
  64. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroupController.cs +0 -0
  65. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroupController.cs.meta +0 -0
  66. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroups.hlsl +0 -0
  67. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroups.hlsl.meta +0 -0
  68. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroupsLit.shader +0 -0
  69. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroupsLit.shader.meta +0 -0
  70. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup.meta +0 -0
  71. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/LightmapMetadata/LightmapMetadataController.cs +0 -0
  72. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/LightmapMetadata/LightmapMetadataController.cs.meta +0 -0
  73. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/LightmapMetadata.meta +0 -0
  74. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/M3trik.Unitytk.asmdef +0 -0
  75. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/M3trik.Unitytk.asmdef.meta +0 -0
  76. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/MayaSurface/MayaSurfaceMaterialImporter.cs +0 -0
  77. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/MayaSurface/MayaSurfaceMaterialImporter.cs.meta +0 -0
  78. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/MayaSurface.meta +0 -0
  79. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/README.md.meta +0 -0
  80. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/RenderEffects/RenderEffectsController.cs +0 -0
  81. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/RenderEffects/RenderEffectsController.cs.meta +0 -0
  82. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/RenderEffects.meta +0 -0
  83. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlane.shader +0 -0
  84. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlane.shader.meta +0 -0
  85. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlaneController.cs +0 -0
  86. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlaneController.cs.meta +0 -0
  87. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlaneHorizon.hlsl +0 -0
  88. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlaneHorizon.hlsl.meta +0 -0
  89. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ShadowPlane.meta +0 -0
  90. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ShotMetadata/ShotMetadataController.cs +0 -0
  91. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ShotMetadata/ShotMetadataController.cs.meta +0 -0
  92. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/ShotMetadata.meta +0 -0
  93. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/UnitytkSettings.cs.meta +0 -0
  94. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk/templates/__init__.py +0 -0
  95. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk.egg-info/SOURCES.txt +0 -0
  96. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk.egg-info/dependency_links.txt +0 -0
  97. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk.egg-info/requires.txt +0 -0
  98. {unitytk-0.2.0 → unitytk-0.2.2}/unitytk.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitytk
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A modular Python toolkit for Unity interaction.
5
5
  Author-email: Ryan Simpson <m3trik@outlook.com>
6
6
  License: MIT
@@ -1,6 +1,7 @@
1
1
  # !/usr/bin/python
2
2
  # coding=utf-8
3
3
  """Tests for unitytk.CopyToAssetsDeliverer -- copy-to-Assets hand-off Strategy (no DCC)."""
4
+
4
5
  import json
5
6
  import os
6
7
  import struct
@@ -20,6 +21,7 @@ class _Logger:
20
21
  self.errors = []
21
22
  self.warnings = []
22
23
  self.infos = []
24
+ self.debugs = []
23
25
 
24
26
  def error(self, m, *args):
25
27
  self.errors.append(m % args if args else m)
@@ -30,6 +32,9 @@ class _Logger:
30
32
  def info(self, m, *args):
31
33
  self.infos.append(m % args if args else m)
32
34
 
35
+ def debug(self, m, *args):
36
+ self.debugs.append(m % args if args else m)
37
+
33
38
 
34
39
  def _carrier_fbx(path: str, channels) -> str:
35
40
  """A binary FBX (7700 layout) whose ``data_export`` carrier holds
@@ -108,7 +113,9 @@ def _payload(fbx, default_name="Export"):
108
113
 
109
114
  class SanitizeTest(unittest.TestCase):
110
115
  def test_strips_invalid_and_paths(self):
111
- self.assertEqual(CopyToAssetsDeliverer.sanitize_asset_name("my/model:v2"), "my_model_v2")
116
+ self.assertEqual(
117
+ CopyToAssetsDeliverer.sanitize_asset_name("my/model:v2"), "my_model_v2"
118
+ )
112
119
 
113
120
  def test_collapses_and_trims(self):
114
121
  self.assertEqual(CopyToAssetsDeliverer.sanitize_asset_name(" a b "), "a b")
@@ -160,6 +167,31 @@ class DeliveryTest(unittest.TestCase):
160
167
  self.assertEqual(open(dest, "rb").read(), b"FBX-BYTES")
161
168
  self.assertFalse(result["launched"])
162
169
 
170
+ def test_a_copy_cut_short_never_replaces_the_asset_unity_reads(self):
171
+ """The copy lands whole or not at all (``ptk.FileUtils.atomic_write``):
172
+ a copy into the project cut short (a full disk, a sync client holding
173
+ the file) left Unity a truncated FBX under the asset's own name.
174
+ Added: 2026-10-13"""
175
+ from unittest import mock
176
+
177
+ import unitytk.asset_delivery as delivery
178
+
179
+ dest = os.path.join(self.project, "Assets", "Models", "Robot.fbx")
180
+ os.makedirs(os.path.dirname(dest))
181
+ with open(dest, "wb") as fh:
182
+ fh.write(b"OLD")
183
+
184
+ def cut_short(src, dst, *args, **kwargs):
185
+ with open(dst, "wb") as fh:
186
+ fh.write(b"HALF")
187
+ raise OSError(28, "No space left on device")
188
+
189
+ with mock.patch.object(delivery.shutil, "copy2", side_effect=cut_short):
190
+ self._deliver({"ASSETS_SUBDIR": "Models"}, default_name="Robot")
191
+ with open(dest, "rb") as fh:
192
+ self.assertEqual(fh.read(), b"OLD")
193
+ self.assertEqual(os.listdir(os.path.dirname(dest)), ["Robot.fbx"])
194
+
163
195
  def test_deliver_uses_default_subdir_and_asset_name(self):
164
196
  _bridge, result = self._deliver({}, default_name="Cube")
165
197
  self.assertEqual(
@@ -168,7 +200,9 @@ class DeliveryTest(unittest.TestCase):
168
200
  )
169
201
 
170
202
  def test_explicit_asset_name_overrides_and_sanitizes(self):
171
- _bridge, result = self._deliver({"ASSET_NAME": "Hero/Mesh:01"}, default_name="Cube")
203
+ _bridge, result = self._deliver(
204
+ {"ASSET_NAME": "Hero/Mesh:01"}, default_name="Cube"
205
+ )
172
206
  self.assertEqual(os.path.basename(result["asset"]), "Hero_Mesh_01.fbx")
173
207
 
174
208
  def test_launch_mode_open_invokes_windowed_launch(self):
@@ -257,6 +291,7 @@ class DeliveryTest(unittest.TestCase):
257
291
  orig = ad.CopyToAssetsDeliverer._launch_editor
258
292
  # Patch UnityLauncher inside the real hook (deferred import target).
259
293
  import unitytk.launcher as launcher_mod
294
+
260
295
  real = launcher_mod.UnityLauncher
261
296
  launcher_mod.UnityLauncher = _FakeLauncher
262
297
  try:
@@ -319,15 +354,14 @@ class DeliveryTest(unittest.TestCase):
319
354
  )
320
355
  # Genuinely under Assets/ (no drive escape).
321
356
  assets = os.path.realpath(os.path.join(self.project, "Assets"))
322
- self.assertTrue(
323
- os.path.realpath(dest).startswith(assets), f"raw={raw!r}"
324
- )
357
+ self.assertTrue(os.path.realpath(dest).startswith(assets), f"raw={raw!r}")
325
358
 
326
359
  def test_explicit_subdir_reducing_to_nothing_drops_into_assets_root(self):
327
360
  for raw in (".", "/", "../.."):
328
361
  _bridge, result = self._deliver({"ASSETS_SUBDIR": raw}, default_name="Cube")
329
362
  self.assertEqual(
330
- result["asset"], os.path.join(self.project, "Assets", "Cube.fbx"),
363
+ result["asset"],
364
+ os.path.join(self.project, "Assets", "Cube.fbx"),
331
365
  f"raw={raw!r}",
332
366
  )
333
367
  self.assertEqual(result["subdir"], "")
@@ -363,7 +397,11 @@ class SelfContainedDeliveryTest(unittest.TestCase):
363
397
  "lightmap_metadata": {
364
398
  "version": 1,
365
399
  "objects": [{"name": "Crate", "map": "Crate_Lightmap.exr", "uvIndex": 1}],
366
- "probe": {"map": "Room_Probe.exr", "position": [0, 0, 0], "unit_scale": 0.01},
400
+ "probe": {
401
+ "map": "Room_Probe.exr",
402
+ "position": [0, 0, 0],
403
+ "unit_scale": 0.01,
404
+ },
367
405
  },
368
406
  "shadow_metadata": {
369
407
  "version": 2,
@@ -386,7 +424,12 @@ class SelfContainedDeliveryTest(unittest.TestCase):
386
424
  self.maps = os.path.join(self.tmp, "sourceimages")
387
425
  os.makedirs(self.maps)
388
426
  self.bytes = {}
389
- for name in ("Crate_Lightmap.exr", "Room_Probe.exr", "Crate_shadow.png", "Crate_horizon.png"):
427
+ for name in (
428
+ "Crate_Lightmap.exr",
429
+ "Room_Probe.exr",
430
+ "Crate_shadow.png",
431
+ "Crate_horizon.png",
432
+ ):
390
433
  self.bytes[name] = name.encode() * 8
391
434
  with open(os.path.join(self.maps, name), "wb") as fh:
392
435
  fh.write(self.bytes[name])
@@ -24,6 +24,7 @@ Requires:
24
24
  - Maya 2025 installed (mayapy.exe)
25
25
  - Unity Hub with at least one editor installed
26
26
  """
27
+
27
28
  import os
28
29
  import json
29
30
  import shutil
@@ -34,7 +35,15 @@ import logging
34
35
  logger = logging.getLogger(__name__)
35
36
 
36
37
  import base_test # noqa: F401 — sys.path bootstrap for the sibling repos
37
- from base_test import EnvSkip, MayaEnv, TempProjects, UnityEnv, UserPropsProbe
38
+ from base_test import (
39
+ BlenderEnv,
40
+ CarrierFbx,
41
+ EnvSkip,
42
+ MayaEnv,
43
+ TempProjects,
44
+ UnityEnv,
45
+ UserPropsProbe,
46
+ )
38
47
 
39
48
  scripts_dir = base_test.SCRIPTS_ROOT
40
49
 
@@ -471,9 +480,7 @@ class TestAudioEventsIntegration(unittest.TestCase):
471
480
  events,
472
481
  {("", 9, "footstep"), ("", 29, "jump"), ("", 44, "land")},
473
482
  )
474
- self.assertEqual(
475
- sorted(r["tracks"]), ["footstep", "jump", "land"]
476
- )
483
+ self.assertEqual(sorted(r["tracks"]), ["footstep", "jump", "land"])
477
484
 
478
485
  # ------------------------------------------------------------------
479
486
  # Step 2: Unity import and readback
@@ -552,17 +559,26 @@ class TestAudioEventsIntegration(unittest.TestCase):
552
559
  full_log = f.read()
553
560
  # Surface AudioEvent / error / warning lines for analysis
554
561
  relevant = [
555
- ln for ln in full_log.splitlines()
556
- if any(s in ln for s in (
557
- "AudioEvent", "ERROR", "Error", "Warning",
558
- "NullReferenceException", "Failed",
559
- ))
562
+ ln
563
+ for ln in full_log.splitlines()
564
+ if any(
565
+ s in ln
566
+ for s in (
567
+ "AudioEvent",
568
+ "ERROR",
569
+ "Error",
570
+ "Warning",
571
+ "NullReferenceException",
572
+ "Failed",
573
+ )
574
+ )
560
575
  ]
561
576
  self.__class__.unity_log_relevant = relevant
562
577
  if relevant:
563
578
  logger.info(
564
579
  "Unity log relevant lines (%d):\n %s",
565
- len(relevant), "\n ".join(relevant[-40:])
580
+ len(relevant),
581
+ "\n ".join(relevant[-40:]),
566
582
  )
567
583
  else:
568
584
  logger.info("Unity log: no AudioEvent/Warning/Error lines.")
@@ -571,8 +587,7 @@ class TestAudioEventsIntegration(unittest.TestCase):
571
587
  with open(results_path) as f:
572
588
  self.__class__.unity_results = json.load(f)
573
589
 
574
- postproc_path = os.path.join(
575
- self.temp_dir, UserPropsProbe.RESULTS_FILENAME)
590
+ postproc_path = os.path.join(self.temp_dir, UserPropsProbe.RESULTS_FILENAME)
576
591
  if os.path.exists(postproc_path):
577
592
  with open(postproc_path) as f:
578
593
  self.__class__.postproc = json.load(f)
@@ -608,7 +623,8 @@ class TestAudioEventsIntegration(unittest.TestCase):
608
623
  f"in postprocessor results: {list(postproc)}",
609
624
  )
610
625
  self.assertIn(
611
- "audio_manifest", props,
626
+ "audio_manifest",
627
+ props,
612
628
  f"audio_manifest user-property missing on {name}; saw: {list(props)}",
613
629
  )
614
630
  manifest = props["audio_manifest"]
@@ -655,7 +671,8 @@ class TestAudioEventsIntegration(unittest.TestCase):
655
671
 
656
672
  # (d) AnimationEvents injected on the imported clip
657
673
  self.assertGreaterEqual(
658
- unity.get("clip_count", 0), 1,
674
+ unity.get("clip_count", 0),
675
+ 1,
659
676
  "Expected at least one AnimationClip in the imported FBX",
660
677
  )
661
678
  events = unity.get("animation_events", [])
@@ -676,8 +693,8 @@ class TestAudioEventsIntegration(unittest.TestCase):
676
693
  # In Unity-frame terms the events are at clip-relative frames 9,
677
694
  # 29, 44 — which is what AnimationEvent.time * frameRate yields.
678
695
  self.assertEqual(events_by_name.get("footstep"), 9)
679
- self.assertEqual(events_by_name.get("jump"), 29)
680
- self.assertEqual(events_by_name.get("land"), 44)
696
+ self.assertEqual(events_by_name.get("jump"), 29)
697
+ self.assertEqual(events_by_name.get("land"), 44)
681
698
 
682
699
  # (e) Reimport idempotency. Two probes injected before the
683
700
  # reimport tell us about both correctness and Unity behavior:
@@ -721,6 +738,140 @@ class TestAudioEventsIntegration(unittest.TestCase):
721
738
  )
722
739
 
723
740
 
741
+ # ======================================================================
742
+ # Carrier precedence: one audio_manifest per model, the scene's own first
743
+ # ======================================================================
744
+
745
+ CARRIER_VERIFIER_CS = r"""
746
+ using UnityEngine;
747
+ using UnityEditor;
748
+ using System;
749
+ using System.IO;
750
+ using System.Linq;
751
+ using System.Collections.Generic;
752
+
753
+ public class AudioCarrierVerifier
754
+ {
755
+ static string Q(string s) { return "\"" + (s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; }
756
+
757
+ public static void Verify()
758
+ {
759
+ var o = new List<string>();
760
+ try
761
+ {
762
+ // Again, now the clips are in the database: the startup import
763
+ // may have met the model before the audio.
764
+ AssetDatabase.ImportAsset("__FBX__",
765
+ ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
766
+ AssetDatabase.Refresh();
767
+ var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("__FBX__");
768
+ o.Add("\"prefab\":" + (prefab != null ? "true" : "false"));
769
+ var nodes = prefab != null
770
+ ? prefab.GetComponentsInChildren<Transform>(true).Select(t => Q(t.name))
771
+ : Enumerable.Empty<string>();
772
+ o.Add("\"nodes\":[" + string.Join(",", nodes) + "]");
773
+ var ctrl = prefab != null ? prefab.GetComponent<AudioEventController>() : null;
774
+ o.Add("\"controller\":" + (ctrl != null ? "true" : "false"));
775
+ var clips = ctrl != null ? ctrl.clips : new List<AudioClip>();
776
+ o.Add("\"clips\":[" + string.Join(",", clips.Select(c => Q(c != null ? c.name : ""))) + "]");
777
+ }
778
+ catch (Exception e) { o.Add("\"error\":" + Q(e.GetType().Name + ": " + e.Message)); }
779
+ File.WriteAllText(@"__RESULTS__", "{" + string.Join(",\n", o) + "}");
780
+ EditorApplication.Exit(0);
781
+ }
782
+ }
783
+ """
784
+
785
+
786
+ class TestAudioCarrierPrecedence(unittest.TestCase):
787
+ """An FBX carrying ``audio_manifest`` on several carriers plays the
788
+ scene's own only.
789
+
790
+ An assembly ships a stray ``data_export1`` (and a module's
791
+ ``NS:data_export``) beside the scene's own, each holding the events as
792
+ last exported alone -- measured on an assembly, 2026-10-13. The scene's
793
+ own record is re-derived from the whole merged scene, so every reader
794
+ takes ONE carrier per channel, the scene's own first: the other
795
+ importers (``CarrierImport.Collect``), the GLB applier, and pythontk's
796
+ ``FbxMedia.audio_events``. The audio importer concatenated every
797
+ carrier's events instead, so the stray's stale event was bound and fired.
798
+
799
+ The fixture is a real FBX with both carriers (``base_test.CarrierFbx``,
800
+ written by a fresh Blender); pythontk's ``FbxMedia.audio_events`` pins the
801
+ same rule on the same shape (``test_fbx_media``).
802
+ """
803
+
804
+ FBX = "Assets/Delivered/assembly.fbx"
805
+
806
+ @classmethod
807
+ def setUpClass(cls):
808
+ cls.temp_dir = TempProjects.allocate("audio_carrier_precedence")
809
+
810
+ @BlenderEnv.skip_unless_available()
811
+ @EnvSkip.unless(_unity_available(), "no Unity editor found")
812
+ def test_the_scenes_own_carrier_wins(self):
813
+ own = {"version": 2, "events": [{"clip": "", "frame": 0, "name": "footstep"}]}
814
+ stray = {"version": 2, "events": [{"clip": "", "frame": 3, "name": "door"}]}
815
+ fixture = CarrierFbx.build(
816
+ os.path.join(self.temp_dir, "assembly.fbx"),
817
+ {
818
+ "data_export": {"audio_manifest": own},
819
+ "data_export1": {"audio_manifest": stray},
820
+ },
821
+ )
822
+
823
+ launcher = UnityLauncher()
824
+ project = os.path.join(self.temp_dir, "UnityProject")
825
+ if not launcher.create_project(project, batch_mode=True):
826
+ self.skipTest(UnityEnv.project_creation_reason())
827
+ delivered = os.path.join(project, "Assets", "Delivered")
828
+ os.makedirs(delivered, exist_ok=True)
829
+ shutil.copy2(fixture, os.path.join(delivered, "assembly.fbx"))
830
+ # Both tracks resolve, so only the carrier rule decides what binds.
831
+ for name in ("footstep", "door"):
832
+ CarrierFbx.wav(
833
+ os.path.join(project, "Assets", "Audio", f"{name}.wav"), name.encode()
834
+ )
835
+ TemplateDeployer.deploy_package(project, components=["audio_event"])
836
+ results = os.path.join(self.temp_dir, "unity_results.json")
837
+ editor_dir = os.path.join(project, "Assets", "Editor")
838
+ os.makedirs(editor_dir, exist_ok=True)
839
+ with open(
840
+ os.path.join(editor_dir, "AudioCarrierVerifier.cs"), "w", encoding="utf-8"
841
+ ) as fh:
842
+ fh.write(
843
+ CARRIER_VERIFIER_CS.replace("__FBX__", self.FBX).replace(
844
+ "__RESULTS__", results
845
+ )
846
+ )
847
+ log = os.path.join(self.temp_dir, "unity_log.txt")
848
+ launcher.project_path = project
849
+ proc = launcher.launch_editor(
850
+ batch_mode=True,
851
+ execute_method="AudioCarrierVerifier.Verify",
852
+ log_file=log,
853
+ extra_args=["-quit"],
854
+ detached=False,
855
+ )
856
+ returncode = None
857
+ if proc:
858
+ try:
859
+ returncode = proc.wait(timeout=900)
860
+ except subprocess.TimeoutExpired:
861
+ proc.kill()
862
+ raise TimeoutError("Unity timed out")
863
+ UnityEnv.require_results(self, results, returncode, log)
864
+ with open(results) as fh:
865
+ unity = json.load(fh)
866
+ logger.info(f"Unity results:\n{json.dumps(unity, indent=2)}")
867
+ self.assertNotIn("error", unity, unity.get("error"))
868
+ self.assertTrue(unity["prefab"], unity)
869
+ # Premise: both carriers reached the import.
870
+ self.assertTrue({"data_export", "data_export1"} <= set(unity["nodes"]), unity)
871
+ self.assertTrue(unity["controller"], unity)
872
+ self.assertEqual(unity["clips"], ["footstep"], unity)
873
+
874
+
724
875
  if __name__ == "__main__":
725
876
  logging.basicConfig(level=logging.INFO)
726
877
  unittest.main()
@@ -38,7 +38,7 @@ import subprocess
38
38
  import unittest
39
39
 
40
40
  import base_test # noqa: F401 -- sys.path bootstrap for the sibling repos
41
- from base_test import EnvSkip, MayaEnv, TempProjects, UnityEnv
41
+ from base_test import BlenderEnv, CarrierFbx, EnvSkip, MayaEnv, TempProjects, UnityEnv
42
42
 
43
43
  from unitytk import TemplateDeployer, UnityFinder, UnityLauncher
44
44
 
@@ -456,6 +456,188 @@ class TestEmbeddedDependenciesIntegration(unittest.TestCase):
456
456
  self.assertTrue(unity["reexport_still_lightmap"], unity)
457
457
 
458
458
 
459
+ # ======================================================================
460
+ # When an import asks for the FBX's embedded files to be extracted
461
+ # ======================================================================
462
+
463
+ REQUEST_VERIFIER_CS = r"""
464
+ using UnityEngine;
465
+ using UnityEditor;
466
+ using System;
467
+ using System.IO;
468
+ using System.Linq;
469
+ using System.Collections.Generic;
470
+
471
+ public class EmbeddedRequestVerifier
472
+ {
473
+ static string Q(string s) { return "\"" + (s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; }
474
+ static string B(bool b) { return b ? "true" : "false"; }
475
+
476
+ static void Finish(List<string> o)
477
+ {
478
+ File.WriteAllText(@"__RESULTS__", "{" + string.Join(",\n", o) + "}");
479
+ EditorApplication.Exit(0);
480
+ }
481
+
482
+ // Session 1: the audio is in the database; the models arrive now.
483
+ public static void First()
484
+ {
485
+ var o = new List<string>();
486
+ try
487
+ {
488
+ foreach (var pair in new[] {
489
+ new[] { @"__RESOLVED_SRC__", "Assets/Resolved/resolved.fbx" },
490
+ new[] { @"__GHOSTED_SRC__", "Assets/Ghosted/ghosted.fbx" } })
491
+ {
492
+ Directory.CreateDirectory(Path.GetDirectoryName(pair[1]));
493
+ File.Copy(pair[0], pair[1], true);
494
+ }
495
+ AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
496
+ o.Add("\"resolved_beside\":" + B(File.Exists("Assets/Resolved/footstep.wav")));
497
+ var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Resolved/resolved.fbx");
498
+ var ctrl = prefab != null ? prefab.GetComponent<AudioEventController>() : null;
499
+ var clip = ctrl != null && ctrl.clips.Count > 0 ? ctrl.clips[0] : null;
500
+ o.Add("\"resolved_clip\":" + Q(clip != null ? AssetDatabase.GetAssetPath(clip) : ""));
501
+ o.Add("\"hit_beside\":" + B(File.Exists("Assets/Ghosted/hit.wav")));
502
+ }
503
+ catch (Exception e) { o.Add("\"error\":" + Q(e.GetType().Name + ": " + e.Message)); }
504
+ Finish(o);
505
+ }
506
+
507
+ // Session 2: hit.wav was touched up beside the model; the model reimports.
508
+ public static void Second()
509
+ {
510
+ var o = new List<string>();
511
+ try
512
+ {
513
+ AssetDatabase.ImportAsset("Assets/Ghosted/ghosted.fbx",
514
+ ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
515
+ AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
516
+ o.Add("\"touched_kept\":" + B(File.ReadAllBytes("Assets/Ghosted/hit.wav")
517
+ .SequenceEqual(File.ReadAllBytes(@"__TOUCHED__"))));
518
+ }
519
+ catch (Exception e) { o.Add("\"error\":" + Q(e.GetType().Name + ": " + e.Message)); }
520
+ Finish(o);
521
+ }
522
+ }
523
+ """
524
+
525
+
526
+ class TestEmbeddedRequestPolicy(unittest.TestCase):
527
+ """When an import asks for the FBX's embedded files to be extracted
528
+ beside it (``CarrierImport.RequestEmbeddedFiles``).
529
+
530
+ Two ways it overwrote what the user had:
531
+
532
+ - a name its importer finds ELSEWHERE (the audio lookup searches
533
+ ``Assets/Audio``) counted as missing, because only the model's own folder
534
+ was looked in -- so the whole FBX was extracted and the clip rebound to
535
+ the copy beside the model;
536
+ - the memo of what was already asked lived for one editor session, so a
537
+ model naming a file it does not embed asked again on every session's
538
+ reimport, and each extraction overwrote the files beside it -- a
539
+ touched-up clip included.
540
+
541
+ The fixture is two real FBXs (``base_test.CarrierFbx``) with their audio
542
+ embedded as the DCC exporters do (``ptk.FbxMedia.embed``).
543
+ """
544
+
545
+ @classmethod
546
+ def setUpClass(cls):
547
+ cls.temp_dir = TempProjects.allocate("embedded_request_policy")
548
+
549
+ def _launch(self, launcher, method: str, results: str) -> dict:
550
+ log = os.path.join(self.temp_dir, f"unity_{method}.txt")
551
+ proc = launcher.launch_editor(
552
+ batch_mode=True,
553
+ execute_method=f"EmbeddedRequestVerifier.{method}",
554
+ log_file=log,
555
+ extra_args=["-quit"],
556
+ detached=False,
557
+ )
558
+ returncode = None
559
+ if proc:
560
+ try:
561
+ returncode = proc.wait(timeout=900)
562
+ except subprocess.TimeoutExpired:
563
+ proc.kill()
564
+ raise TimeoutError("Unity timed out")
565
+ UnityEnv.require_results(self, results, returncode, log)
566
+ with open(results) as fh:
567
+ unity = json.load(fh)
568
+ logger.info(f"Unity {method}:\n{json.dumps(unity, indent=2)}")
569
+ self.assertNotIn("error", unity, unity.get("error"))
570
+ return unity
571
+
572
+ @BlenderEnv.skip_unless_available()
573
+ @EnvSkip.unless(_unity_available(), "no Unity editor found")
574
+ def test_found_elsewhere_or_asked_before_is_not_extracted_again(self):
575
+ import pythontk as ptk
576
+
577
+ def manifest(*names):
578
+ events = [{"clip": "", "frame": 0, "name": n} for n in names]
579
+ return {"data_export": {"audio_manifest": {"version": 2, "events": events}}}
580
+
581
+ staged = os.path.join(self.temp_dir, "staged")
582
+ os.makedirs(staged)
583
+ sources = {}
584
+ for stem, names, embed in (
585
+ ("resolved", ("footstep",), "footstep"),
586
+ # "ghost" is embedded nowhere and resolves nowhere: always missing.
587
+ ("ghosted", ("hit", "ghost"), "hit"),
588
+ ):
589
+ bare = CarrierFbx.build(
590
+ os.path.join(self.temp_dir, f"{stem}_bare.fbx"), manifest(*names)
591
+ )
592
+ clip = CarrierFbx.wav(
593
+ os.path.join(self.temp_dir, "sound", f"{embed}.wav"), b"embedded"
594
+ )
595
+ sources[stem] = os.path.join(staged, f"{stem}.fbx")
596
+ ptk.FbxMedia.embed(bare, sources[stem], files=[(f"{embed}.wav", clip)])
597
+
598
+ launcher = UnityLauncher()
599
+ project = os.path.join(self.temp_dir, "UnityProject")
600
+ if not launcher.create_project(project, batch_mode=True):
601
+ self.skipTest(UnityEnv.project_creation_reason())
602
+ CarrierFbx.wav(
603
+ os.path.join(project, "Assets", "Audio", "footstep.wav"), b"library"
604
+ )
605
+ touched = CarrierFbx.wav(os.path.join(self.temp_dir, "touched.wav"), b"touched")
606
+ TemplateDeployer.deploy_package(project, components=["audio_event"])
607
+ results = os.path.join(self.temp_dir, "unity_results.json")
608
+ editor_dir = os.path.join(project, "Assets", "Editor")
609
+ os.makedirs(editor_dir, exist_ok=True)
610
+ with open(
611
+ os.path.join(editor_dir, "EmbeddedRequestVerifier.cs"),
612
+ "w",
613
+ encoding="utf-8",
614
+ ) as fh:
615
+ fh.write(
616
+ REQUEST_VERIFIER_CS.replace("__RESULTS__", results)
617
+ .replace("__RESOLVED_SRC__", sources["resolved"])
618
+ .replace("__GHOSTED_SRC__", sources["ghosted"])
619
+ .replace("__TOUCHED__", touched)
620
+ )
621
+ launcher.project_path = project
622
+
623
+ first = self._launch(launcher, "First", results)
624
+ self.assertFalse(
625
+ first["resolved_beside"],
626
+ "a clip the lookup finds in Assets/Audio was extracted beside the model",
627
+ )
628
+ self.assertEqual(first["resolved_clip"], "Assets/Audio/footstep.wav", first)
629
+ self.assertTrue(first["hit_beside"], "premise: a missing clip is extracted")
630
+
631
+ # The user touches the extracted clip up; the next session reimports.
632
+ shutil.copy2(touched, os.path.join(project, "Assets", "Ghosted", "hit.wav"))
633
+ second = self._launch(launcher, "Second", results)
634
+ self.assertTrue(
635
+ second["touched_kept"],
636
+ "a later session's reimport extracted the FBX again over the touched-up "
637
+ "clip",
638
+ )
639
+
640
+
459
641
  if __name__ == "__main__":
460
642
  logging.basicConfig(level=logging.INFO)
461
643
  unittest.main()
@@ -519,8 +519,11 @@ class ChildSettingsSandboxTest(unittest.TestCase):
519
519
  with mock.patch.dict(os.environ, env, clear=True):
520
520
  UnityTestRunner.sandbox_children(self.root)
521
521
  armed = os.environ.copy()
522
- # As in a run: TEMP names the run's root, which the stores nest in.
523
- armed.update(TEMP=self.root, TMP=self.root, TMPDIR=self.root)
522
+ # As in a run: TEMP and pythontk's own root (read first, since
523
+ # pythontk 0.15.0) name the run's root, which the stores nest in.
524
+ armed.update(
525
+ TEMP=self.root, TMP=self.root, TMPDIR=self.root, PYTHONTK_TEMP_ROOT=self.root
526
+ )
524
527
  armed.pop("PYTHONTK_TEST_TEMP_ROOT", None)
525
528
  scripts = pathlib.Path(__file__).resolve().parents[2]
526
529
  armed["PYTHONPATH"] = os.pathsep.join(str(scripts / pkg) for pkg in packages)
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.0"
1
+ __version__ = "0.2.2"
2
2
 
3
3
  from .launcher import UnityLauncher, UnityFinder
4
4
  from .scene_builder import SceneBuilder
@@ -156,7 +156,7 @@ class CopyToAssetsDeliverer(ptk.Deliverer):
156
156
  OSError: The copy failed.
157
157
  """
158
158
  if not ptk.FbxFile.is_fbx(source):
159
- shutil.copy2(source, dest)
159
+ CopyToAssetsDeliverer._place(source, dest)
160
160
  return
161
161
  hook = getattr(bridge, "lightmap_search_dirs", None)
162
162
  try:
@@ -181,10 +181,27 @@ class CopyToAssetsDeliverer(ptk.Deliverer):
181
181
  "exported."
182
182
  )
183
183
  report = None
184
- shutil.copy2(staged if report and report["embedded"] else source, dest)
184
+ CopyToAssetsDeliverer._place(
185
+ staged if report and report["embedded"] else source, dest
186
+ )
185
187
  finally:
186
188
  scratch.cleanup()
187
189
 
190
+ @staticmethod
191
+ def _place(source: str, dest: str) -> None:
192
+ """Copy *source* to *dest* whole or not at all.
193
+
194
+ Through a ``.part`` beside *dest*, promoted once complete
195
+ (``ptk.FileUtils.atomic_write``): a copy cut short -- a full disk, a
196
+ sync client holding the file -- leaves the asset Unity already
197
+ imported as it was, never a truncated FBX under its name. *source* is
198
+ only read (it may be the user's own file).
199
+
200
+ Raises:
201
+ OSError: The copy failed; *dest* is untouched.
202
+ """
203
+ ptk.FileUtils.atomic_write(dest, lambda part: shutil.copy2(source, part))
204
+
188
205
  @staticmethod
189
206
  def sanitize_asset_name(name: str) -> str:
190
207
  """Return *name* reduced to a safe Unity asset filename stem.
@@ -273,8 +290,7 @@ class FileToUnityBridge(ptk.HandoffBridge):
273
290
  those export a live selection then deliver; this one takes a model already on
274
291
  disk. It shares the exact same delivery Strategy -- :class:`CopyToAssetsDeliverer`
275
292
  (copy into ``Assets/`` + optional Editor launch) -- so the standalone Unity
276
- Workflow panel, the Blender 'Unity Bridge' (which exports the selection to FBX,
277
- then hands the path here), a CLI, or a test all reuse one engine.
293
+ Workflow panel, a CLI, or a test all reuse one engine.
278
294
 
279
295
  Set :attr:`project_path` to the target Unity project (the folder containing
280
296
  ``Assets/``) before calling :meth:`send`::