unitytk 0.1.0__tar.gz → 0.2.0__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 (102) hide show
  1. {unitytk-0.1.0/unitytk.egg-info → unitytk-0.2.0}/PKG-INFO +2 -2
  2. {unitytk-0.1.0 → unitytk-0.2.0}/pyproject.toml +6 -4
  3. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_articulated_rig_runtime.py +310 -1
  4. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_asset_delivery.py +190 -9
  5. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_base_test.py +109 -0
  6. unitytk-0.2.0/test/test_blender_render_effects_e2e.py +496 -0
  7. unitytk-0.2.0/test/test_blender_shadow_plane_integration.py +875 -0
  8. unitytk-0.2.0/test/test_blender_shot_metadata_integration.py +472 -0
  9. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_deploy_templates.py +14 -5
  10. unitytk-0.2.0/test/test_embedded_dependencies_integration.py +461 -0
  11. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_lightmap_metadata_integration.py +244 -4
  12. unitytk-0.2.0/test/test_maya_surface_materials_integration.py +1027 -0
  13. unitytk-0.2.0/test/test_render_effects_e2e.py +1002 -0
  14. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_run_tests.py +149 -1
  15. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_scene_builder.py +1 -1
  16. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_shadow_plane_integration.py +82 -3
  17. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_shadow_plane_runtime_integration.py +402 -2
  18. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_shots_audio_sidebyside_integration.py +130 -10
  19. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/__init__.py +1 -1
  20. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/asset_delivery.py +56 -1
  21. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/template_deployer.py +6 -2
  22. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/ArticulatedRigController.cs +113 -20
  23. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/AudioEvent/AudioEventController.cs +176 -2
  24. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/EmissiveGroup/EmissiveGroupController.cs +1 -1
  25. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/LightmapMetadata/LightmapMetadataController.cs +402 -64
  26. unitytk-0.2.0/unitytk/templates/MayaSurface/MayaSurfaceMaterialImporter.cs +763 -0
  27. unitytk-0.2.0/unitytk/templates/MayaSurface/MayaSurfaceMaterialImporter.cs.meta +11 -0
  28. unitytk-0.2.0/unitytk/templates/MayaSurface.meta +8 -0
  29. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/README.md +47 -15
  30. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/RenderEffects/RenderEffectsController.cs +443 -30
  31. unitytk-0.2.0/unitytk/templates/ShadowPlane/ShadowPlane.shader +319 -0
  32. unitytk-0.2.0/unitytk/templates/ShadowPlane/ShadowPlaneController.cs +1949 -0
  33. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ShadowPlane/ShadowPlaneHorizon.hlsl +157 -63
  34. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ShotMetadata/ShotMetadataController.cs +2 -2
  35. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/UnitytkSettings.cs +118 -15
  36. {unitytk-0.1.0 → unitytk-0.2.0/unitytk.egg-info}/PKG-INFO +2 -2
  37. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk.egg-info/SOURCES.txt +8 -0
  38. unitytk-0.2.0/unitytk.egg-info/requires.txt +1 -0
  39. unitytk-0.1.0/test/test_render_effects_e2e.py +0 -479
  40. unitytk-0.1.0/unitytk/templates/ShadowPlane/ShadowPlane.shader +0 -223
  41. unitytk-0.1.0/unitytk/templates/ShadowPlane/ShadowPlaneController.cs +0 -1201
  42. unitytk-0.1.0/unitytk.egg-info/requires.txt +0 -1
  43. {unitytk-0.1.0 → unitytk-0.2.0}/LICENSE +0 -0
  44. {unitytk-0.1.0 → unitytk-0.2.0}/MANIFEST.in +0 -0
  45. {unitytk-0.1.0 → unitytk-0.2.0}/docs/README.md +0 -0
  46. {unitytk-0.1.0 → unitytk-0.2.0}/setup.cfg +0 -0
  47. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_audio_events_integration.py +0 -0
  48. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_bool_to_fade_curve.py +0 -0
  49. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_clip_name_contract_integration.py +0 -0
  50. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_controller_runtime_integration.py +0 -0
  51. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_emissive_groups_integration.py +0 -0
  52. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_fade_window_preservation.py +0 -0
  53. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_keyed_visibility.py +0 -0
  54. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_launcher.py +0 -0
  55. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_multi_section_audio_integration.py +0 -0
  56. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_render_opacity_controller.py +0 -0
  57. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_render_opacity_e2e.py +0 -0
  58. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_render_opacity_integration.py +0 -0
  59. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_shared_material_standalone.py +0 -0
  60. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_shot_metadata_integration.py +0 -0
  61. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_standalone_enduser.py +0 -0
  62. {unitytk-0.1.0 → unitytk-0.2.0}/test/test_temp_projects.py +0 -0
  63. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/launcher.py +0 -0
  64. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/scene_builder.py +0 -0
  65. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/ArticulatedRigController.cs.meta +0 -0
  66. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/ArticulatedRigPointerGrab.cs +0 -0
  67. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/ArticulatedRigPointerGrab.cs.meta +0 -0
  68. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/ArticulationRecord.g.cs +0 -0
  69. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/ArticulationRecord.g.cs.meta +0 -0
  70. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRGrab.cs +0 -0
  71. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRGrab.cs.meta +0 -0
  72. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRSetup.cs +0 -0
  73. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/XR/ArticulatedRigXRSetup.cs.meta +0 -0
  74. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/XR/M3trik.Unitytk.XR.asmdef +0 -0
  75. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/XR/M3trik.Unitytk.XR.asmdef.meta +0 -0
  76. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig/XR.meta +0 -0
  77. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ArticulatedRig.meta +0 -0
  78. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/AudioEvent/AudioEventController.cs.meta +0 -0
  79. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/AudioEvent.meta +0 -0
  80. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/EmissiveGroup/EmissiveGroupController.cs.meta +0 -0
  81. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/EmissiveGroup/EmissiveGroups.hlsl +0 -0
  82. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/EmissiveGroup/EmissiveGroups.hlsl.meta +0 -0
  83. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/EmissiveGroup/EmissiveGroupsLit.shader +0 -0
  84. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/EmissiveGroup/EmissiveGroupsLit.shader.meta +0 -0
  85. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/EmissiveGroup.meta +0 -0
  86. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/LightmapMetadata/LightmapMetadataController.cs.meta +0 -0
  87. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/LightmapMetadata.meta +0 -0
  88. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/M3trik.Unitytk.asmdef +0 -0
  89. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/M3trik.Unitytk.asmdef.meta +0 -0
  90. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/README.md.meta +0 -0
  91. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/RenderEffects/RenderEffectsController.cs.meta +0 -0
  92. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/RenderEffects.meta +0 -0
  93. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ShadowPlane/ShadowPlane.shader.meta +0 -0
  94. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ShadowPlane/ShadowPlaneController.cs.meta +0 -0
  95. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ShadowPlane/ShadowPlaneHorizon.hlsl.meta +0 -0
  96. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ShadowPlane.meta +0 -0
  97. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ShotMetadata/ShotMetadataController.cs.meta +0 -0
  98. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/ShotMetadata.meta +0 -0
  99. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/UnitytkSettings.cs.meta +0 -0
  100. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk/templates/__init__.py +0 -0
  101. {unitytk-0.1.0 → unitytk-0.2.0}/unitytk.egg-info/dependency_links.txt +0 -0
  102. {unitytk-0.1.0 → unitytk-0.2.0}/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.1.0
3
+ Version: 0.2.0
4
4
  Summary: A modular Python toolkit for Unity interaction.
5
5
  Author-email: Ryan Simpson <m3trik@outlook.com>
6
6
  License: MIT
@@ -13,7 +13,7 @@ Classifier: Operating System :: POSIX :: Linux
13
13
  Requires-Python: >=3.9
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
- Requires-Dist: pythontk>=0.13.0
16
+ Requires-Dist: pythontk>=0.13.1
17
17
  Dynamic: license-file
18
18
 
19
19
  [![Tests](https://img.shields.io/badge/Tests-146%20passed-brightgreen.svg)](../test/)
@@ -17,15 +17,17 @@ classifiers = [
17
17
  ]
18
18
  # The floor is HAND-RATCHETED: unitytk is off-chain from m3trik/push.ps1, so no
19
19
  # release cascade bumps it.
20
- # pythontk 0.13.0, the release this one ships and was tested with: the suite
21
- # holds the templates to its `Conformance` cases and its articulation solve's
22
- # halving step (both new in 0.13.0). The hard minimum is 0.11.3:
20
+ # pythontk 0.13.1, the release this one ships and was tested with: an FBX
21
+ # deliverable carries the files its records name through
22
+ # `FbxMedia.embed_dependencies` (new in 0.13.1; on 0.13.0 the copy deliverer
23
+ # ships the FBX without its maps), and the suite holds the templates to its
24
+ # `Conformance` cases and its articulation solve's halving step (0.13.0). The hard minimum is 0.11.3:
23
25
  # TemplateDeployer builds its deprecation notices at import through
24
26
  # `Deprecation.values(..., since=)` (new in 0.11.3). UnityFinder.find_editors
25
27
  # needs 0.8.84: it scans through `AppLauncher.scan_install_dirs` (new in
26
28
  # 0.8.72) and needs its pattern order to be a priority (0.8.84), so the Hub's
27
29
  # chosen install location wins over the default.
28
- dependencies = ["pythontk>=0.13.0"]
30
+ dependencies = ["pythontk>=0.13.1"]
29
31
 
30
32
  [project.urls]
31
33
  Homepage = "https://github.com/m3trik/unitytk"
@@ -58,6 +58,7 @@ import os
58
58
  import random
59
59
  import re
60
60
  import shutil
61
+ import struct
61
62
  import subprocess
62
63
  import unittest
63
64
  import logging
@@ -562,6 +563,151 @@ class FixtureScenario:
562
563
  return out
563
564
 
564
565
 
566
+ class BindingScenario:
567
+ """Hierarchies that put the controller's name binding to the test, with
568
+ the GLB applier as the reference: pythontk's
569
+ ``MeshConvert.apply_glb_articulation`` binds a joint to the one node of
570
+ its name -- exactly, else by namespace-stripped leaf -- and a grab part
571
+ to the one of its name UNDER its joint, and refuses an ambiguous name.
572
+ The two deliverables of one export must pose the same parts, so each
573
+ case is also written as a GLB of the same nodes and bound there
574
+ (:meth:`glb_binding`).
575
+
576
+ A case is ``{"nodes": [[name, parent index or None], ...], "record"}``;
577
+ ``None`` hangs a node off the model root.
578
+ """
579
+
580
+ class Tree:
581
+ """A node list under construction."""
582
+
583
+ def __init__(self):
584
+ self.nodes = []
585
+
586
+ def add(self, name, parent=None):
587
+ self.nodes.append([name, parent])
588
+ return len(self.nodes) - 1
589
+
590
+ def rig(self, rig, group, ns=""):
591
+ """*rig*'s joints under a group node, every name prefixed with
592
+ *ns*; returns joint index -> node index."""
593
+ ids = {}
594
+ parent = self.add(ns + group)
595
+ for index, joint in enumerate(rig["joints"]):
596
+ up = parent if joint["parent"] is None else ids[joint["parent"]]
597
+ ids[index] = self.add(ns + joint["name"], up)
598
+ return ids
599
+
600
+ def __init__(self, doc):
601
+ arm, tree = doc["rigs"]["arm"], doc["rigs"]["tree"]
602
+ self.cases = {}
603
+ # Parts named alike under sibling joints: each grab entry takes the
604
+ # one under ITS joint.
605
+ t = self.Tree()
606
+ ids = t.rig(tree, "tree_RIG")
607
+ t.add("cap_geo", ids[1])
608
+ t.add("cap_geo", ids[3])
609
+ self._case(t, tree, "repeated_part", ("cap_geo", 1), ("cap_geo", 3))
610
+ # A namespaced export of a rig the record names bare; one part
611
+ # namespaced on both sides, one only in the record.
612
+ t = self.Tree()
613
+ ids = t.rig(arm, "arm_RIG", ns="lamp:")
614
+ t.add("lamp:head_geo", ids[4])
615
+ t.add("elbow_geo", ids[1])
616
+ self._case(t, arm, "namespaced", ("lamp:head_geo", 4), ("rig:elbow_geo", 1))
617
+ # A joint's exact name twice: the rig is refused, not bound to the first.
618
+ t = self.Tree()
619
+ t.rig(arm, "arm_RIG")
620
+ t.add("elbow", t.add("props"))
621
+ self._case(t, arm, "duplicate_joint")
622
+ # Two namespaced instances of the rig: every bare name matches both.
623
+ t = self.Tree()
624
+ t.rig(arm, "arm_RIG", ns="A:")
625
+ t.rig(arm, "arm_RIG", ns="B:")
626
+ self._case(t, arm, "two_instances")
627
+ # Two parts of one name under one joint: that part is not grabbable,
628
+ # the rest of the rig binds.
629
+ t = self.Tree()
630
+ ids = t.rig(arm, "arm_RIG")
631
+ t.add("head_geo", ids[4])
632
+ t.add("head_geo", ids[4])
633
+ t.add("elbow_geo", ids[1])
634
+ self._case(t, arm, "ambiguous_part", ("head_geo", 4), ("elbow_geo", 1))
635
+ # Namesakes ahead of the real joints -- one differing only by case,
636
+ # one by namespace -- never steal an exact match.
637
+ t = self.Tree()
638
+ t.add("Elbow")
639
+ t.add("NS:base")
640
+ t.rig(arm, "arm_RIG")
641
+ self._case(t, arm, "exact_wins")
642
+ # Two root joints under different nodes: a rig is solved in ONE space,
643
+ # its roots' parent -- refused by the page, the GLB applier and here.
644
+ split = json.loads(json.dumps(arm))
645
+ split["joints"][1]["parent"] = None
646
+ t = self.Tree()
647
+ spaces = (t.add("arm_RIG"), t.add("other_RIG"))
648
+ ids = {}
649
+ for index, joint in enumerate(split["joints"]):
650
+ if joint["parent"] is None:
651
+ up = spaces[min(index, 1)]
652
+ else:
653
+ up = ids[joint["parent"]]
654
+ ids[index] = t.add(joint["name"], up)
655
+ self._case(t, split, "split_roots")
656
+
657
+ def _case(self, tree, rig, name, *grab):
658
+ record = dict(rig, grab=[{"node": n, "joint": j} for n, j in grab])
659
+ self.cases[name] = {
660
+ "nodes": tree.nodes,
661
+ "record": json.dumps({"version": 1, "rigs": [record]}),
662
+ }
663
+
664
+ @staticmethod
665
+ def glb_binding(case, path):
666
+ """What the GLB applier binds of *case*: ``{"joints": [path, ...],
667
+ "grabs": {path: joint}}`` (paths below the model root), or None when
668
+ it refuses the rig. Writes a JSON-only GLB of the case's nodes plus
669
+ a ``data_export`` carrier to *path* and runs the applier on it."""
670
+ nodes = [{"name": name} for name, _parent in case["nodes"]]
671
+ roots = []
672
+ for index, (_name, parent) in enumerate(case["nodes"]):
673
+ if parent is None:
674
+ roots.append(index)
675
+ else:
676
+ nodes[parent].setdefault("children", []).append(index)
677
+ nodes.append(
678
+ {"name": "data_export", "extras": {"articulation": case["record"]}}
679
+ )
680
+ roots.append(len(nodes) - 1)
681
+ gltf = {
682
+ "asset": {"version": "2.0"},
683
+ "scene": 0,
684
+ "scenes": [{"nodes": roots}],
685
+ "nodes": nodes,
686
+ }
687
+ payload = json.dumps(gltf).encode("utf-8")
688
+ payload += b" " * (-len(payload) % 4)
689
+ with open(path, "wb") as fh:
690
+ fh.write(struct.pack("<4sII", b"glTF", 2, 20 + len(payload)))
691
+ fh.write(struct.pack("<I4s", len(payload), b"JSON"))
692
+ fh.write(payload)
693
+ manifest = ptk.MeshConvert.apply_glb_articulation(path)
694
+ if not manifest:
695
+ return None
696
+
697
+ def path_of(index):
698
+ names = []
699
+ while index is not None:
700
+ names.append(case["nodes"][index][0])
701
+ index = case["nodes"][index][1]
702
+ return "/".join(reversed(names))
703
+
704
+ rig = manifest["rigs"][0]
705
+ return {
706
+ "joints": [path_of(j["node"]) for j in rig["joints"]],
707
+ "grabs": {path_of(g["node"]): g["joint"] for g in rig["grab"]},
708
+ }
709
+
710
+
565
711
  #: A real DCC export of an articulated rig (an FBX carrying the
566
712
  #: ``articulation`` record and a baked clip). Machine-local and never checked
567
713
  #: in: unset (or missing) = the live-export tests env-skip.
@@ -799,6 +945,7 @@ public class ArticulatedRigVerifier
799
945
  Run("boundary", () => Boundary(O(inputs["boundary"])));
800
946
  Run("hierarchy", () => Hierarchy(O(inputs["doc"]), O(inputs["hierarchy"])));
801
947
  Run("controller", () => Controller(O(inputs["controller"])));
948
+ Run("binding", () => Binding(O(inputs["binding"])));
802
949
  Run("animator", () => AnimatorOverride(O(inputs["controller"])));
803
950
  Run("importer", () => Importer(O(inputs["importer"])));
804
951
  if (inputs.ContainsKey("live")) Run("live", () => Live(O(inputs["live"])));
@@ -1149,6 +1296,69 @@ public class ArticulatedRigVerifier
1149
1296
  UnityEngine.Object.DestroyImmediate(root.gameObject);
1150
1297
  }
1151
1298
 
1299
+ // ---- 3a. binding by name (the GLB applier's rules) ----------------------
1300
+ // Each case: a hierarchy of named transforms under a model root ([name,
1301
+ // parent index], null = the root) and a record. Reported: what the
1302
+ // controller bound, as paths below the root (null joints = the rig is not
1303
+ // bound), and the [ArticulatedRig] errors / warnings Resolve logged.
1304
+ static string RelPath(Transform root, Transform t)
1305
+ {
1306
+ if (t == null) return null;
1307
+ var names = new List<string>();
1308
+ for (var p = t; p != null && p != root; p = p.parent) names.Add(p.name);
1309
+ names.Reverse();
1310
+ return string.Join("/", names);
1311
+ }
1312
+
1313
+ static void Binding(IDictionary<string, object> cases)
1314
+ {
1315
+ var rows = new List<string>();
1316
+ foreach (var kv in cases)
1317
+ {
1318
+ var c = O(kv.Value);
1319
+ var root = new GameObject("BindingModel").transform;
1320
+ var made = new List<Transform>();
1321
+ foreach (var raw in L(c["nodes"]))
1322
+ {
1323
+ var n = L(raw);
1324
+ var t = new GameObject((string)n[0]).transform;
1325
+ t.SetParent(n[1] == null ? root : made[(int)D(n[1])], false);
1326
+ made.Add(t);
1327
+ }
1328
+ var errors = new List<string>();
1329
+ var warnings = new List<string>();
1330
+ Application.LogCallback hook = (message, stack, type) =>
1331
+ {
1332
+ if (!message.StartsWith("[ArticulatedRig]")) return;
1333
+ if (type == LogType.Error) errors.Add(message);
1334
+ else if (type == LogType.Warning) warnings.Add(message);
1335
+ };
1336
+ var ctrl = root.gameObject.AddComponent<ArticulatedRigController>();
1337
+ ctrl.record = (string)c["record"];
1338
+ bool resolved;
1339
+ Application.logMessageReceived += hook;
1340
+ try { resolved = ctrl.Resolve(); }
1341
+ finally { Application.logMessageReceived -= hook; }
1342
+ string joints = "null", grabs = "{}";
1343
+ if (ctrl.IsBound(0))
1344
+ {
1345
+ var model = ctrl.GetModel(0);
1346
+ joints = "[" + string.Join(",", Enumerable.Range(0, model.JointCount)
1347
+ .Select(j => S(RelPath(root, ctrl.GetJoint(0, j))))) + "]";
1348
+ grabs = "{" + string.Join(",", ctrl.GetGrabNodes().Select(t =>
1349
+ {
1350
+ ctrl.FindGrab(t, out int rig, out int joint, out Transform node);
1351
+ return S(RelPath(root, t)) + ": " + joint;
1352
+ })) + "}";
1353
+ }
1354
+ rows.Add(S(kv.Key) + ": {\"resolved\": " + B(resolved) + ", \"joints\": " + joints
1355
+ + ", \"grabs\": " + grabs + ", \"errors\": [" + string.Join(",", errors.Select(S))
1356
+ + "], \"warnings\": [" + string.Join(",", warnings.Select(S)) + "]}");
1357
+ UnityEngine.Object.DestroyImmediate(root.gameObject);
1358
+ }
1359
+ Put("binding", "{" + string.Join(",\n", rows) + "}");
1360
+ }
1361
+
1152
1362
  // ---- 3b. the Animator override (edit mode, no play mode) ---------------
1153
1363
  // A frame is driven by hand: Animator.Update(dt) -- which evaluates the
1154
1364
  // controller's clip and writes the transforms in edit mode -- then the
@@ -1515,6 +1725,7 @@ class TestArticulatedRigRuntime(unittest.TestCase):
1515
1725
  cls.doc = ptk.ArticulationConformance.cases(seed=SEED, per_rig=PER_RIG)
1516
1726
  cls.scenario = ControllerScenario(cls.doc)
1517
1727
  cls.fixture = FixtureScenario(cls.doc)
1728
+ cls.binding = BindingScenario(cls.doc)
1518
1729
  rng = random.Random(5)
1519
1730
  cls.boundary = {
1520
1731
  "k": 0.037,
@@ -1607,6 +1818,7 @@ class TestArticulatedRigRuntime(unittest.TestCase):
1607
1818
  "boundary": cls.boundary,
1608
1819
  "hierarchy": cls.hierarchy,
1609
1820
  "controller": cls.scenario.inputs(),
1821
+ "binding": cls.binding.cases,
1610
1822
  "importer": dict(variants, asset=f"Assets/{fixture_name}"),
1611
1823
  }
1612
1824
  live = _live_fbx()
@@ -1792,7 +2004,7 @@ class TestArticulatedRigRuntime(unittest.TestCase):
1792
2004
 
1793
2005
  @EnvSkip.unless(_unity_available(), "no Unity editor found")
1794
2006
  def test_09_controller_binds_and_measures(self):
1795
- """Joints bind by leaf name, rig space is the rig group, the unit
2007
+ """Joints bind by name, rig space is the rig group, the unit
1796
2008
  scale is MEASURED (0.37), grab parts get convex colliders, and the
1797
2009
  state reads back off the float transforms."""
1798
2010
  unity = self._results()
@@ -1817,6 +2029,103 @@ class TestArticulatedRigRuntime(unittest.TestCase):
1817
2029
  "a transform outside every grab part is refused",
1818
2030
  )
1819
2031
 
2032
+ # ---- Step 4a: binding by name ----------------------------------------------
2033
+
2034
+ def _binding(self, case):
2035
+ got = self._section("binding")
2036
+ self.assertIn(case, got, "no such binding case in the Unity results")
2037
+ return got[case]
2038
+
2039
+ @EnvSkip.unless(_unity_available(), "no Unity editor found")
2040
+ def test_09b_binding_matches_the_glb_applier(self):
2041
+ """Every hierarchy binds in Unity exactly as pythontk's GLB applier
2042
+ binds a GLB of the same nodes: the same joints, the same grab parts,
2043
+ the same rigs refused -- the two deliverables of one export pose the
2044
+ same parts."""
2045
+ for case, spec in self.binding.cases.items():
2046
+ glb = BindingScenario.glb_binding(
2047
+ spec, os.path.join(self.temp_dir, f"binding_{case}.glb")
2048
+ )
2049
+ got = self._binding(case)
2050
+ self.assertEqual(got["joints"], glb and glb["joints"], f"{case}: joints")
2051
+ self.assertEqual(
2052
+ got["grabs"], glb["grabs"] if glb else {}, f"{case}: grab parts"
2053
+ )
2054
+
2055
+ @EnvSkip.unless(_unity_available(), "no Unity editor found")
2056
+ def test_09c_a_repeated_part_name_binds_under_its_own_joint(self):
2057
+ """Two parts share a name under sibling joints: each grab entry takes
2058
+ the one under its own joint, and both are grabbable."""
2059
+ got = self._binding("repeated_part")
2060
+ self.assertTrue(got["resolved"])
2061
+ self.assertEqual(
2062
+ got["grabs"],
2063
+ {"tree_RIG/hub/left/cap_geo": 1, "tree_RIG/hub/right/tip/cap_geo": 3},
2064
+ )
2065
+ self.assertEqual(got["errors"], [])
2066
+
2067
+ @EnvSkip.unless(_unity_available(), "no Unity editor found")
2068
+ def test_09d_namespaced_names_bind_by_their_leaf(self):
2069
+ """A namespaced export binds a record that names its joints bare, and
2070
+ a grab part namespaced on either side binds by its leaf."""
2071
+ got = self._binding("namespaced")
2072
+ self.assertTrue(got["resolved"])
2073
+ chain = "lamp:arm_RIG/lamp:base/lamp:elbow"
2074
+ self.assertEqual(got["joints"][1], chain)
2075
+ self.assertEqual(
2076
+ got["grabs"],
2077
+ {
2078
+ f"{chain}/lamp:collar/lamp:extend/lamp:head/lamp:head_geo": 4,
2079
+ f"{chain}/elbow_geo": 1,
2080
+ },
2081
+ )
2082
+ self.assertEqual(got["errors"], [])
2083
+
2084
+ @EnvSkip.unless(_unity_available(), "no Unity editor found")
2085
+ def test_09e_an_ambiguous_name_is_refused_with_an_error(self):
2086
+ """A joint name that matches several transforms -- exactly, or by leaf
2087
+ across two namespaced instances -- leaves the rig unbound; two parts
2088
+ of one name under one joint leave that part ungrabbable while the rig
2089
+ binds. Each logs an error naming what was ambiguous, never a guess."""
2090
+ for case, named, bound in (
2091
+ ("duplicate_joint", "joint 'elbow'", False),
2092
+ ("two_instances", "joint 'base'", False),
2093
+ ("ambiguous_part", "grab part 'head_geo'", True),
2094
+ ):
2095
+ got = self._binding(case)
2096
+ self.assertEqual(got["resolved"], bound, case)
2097
+ self.assertTrue(
2098
+ any(named in e and "ambiguous" in e for e in got["errors"]),
2099
+ f"{case}: no error naming {named}: {got['errors']}",
2100
+ )
2101
+ self.assertIsNone(self._binding("duplicate_joint")["joints"])
2102
+ self.assertEqual(
2103
+ self._binding("ambiguous_part")["grabs"],
2104
+ {"arm_RIG/base/elbow/elbow_geo": 1},
2105
+ )
2106
+
2107
+ @EnvSkip.unless(_unity_available(), "no Unity editor found")
2108
+ def test_09g_a_rig_whose_roots_hang_apart_is_refused(self):
2109
+ """Root joints under different nodes leave no one space to solve the
2110
+ rig in: it is not wired, with an error saying why -- as the page and
2111
+ the GLB applier refuse it (test_09b holds the GLB half)."""
2112
+ got = self._binding("split_roots")
2113
+ self.assertFalse(got["resolved"])
2114
+ self.assertTrue(
2115
+ any("different nodes" in e for e in got["errors"]), got["errors"]
2116
+ )
2117
+
2118
+ @EnvSkip.unless(_unity_available(), "no Unity editor found")
2119
+ def test_09f_an_exact_name_wins_over_its_namesakes(self):
2120
+ """Transforms ahead of the joints that differ only by case
2121
+ (``Elbow``) or namespace (``NS:base``) never take an exact match."""
2122
+ got = self._binding("exact_wins")
2123
+ chain = ["arm_RIG/base"]
2124
+ for name in ("elbow", "collar", "extend", "head"):
2125
+ chain.append(f"{chain[-1]}/{name}")
2126
+ self.assertEqual(got["joints"], chain)
2127
+ self.assertEqual(got["errors"], [])
2128
+
1820
2129
  def _solve(self, state, joint, local, target, rotation):
1821
2130
  return self.scenario.model.solve(state, joint, local, target, rotation)
1822
2131
 
@@ -1,10 +1,12 @@
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
+ import json
4
5
  import os
6
+ import struct
5
7
  import unittest
6
8
 
7
- from pythontk import HandoffRequest, Payload, TempArtifacts
9
+ from pythontk import FbxMedia, HandoffRequest, Payload, TempArtifacts
8
10
  from unitytk.asset_delivery import (
9
11
  CopyToAssetsDeliverer,
10
12
  FileToUnityBridge,
@@ -12,21 +14,80 @@ from unitytk.asset_delivery import (
12
14
 
13
15
 
14
16
  class _Logger:
15
- """Minimal logger capturing messages by level."""
17
+ """Minimal logger capturing messages by level (``%``-args formatted)."""
16
18
 
17
19
  def __init__(self):
18
20
  self.errors = []
19
21
  self.warnings = []
20
22
  self.infos = []
21
23
 
22
- def error(self, m):
23
- self.errors.append(m)
24
-
25
- def warning(self, m):
26
- self.warnings.append(m)
24
+ def error(self, m, *args):
25
+ self.errors.append(m % args if args else m)
26
+
27
+ def warning(self, m, *args):
28
+ self.warnings.append(m % args if args else m)
29
+
30
+ def info(self, m, *args):
31
+ self.infos.append(m % args if args else m)
32
+
33
+
34
+ def _carrier_fbx(path: str, channels) -> str:
35
+ """A binary FBX (7700 layout) whose ``data_export`` carrier holds
36
+ *channels* ``{record key: payload}`` -- what a DCC export hands over."""
37
+
38
+ def prop(value):
39
+ if isinstance(value, int):
40
+ return b"L" + struct.pack("<q", value)
41
+ return b"S" + struct.pack("<I", len(value)) + value
42
+
43
+ def record(name, props, children, offset):
44
+ body_props = b"".join(prop(p) for p in props)
45
+ head = 24 + 1 + len(name)
46
+ body, at = b"", offset + head + len(body_props)
47
+ for child in children:
48
+ chunk = record(*child, at)
49
+ body, at = body + chunk, at + len(chunk)
50
+ if children:
51
+ body += b"\0" * 25
52
+ end = offset + head + len(body_props) + len(body)
53
+ return (
54
+ struct.pack("<QQQB", end, len(props), len(body_props), len(name))
55
+ + name
56
+ + body_props
57
+ + body
58
+ )
27
59
 
28
- def info(self, m):
29
- self.infos.append(m)
60
+ p70 = [
61
+ (b"P", [k.encode(), b"KString", b"", b"U", json.dumps(v).encode()], [])
62
+ for k, v in channels.items()
63
+ ]
64
+ roots = [
65
+ (
66
+ b"Objects",
67
+ [],
68
+ [
69
+ (b"Model", [1001, b"Crate\x00\x01Model", b"Mesh"], []),
70
+ (
71
+ b"Model",
72
+ [1002, b"data_export\x00\x01Model", b"Null"],
73
+ [(b"Properties70", [], p70)],
74
+ ),
75
+ ],
76
+ ),
77
+ (b"Connections", [], [(b"C", [b"OO", 1001, 0], [])]),
78
+ ]
79
+ magic = b"Kaydara FBX Binary \x00\x1a\x00"
80
+ body, offset = b"", len(magic) + 4
81
+ for root in roots:
82
+ chunk = record(*root, offset)
83
+ body, offset = body + chunk, offset + len(chunk)
84
+ at = offset + 25 + 16
85
+ pad = ((at + 15) & ~15) - at or 16
86
+ footer = bytes(range(16)) + b"\0" * pad + b"\0" * 4 + struct.pack("<I", 7700)
87
+ tail = b"\0" * 120 + bytes(16)
88
+ with open(path, "wb") as fh:
89
+ fh.write(magic + struct.pack("<I", 7700) + body + b"\0" * 25 + footer + tail)
90
+ return path
30
91
 
31
92
 
32
93
  class _StubBridge:
@@ -281,6 +342,126 @@ class DeliveryTest(unittest.TestCase):
281
342
  )
282
343
 
283
344
 
345
+ class _HostBridge(_StubBridge):
346
+ """A DCC bridge: it says where its host keeps the maps the records name
347
+ (the ``lightmap_search_dirs`` hook every DCC export mixin answers)."""
348
+
349
+ def __init__(self, project_path, search_dirs):
350
+ super().__init__(project_path)
351
+ self._dirs = search_dirs
352
+
353
+ def lightmap_search_dirs(self):
354
+ return list(self._dirs)
355
+
356
+
357
+ class SelfContainedDeliveryTest(unittest.TestCase):
358
+ """The FBX lands in Assets/ carrying every file its records name, so the
359
+ Unity importers find them with nothing copied beside it (the user's
360
+ decision of 2026-10-05: every dependency embedded, zero sidecar files)."""
361
+
362
+ RECORDS = {
363
+ "lightmap_metadata": {
364
+ "version": 1,
365
+ "objects": [{"name": "Crate", "map": "Crate_Lightmap.exr", "uvIndex": 1}],
366
+ "probe": {"map": "Room_Probe.exr", "position": [0, 0, 0], "unit_scale": 0.01},
367
+ },
368
+ "shadow_metadata": {
369
+ "version": 2,
370
+ "planes": [
371
+ {
372
+ "name": "Crate_shadow",
373
+ "texture": "Crate_shadow.png",
374
+ "horizon": {"texture": "Crate_horizon.png"},
375
+ }
376
+ ],
377
+ },
378
+ }
379
+
380
+ def setUp(self):
381
+ store = TempArtifacts("unitytk_delivery_embed", policy="scoped")
382
+ self.addCleanup(store.cleanup)
383
+ self.tmp = store.dir_path()
384
+ self.project = os.path.join(self.tmp, "MyUnityProj")
385
+ os.makedirs(os.path.join(self.project, "Assets"))
386
+ self.maps = os.path.join(self.tmp, "sourceimages")
387
+ os.makedirs(self.maps)
388
+ self.bytes = {}
389
+ for name in ("Crate_Lightmap.exr", "Room_Probe.exr", "Crate_shadow.png", "Crate_horizon.png"):
390
+ self.bytes[name] = name.encode() * 8
391
+ with open(os.path.join(self.maps, name), "wb") as fh:
392
+ fh.write(self.bytes[name])
393
+ self.fbx = _carrier_fbx(os.path.join(self.tmp, "export.fbx"), self.RECORDS)
394
+ with open(self.fbx, "rb") as fh:
395
+ self.source = fh.read()
396
+ self.deliverer = CopyToAssetsDeliverer()
397
+
398
+ def _deliver(self, bridge):
399
+ return self.deliverer.deliver(bridge, _payload(self.fbx, "Crate"), _request({}))
400
+
401
+ def test_the_fbx_carries_every_file_its_records_name(self):
402
+ bridge = _HostBridge(self.project, [self.maps])
403
+ result = self._deliver(bridge)
404
+ dest = result["asset"]
405
+ self.assertEqual(
406
+ {row["name"]: row["bytes"] for row in FbxMedia.embedded(dest)},
407
+ {name: len(data) for name, data in self.bytes.items()},
408
+ )
409
+ # Nothing ships beside it, and the source is the file it was.
410
+ self.assertEqual(os.listdir(os.path.dirname(dest)), ["Crate.fbx"])
411
+ with open(self.fbx, "rb") as fh:
412
+ self.assertEqual(fh.read(), self.source)
413
+ self.assertFalse(bridge.logger.warnings, bridge.logger.warnings)
414
+
415
+ def test_a_bridge_without_a_host_answer_looks_beside_the_source(self):
416
+ """The file-driven bridge has no host: the maps beside the picked FBX."""
417
+ for name, data in self.bytes.items():
418
+ with open(os.path.join(self.tmp, name), "wb") as fh:
419
+ fh.write(data)
420
+ result = self._deliver(_StubBridge(self.project))
421
+ self.assertEqual(
422
+ sorted(row["name"] for row in FbxMedia.embedded(result["asset"])),
423
+ sorted(self.bytes),
424
+ )
425
+
426
+ def test_a_map_found_nowhere_is_warned_and_the_fbx_still_ships(self):
427
+ os.remove(os.path.join(self.maps, "Crate_horizon.png"))
428
+ bridge = _HostBridge(self.project, [self.maps])
429
+ result = self._deliver(bridge)
430
+ self.assertTrue(os.path.isfile(result["asset"]))
431
+ self.assertIn("Crate_horizon.png", "\n".join(bridge.logger.warnings))
432
+ self.assertNotIn(
433
+ "Crate_horizon.png", [r["name"] for r in FbxMedia.embedded(result["asset"])]
434
+ )
435
+
436
+ def test_an_embed_pass_that_fails_ships_the_fbx_as_exported(self):
437
+ """The pass is an addition to the send, never a reason to fail it: a
438
+ file it cannot read (a pythontk before the truncated-file fix raised
439
+ ``struct.error``) ships as exported, with a warning."""
440
+ from unittest.mock import patch
441
+
442
+ bridge = _HostBridge(self.project, [self.maps])
443
+ with patch.object(
444
+ FbxMedia, "embed_dependencies", side_effect=struct.error("cut short")
445
+ ):
446
+ result = self._deliver(bridge)
447
+ with open(result["asset"], "rb") as fh:
448
+ self.assertEqual(fh.read(), self.source)
449
+ self.assertIn("cut short", "\n".join(bridge.logger.warnings))
450
+
451
+ def test_a_truncated_fbx_ships_as_it_is(self):
452
+ """The magic but not the body: nothing to embed into, the copy stands."""
453
+ from pythontk.file_utils.mesh_convert.fbx_file import FBX_MAGIC
454
+
455
+ cut = FBX_MAGIC + struct.pack("<I", 7700) + b"\x10" * 20
456
+ with open(self.fbx, "wb") as fh:
457
+ fh.write(cut)
458
+ bridge = _HostBridge(self.project, [self.maps])
459
+ result = self._deliver(bridge)
460
+ with open(result["asset"], "rb") as fh:
461
+ self.assertEqual(fh.read(), cut)
462
+ self.assertTrue(bridge.logger.warnings)
463
+
464
+
284
465
  class FileToUnityBridgeTest(unittest.TestCase):
285
466
  """The DCC-free file-driven bridge: pick a model file -> copy into Assets/."""
286
467