unitytk 0.0.12__tar.gz → 0.0.14__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 (57) hide show
  1. {unitytk-0.0.12/unitytk.egg-info → unitytk-0.0.14}/PKG-INFO +5 -3
  2. {unitytk-0.0.12 → unitytk-0.0.14}/docs/README.md +1 -0
  3. {unitytk-0.0.12 → unitytk-0.0.14}/pyproject.toml +10 -2
  4. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_asset_delivery.py +1 -2
  5. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_audio_events_integration.py +0 -1
  6. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_base_test.py +8 -54
  7. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_clip_name_contract_integration.py +54 -36
  8. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_emissive_groups_integration.py +11 -11
  9. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_fade_window_preservation.py +4 -4
  10. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_keyed_visibility.py +12 -21
  11. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_launcher.py +62 -26
  12. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_lightmap_metadata_integration.py +87 -4
  13. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_multi_section_audio_integration.py +10 -13
  14. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_render_opacity_controller.py +2 -2
  15. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_render_opacity_e2e.py +4 -4
  16. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_render_opacity_integration.py +3 -4
  17. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_shadow_plane_integration.py +24 -35
  18. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_shared_material_standalone.py +9 -16
  19. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_shot_metadata_integration.py +11 -2
  20. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_shots_audio_sidebyside_integration.py +22 -10
  21. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_standalone_enduser.py +11 -20
  22. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/__init__.py +1 -1
  23. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/launcher.py +70 -35
  24. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/LightmapMetadataController.cs +147 -12
  25. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/README.md +4 -4
  26. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/ShadowPlaneController.cs +2 -2
  27. {unitytk-0.0.12 → unitytk-0.0.14/unitytk.egg-info}/PKG-INFO +5 -3
  28. unitytk-0.0.14/unitytk.egg-info/requires.txt +1 -0
  29. unitytk-0.0.12/unitytk.egg-info/requires.txt +0 -1
  30. {unitytk-0.0.12 → unitytk-0.0.14}/LICENSE +0 -0
  31. {unitytk-0.0.12 → unitytk-0.0.14}/MANIFEST.in +0 -0
  32. {unitytk-0.0.12 → unitytk-0.0.14}/setup.cfg +0 -0
  33. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_bool_to_fade_curve.py +0 -0
  34. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_controller_runtime_integration.py +0 -0
  35. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_deploy_templates.py +0 -0
  36. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_render_effects_e2e.py +0 -0
  37. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_run_tests.py +0 -0
  38. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_scene_builder.py +0 -0
  39. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_shadow_plane_runtime_integration.py +0 -0
  40. {unitytk-0.0.12 → unitytk-0.0.14}/test/test_temp_projects.py +0 -0
  41. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/asset_delivery.py +0 -0
  42. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/scene_builder.py +0 -0
  43. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/template_deployer.py +0 -0
  44. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/AudioEventController.cs +0 -0
  45. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/EmissiveGroupController.cs +0 -0
  46. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/EmissiveGroups.hlsl +0 -0
  47. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/EmissiveGroupsLit.shader +0 -0
  48. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/M3trik.Unitytk.asmdef +0 -0
  49. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/RenderEffectsController.cs +0 -0
  50. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/ShadowPlane.shader +0 -0
  51. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/ShadowPlaneHorizon.hlsl +0 -0
  52. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/ShotMetadataController.cs +0 -0
  53. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/UnitytkSettings.cs +0 -0
  54. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk/templates/__init__.py +0 -0
  55. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk.egg-info/SOURCES.txt +0 -0
  56. {unitytk-0.0.12 → unitytk-0.0.14}/unitytk.egg-info/dependency_links.txt +0 -0
  57. {unitytk-0.0.12 → unitytk-0.0.14}/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.0.12
3
+ Version: 0.0.14
4
4
  Summary: A modular Python toolkit for Unity interaction.
5
5
  Author-email: Ryan Simpson <m3trik@outlook.com>
6
6
  License: MIT
@@ -8,14 +8,16 @@ Project-URL: Homepage, https://github.com/m3trik/unitytk
8
8
  Project-URL: Repository, https://github.com/m3trik/unitytk
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Operating System :: OS Independent
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Operating System :: POSIX :: Linux
12
13
  Requires-Python: >=3.9
13
14
  Description-Content-Type: text/markdown
14
15
  License-File: LICENSE
15
- Requires-Dist: pythontk>=0.7.71
16
+ Requires-Dist: pythontk>=0.12.0
16
17
  Dynamic: license-file
17
18
 
18
19
  [![Tests](https://img.shields.io/badge/Tests-146%20passed-brightgreen.svg)](../test/)
20
+ ![Platform](https://img.shields.io/badge/Platform-Windows%2C%20Linux-lightgrey.svg)
19
21
 
20
22
  # unitytk
21
23
 
@@ -1,4 +1,5 @@
1
1
  [![Tests](https://img.shields.io/badge/Tests-146%20passed-brightgreen.svg)](../test/)
2
+ ![Platform](https://img.shields.io/badge/Platform-Windows%2C%20Linux-lightgrey.svg)
2
3
 
3
4
  # unitytk
4
5
 
@@ -12,9 +12,17 @@ requires-python = ">=3.9"
12
12
  classifiers = [
13
13
  "Programming Language :: Python :: 3",
14
14
  "License :: OSI Approved :: MIT License",
15
- "Operating System :: OS Independent",
15
+ "Operating System :: Microsoft :: Windows",
16
+ "Operating System :: POSIX :: Linux",
16
17
  ]
17
- dependencies = ["pythontk>=0.7.71"]
18
+ # The floor is HAND-RATCHETED: unitytk is off-chain from m3trik/push.ps1, so no
19
+ # release cascade bumps it.
20
+ # pythontk 0.12.0, the release this one ships and was tested with. The hard
21
+ # minimum is 0.8.84: UnityFinder.find_editors scans through
22
+ # `AppLauncher.scan_install_dirs` (new in 0.8.72) and needs its pattern order to
23
+ # be a priority (0.8.84), so the Hub's chosen install location wins over the
24
+ # default. Below 0.8.72 the scan fails, is logged, and no editor is found.
25
+ dependencies = ["pythontk>=0.12.0"]
18
26
 
19
27
  [project.urls]
20
28
  Homepage = "https://github.com/m3trik/unitytk"
@@ -6,11 +6,10 @@ import shutil
6
6
  import tempfile
7
7
  import unittest
8
8
 
9
- from pythontk.core_utils.app_handoff import HandoffRequest, Payload
9
+ from pythontk import HandoffRequest, Payload
10
10
  from unitytk.asset_delivery import (
11
11
  CopyToAssetsDeliverer,
12
12
  FileToUnityBridge,
13
- CopyToAssetsDeliverer,
14
13
  )
15
14
 
16
15
 
@@ -25,7 +25,6 @@ Requires:
25
25
  - Unity Hub with at least one editor installed
26
26
  """
27
27
  import os
28
- import sys
29
28
  import json
30
29
  import shutil
31
30
  import unittest
@@ -356,7 +356,7 @@ class SuiteOptionsTest(unittest.TestCase):
356
356
 
357
357
 
358
358
  class TestAssetsTest(unittest.TestCase):
359
- """Fixture resolution, including the one-release legacy-name alias map."""
359
+ """Fixture resolution: a plain join under ``UNITYTK_TEST_ASSETS``."""
360
360
 
361
361
  def setUp(self):
362
362
  import tempfile
@@ -368,41 +368,15 @@ class TestAssetsTest(unittest.TestCase):
368
368
  patcher.start()
369
369
  self.addCleanup(patcher.stop)
370
370
 
371
- def _touch(self, *parts):
372
- path = os.path.join(self.root, *parts)
373
- os.makedirs(os.path.dirname(path), exist_ok=True)
374
- with open(path, "w", encoding="utf-8") as fh:
375
- fh.write("x")
376
- return path
377
-
378
371
  def test_path_joins_under_the_root(self):
379
372
  self.assertEqual(
380
373
  TestAssets.path("a", "b.fbx"), os.path.join(self.root, "a", "b.fbx")
381
374
  )
382
375
 
383
- def test_alias_map_is_not_empty(self):
384
- self.assertTrue(TestAssets.LEGACY_ALIASES)
385
-
386
- def test_legacy_name_resolves_when_only_it_is_staged(self):
387
- """An already-staged asset tree keeps working for one release."""
388
- for new_parts, legacy_parts in TestAssets.LEGACY_ALIASES.items():
389
- with self.subTest(asset=new_parts):
390
- staged = self._touch(*legacy_parts)
391
- try:
392
- self.assertEqual(
393
- os.path.normcase(TestAssets.path(*new_parts)),
394
- os.path.normcase(staged),
395
- )
396
- finally:
397
- os.remove(staged)
398
-
399
- def test_new_name_wins_when_both_are_staged(self):
400
- new_parts, legacy_parts = next(iter(TestAssets.LEGACY_ALIASES.items()))
401
- self._touch(*legacy_parts)
402
- fresh = self._touch(*new_parts)
403
- self.assertEqual(
404
- os.path.normcase(TestAssets.path(*new_parts)), os.path.normcase(fresh)
405
- )
376
+ def test_the_one_release_alias_map_is_gone(self):
377
+ """Deleted 2026-09-23, as its own note asked: nothing maps a neutral
378
+ fixture name to a legacy one, so ``path`` is the plain join above."""
379
+ self.assertFalse(hasattr(TestAssets, "LEGACY_ALIASES"))
406
380
 
407
381
  def test_unset_root_cannot_exist_under_either_guard(self):
408
382
  from pathlib import Path
@@ -566,17 +540,13 @@ class PublicHygieneTest(unittest.TestCase):
566
540
  The absolute studio paths, vendor name and person name went 2026-08-15; the
567
541
  program identifiers went 2026-08-17. This is the guard that keeps them out.
568
542
  Markdown is deliberately NOT scanned: ``CHANGELOG.md`` records what factually
569
- happened and ``CLAUDE.md`` has to document the legacy asset names so a user
570
- can rename an already-staged tree.
543
+ happened. Since the one-release alias map went (2026-09-23), no Python or C#
544
+ file may name the legacy assets -- ``base_test.py`` included.
571
545
  """
572
546
 
573
547
  #: Program identifiers that must not appear as code identifiers.
574
548
  PROGRAM_RE = re.compile(r"C-?130|C5M|FCR", re.IGNORECASE)
575
549
 
576
- #: The one file allowed to name the legacy assets: the deprecation alias
577
- #: map, which exists precisely so the old names keep resolving.
578
- ALIAS_HOME = "base_test.py"
579
-
580
550
  def test_no_module_filename_carries_a_program_identifier(self):
581
551
  offenders = [
582
552
  os.path.relpath(p, REPO_ROOT)
@@ -587,7 +557,7 @@ class PublicHygieneTest(unittest.TestCase):
587
557
 
588
558
  def test_no_program_identifier_in_python_or_cs_source(self):
589
559
  offenders = []
590
- for path in _source_files(".py", ".cs", exclude=(self.ALIAS_HOME,)):
560
+ for path in _source_files(".py", ".cs"):
591
561
  with open(path, encoding="utf-8", errors="replace") as fh:
592
562
  for lineno, line in enumerate(fh, 1):
593
563
  if self.PROGRAM_RE.search(line):
@@ -596,22 +566,6 @@ class PublicHygieneTest(unittest.TestCase):
596
566
  )
597
567
  self.assertEqual(offenders, [], f"program identifier in source: {offenders}")
598
568
 
599
- def test_the_alias_map_is_the_only_home_for_the_legacy_names(self):
600
- """base_test may name them -- but only inside LEGACY_ALIASES."""
601
- legacy = [part for parts in TestAssets.LEGACY_ALIASES.values() for part in parts]
602
- self.assertTrue(
603
- any(self.PROGRAM_RE.search(p) for p in legacy),
604
- "the alias map should be carrying the legacy program-identified names",
605
- )
606
- path = os.path.join(os.path.dirname(base_test.__file__), self.ALIAS_HOME)
607
- with open(path, encoding="utf-8") as fh:
608
- source = fh.read()
609
- start = source.index("LEGACY_ALIASES")
610
- end = source.index("@classmethod", start)
611
- outside = source[:start] + source[end:]
612
- hits = sorted({m.group(0) for m in self.PROGRAM_RE.finditer(outside)})
613
- self.assertEqual(hits, [], f"legacy identifier outside the alias map: {hits}")
614
-
615
569
 
616
570
  if __name__ == "__main__":
617
571
  unittest.main()
@@ -2,19 +2,21 @@
2
2
  # coding=utf-8
3
3
  """Integration test: the Maya→Unity clip-name JOIN contract under stress.
4
4
 
5
- The whole shots→Unity scheme hinges on one identity: the FBX take name == the
6
- imported ``AnimationClip.name`` == the ``shot_metadata`` ``clip`` join key. Maya
7
- guarantees the first equality (single resolution) and sanitizes to strictly
8
- ``[A-Za-z0-9_]``; this test proves the *second* — that Unity preserves such names
9
- verbatim on FBX import — for the cases most likely to diverge: a space, embedded
10
- punctuation, a leading digit, and an exact collision (de-duped with a suffix).
5
+ The whole shots→Unity scheme hinges on one identity: the shot name == the FBX
6
+ take name == the imported ``AnimationClip.name`` == the ``shot_metadata`` ``clip``
7
+ join key. The store refuses any name outside ``ShotStore.NAME_PATTERN``
8
+ (``[A-Za-z0-9_]``, unique ignoring case) instead of respelling it on export, so
9
+ what the user typed is what ships. This test proves both halves end to end:
10
+ Maya refuses the names the carrier would respell, and every name it accepts
11
+ arrives in Unity VERBATIM -- including the forms the pre-2026-09-18 respelling
12
+ collapsed or stripped (a doubled ``_``, a trailing ``_``), a leading digit and
13
+ a leading ``_``.
11
14
 
12
15
  If Unity ever re-sanitized clip names (e.g. stripped a leading digit), the join
13
16
  would silently break and ``ShotMetadataController.GetShot`` would return null; this
14
17
  test is the guard against that.
15
18
  """
16
19
  import os
17
- import sys
18
20
  import json
19
21
  import shutil
20
22
  import unittest
@@ -36,14 +38,16 @@ from unitytk import UnityLauncher, UnityFinder, TemplateDeployer
36
38
  #: any other machine while the run still reported green.
37
39
  MAYAPY = MayaEnv.mayapy()
38
40
 
39
- # Authored shot name -> expected sanitized clip key (Maya side, verified by the
40
- # mayatk pure tests). Unity must reproduce these exactly as clip names.
41
- EXPECTED = {
42
- "01 Start": "01_Start", # leading digit + space
43
- "Fade-In!": "Fade_In", # punctuation
44
- "Wipe Out": "Wipe_Out", # space
45
- "Wipe Out!": "Wipe_Out_1", # collides with "Wipe Out" -> suffixed
46
- }
41
+ # Names the store accepts; each must be the clip key AND the Unity clip name,
42
+ # character for character.
43
+ NAMES = [
44
+ "01_Start", # leading digit
45
+ "Fade__In", # doubled underscore (the legacy respelling collapsed it)
46
+ "Wipe_Out_", # trailing underscore (the legacy respelling stripped it)
47
+ "_Lead", # leading underscore
48
+ ]
49
+ # Names the store refuses rather than let the export respell them.
50
+ REFUSED = ["01 Start", "Fade-In!", "fade__in"] # space, punctuation, case twin
47
51
 
48
52
 
49
53
  def _unity_available() -> bool:
@@ -60,8 +64,10 @@ maya.standalone.initialize(name="python")
60
64
  import maya.cmds as cmds
61
65
  import maya.mel as mel
62
66
 
67
+ import pythontk as ptk
63
68
  from mayatk.anim_utils.shots._shots import ShotStore
64
69
  from mayatk.env_utils.fbx_utils import FbxUtils
70
+ from mayatk.node_utils.data_nodes import DataNodes
65
71
 
66
72
  output_dir = sys.argv[1]
67
73
  fbx_path = os.path.join(output_dir, "clip_name_contract.fbx").replace(os.sep, "/")
@@ -77,16 +83,24 @@ for t, v in ((1, 0.0), (5, 1.0), (10, 2.0), (15, 3.0), (20, 4.0),
77
83
 
78
84
  store = ShotStore()
79
85
  ShotStore.set_active(store)
80
- # Order is significant: "Wipe Out" before "Wipe Out!" so the collision suffixes
81
- # the second one (matches the deterministic resolve over sorted order).
82
- store.define_shot("01 Start", 1, 10)
83
- store.define_shot("Fade-In!", 11, 20)
84
- store.define_shot("Wipe Out", 21, 30)
85
- store.define_shot("Wipe Out!", 31, 40)
86
+ names = json.loads(sys.argv[2])
87
+ for i, name in enumerate(names):
88
+ store.define_shot(name, 1 + 10 * i, 10 + 10 * i)
89
+ refused = []
90
+ for name in json.loads(sys.argv[3]):
91
+ try:
92
+ store.define_shot(name, 41, 45)
93
+ except ValueError:
94
+ refused.append(name)
86
95
 
87
96
  view = store.to_export_view()
88
97
  store.publish_export_view()
89
98
  n_takes = FbxUtils.apply_takes_from_node()
99
+ # The take list the split realizes, read back off the carrier through the ONE
100
+ # reader of it (each shot_metadata clip's own range).
101
+ takes = ptk.SceneRecords.declared_takes(
102
+ lambda key: ptk.SceneRecords.resolve(key).load(DataNodes)
103
+ )
90
104
 
91
105
  if not cmds.pluginInfo("fbxmaya", query=True, loaded=True):
92
106
  cmds.loadPlugin("fbxmaya")
@@ -98,7 +112,9 @@ results = {
98
112
  "fbx_path": fbx_path,
99
113
  "fbx_exists": os.path.exists(fbx_path),
100
114
  "n_takes": n_takes,
101
- "clip_keys": [t["name"] for t in view["fbx_takes"]],
115
+ "clip_keys": [t["name"] for t in takes],
116
+ "meta_keys": [s["clip"] for s in view["shot_metadata"]["shots"]],
117
+ "refused": refused,
102
118
  }
103
119
  with open(os.path.join(output_dir, "maya_results.json"), "w") as f:
104
120
  json.dump(results, f, indent=2)
@@ -214,7 +230,7 @@ class TestClipNameContract(unittest.TestCase):
214
230
  )
215
231
  env["PYTHONIOENCODING"] = "utf-8"
216
232
  proc = subprocess.run(
217
- [MAYAPY, script_path, self.temp_dir],
233
+ [MAYAPY, script_path, self.temp_dir, json.dumps(NAMES), json.dumps(REFUSED)],
218
234
  capture_output=True, text=True, timeout=180, env=env,
219
235
  )
220
236
  if proc.stderr:
@@ -226,9 +242,12 @@ class TestClipNameContract(unittest.TestCase):
226
242
  r = self.__class__.maya_results
227
243
  self.__class__.fbx_path = r["fbx_path"]
228
244
  self.assertTrue(r["fbx_exists"])
229
- self.assertEqual(r["n_takes"], 4)
230
- # Maya half already produced the expected sanitized keys.
231
- self.assertEqual(set(r["clip_keys"]), set(EXPECTED.values()))
245
+ self.assertEqual(r["n_takes"], len(NAMES))
246
+ # The Maya half: every refused name was refused, every accepted name
247
+ # is its own take and join key, unchanged and in order.
248
+ self.assertEqual(r["refused"], REFUSED)
249
+ self.assertEqual(r["clip_keys"], NAMES)
250
+ self.assertEqual(r["meta_keys"], NAMES)
232
251
 
233
252
  @MayaEnv.skip_unless_available()
234
253
  @EnvSkip.unless(_unity_available(), "no Unity editor found")
@@ -304,18 +323,17 @@ class TestClipNameContract(unittest.TestCase):
304
323
  clip_names = set(unity.get("clip_names", []))
305
324
  clip_keys = set(unity.get("clip_keys", []))
306
325
 
307
- # Every expected sanitized name imported as a clip with that exact name —
308
- # space→_, punctuation→_, leading digit, and collision suffix all survive
309
- # Unity's FBX import verbatim. (Unity also imports a default "Take 001"
310
- # clip for the full baked timeline; it carries no shot record and is
311
- # ignored by the join — so this is a superset check, not equality.)
312
- for sanitized in EXPECTED.values():
326
+ # Every authored name imported as a clip with that exact name. (Unity
327
+ # also imports a default "Take 001" clip for the full baked timeline; it
328
+ # carries no shot record and is ignored by the join — so this is a
329
+ # superset check, not equality.)
330
+ for name in NAMES:
313
331
  self.assertIn(
314
- sanitized, clip_names,
315
- f"Unity altered/dropped clip {sanitized!r}; imported: {sorted(clip_names)}",
332
+ name, clip_names,
333
+ f"Unity altered/dropped clip {name!r}; imported: {sorted(clip_names)}",
316
334
  )
317
- # Metadata keys are exactly the four shots, and each joins to a clip.
318
- self.assertEqual(clip_keys, set(EXPECTED.values()))
335
+ # Metadata keys are exactly the authored shots, and each joins to a clip.
336
+ self.assertEqual(clip_keys, set(NAMES))
319
337
  for key in clip_keys:
320
338
  self.assertIn(key, clip_names, f"Join broken: key {key!r} has no clip.")
321
339
 
@@ -7,9 +7,9 @@ transports, curve rebinding, proxy-node deletion) and was the only one with no
7
7
  batch-Unity coverage at all. This closes the import-side half:
8
8
 
9
9
  1. Maya (mayapy): cube → two real ``EmissiveGroups.add_group`` calls with
10
- distinct default weights → ``FbxUtils.run_export_preparers()`` (the
11
- registered known-producer refresh the Scene Exporter calls, validating
12
- the ``_KNOWN_PRODUCERS`` wiring) → export mesh + ``data_export`` carrier.
10
+ distinct default weights → ``FbxUtils.publish()`` (the one publish of
11
+ every producer the Scene Exporter makes, validating the
12
+ ``FbxUtils.PRODUCERS`` wiring) → export mesh + ``data_export`` carrier.
13
13
  Then clear the groups and export a SECOND FBX with no channel.
14
14
  2. Unity (batchmode): import the first FBX with the real templates
15
15
  deployed and verify the populated ``EmissiveGroupController`` on the
@@ -30,7 +30,6 @@ Requires:
30
30
  - Unity Hub with at least one editor installed (skips on license wall)
31
31
  """
32
32
  import os
33
- import sys
34
33
  import json
35
34
  import shutil
36
35
  import unittest
@@ -70,6 +69,7 @@ import maya.standalone
70
69
  maya.standalone.initialize(name="python")
71
70
  import maya.cmds as cmds
72
71
 
72
+ import pythontk as ptk
73
73
  from mayatk.mat_utils.emissive_groups import EmissiveGroups
74
74
  from mayatk.env_utils.fbx_utils import FbxUtils
75
75
  from mayatk.node_utils.data_nodes import DataNodes
@@ -86,10 +86,10 @@ EmissiveGroups.add_group("taillights", faces=[cube + ".f[2:3]"], default=0.25)
86
86
 
87
87
  warnings = EmissiveGroups.validate()
88
88
 
89
- # The registered known-producer refresh — exactly what the Scene Exporter
90
- # calls before an export (validates the _KNOWN_PRODUCERS wiring).
91
- FbxUtils.run_export_preparers()
92
- manifest = DataNodes.get_export_string(EmissiveGroups.DATA_CHANNEL)
89
+ # The one publish of every producer -- exactly what the Scene Exporter makes
90
+ # before an export (validates the FbxUtils.PRODUCERS wiring).
91
+ FbxUtils.publish()
92
+ manifest = DataNodes.read(ptk.Scope.DELIVERABLE, EmissiveGroups.DATA_CHANNEL)
93
93
 
94
94
  if not cmds.pluginInfo("fbxmaya", query=True, loaded=True):
95
95
  cmds.loadPlugin("fbxmaya")
@@ -109,8 +109,8 @@ _export(with_path)
109
109
  # Second export with the channel gone — drives the stale-removal path.
110
110
  for name in list(EmissiveGroups.list_groups()):
111
111
  EmissiveGroups.remove_group(name)
112
- FbxUtils.run_export_preparers()
113
- cleared = DataNodes.get_export_string(EmissiveGroups.DATA_CHANNEL)
112
+ FbxUtils.publish()
113
+ cleared = DataNodes.read(ptk.Scope.DELIVERABLE, EmissiveGroups.DATA_CHANNEL)
114
114
 
115
115
  without_path = os.path.join(output_dir, "Lamp_emissive_cleared.fbx")
116
116
  _export(without_path)
@@ -233,7 +233,7 @@ class TestEmissiveGroupsIntegration(unittest.TestCase):
233
233
 
234
234
  @MayaEnv.skip_unless_available()
235
235
  def test_01_maya_export(self):
236
- """cube → two add_group calls → run_export_preparers → FBX with carrier."""
236
+ """cube → two add_group calls → FbxUtils.publish → FBX with carrier."""
237
237
  script_path = os.path.join(self.temp_dir, "maya_export.py")
238
238
  with open(script_path, "w", encoding="utf-8") as f:
239
239
  f.write(MAYA_EXPORT_SCRIPT)
@@ -57,7 +57,7 @@ import maya.standalone
57
57
  maya.standalone.initialize(name="python")
58
58
  import maya.cmds as cmds
59
59
  import maya.mel as mel
60
- from mayatk import RenderOpacity
60
+ from mayatk import RenderEffects
61
61
 
62
62
  output_dir = sys.argv[1]
63
63
  fbx_path = os.path.join(output_dir, "fade_window.fbx")
@@ -68,12 +68,12 @@ cmds.currentUnit(time="film")
68
68
  cmds.playbackOptions(min=1, max=160)
69
69
 
70
70
  cube = cmds.polyCube(name="WindowCube")[0]
71
- RenderOpacity.create(objects=[cube], mode="attribute")
71
+ RenderEffects.create(objects=[cube], mode="attribute")
72
72
 
73
73
  # Short fade-in: frame 1 → 11 (10 frames, 0.417s @ 24fps)
74
- RenderOpacity.key_fade(objects=[cube], start=1, end=11, direction="in")
74
+ RenderEffects.key_fade(objects=[cube], start=1, end=11, direction="in")
75
75
  # Long fade-out: frame 91 → 151 (60 frames, 2.5s @ 24fps)
76
- RenderOpacity.key_fade(objects=[cube], start=91, end=151, direction="out")
76
+ RenderEffects.key_fade(objects=[cube], start=91, end=151, direction="out")
77
77
 
78
78
  # Add a translate carrier so the FBX has a clip spanning the timeline
79
79
  cmds.setKeyframe(cube, attribute="translateY", time=1, value=0)
@@ -40,9 +40,7 @@ FBX_PATH = asset_path("fixture_keyed_visibility.fbx")
40
40
 
41
41
  #: Name the FBX is copied to inside the Unity project. Fixed rather than
42
42
  #: derived from FBX_PATH so the C# "Assets/<name>" literal and the importer
43
- #: allowlist stay true whichever name the fixture carries on disk (see
44
- #: base_test.TestAssets.LEGACY_ALIASES, which keeps the pre-2026-08-17
45
- #: fixture names resolving for one release).
43
+ #: allowlist stay true whatever the fixture file on disk is called.
46
44
  FBX_ASSET_NAME = "fixture_keyed_visibility.fbx"
47
45
 
48
46
  # Template CS files — need RenderEffectsController for the importer
@@ -87,18 +85,11 @@ EXPECTED_VISIBILITY_OBJECTS = [
87
85
  # Unity finder
88
86
  # ---------------------------------------------------------------------------
89
87
  def _find_unity_exe() -> str:
90
- hub = r"C:\Program Files\Unity\Hub\Editor"
91
- if not os.path.isdir(hub):
92
- return ""
93
- versions = sorted(
94
- [d for d in os.listdir(hub) if os.path.isdir(os.path.join(hub, d))],
95
- reverse=True,
96
- )
97
- for v in versions:
98
- exe = os.path.join(hub, v, "Editor", "Unity.exe")
99
- if os.path.isfile(exe):
100
- return exe
101
- return ""
88
+ """The newest installed Unity Editor, found the way the product finds it
89
+ (every Hub install location, on every OS), or ""."""
90
+ from unitytk.launcher import UnityLauncher
91
+
92
+ return UnityLauncher().executable_path or ""
102
93
 
103
94
 
104
95
  # ---------------------------------------------------------------------------
@@ -523,7 +514,7 @@ class TestKeyedVisibility(unittest.TestCase):
523
514
  logger.error(f"Unity log tail:\n{tail}")
524
515
  # Check for compilation errors
525
516
  if "error CS" in log_content:
526
- errors = [l for l in log_content.split("\n") if "error CS" in l]
517
+ errors = [ln for ln in log_content.split("\n") if "error CS" in ln]
527
518
  for e in errors[:10]:
528
519
  logger.error(f" {e.strip()}")
529
520
 
@@ -545,7 +536,7 @@ class TestKeyedVisibility(unittest.TestCase):
545
536
  self.assertTrue(r.get("import_visibility"), "importVisibility was not enabled")
546
537
  self.assertTrue(r.get("import_animation"), "importAnimation was not enabled")
547
538
  self.assertFalse(r.get("resample_curves"), "resampleCurves should be OFF")
548
- print(f" Import Settings: visibility=ON, animation=ON, resample=OFF")
539
+ print(" Import Settings: visibility=ON, animation=ON, resample=OFF")
549
540
 
550
541
  # Prefab
551
542
  self.assertTrue(r.get("prefab_loaded"), "FBX prefab failed to load")
@@ -626,7 +617,7 @@ class TestKeyedVisibility(unittest.TestCase):
626
617
  print(f" Found with curves: {len(found)}")
627
618
  print(f" MISSING curves: {len(missing)}")
628
619
  if missing:
629
- print(f"\n *** MISSING VISIBILITY CURVES ***")
620
+ print("\n *** MISSING VISIBILITY CURVES ***")
630
621
  for m in missing:
631
622
  in_hierarchy = any(m in obj for obj in loc_objects)
632
623
  status = "IN HIERARCHY" if in_hierarchy else "NOT IN FBX"
@@ -638,7 +629,7 @@ class TestKeyedVisibility(unittest.TestCase):
638
629
  if sample_error:
639
630
  print(f"\n Sampling Error: {sample_error}")
640
631
  elif sample_results:
641
- print(f"\n AnimationMode Sampling:")
632
+ print("\n AnimationMode Sampling:")
642
633
  for sr in sample_results:
643
634
  print(f" {sr}")
644
635
 
@@ -657,8 +648,8 @@ class TestKeyedVisibility(unittest.TestCase):
657
648
  print(
658
649
  f"\n WARNING: {len(missing)} expected objects missing visibility curves"
659
650
  )
660
- print(f" These objects may need .visibility keyed directly in Maya")
661
- print(f" (display layer visibility does NOT export to FBX)")
651
+ print(" These objects may need .visibility keyed directly in Maya")
652
+ print(" (display layer visibility does NOT export to FBX)")
662
653
 
663
654
  print("\n VERDICT: See results above for visibility import status.")
664
655
  print("=" * 70 + "\n")
@@ -1,7 +1,7 @@
1
1
  import unittest
2
2
  import os
3
3
  import sys
4
- from unittest.mock import patch, MagicMock
4
+ from unittest.mock import patch
5
5
 
6
6
  # Ensure unitytk and module imports work whether run directly or via runner
7
7
  try:
@@ -15,34 +15,70 @@ from unitytk.launcher import UnityFinder, UnityLauncher
15
15
 
16
16
 
17
17
  class TestUnityLauncher(UnityTkTestCase):
18
- @patch("unitytk.launcher.UnityFinder.get_hub_editor_path")
19
- @patch("os.listdir")
20
- @patch("os.path.isdir")
21
- @patch("os.path.exists")
22
- @patch("platform.system")
23
- def test_find_editors_windows(
24
- self, mock_system, mock_exists, mock_isdir, mock_listdir, mock_get_hub
25
- ):
26
- """Verify that find_editors correctly identifies Unity executables on Windows."""
27
- mock_system.return_value = "windows"
28
- mock_get_hub.return_value = "C:\\Hub"
29
- mock_listdir.return_value = ["2021.1.0f1", "2022.2.0b1"]
30
-
31
- # Mock isdir behavior: return True if path contains the mocked versions
32
- mock_isdir.return_value = True
33
-
34
- # Mock existence: Hub exists, and the Unity.exe inside the version folders exist
35
- mock_exists.side_effect = lambda p: (
36
- True if p == "C:\\Hub" or "Unity.exe" in p else False
18
+ def test_find_editors_windows(self):
19
+ """Windows: <Hub>/<ver>/Editor/Unity.exe, found through the shared
20
+ install scan (a real tree -- no listdir/exists mocks to drift from it).
21
+ A version folder without the binary is not an editor."""
22
+ import tempfile
23
+
24
+ with tempfile.TemporaryDirectory() as hub:
25
+ for version in ("2021.1.0f1", "2022.2.0b1"):
26
+ exe = os.path.join(hub, version, "Editor", "Unity.exe")
27
+ os.makedirs(os.path.dirname(exe))
28
+ open(exe, "w").close()
29
+ os.makedirs(os.path.join(hub, "2023.1.0f1", "Editor")) # no binary
30
+ with patch("platform.system", return_value="Windows"), patch(
31
+ "unitytk.launcher.UnityFinder.get_hub_editor_paths", return_value=[hub]
32
+ ):
33
+ editors = UnityFinder.find_editors()
34
+
35
+ self.assertEqual(sorted(editors), ["2021.1.0f1", "2022.2.0b1"])
36
+ self.assertEqual(
37
+ os.path.normpath(editors["2021.1.0f1"]),
38
+ os.path.join(hub, "2021.1.0f1", "Editor", "Unity.exe"),
37
39
  )
38
40
 
39
- editors = UnityFinder.find_editors()
40
-
41
- self.assertIn("2021.1.0f1", editors)
42
- self.assertIn("2022.2.0b1", editors)
41
+ def test_find_editors_reads_the_hubs_install_location_on_linux(self):
42
+ """Linux: ~/Unity/Hub/Editor/<ver>/Editor/Unity, and the location picked
43
+ in the Hub's preferences (secondaryInstallPath.json, where new installs
44
+ go) -- scanned first, on every OS. Only the default was ever read."""
45
+ import json
46
+ import tempfile
47
+
48
+ with tempfile.TemporaryDirectory() as home:
49
+ default = os.path.join(home, "Unity", "Hub", "Editor")
50
+ custom = os.path.join(home, "editors")
51
+ config = os.path.join(home, ".config", "UnityHub")
52
+ for root, version in ((default, "2022.3.1f1"), (custom, "6000.0.1f1")):
53
+ exe = os.path.join(root, version, "Editor", "Unity")
54
+ os.makedirs(os.path.dirname(exe))
55
+ open(exe, "w").close()
56
+ os.makedirs(config)
57
+ with open(os.path.join(config, "secondaryInstallPath.json"), "w") as f:
58
+ json.dump(custom, f)
59
+ env = {"HOME": home, "XDG_CONFIG_HOME": os.path.join(home, ".config")}
60
+
61
+ def expanduser(path):
62
+ # A LEADING "~" only, as the real one: the scan expands every
63
+ # pattern, and a Windows temp dir can hold one mid-path (the
64
+ # 8.3 name C:\Users\RUNNER~1 on GitHub's runners).
65
+ return home + path[1:] if path.startswith("~") else path
66
+
67
+ with patch("platform.system", return_value="Linux"), patch.dict(
68
+ os.environ, env
69
+ ), patch("os.path.expanduser", expanduser):
70
+ self.assertEqual(
71
+ [os.path.normpath(r) for r in UnityFinder.get_hub_editor_paths()],
72
+ [custom, default],
73
+ )
74
+ editors = {
75
+ v: os.path.normpath(e) for v, e in UnityFinder.find_editors().items()
76
+ }
77
+ self.assertEqual(
78
+ editors["6000.0.1f1"], os.path.join(custom, "6000.0.1f1", "Editor", "Unity")
79
+ )
43
80
  self.assertEqual(
44
- editors["2021.1.0f1"],
45
- os.path.join("C:\\Hub", "2021.1.0f1", "Editor", "Unity.exe"),
81
+ editors["2022.3.1f1"], os.path.join(default, "2022.3.1f1", "Editor", "Unity")
46
82
  )
47
83
 
48
84
  @patch("unitytk.launcher.UnityFinder.find_editors")