unitytk 0.0.1__tar.gz → 0.0.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 (55) hide show
  1. {unitytk-0.0.1 → unitytk-0.0.2}/LICENSE +21 -21
  2. unitytk-0.0.2/MANIFEST.in +4 -0
  3. unitytk-0.0.2/PKG-INFO +29 -0
  4. unitytk-0.0.2/docs/README.md +12 -0
  5. {unitytk-0.0.1 → unitytk-0.0.2}/pyproject.toml +32 -32
  6. {unitytk-0.0.1 → unitytk-0.0.2}/setup.cfg +4 -4
  7. unitytk-0.0.2/test/test_asset_delivery.py +343 -0
  8. unitytk-0.0.2/test/test_audio_events_integration.py +804 -0
  9. unitytk-0.0.2/test/test_bool_to_fade_curve.py +286 -0
  10. {unitytk-0.0.1 → unitytk-0.0.2}/test/test_c130_fcr_integration.py +1295 -1283
  11. unitytk-0.0.2/test/test_c5m_visibility.py +636 -0
  12. unitytk-0.0.2/test/test_clip_name_contract_integration.py +328 -0
  13. unitytk-0.0.2/test/test_deploy_templates.py +126 -0
  14. unitytk-0.0.2/test/test_fade_window_preservation.py +317 -0
  15. {unitytk-0.0.1 → unitytk-0.0.2}/test/test_launcher.py +159 -95
  16. unitytk-0.0.2/test/test_lightmap_metadata_integration.py +571 -0
  17. {unitytk-0.0.1 → unitytk-0.0.2}/test/test_render_opacity_controller.py +357 -357
  18. unitytk-0.0.2/test/test_render_opacity_e2e.py +657 -0
  19. {unitytk-0.0.1 → unitytk-0.0.2}/test/test_render_opacity_integration.py +595 -595
  20. unitytk-0.0.2/test/test_scene_builder.py +87 -0
  21. unitytk-0.0.2/test/test_shadow_plane_integration.py +418 -0
  22. unitytk-0.0.2/test/test_shared_material_standalone.py +684 -0
  23. unitytk-0.0.2/test/test_shot_metadata_integration.py +526 -0
  24. unitytk-0.0.2/test/test_shots_audio_sidebyside_integration.py +556 -0
  25. {unitytk-0.0.1 → unitytk-0.0.2}/test/test_standalone_enduser.py +1511 -1492
  26. unitytk-0.0.2/unitytk/__init__.py +15 -0
  27. unitytk-0.0.2/unitytk/asset_delivery.py +280 -0
  28. {unitytk-0.0.1 → unitytk-0.0.2}/unitytk/launcher.py +291 -231
  29. {unitytk-0.0.1 → unitytk-0.0.2}/unitytk/scene_builder.py +131 -86
  30. unitytk-0.0.2/unitytk/template_deployer.py +92 -0
  31. {unitytk-0.0.1 → unitytk-0.0.2}/unitytk/templates/AudioEventController.cs +587 -471
  32. unitytk-0.0.2/unitytk/templates/EmissiveGroupController.cs +447 -0
  33. unitytk-0.0.2/unitytk/templates/EmissiveGroups.hlsl +55 -0
  34. unitytk-0.0.2/unitytk/templates/EmissiveGroupsLit.shader +252 -0
  35. unitytk-0.0.2/unitytk/templates/LightmapMetadataController.cs +418 -0
  36. unitytk-0.0.2/unitytk/templates/README.md +148 -0
  37. {unitytk-0.0.1 → unitytk-0.0.2}/unitytk/templates/RenderOpacityController.cs +732 -602
  38. unitytk-0.0.2/unitytk/templates/ShadowPlaneController.cs +300 -0
  39. unitytk-0.0.2/unitytk/templates/ShotMetadataController.cs +171 -0
  40. {unitytk-0.0.1 → unitytk-0.0.2}/unitytk/templates/__init__.py +2 -2
  41. {unitytk-0.0.1 → unitytk-0.0.2}/unitytk/templates/maya_fbx_import_config.json +6 -6
  42. unitytk-0.0.2/unitytk.egg-info/PKG-INFO +29 -0
  43. unitytk-0.0.2/unitytk.egg-info/SOURCES.txt +44 -0
  44. unitytk-0.0.1/MANIFEST.in +0 -4
  45. unitytk-0.0.1/PKG-INFO +0 -20
  46. unitytk-0.0.1/docs/README.md +0 -3
  47. unitytk-0.0.1/test/test_audio_events_integration.py +0 -600
  48. unitytk-0.0.1/test/test_audio_trigger_roundtrip.py +0 -790
  49. unitytk-0.0.1/unitytk/__init__.py +0 -30
  50. unitytk-0.0.1/unitytk/templates/README.md +0 -97
  51. unitytk-0.0.1/unitytk.egg-info/PKG-INFO +0 -20
  52. unitytk-0.0.1/unitytk.egg-info/SOURCES.txt +0 -24
  53. {unitytk-0.0.1 → unitytk-0.0.2}/unitytk.egg-info/dependency_links.txt +0 -0
  54. {unitytk-0.0.1 → unitytk-0.0.2}/unitytk.egg-info/requires.txt +0 -0
  55. {unitytk-0.0.1 → unitytk-0.0.2}/unitytk.egg-info/top_level.txt +0 -0
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 m3trik@live.com
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 m3trik@live.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ include LICENSE
2
+ include docs/README.md
3
+ include pyproject.toml
4
+ recursive-include unitytk *.ui *.gif *.png *.json *.txt *.cs *.hlsl *.shader
unitytk-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: unitytk
3
+ Version: 0.0.2
4
+ Summary: A modular Python toolkit for Unity interaction.
5
+ Author-email: Ryan Simpson <m3trik@outlook.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/unitytk/unitytk
8
+ Project-URL: Repository, https://github.com/unitytk/unitytk
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: pythontk>=0.7.71
16
+ Dynamic: license-file
17
+
18
+ [![Tests](https://img.shields.io/badge/Tests-111%20passed-brightgreen.svg)](../test/)
19
+
20
+ # unitytk
21
+
22
+ A modular Python toolkit for Unity interaction — Python-side helpers for preparing DCC exports for Unity, plus the C# editor scripts they pair with (shipped under [`templates/`](../unitytk/templates/README.md)).
23
+
24
+ ## More
25
+
26
+ - [`templates/README.md`](../unitytk/templates/README.md) — the C# template catalog (opacity, audio events, shot metadata, lightmaps), the FBX user-property import contract, and deployment (`TemplateDeployer.deploy_all`)
27
+ - [mayatk's `data_nodes.md`](../../mayatk/docs/data_nodes.md) — the DCC-side producer spec these templates consume (`data_export` carrier + channel registry)
28
+ - [`CHANGELOG.md`](../CHANGELOG.md) — notable changes
29
+ - [`CLAUDE.md`](../CLAUDE.md) — contributor / agent conventions
@@ -0,0 +1,12 @@
1
+ [![Tests](https://img.shields.io/badge/Tests-111%20passed-brightgreen.svg)](../test/)
2
+
3
+ # unitytk
4
+
5
+ A modular Python toolkit for Unity interaction — Python-side helpers for preparing DCC exports for Unity, plus the C# editor scripts they pair with (shipped under [`templates/`](../unitytk/templates/README.md)).
6
+
7
+ ## More
8
+
9
+ - [`templates/README.md`](../unitytk/templates/README.md) — the C# template catalog (opacity, audio events, shot metadata, lightmaps), the FBX user-property import contract, and deployment (`TemplateDeployer.deploy_all`)
10
+ - [mayatk's `data_nodes.md`](../../mayatk/docs/data_nodes.md) — the DCC-side producer spec these templates consume (`data_export` carrier + channel registry)
11
+ - [`CHANGELOG.md`](../CHANGELOG.md) — notable changes
12
+ - [`CLAUDE.md`](../CLAUDE.md) — contributor / agent conventions
@@ -1,32 +1,32 @@
1
- [build-system]
2
- requires = ["setuptools>=61.0", "wheel"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "unitytk"
7
- dynamic = ["version", "readme"]
8
- description = "A modular Python toolkit for Unity interaction."
9
- authors = [{name = "Ryan Simpson", email = "m3trik@outlook.com"}]
10
- license = {text = "MIT"}
11
- requires-python = ">=3.9"
12
- classifiers = [
13
- "Programming Language :: Python :: 3",
14
- "License :: OSI Approved :: MIT License",
15
- "Operating System :: OS Independent",
16
- ]
17
- dependencies = ["pythontk>=0.7.71"]
18
-
19
- [project.urls]
20
- Homepage = "https://github.com/unitytk/unitytk"
21
- Repository = "https://github.com/unitytk/unitytk"
22
-
23
- [tool.setuptools.dynamic]
24
- version = {attr = "unitytk.__version__"}
25
- readme = {file = "docs/README.md", content-type = "text/markdown"}
26
-
27
- [tool.setuptools.packages.find]
28
- where = ["."]
29
- include = ["unitytk*"]
30
-
31
- [tool.setuptools.package-data]
32
- "*" = ["*.ui", "*.json", "*.txt", "*.md", "*.cs"]
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "unitytk"
7
+ dynamic = ["version", "readme"]
8
+ description = "A modular Python toolkit for Unity interaction."
9
+ authors = [{name = "Ryan Simpson", email = "m3trik@outlook.com"}]
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+ dependencies = ["pythontk>=0.7.71"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/unitytk/unitytk"
21
+ Repository = "https://github.com/unitytk/unitytk"
22
+
23
+ [tool.setuptools.dynamic]
24
+ version = {attr = "unitytk.__version__"}
25
+ readme = {file = "docs/README.md", content-type = "text/markdown"}
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["."]
29
+ include = ["unitytk*"]
30
+
31
+ [tool.setuptools.package-data]
32
+ "*" = ["*.ui", "*.json", "*.txt", "*.md", "*.cs", "*.hlsl", "*.shader"]
@@ -1,4 +1,4 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,343 @@
1
+ # !/usr/bin/python
2
+ # coding=utf-8
3
+ """Tests for unitytk.CopyToAssetsDeliverer -- copy-to-Assets hand-off Strategy (no DCC)."""
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ import unittest
8
+
9
+ from pythontk.core_utils.app_handoff import HandoffRequest, Payload
10
+ from unitytk.asset_delivery import (
11
+ CopyToAssetsDeliverer,
12
+ FileToUnityBridge,
13
+ CopyToAssetsDeliverer,
14
+ )
15
+
16
+
17
+ class _Logger:
18
+ """Minimal logger capturing messages by level."""
19
+
20
+ def __init__(self):
21
+ self.errors = []
22
+ self.warnings = []
23
+ self.infos = []
24
+
25
+ def error(self, m):
26
+ self.errors.append(m)
27
+
28
+ def warning(self, m):
29
+ self.warnings.append(m)
30
+
31
+ def info(self, m):
32
+ self.infos.append(m)
33
+
34
+
35
+ class _StubBridge:
36
+ """A bare host with the contract the deliverer reads off the bridge."""
37
+
38
+ def __init__(self, project_path=None):
39
+ self.project_path = project_path
40
+ self.logger = _Logger()
41
+
42
+
43
+ def _request(params=None):
44
+ return HandoffRequest(template="import", mode="copy_to_assets", params=params or {})
45
+
46
+
47
+ def _payload(fbx, default_name="Export"):
48
+ return Payload(primary=fbx, extras={"default_asset_name": default_name})
49
+
50
+
51
+ class SanitizeTest(unittest.TestCase):
52
+ def test_strips_invalid_and_paths(self):
53
+ self.assertEqual(CopyToAssetsDeliverer.sanitize_asset_name("my/model:v2"), "my_model_v2")
54
+
55
+ def test_collapses_and_trims(self):
56
+ self.assertEqual(CopyToAssetsDeliverer.sanitize_asset_name(" a b "), "a b")
57
+
58
+ def test_empty_falls_back(self):
59
+ self.assertEqual(CopyToAssetsDeliverer.sanitize_asset_name("///"), "Export")
60
+ self.assertEqual(CopyToAssetsDeliverer.sanitize_asset_name(""), "Export")
61
+
62
+
63
+ class DeliveryTest(unittest.TestCase):
64
+ def setUp(self):
65
+ self.tmp = tempfile.mkdtemp()
66
+ self.project = os.path.join(self.tmp, "MyUnityProj")
67
+ os.makedirs(os.path.join(self.project, "Assets"))
68
+ # A fake exported FBX.
69
+ self.fbx = os.path.join(self.tmp, "export.fbx")
70
+ with open(self.fbx, "wb") as fh:
71
+ fh.write(b"FBX-BYTES")
72
+ self.deliverer = CopyToAssetsDeliverer()
73
+
74
+ def tearDown(self):
75
+ shutil.rmtree(self.tmp, ignore_errors=True)
76
+
77
+ def _deliver(self, params, default_name="Export", project=None):
78
+ bridge = _StubBridge(project_path=project or self.project)
79
+ result = self.deliverer.deliver(
80
+ bridge, _payload(self.fbx, default_name), _request(params)
81
+ )
82
+ return bridge, result
83
+
84
+ def test_preflight_rejects_non_project(self):
85
+ bridge = _StubBridge(project_path=os.path.join(self.tmp, "not_a_project"))
86
+ self.assertFalse(self.deliverer.preflight(bridge, _request()))
87
+ self.assertTrue(bridge.logger.errors)
88
+
89
+ def test_preflight_accepts_project(self):
90
+ bridge = _StubBridge(project_path=self.project)
91
+ self.assertTrue(self.deliverer.preflight(bridge, _request()))
92
+
93
+ def test_deliver_copies_into_assets_subdir(self):
94
+ _bridge, result = self._deliver(
95
+ {"ASSETS_SUBDIR": "Models"}, default_name="Robot"
96
+ )
97
+ self.assertIsNotNone(result)
98
+ dest = result["asset"]
99
+ self.assertEqual(
100
+ dest, os.path.join(self.project, "Assets", "Models", "Robot.fbx")
101
+ )
102
+ self.assertTrue(os.path.isfile(dest))
103
+ self.assertEqual(open(dest, "rb").read(), b"FBX-BYTES")
104
+ self.assertFalse(result["launched"])
105
+
106
+ def test_deliver_uses_default_subdir_and_asset_name(self):
107
+ _bridge, result = self._deliver({}, default_name="Cube")
108
+ self.assertEqual(
109
+ result["asset"],
110
+ os.path.join(self.project, "Assets", "Imported", "Cube.fbx"),
111
+ )
112
+
113
+ def test_explicit_asset_name_overrides_and_sanitizes(self):
114
+ _bridge, result = self._deliver({"ASSET_NAME": "Hero/Mesh:01"}, default_name="Cube")
115
+ self.assertEqual(os.path.basename(result["asset"]), "Hero_Mesh_01.fbx")
116
+
117
+ def test_launch_mode_open_invokes_windowed_launch(self):
118
+ # Patch the deliverer's launch hook to avoid spawning a real editor.
119
+ bridge = _StubBridge(project_path=self.project)
120
+ called = {}
121
+
122
+ def _fake_launch(b, params=None, *, batch_mode=False):
123
+ called["project"] = b.project_path
124
+ called["batch"] = batch_mode
125
+ return True
126
+
127
+ self.deliverer._launch_editor = staticmethod(_fake_launch)
128
+ result = self.deliverer.deliver(
129
+ bridge, _payload(self.fbx, "Cube"), _request({"LAUNCH_MODE": "open"})
130
+ )
131
+ self.assertTrue(result["launched"])
132
+ self.assertEqual(called["project"], self.project)
133
+ self.assertFalse(called["batch"]) # "open" -> windowed, not batch
134
+
135
+ def test_launch_mode_headless_runs_batch(self):
136
+ bridge = _StubBridge(project_path=self.project)
137
+ called = {}
138
+
139
+ def _fake_launch(b, params=None, *, batch_mode=False):
140
+ called["batch"] = batch_mode
141
+ return True
142
+
143
+ self.deliverer._launch_editor = staticmethod(_fake_launch)
144
+ result = self.deliverer.deliver(
145
+ bridge, _payload(self.fbx, "Cube"), _request({"LAUNCH_MODE": "headless"})
146
+ )
147
+ self.assertTrue(result["launched"])
148
+ self.assertTrue(called["batch"]) # "headless" -> batch mode
149
+
150
+ def test_no_launch_mode_does_not_launch(self):
151
+ bridge = _StubBridge(project_path=self.project)
152
+ called = {"hit": False}
153
+
154
+ def _fake_launch(b, params=None, *, batch_mode=False):
155
+ called["hit"] = True
156
+ return True
157
+
158
+ self.deliverer._launch_editor = staticmethod(_fake_launch)
159
+ result = self.deliverer.deliver(
160
+ bridge, _payload(self.fbx, "Cube"), _request({})
161
+ )
162
+ self.assertFalse(result["launched"])
163
+ self.assertFalse(called["hit"])
164
+
165
+ def test_legacy_launch_editor_bool_is_ignored(self):
166
+ """The legacy boolean LAUNCH_EDITOR is no longer honored — only LAUNCH_MODE
167
+ drives launching, so an old key resolves to no-launch."""
168
+ bridge = _StubBridge(project_path=self.project)
169
+ called = {"hit": False}
170
+
171
+ def _fake_launch(b, params=None, *, batch_mode=False):
172
+ called["hit"] = True
173
+ return True
174
+
175
+ self.deliverer._launch_editor = staticmethod(_fake_launch)
176
+ result = self.deliverer.deliver(
177
+ bridge, _payload(self.fbx, "Cube"), _request({"LAUNCH_EDITOR": True})
178
+ )
179
+ self.assertFalse(result["launched"])
180
+ self.assertFalse(called["hit"])
181
+
182
+ def test_launch_passes_unity_version_to_launcher(self):
183
+ """A chosen UNITY_VERSION is forwarded to the launcher's executable_path."""
184
+ import unitytk.asset_delivery as ad
185
+
186
+ bridge = _StubBridge(project_path=self.project)
187
+ seen = {}
188
+
189
+ class _FakeLauncher:
190
+ def __init__(self, executable_path=None, project_path=None):
191
+ seen["executable_path"] = executable_path
192
+ seen["project_path"] = project_path
193
+ self.executable_path = executable_path or "auto.exe"
194
+
195
+ def launch_editor(self, batch_mode=False, extra_args=None, detached=True):
196
+ seen["launched"] = True
197
+ seen["batch_mode"] = batch_mode
198
+ return object()
199
+
200
+ orig = ad.CopyToAssetsDeliverer._launch_editor
201
+ # Patch UnityLauncher inside the real hook (deferred import target).
202
+ import unitytk.launcher as launcher_mod
203
+ real = launcher_mod.UnityLauncher
204
+ launcher_mod.UnityLauncher = _FakeLauncher
205
+ try:
206
+ self.deliverer._launch_editor = orig # use the real hook
207
+ self.deliverer.deliver(
208
+ bridge,
209
+ _payload(self.fbx, "Cube"),
210
+ _request({"LAUNCH_MODE": "open", "UNITY_VERSION": "2022.3.4f1"}),
211
+ )
212
+ finally:
213
+ launcher_mod.UnityLauncher = real
214
+ self.assertEqual(seen["executable_path"], "2022.3.4f1")
215
+ self.assertEqual(seen["project_path"], self.project)
216
+ self.assertTrue(seen.get("launched"))
217
+
218
+ def test_deliver_rejects_missing_assets(self):
219
+ _bridge, result = self._deliver({}, project=os.path.join(self.tmp, "nope"))
220
+ self.assertIsNone(result)
221
+
222
+ def test_subdir_supports_nested_and_either_separator(self):
223
+ for raw in ("Models/Hero", r"Models\Hero", "Models//Hero/", "/Models/Hero"):
224
+ _bridge, result = self._deliver({"ASSETS_SUBDIR": raw}, default_name="Cube")
225
+ self.assertEqual(
226
+ result["asset"],
227
+ os.path.join(self.project, "Assets", "Models", "Hero", "Cube.fbx"),
228
+ f"raw={raw!r}",
229
+ )
230
+ self.assertEqual(result["subdir"], "Models/Hero")
231
+
232
+ def test_subdir_cannot_escape_assets_via_traversal(self):
233
+ # '..' components are dropped -> stays inside Assets/ (no path traversal).
234
+ _bridge, result = self._deliver(
235
+ {"ASSETS_SUBDIR": "../../Plugins"}, default_name="Cube"
236
+ )
237
+ dest = result["asset"]
238
+ self.assertEqual(
239
+ dest, os.path.join(self.project, "Assets", "Plugins", "Cube.fbx")
240
+ )
241
+ # The resolved path is genuinely under Assets/ (defensive realpath check).
242
+ assets = os.path.realpath(os.path.join(self.project, "Assets"))
243
+ self.assertTrue(os.path.realpath(dest).startswith(assets))
244
+
245
+ def test_subdir_cannot_escape_assets_via_drive_letter(self):
246
+ # A Windows drive designator in a component ('C:/Models', or the
247
+ # drive-relative 'C:Models') must not make os.path.join produce a path
248
+ # that escapes Assets/ (which then also crashed os.path.relpath cross-drive).
249
+ for raw, expected_sub in (
250
+ ("C:/Models", os.path.join("Assets", "Models")),
251
+ ("C:Models", os.path.join("Assets", "Models")),
252
+ ("C:", "Assets"),
253
+ (r"C:\Models\Hero", os.path.join("Assets", "Models", "Hero")),
254
+ ):
255
+ _bridge, result = self._deliver({"ASSETS_SUBDIR": raw}, default_name="Cube")
256
+ self.assertIsNotNone(result, f"raw={raw!r}")
257
+ dest = result["asset"]
258
+ self.assertEqual(
259
+ dest,
260
+ os.path.join(self.project, expected_sub, "Cube.fbx"),
261
+ f"raw={raw!r}",
262
+ )
263
+ # Genuinely under Assets/ (no drive escape).
264
+ assets = os.path.realpath(os.path.join(self.project, "Assets"))
265
+ self.assertTrue(
266
+ os.path.realpath(dest).startswith(assets), f"raw={raw!r}"
267
+ )
268
+
269
+ def test_explicit_subdir_reducing_to_nothing_drops_into_assets_root(self):
270
+ for raw in (".", "/", "../.."):
271
+ _bridge, result = self._deliver({"ASSETS_SUBDIR": raw}, default_name="Cube")
272
+ self.assertEqual(
273
+ result["asset"], os.path.join(self.project, "Assets", "Cube.fbx"),
274
+ f"raw={raw!r}",
275
+ )
276
+ self.assertEqual(result["subdir"], "")
277
+
278
+ def test_blank_subdir_falls_back_to_default(self):
279
+ for raw in ("", " "):
280
+ _bridge, result = self._deliver({"ASSETS_SUBDIR": raw}, default_name="Cube")
281
+ self.assertEqual(
282
+ result["asset"],
283
+ os.path.join(self.project, "Assets", "Imported", "Cube.fbx"),
284
+ f"raw={raw!r}",
285
+ )
286
+
287
+
288
+ class FileToUnityBridgeTest(unittest.TestCase):
289
+ """The DCC-free file-driven bridge: pick a model file -> copy into Assets/."""
290
+
291
+ def setUp(self):
292
+ self.tmp = tempfile.mkdtemp()
293
+ self.project = os.path.join(self.tmp, "MyUnityProj")
294
+ os.makedirs(os.path.join(self.project, "Assets"))
295
+ self.fbx = os.path.join(self.tmp, "robot.fbx")
296
+ with open(self.fbx, "wb") as fh:
297
+ fh.write(b"FBX-BYTES")
298
+
299
+ def tearDown(self):
300
+ shutil.rmtree(self.tmp, ignore_errors=True)
301
+
302
+ def test_send_copies_file_into_assets(self):
303
+ bridge = FileToUnityBridge(project_path=self.project)
304
+ result = bridge.send(model_path=self.fbx, params={"ASSETS_SUBDIR": "Models"})
305
+ self.assertIsNotNone(result)
306
+ dest = os.path.join(self.project, "Assets", "Models", "robot.fbx")
307
+ self.assertEqual(result["asset"], dest)
308
+ self.assertTrue(os.path.isfile(dest))
309
+ self.assertFalse(result["launched"])
310
+
311
+ def test_send_default_asset_name_is_file_stem(self):
312
+ bridge = FileToUnityBridge(project_path=self.project)
313
+ result = bridge.send(model_path=self.fbx, params={})
314
+ self.assertEqual(
315
+ result["asset"],
316
+ os.path.join(self.project, "Assets", "Imported", "robot.fbx"),
317
+ )
318
+
319
+ def test_send_preserves_non_fbx_extension(self):
320
+ obj = os.path.join(self.tmp, "scan.obj")
321
+ with open(obj, "wb") as fh:
322
+ fh.write(b"OBJ")
323
+ bridge = FileToUnityBridge(project_path=self.project)
324
+ result = bridge.send(model_path=obj, params={})
325
+ self.assertEqual(os.path.basename(result["asset"]), "scan.obj")
326
+
327
+ def test_send_missing_file_returns_none(self):
328
+ bridge = FileToUnityBridge(project_path=self.project)
329
+ self.assertIsNone(bridge.send(model_path=os.path.join(self.tmp, "nope.fbx")))
330
+
331
+ def test_send_not_a_project_returns_none(self):
332
+ bridge = FileToUnityBridge(project_path=os.path.join(self.tmp, "not_a_proj"))
333
+ self.assertIsNone(bridge.send(model_path=self.fbx))
334
+
335
+ def test_list_template_modes_matches_deliverer(self):
336
+ bridge = FileToUnityBridge()
337
+ self.assertEqual(
338
+ bridge.list_template_modes(), list(CopyToAssetsDeliverer.DELIVERY_MODES)
339
+ )
340
+
341
+
342
+ if __name__ == "__main__":
343
+ unittest.main()