unitytk 0.2.1__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.1/unitytk.egg-info → unitytk-0.2.2}/PKG-INFO +1 -1
  2. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_asset_delivery.py +26 -8
  3. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_audio_events_integration.py +167 -16
  4. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_embedded_dependencies_integration.py +183 -1
  5. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/__init__.py +1 -1
  6. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/asset_delivery.py +1 -2
  7. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulationRecord.g.cs +5 -2
  8. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/AudioEvent/AudioEventController.cs +48 -19
  9. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/README.md +1 -1
  10. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/UnitytkSettings.cs +60 -8
  11. {unitytk-0.2.1 → unitytk-0.2.2/unitytk.egg-info}/PKG-INFO +1 -1
  12. {unitytk-0.2.1 → unitytk-0.2.2}/LICENSE +0 -0
  13. {unitytk-0.2.1 → unitytk-0.2.2}/MANIFEST.in +0 -0
  14. {unitytk-0.2.1 → unitytk-0.2.2}/docs/README.md +0 -0
  15. {unitytk-0.2.1 → unitytk-0.2.2}/pyproject.toml +0 -0
  16. {unitytk-0.2.1 → unitytk-0.2.2}/setup.cfg +0 -0
  17. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_articulated_rig_runtime.py +0 -0
  18. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_base_test.py +0 -0
  19. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_blender_render_effects_e2e.py +0 -0
  20. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_blender_shadow_plane_integration.py +0 -0
  21. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_blender_shot_metadata_integration.py +0 -0
  22. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_bool_to_fade_curve.py +0 -0
  23. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_clip_name_contract_integration.py +0 -0
  24. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_controller_runtime_integration.py +0 -0
  25. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_deploy_templates.py +0 -0
  26. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_emissive_groups_integration.py +0 -0
  27. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_fade_window_preservation.py +0 -0
  28. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_keyed_visibility.py +0 -0
  29. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_launcher.py +0 -0
  30. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_lightmap_metadata_integration.py +0 -0
  31. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_maya_surface_materials_integration.py +0 -0
  32. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_multi_section_audio_integration.py +0 -0
  33. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_render_effects_e2e.py +0 -0
  34. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_render_opacity_controller.py +0 -0
  35. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_render_opacity_e2e.py +0 -0
  36. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_render_opacity_integration.py +0 -0
  37. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_run_tests.py +0 -0
  38. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_scene_builder.py +0 -0
  39. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_shadow_plane_integration.py +0 -0
  40. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_shadow_plane_runtime_integration.py +0 -0
  41. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_shared_material_standalone.py +0 -0
  42. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_shot_metadata_integration.py +0 -0
  43. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_shots_audio_sidebyside_integration.py +0 -0
  44. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_standalone_enduser.py +0 -0
  45. {unitytk-0.2.1 → unitytk-0.2.2}/test/test_temp_projects.py +0 -0
  46. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/launcher.py +0 -0
  47. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/scene_builder.py +0 -0
  48. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/template_deployer.py +0 -0
  49. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulatedRigController.cs +0 -0
  50. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulatedRigController.cs.meta +0 -0
  51. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulatedRigPointerGrab.cs +0 -0
  52. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulatedRigPointerGrab.cs.meta +0 -0
  53. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/ArticulationRecord.g.cs.meta +0 -0
  54. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRGrab.cs +0 -0
  55. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRGrab.cs.meta +0 -0
  56. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRSetup.cs +0 -0
  57. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRSetup.cs.meta +0 -0
  58. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/M3trik.Unitytk.XR.asmdef +0 -0
  59. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR/M3trik.Unitytk.XR.asmdef.meta +0 -0
  60. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig/XR.meta +0 -0
  61. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ArticulatedRig.meta +0 -0
  62. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/AudioEvent/AudioEventController.cs.meta +0 -0
  63. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/AudioEvent.meta +0 -0
  64. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroupController.cs +0 -0
  65. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroupController.cs.meta +0 -0
  66. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroups.hlsl +0 -0
  67. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroups.hlsl.meta +0 -0
  68. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroupsLit.shader +0 -0
  69. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup/EmissiveGroupsLit.shader.meta +0 -0
  70. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/EmissiveGroup.meta +0 -0
  71. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/LightmapMetadata/LightmapMetadataController.cs +0 -0
  72. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/LightmapMetadata/LightmapMetadataController.cs.meta +0 -0
  73. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/LightmapMetadata.meta +0 -0
  74. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/M3trik.Unitytk.asmdef +0 -0
  75. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/M3trik.Unitytk.asmdef.meta +0 -0
  76. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/MayaSurface/MayaSurfaceMaterialImporter.cs +0 -0
  77. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/MayaSurface/MayaSurfaceMaterialImporter.cs.meta +0 -0
  78. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/MayaSurface.meta +0 -0
  79. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/README.md.meta +0 -0
  80. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/RenderEffects/RenderEffectsController.cs +0 -0
  81. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/RenderEffects/RenderEffectsController.cs.meta +0 -0
  82. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/RenderEffects.meta +0 -0
  83. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlane.shader +0 -0
  84. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlane.shader.meta +0 -0
  85. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlaneController.cs +0 -0
  86. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlaneController.cs.meta +0 -0
  87. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlaneHorizon.hlsl +0 -0
  88. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ShadowPlane/ShadowPlaneHorizon.hlsl.meta +0 -0
  89. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ShadowPlane.meta +0 -0
  90. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ShotMetadata/ShotMetadataController.cs +0 -0
  91. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ShotMetadata/ShotMetadataController.cs.meta +0 -0
  92. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/ShotMetadata.meta +0 -0
  93. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/UnitytkSettings.cs.meta +0 -0
  94. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk/templates/__init__.py +0 -0
  95. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk.egg-info/SOURCES.txt +0 -0
  96. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk.egg-info/dependency_links.txt +0 -0
  97. {unitytk-0.2.1 → unitytk-0.2.2}/unitytk.egg-info/requires.txt +0 -0
  98. {unitytk-0.2.1 → 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.1
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")
@@ -193,7 +200,9 @@ class DeliveryTest(unittest.TestCase):
193
200
  )
194
201
 
195
202
  def test_explicit_asset_name_overrides_and_sanitizes(self):
196
- _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
+ )
197
206
  self.assertEqual(os.path.basename(result["asset"]), "Hero_Mesh_01.fbx")
198
207
 
199
208
  def test_launch_mode_open_invokes_windowed_launch(self):
@@ -282,6 +291,7 @@ class DeliveryTest(unittest.TestCase):
282
291
  orig = ad.CopyToAssetsDeliverer._launch_editor
283
292
  # Patch UnityLauncher inside the real hook (deferred import target).
284
293
  import unitytk.launcher as launcher_mod
294
+
285
295
  real = launcher_mod.UnityLauncher
286
296
  launcher_mod.UnityLauncher = _FakeLauncher
287
297
  try:
@@ -344,15 +354,14 @@ class DeliveryTest(unittest.TestCase):
344
354
  )
345
355
  # Genuinely under Assets/ (no drive escape).
346
356
  assets = os.path.realpath(os.path.join(self.project, "Assets"))
347
- self.assertTrue(
348
- os.path.realpath(dest).startswith(assets), f"raw={raw!r}"
349
- )
357
+ self.assertTrue(os.path.realpath(dest).startswith(assets), f"raw={raw!r}")
350
358
 
351
359
  def test_explicit_subdir_reducing_to_nothing_drops_into_assets_root(self):
352
360
  for raw in (".", "/", "../.."):
353
361
  _bridge, result = self._deliver({"ASSETS_SUBDIR": raw}, default_name="Cube")
354
362
  self.assertEqual(
355
- result["asset"], os.path.join(self.project, "Assets", "Cube.fbx"),
363
+ result["asset"],
364
+ os.path.join(self.project, "Assets", "Cube.fbx"),
356
365
  f"raw={raw!r}",
357
366
  )
358
367
  self.assertEqual(result["subdir"], "")
@@ -388,7 +397,11 @@ class SelfContainedDeliveryTest(unittest.TestCase):
388
397
  "lightmap_metadata": {
389
398
  "version": 1,
390
399
  "objects": [{"name": "Crate", "map": "Crate_Lightmap.exr", "uvIndex": 1}],
391
- "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
+ },
392
405
  },
393
406
  "shadow_metadata": {
394
407
  "version": 2,
@@ -411,7 +424,12 @@ class SelfContainedDeliveryTest(unittest.TestCase):
411
424
  self.maps = os.path.join(self.tmp, "sourceimages")
412
425
  os.makedirs(self.maps)
413
426
  self.bytes = {}
414
- 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
+ ):
415
433
  self.bytes[name] = name.encode() * 8
416
434
  with open(os.path.join(self.maps, name), "wb") as fh:
417
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()
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.1"
1
+ __version__ = "0.2.2"
2
2
 
3
3
  from .launcher import UnityLauncher, UnityFinder
4
4
  from .scene_builder import SceneBuilder
@@ -290,8 +290,7 @@ class FileToUnityBridge(ptk.HandoffBridge):
290
290
  those export a live selection then deliver; this one takes a model already on
291
291
  disk. It shares the exact same delivery Strategy -- :class:`CopyToAssetsDeliverer`
292
292
  (copy into ``Assets/`` + optional Editor launch) -- so the standalone Unity
293
- Workflow panel, the Blender 'Unity Bridge' (which exports the selection to FBX,
294
- 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.
295
294
 
296
295
  Set :attr:`project_path` to the target Unity project (the folder containing
297
296
  ``Assets/``) before calling :meth:`send`::
@@ -72,7 +72,10 @@ public static partial class ArticulationRecord
72
72
  public string name;
73
73
  /// <summary>An earlier joint's index, or null for a root (its parent is the rig's space).</summary>
74
74
  public int? parent;
75
- /// <summary>The rest translate in the parent's space, in the DCC's units.</summary>
75
+ /// <summary>
76
+ /// The rest translate in the parent's space, in the file's units (a runtime
77
+ /// measures its scale against the rest pose: ArticulationModel.scale_of).
78
+ /// </summary>
76
79
  public double[] t;
77
80
  /// <summary>The rest rotation (the DCC's joint orient), a quaternion x, y, z, w.</summary>
78
81
  public double[] q;
@@ -102,7 +105,7 @@ public static partial class ArticulationRecord
102
105
  {
103
106
  /// <summary>
104
107
  /// rx/ry/rz turn about the rest frame's own axes (degrees); tx/ty/tz slide along
105
- /// them (the parent's units).
108
+ /// them (the file's units, in the parent's space).
106
109
  /// </summary>
107
110
  public string channel;
108
111
  /// <summary>The lower limit, or null for none.</summary>
@@ -240,10 +240,10 @@ public class AudioEventImporter : AssetPostprocessor
240
240
  {
241
241
  // Fresh-import reset. OnPostprocessModel normally cleans this up, but
242
242
  // an import that aborts between the callbacks (another postprocessor
243
- // throwing) strands entries here — Step 1 ADDS to the surviving list,
244
- // so the next import of this asset would inject every event twice.
245
- _pendingManifests.Remove(assetPath);
246
- _importedClips.Remove(assetPath);
243
+ // throwing) strands a manifest here — Step 1 keeps the first one it
244
+ // meets, so a stranded one could outrank this import's own, or
245
+ // resurrect on a re-export without the carrier.
246
+ ClearPending();
247
247
 
248
248
  // String channel: user properties arrive with Unity's default
249
249
  // importer settings (probe-verified) — nothing to configure here.
@@ -274,20 +274,49 @@ public class AudioEventImporter : AssetPostprocessor
274
274
  if (!CarrierImport.TryGetProp(propNames, values, ATTR_MANIFEST, out var manifest))
275
275
  return;
276
276
 
277
- if (!_pendingManifests.ContainsKey(assetPath))
278
- _pendingManifests[assetPath] = new List<ManifestEntry>();
279
- _pendingManifests[assetPath].AddRange(ParseManifest(manifest, assetPath));
277
+ // ONE manifest per model, by the readers' precedence: the scene's own
278
+ // carrier, else the first met (CarrierImport.Collect, as every other
279
+ // channel importer and the GLB applier read). An assembly ships a
280
+ // stray data_export1 or a module's NS:data_export beside the scene's
281
+ // own, each holding its events as last exported alone, while the
282
+ // scene's own is re-derived from the whole merged scene: concatenated,
283
+ // the stale events were bound and fired.
284
+ CarrierImport.Collect(_pendingManifestJson, assetPath, go, manifest);
285
+ _pendingManifests.Remove(assetPath); // parsed again from the one kept
280
286
  }
281
287
 
282
288
  // --- Step 2: Inject AnimationEvents onto clips ----------------------
283
289
 
284
- // Per-assetPath accumulator of manifest entries discovered during
285
- // OnPostprocessGameObjectWithUserProperties. Drained by
290
+ // Per-assetPath manifest JSON Step 1 kept (CarrierImport.Collect), and its
291
+ // entries parsed once on first use (PendingEntries). Drained by
286
292
  // OnPostprocessAnimation (per clip) and OnPostprocessModel (final
287
293
  // controller attach + cleanup).
294
+ static readonly Dictionary<string, string>
295
+ _pendingManifestJson = new Dictionary<string, string>();
288
296
  static readonly Dictionary<string, List<ManifestEntry>>
289
297
  _pendingManifests = new Dictionary<string, List<ManifestEntry>>();
290
298
 
299
+ /// <summary>The entries of the manifest Step 1 kept for this asset, or
300
+ /// null when it carries none.</summary>
301
+ List<ManifestEntry> PendingEntries()
302
+ {
303
+ if (_pendingManifests.TryGetValue(assetPath, out var entries))
304
+ return entries;
305
+ if (!_pendingManifestJson.TryGetValue(assetPath, out var json))
306
+ return null;
307
+ entries = ParseManifest(json, assetPath);
308
+ _pendingManifests[assetPath] = entries;
309
+ return entries;
310
+ }
311
+
312
+ /// <summary>Forget this asset's pending manifest, kept and parsed.</summary>
313
+ void ClearPending()
314
+ {
315
+ _pendingManifestJson.Remove(assetPath);
316
+ _pendingManifests.Remove(assetPath);
317
+ _importedClips.Remove(assetPath);
318
+ }
319
+
291
320
  // Per-assetPath clips this import built, by name: the sequence clip is
292
321
  // only known in OnPostprocessModel (PlaceOnSequenceClip), after every
293
322
  // clip has passed through here.
@@ -318,8 +347,8 @@ public class AudioEventImporter : AssetPostprocessor
318
347
 
319
348
  bool modified = removedStale > 0;
320
349
 
321
- if (_pendingManifests.TryGetValue(assetPath, out var entries)
322
- && entries.Count > 0)
350
+ var entries = PendingEntries();
351
+ if (entries != null && entries.Count > 0)
323
352
  {
324
353
  float frameRate = clip.frameRate > 0 ? clip.frameRate : 30f;
325
354
  foreach (var entry in entries)
@@ -381,13 +410,12 @@ public class AudioEventImporter : AssetPostprocessor
381
410
  {
382
411
  if (!ShouldProcess())
383
412
  {
384
- _pendingManifests.Remove(assetPath);
385
- _importedClips.Remove(assetPath);
413
+ ClearPending();
386
414
  return;
387
415
  }
388
416
 
389
- bool hasEntries = _pendingManifests.TryGetValue(assetPath, out var entries)
390
- && entries.Count > 0;
417
+ var entries = PendingEntries();
418
+ bool hasEntries = entries != null && entries.Count > 0;
391
419
  var existingController = root.GetComponent<AudioEventController>();
392
420
 
393
421
  if (hasEntries)
@@ -402,10 +430,12 @@ public class AudioEventImporter : AssetPostprocessor
402
430
  // file under its event's name (pythontk
403
431
  // FbxMedia.embed_dependencies): extracted beside the model, the
404
432
  // first folder the lookup below searches, on the one reimport
405
- // that brings.
433
+ // that brings. A clip that lookup already finds elsewhere (an
434
+ // Assets/Audio library) is not asked for.
406
435
  var names = new List<string>();
407
436
  foreach (var entry in entries) names.Add(entry.eventName);
408
- bool extracting = CarrierImport.RequestEmbeddedFiles(assetPath, names);
437
+ bool extracting = CarrierImport.RequestEmbeddedFiles(
438
+ assetPath, names, n => FindAudioClip(n, assetPath) != null);
409
439
 
410
440
  var seen = new HashSet<string>(
411
441
  System.StringComparer.OrdinalIgnoreCase);
@@ -434,8 +464,7 @@ public class AudioEventImporter : AssetPostprocessor
434
464
  + root.name + "' (no audio_manifest in re-exported FBX)");
435
465
  }
436
466
 
437
- _pendingManifests.Remove(assetPath);
438
- _importedClips.Remove(assetPath);
467
+ ClearPending();
439
468
  }
440
469
 
441
470
  // --- Step 3b: shot events onto the sequence clip -------------------
@@ -182,7 +182,7 @@ Or for objects already placed in a scene:
182
182
  |---|---|---|
183
183
  | `UnitytkSettings.cs` | — (editor-only) | The shared, compile-coupled home: the **Project Settings ▸ unitytk** page (per-channel toggles + FBX allowlist, stored in `ProjectSettings/UnitytkSettings.json`), the `ImportGate` every importer asks before doing any work, and the `CarrierImport` channel-reading helpers. |
184
184
  | `RenderEffects/RenderEffectsController.cs` | Fades object alpha via MaterialPropertyBlock. Auto-hides at zero opacity. | Detects Maya `opacity` attribute, adds component, converts visibility curves to smooth fades. Switches a fading object's material to its pipeline's alpha blend (Built-in Fade, URP Transparent/Alpha, HDRP Transparent) and drops a pulsing highlight's emission map, as the GLB route does -- a copy when an object that needs the original shares it, and a material remapped in from the project is never edited. |
185
- | `AudioEvent/AudioEventController.cs` | Plays audio clips from AnimationEvent callbacks. | Detects Maya `audio_manifest` attribute on the `data_export` carrier (baked from the `data_internal` authoring state), adds the component to the prefab root (where the Animator goes), injects AnimationEvents (in a Shots + Full Sequence file, each shot's events onto the whole-timeline clip too, at the shot's window -- needs the Shot Metadata component deployed), matches audio clips by name (an FBX that embeds them has them extracted beside it first). |
185
+ | `AudioEvent/AudioEventController.cs` | Plays audio clips from AnimationEvent callbacks. | Detects Maya `audio_manifest` attribute on the `data_export` carrier (baked from the `data_internal` authoring state), adds the component to the prefab root (where the Animator goes), injects AnimationEvents (in a Shots + Full Sequence file, each shot's events onto the whole-timeline clip too, at the shot's window -- needs the Shot Metadata component deployed), matches audio clips by name (an FBX that embeds them has them extracted beside it first -- unless the lookup already finds the clip, an `Assets/Audio` library say -- once per version of the model per project). Reads the scene's own carrier only, else the first that carries a manifest, as every channel does: an assembly's stray `data_export1` holds stale events. |
186
186
  | `ShotMetadata/ShotMetadataController.cs` | Passive per-shot lookup: `GetShot(clipName)` returns the shot's description / section / object list and its `start`/`end` frames; `TryGetSequenceWindow(clipName, ...)` gives the shot's window in seconds inside `sequenceClip`, the clip cut over the whole-timeline take. | Parses the `shot_metadata` JSON off `data_export`, attaches one component to the prefab root, joins records to imported AnimationClips **by clip name** (case-insensitive). |
187
187
  | `LightmapMetadata/LightmapMetadataController.cs` | Holds Maya-baked lightmap bindings; **Tools → unitytk → Lightmaps → Apply Scene Lightmaps** binds them into `LightmapSettings` + per-renderer `lightmapScaleOffset` (objects sharing an atlas share one lightmap slot) and records what it bound on the controller, which binds it again whenever it is enabled -- Unity keeps no lightmap it did not bake itself, so a reopened scene or a player build would otherwise come up unlit (measured on 2022.3 and 6000.3). A re-run takes the previous bindings down first; a controller whose records changed since Apply (a re-imported model) binds nothing until Apply runs again. A bound renderer gives up its material's occlusion map (`_OcclusionStrength` 0, per renderer: the bake already holds the occlusion, as the GLB pipeline rules; `bakeReplacesOcclusion` off keeps the map, and URP's SRP Batcher for that renderer); a mesh with no uv2 is left unbound and named; a realtime or mixed light reaching the baked renderers is warned about (the bake already holds the scene's lighting). Apply also puts up the bake's reflection probe as a child `ReflectionProbe` of the model root ([the reflection probe](#the-lightmap-bakes-reflection-probe)). The applier pins the texture import settings HDR lightmaps need (Lightmap type, sRGB off, mips on, BC6H / ASTC-HDR per platform), turns off **Auto Generate** lighting so a Unity re-bake can't clobber the bindings, and warns on a Gamma color-space project, a non-uv2 `uvIndex`, or a duplicate record name with no hierarchy to tell it apart by (records carry the Maya hierarchy, and same-named objects -- two machine bodies both called `BODY` -- bind by it). | Parses the `lightmap_metadata` JSON off `data_export` (the probe's nested `box` by hand — JsonUtility cannot hold it), attaches the component, warns if `generateSecondaryUV` would overwrite the DCC's UV2. A re-export without a probe clears the record. |
188
188
  | `ShadowPlane/ShadowPlaneController.cs` (+ `ShadowPlane.shader`, `ShadowPlaneHorizon.hlsl`) | The runtime shadow rig (mayatk/blendertk `ShadowRig`, `shadow_metadata` v2): resolves the record's `source` and `contact` by name (exact, else the namespace-stripped leaf; an ambiguous name is refused), evaluates the projection model (pythontk `ShadowProjection.model` ported in `ShadowPlaneModel`, double precision) in `LateUpdate` while `followSource` is on and both resolve — placing the plane from a runtime light — otherwise the imported keys play; writes the per-plane block (`_Rect`, `_Mode`, `_Intensity`, the horizon uniforms, the contact frame, the source) read-modify-write beside `RenderEffectsController`'s `_BaseColor.a`. `ApplyNow()` for scripts. A horizon plane's march is cached (`cacheShadow`, on by default): marched into one shared atlas only when the source, the placement or the ground moves, and sampled at one fetch a pixel. Directional sources: a `Light` shines along its +Z, a bare DCC locator along its local -Z. | Parses the `shadow_metadata` JSON off `data_export` and joins each record to every renderer **its name** denotes (exact, else the namespace-stripped leaf), rewires every plane of a rig type to **one shared, instanced material** on `m3trik/ShadowPlane` (`ShadowPlane.shader`; Built-in and URP — HDRP, or a missing shader, falls back to the per-plane pipeline unlit-transparent rewire) bound to the silhouette / atlas / `_horizon` PNGs resolved by the recorded filenames, normalises an atlased plane's UVs and shares the first plane's mesh so instancing batches, turns off shadow casting/receiving + light/reflection probes, fills the controller from the record (plus `unit_scale`; `frame_a`/`frame_b` converted from FBX axes by the importer's X mirror; the horizon block's `max_stretch` — the scale its cotangents were **baked** with — kept apart from the plane's live placement cap, falling back to it only for a map baked before that field existed), pins `*_shadow` (`alphaIsTransparency`) and `*_horizon` (data) PNG imports, and removes stale controllers when a re-exported FBX no longer carries the channel. |
@@ -286,10 +286,50 @@ public static class CarrierImport
286
286
  public static void RequestTextureExtraction(string assetPath)
287
287
  => _needExtractedTextures.Add(assetPath);
288
288
 
289
- // Model versions (path, write time, size) this session already asked to
289
+ // Model versions (path, write time, size) this PROJECT already asked to
290
290
  // have extracted: a name the FBX does not carry stays missing after the
291
- // extraction, and must not ask again on every reimport.
292
- static readonly HashSet<string> _embeddedRequested = new HashSet<string>();
291
+ // extraction, and must not ask again on every reimport. Kept in Library/
292
+ // (per project, never versioned) rather than for one editor session: a
293
+ // later session's reimport (a package update, Reimport All) asked again,
294
+ // and each extraction overwrote the files beside the model, a touched-up
295
+ // one included. One line per model, its latest version; a Library rebuild
296
+ // forgets it and extracts once more.
297
+ const string EMBEDDED_REQUESTED = "Library/unitytk/embedded_requested.txt";
298
+ static HashSet<string> _embeddedRequested;
299
+
300
+ /// <summary>Remember that <paramref name="assetPath"/>'s version
301
+ /// <paramref name="key"/> asked; false when it already had.</summary>
302
+ static bool RememberRequest(string assetPath, string key)
303
+ {
304
+ if (_embeddedRequested == null)
305
+ {
306
+ _embeddedRequested = new HashSet<string>(StringComparer.Ordinal);
307
+ try
308
+ {
309
+ if (File.Exists(EMBEDDED_REQUESTED))
310
+ foreach (var line in File.ReadAllLines(EMBEDDED_REQUESTED))
311
+ if (line.Length > 0) _embeddedRequested.Add(line);
312
+ }
313
+ catch (IOException) { }
314
+ catch (UnauthorizedAccessException) { }
315
+ }
316
+ if (_embeddedRequested.Contains(key)) return false;
317
+ string prefix = assetPath + "|";
318
+ bool replaced = _embeddedRequested.RemoveWhere(
319
+ k => k.StartsWith(prefix, StringComparison.Ordinal)) > 0;
320
+ _embeddedRequested.Add(key);
321
+ try
322
+ {
323
+ Directory.CreateDirectory(Path.GetDirectoryName(EMBEDDED_REQUESTED));
324
+ if (replaced)
325
+ File.WriteAllLines(EMBEDDED_REQUESTED, _embeddedRequested);
326
+ else
327
+ File.AppendAllText(EMBEDDED_REQUESTED, key + "\n");
328
+ }
329
+ catch (IOException) { }
330
+ catch (UnauthorizedAccessException) { }
331
+ return true;
332
+ }
293
333
 
294
334
  /// <summary>
295
335
  /// The channels of the model at <paramref name="assetPath"/> name these
@@ -299,10 +339,14 @@ public static class CarrierImport
299
339
  /// when one is missing there or older than the model -- a re-export:
300
340
  /// extraction overwrites in place, keeping each file's GUID and import
301
341
  /// settings (measured, 2022.3 / 6000.3). A name with no extension (an
302
- /// audio event's clip) matches the file of that stem. Once per version of
303
- /// the model per session; true when this call asked.
342
+ /// audio event's clip) matches the file of that stem. A name missing
343
+ /// beside the model that <paramref name="resolves"/> finds where its
344
+ /// importer looks (the audio lookup's Assets/Audio) is not missing:
345
+ /// extracting for it rebound the asset to a copy beside the model.
346
+ /// Once per version of the model per project; true when this call asked.
304
347
  /// </summary>
305
- public static bool RequestEmbeddedFiles(string assetPath, IEnumerable<string> names)
348
+ public static bool RequestEmbeddedFiles(
349
+ string assetPath, IEnumerable<string> names, Func<string, bool> resolves = null)
306
350
  {
307
351
  var model = new FileInfo(assetPath);
308
352
  if (!model.Exists) return false;
@@ -311,9 +355,17 @@ public static class CarrierImport
311
355
  {
312
356
  if (string.IsNullOrEmpty(name)) continue;
313
357
  var copy = Beside(model.DirectoryName, Path.GetFileName(name));
314
- if (copy == null || copy.LastWriteTimeUtc < model.LastWriteTimeUtc) { stale = true; break; }
358
+ if (copy != null)
359
+ {
360
+ // Older than the model: a re-export, whose copy is replaced.
361
+ if (copy.LastWriteTimeUtc < model.LastWriteTimeUtc) { stale = true; break; }
362
+ continue;
363
+ }
364
+ if (resolves != null && resolves(name)) continue;
365
+ stale = true;
366
+ break;
315
367
  }
316
- if (!stale || !_embeddedRequested.Add(
368
+ if (!stale || !RememberRequest(assetPath,
317
369
  assetPath + "|" + model.LastWriteTimeUtc.Ticks + "|" + model.Length))
318
370
  return false;
319
371
  RequestTextureExtraction(assetPath);
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitytk
3
- Version: 0.2.1
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes