├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── README.md ├── animation_retarget ├── __init__.py ├── core.py ├── ops.py └── ui.py ├── mk_release.py └── tests ├── __init__.py ├── cases ├── __init__.py ├── test_addon.py ├── test_ops.py └── test_ui.py ├── ci-prepare.sh ├── runner.py └── utils.py /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push] 3 | jobs: 4 | lint: 5 | name: Lint 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: actions/setup-python@v3 10 | with: 11 | python-version: '3.7' 12 | architecture: 'x64' 13 | - name: Install PyLint 14 | run: python -m pip install pylint coverage 15 | - name: Lint 16 | run: pylint animation_retarget tests 17 | test: 18 | name: Run 19 | strategy: 20 | matrix: 21 | blender: 22 | - version: '2.83.20' 23 | python: '3.7' 24 | - version: '3.0.1' 25 | python: '3.9' 26 | - version: '3.1.2' 27 | python: '3.10' 28 | coverage: 'coverage' 29 | runs-on: ubuntu-latest 30 | env: 31 | BLENDER_PATH: .blender 32 | BLENDER_VERSION: ${{ matrix.blender.version }} 33 | BLENDER_PYTHON_VERSION: ${{ matrix.blender.python }} 34 | steps: 35 | - uses: actions/checkout@v3 36 | - uses: actions/cache@v3 37 | with: 38 | path: ${{ env.BLENDER_PATH }} 39 | key: blender:${{ env.BLENDER_VERSION }}:cov632 40 | - name: Prepare 41 | run: ./tests/ci-prepare.sh 42 | - name: Test 43 | run: ./$BLENDER_PATH/blender --factory-startup -noaudio -b --python-exit-code 1 --python tests/runner.py 44 | - name: Coverage 45 | if: ${{ matrix.blender.coverage == 'coverage' }} 46 | run: bash <(curl -s https://codecov.io/bash) 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /htmlcov/ 2 | /coverage.xml 3 | .* 4 | *.pyc 5 | *.zip 6 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [BASIC] 2 | no-docstring-rgx=. 3 | class-rgx=[A-Z_][a-zA-Z0-9_]+$ 4 | 5 | [TYPECHECK] 6 | ignored-modules=addon_utils, bgl, bmesh, bpy, bpy_extras, mathutils, gpu, gpu_extras 7 | 8 | [MESSAGES CONTROL] 9 | disable=C0111, R0201, R0903 10 | 11 | [REPORTS] 12 | output-format=colorized 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017, 2019-2020, 2022 Vakhurin Sergei 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blender-retarget 2 | Animation retargeting addon for [Blender 3D](http://www.blender.org/). 3 | 4 | [![Tests](https://github.com/igelbox/blender-retarget/actions/workflows/tests.yml/badge.svg)](https://github.com/igelbox/blender-retarget/actions/workflows/tests.yml) 5 | [![Code Coverage](https://codecov.io/gh/igelbox/blender-retarget/graph/badge.svg)](https://codecov.io/gh/igelbox/blender-retarget) 6 | 7 | # The Main Goal 8 | The main goal is to __link/synchronize__ target armature __bones__ with a source ones in __real-time__ using some math magic. 9 | 10 | Thus, allowing you to __use your next favorite tools__ for baking/exporting/using the result motions. For the glory of the Unix way. 11 | 12 | # How to Install 13 | * For Blender 2.80+ (except 2.82) - use *animation-retarget-x.x.x.zip* from the [latest release](https://github.com/igelbox/blender-retarget/releases/latest) page. 14 | * For Blender 2.79 - the initial [animation-retarget-0.1.0.zip](https://github.com/igelbox/blender-retarget/releases/download/v0.1.0/animation-retarget-0.1.0.zip) should work fine. 15 | 16 | _Blender 2.82 isn't supported by this addon coz there's no known trick to force `depsgraph` to refresh pose on each frame._ 17 | 18 | # How to Use 19 | - Select the destination armature object in a 3D View area 20 | - Go to the Object Properties panel 21 | - Choose the source armature object in the `Select Source:` field 22 | - Choose source bones for each bone that should be linked 23 | - Position target bones along with source ones, to make the target armature almost the same pose as the source one 24 | - Use the `Link rotation` and `Link location` buttons to specify which kind of animation should be copied: rotation only, translation only, or both. 25 | - Change the current frame number to see how target bones follow to source ones 26 | 27 | Relative positions of bones can be adjusted at any time. 28 | To do this the respective `Link rotation` or `Link location` button should be temporarily switched off. 29 | 30 | **TL;DR** - use the following video tutorial (text subtitles can be auto-translated by YouTube): 31 | 32 | [![Video Tutorial](https://i.ytimg.com/vi/rPLdn0nf5Kw/hqdefault.jpg)](http://www.youtube.com/watch?v=rPLdn0nf5Kw) 33 | -------------------------------------------------------------------------------- /animation_retarget/__init__.py: -------------------------------------------------------------------------------- 1 | bl_info = { # pylint: disable-msg=C0103 2 | 'name': 'Animation Retargeting Tools', 3 | 'author': 'Vakhurin Sergei (igelbox)', 4 | 'version': (1, 0, 3), 5 | 'blender': (2, 80, 0), 6 | 'category': 'Animation', 7 | 'location': 'Properties > Object, Properties > Bone', 8 | 'description': 'Applies an animation from one armature to another', 9 | 'wiki_url': 'https://github.com/igelbox/blender-retarget', 10 | 'tracker_url': 'https://github.com/igelbox/blender-retarget/issues', 11 | } 12 | 13 | 14 | def register(): 15 | from . import core, ui, ops # pylint:disable-msg=C0415 16 | core.register() 17 | ops.register() 18 | ui.register() 19 | 20 | 21 | def unregister(): 22 | from . import core, ui, ops # pylint:disable-msg=C0415 23 | ui.unregister() 24 | ops.unregister() 25 | core.unregister() 26 | -------------------------------------------------------------------------------- /animation_retarget/core.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import configparser 3 | from io import StringIO 4 | 5 | import bpy 6 | import mathutils 7 | 8 | 9 | __CONFIG_PREFIX_BONE__ = 'bone:' 10 | 11 | __TRICK_BLENDER28_PREFIX__ = 'Just-a-Trick-to-Refresh' 12 | 13 | 14 | def auto_mapping(source_obj, target_obj): 15 | used_sources, used_targets = set(), set() 16 | 17 | # use the current mapping as a basis 18 | # if it's not desired so user may use Clear op beforehand 19 | for bone in target_obj.pose.bones: 20 | prop = bone.animation_retarget 21 | if prop.source: 22 | used_sources.add(prop.source) 23 | used_targets.add(bone.name) 24 | 25 | def world_bones(obj, bones): 26 | return [(b, (obj.matrix_world @ b.matrix).translation) for b in bones] 27 | tbones = world_bones(target_obj, target_obj.pose.bones) 28 | distances = { 29 | (sbone.name, tbone.name): (tloc - sloc).length 30 | for sbone, sloc in world_bones(source_obj, source_obj.pose.bones) 31 | for tbone, tloc in tbones 32 | } 33 | 34 | def find_pairs(source_bones, target_bones, fbones): 35 | def calc_score(sbones, tbones): 36 | return sum(map( 37 | lambda s: 1 / (1 + min(map( 38 | lambda t: distances[(s.name, t.name)], 39 | tbones 40 | ))), 41 | sbones 42 | )) 43 | 44 | sused, tused = set(), set() 45 | pairs = [] 46 | tbone_pairs = [(bone, tuple(fbones(bone))) for bone in target_bones] 47 | for sbone in source_bones: 48 | sbones = tuple(fbones(sbone)) 49 | for tbone, tbones in tbone_pairs: 50 | pairs.append((calc_score(sbones, tbones), sbone, tbone)) 51 | pairs.sort(key=lambda e: -e[0]) 52 | result = [] 53 | for score, sbone, tbone in pairs: 54 | if (sbone.name in sused) or (tbone.name in tused): 55 | continue 56 | sused.add(sbone.name) 57 | tused.add(tbone.name) 58 | result.append((score, sbone, tbone)) 59 | return result 60 | 61 | mapping = {} 62 | 63 | def process(source_bones, target_bones): 64 | def find_chain_until_fork(bone): 65 | result = [bone] 66 | while len(bone.children) == 1: 67 | bone = bone.children[0] 68 | result.append(bone) 69 | return result 70 | 71 | pairs = find_pairs( 72 | source_bones, target_bones, 73 | lambda bone: (bone, *bone.children_recursive) 74 | ) 75 | # print('pairs:', [(s, a.name, b.name) for s, a, b in pairs]) 76 | for _, spbone, tpbone in pairs: 77 | sbones = find_chain_until_fork(spbone) 78 | tbones = find_chain_until_fork(tpbone) 79 | mpairs = find_pairs(sbones, tbones, lambda bone: (bone,)) 80 | # print('mpairs:', [(s, a.name, b.name) for s, a, b in mpairs]) 81 | for __, sbone, tbone in mpairs: 82 | if sbone.name in used_sources: 83 | continue 84 | if tbone.name in used_targets: 85 | continue 86 | mapping[tbone.name] = sbone.name 87 | process(sbones[-1].children, tbones[-1].children) 88 | 89 | process(*( 90 | (b for b in obj.pose.bones if b.parent is None) 91 | for obj in (source_obj, target_obj) 92 | )) 93 | 94 | target_obj.animation_retarget.source = source_obj.name 95 | for bone in target_obj.pose.bones: 96 | prop = bone.animation_retarget 97 | source = mapping.get(bone.name) 98 | if source is not None: 99 | prop.source = source 100 | prop.use_location = prop.use_rotation = True 101 | prop.invalidate_cache() 102 | bpy.context.view_layer.update() 103 | 104 | 105 | def mapping_to_text(target_obj): 106 | def put_nonempty_value(data, name, value): 107 | if value: 108 | data[name] = value 109 | 110 | def put_nonzero_tuple(data, name, value): 111 | for val in value: 112 | if val: 113 | data[name] = value 114 | break 115 | 116 | def prepare_value(value): 117 | value = round(value, 6) 118 | ivalue = int(value) 119 | return ivalue if ivalue == value else value 120 | 121 | config = configparser.ConfigParser() 122 | config['object'] = { 123 | 'source': target_obj.animation_retarget.source, 124 | } 125 | 126 | for bone in target_obj.pose.bones: 127 | prop = bone.animation_retarget 128 | data = OrderedDict() 129 | for name in ('source', 'use_location', 'use_rotation'): 130 | put_nonempty_value(data, name, getattr(prop, name)) 131 | for name in ('source_to_target_rest', 'delta_transform'): 132 | value = tuple(map(prepare_value, getattr(prop, name))) 133 | put_nonzero_tuple(data, name, value) 134 | if data: 135 | config[__CONFIG_PREFIX_BONE__ + bone.name] = data 136 | 137 | buffer = StringIO() 138 | config.write(buffer) 139 | return buffer.getvalue() 140 | 141 | 142 | def text_to_mapping(text, target_obj): 143 | def parse_boolean(text): 144 | return { 145 | 'True': True, 146 | 'False': False, 147 | }[text] 148 | 149 | def parse_tuple(text): 150 | text = text.replace('(', '').replace(')', '') 151 | return tuple(map(float, text.split(','))) 152 | 153 | config = configparser.ConfigParser() 154 | config.read_string(text) 155 | 156 | source = config['object']['source'] 157 | target_obj.animation_retarget.source = source 158 | 159 | for key, value in config.items(): 160 | if not key.startswith(__CONFIG_PREFIX_BONE__): 161 | continue 162 | name = key[len(__CONFIG_PREFIX_BONE__):] 163 | target_bone = target_obj.pose.bones.get(name) 164 | if not target_bone: 165 | print('bone ' + name + ' is not found') 166 | continue 167 | prop = target_bone.animation_retarget 168 | if 'source' in value: 169 | prop.source = value['source'] 170 | for name in ('use_location', 'use_rotation'): 171 | if name in value: 172 | setattr(prop, name, parse_boolean(value[name])) 173 | for name in ('source_to_target_rest', 'delta_transform'): 174 | if name in value: 175 | setattr(prop, name, parse_tuple(value[name])) 176 | prop.invalidate_cache() 177 | bpy.context.view_layer.update() 178 | 179 | 180 | __ZERO_V16__ = (0,) * 16 181 | 182 | 183 | def clear_mapping(target_obj): 184 | for bone in target_obj.pose.bones: 185 | prop = bone.animation_retarget 186 | prop.source = '' 187 | prop.use_location = prop.use_rotation = False 188 | prop.source_to_target_rest = prop.delta_transform = __ZERO_V16__ 189 | prop.invalidate_cache() 190 | bpy.context.view_layer.update() 191 | 192 | 193 | def need_to_trick_blender28(target_obj): 194 | if not target_obj.animation_retarget.source: 195 | return False # No worries, we fix this on source prop update 196 | 197 | animation_data = target_obj.animation_data 198 | if not animation_data: 199 | return True 200 | if not animation_data.action: 201 | return True 202 | 203 | for fcurve in animation_data.drivers: 204 | for var in fcurve.driver.variables: 205 | for target in var.targets: 206 | if target.data_path == 'animation_retarget.fake_dependency': 207 | return False 208 | return bool(animation_data.drivers) # not empty 209 | 210 | 211 | def trick_blender283(target_obj, driver): 212 | var = driver.variables.new() 213 | var.name = __TRICK_BLENDER28_PREFIX__ 214 | tgt = var.targets[0] 215 | tgt.id = bpy.data.objects[target_obj.animation_retarget.source] 216 | tgt.data_path = 'animation_retarget.fake_dependency' 217 | 218 | 219 | def trick_blender28(target_obj): 220 | animation_data = target_obj.animation_data_create() 221 | action = animation_data.action 222 | if not action: 223 | for act in bpy.data.actions: 224 | if act.name.startswith(__TRICK_BLENDER28_PREFIX__): 225 | action = act 226 | break 227 | if not action: 228 | action = bpy.data.actions.new(__TRICK_BLENDER28_PREFIX__) 229 | animation_data.action = action 230 | 231 | for fcurve in animation_data.drivers: 232 | trick_blender283(target_obj, fcurve.driver) 233 | break 234 | 235 | 236 | class RelativeObjectTransform(bpy.types.PropertyGroup): 237 | b_type = bpy.types.Object 238 | 239 | def _update_source(self, _context): 240 | if self.source: 241 | target_obj = self.id_data 242 | for bone in target_obj.pose.bones: 243 | if bone.animation_retarget.source: 244 | bone.animation_retarget.update_link() 245 | 246 | trick_blender28(target_obj) 247 | 248 | draw_links: bpy.props.BoolProperty( 249 | name='Draw Links', 250 | description='Draw lines to source bones\' origins', 251 | ) 252 | 253 | source: bpy.props.StringProperty( 254 | name='Source Object', 255 | description='An object whose animation will be used', 256 | update=_update_source, 257 | ) 258 | 259 | fake_dependency: bpy.props.FloatProperty( 260 | description='Trick the depsgraph to force Target armature drivers update', 261 | get=lambda _s: 0, 262 | ) 263 | 264 | 265 | def _prop_to_pose_bone(obj, prop): 266 | for bone in obj.pose.bones: 267 | if bone.animation_retarget == prop: 268 | return bone 269 | return None 270 | 271 | 272 | def _fvec16_to_matrix4(fvec): 273 | return mathutils.Matrix((fvec[0:4], fvec[4:8], fvec[8:12], fvec[12:16])) 274 | 275 | 276 | __ROTATION_MODES__ = ('quaternion', 'euler', 'axis_angle') 277 | 278 | 279 | class RelativeBoneTransform(bpy.types.PropertyGroup): 280 | b_type = bpy.types.PoseBone 281 | 282 | def _update_source(self, _context): 283 | if self.source: 284 | self.update_link() 285 | 286 | source: bpy.props.StringProperty( 287 | name='Source Bone', 288 | description='A bone whose animation will be used', 289 | update=_update_source, 290 | ) 291 | 292 | def _get_use_rotation(self): 293 | animation_data = self.id_data.animation_data 294 | if not animation_data: 295 | return False 296 | bone = _prop_to_pose_bone(self.id_data, self) 297 | data_path_prefix = f'pose.bones["{bone.name}"].rotation_' 298 | for mode in __ROTATION_MODES__: 299 | if animation_data.drivers.find(data_path_prefix + mode) is not None: 300 | return True 301 | return False 302 | 303 | def _set_use_rotation(self, value): 304 | bone = _prop_to_pose_bone(self.id_data, self) 305 | if not value: 306 | for mode in __ROTATION_MODES__: 307 | bone.driver_remove('rotation_' + mode) 308 | return 309 | self.update_link() 310 | for mode in __ROTATION_MODES__: 311 | for fcurve in bone.driver_add('rotation_' + mode): 312 | driver = fcurve.driver 313 | driver.type = 'SUM' 314 | variables = driver.variables 315 | var = variables[0] if variables else variables.new() 316 | tgt = var.targets[0] 317 | tgt.id = self.id_data 318 | fc_idx = fcurve.array_index + 3 319 | tgt.data_path = f'pose.bones["{bone.name}"].animation_retarget.transform[{fc_idx}]' 320 | trick_blender283(self.id_data, driver) 321 | 322 | use_rotation: bpy.props.BoolProperty( 323 | name='Link Rotation', 324 | description='Link rotation to the source bone', 325 | get=_get_use_rotation, set=_set_use_rotation, 326 | ) 327 | 328 | def _get_use_location(self): 329 | animation_data = self.id_data.animation_data 330 | if not animation_data: 331 | return False 332 | bone = _prop_to_pose_bone(self.id_data, self) 333 | data_path = f'pose.bones["{bone.name}"].location' 334 | return animation_data.drivers.find(data_path) is not None 335 | 336 | def _set_use_location(self, value): 337 | bone = _prop_to_pose_bone(self.id_data, self) 338 | if not value: 339 | bone.driver_remove('location') 340 | return 341 | self.update_link() 342 | for fcurve in bone.driver_add('location'): 343 | driver = fcurve.driver 344 | driver.type = 'SUM' 345 | variables = driver.variables 346 | var = variables[0] if variables else variables.new() 347 | tgt = var.targets[0] 348 | tgt.id = self.id_data 349 | fc_idx = fcurve.array_index 350 | tgt.data_path = f'pose.bones["{bone.name}"].animation_retarget.transform[{fc_idx}]' 351 | trick_blender283(self.id_data, driver) 352 | 353 | use_location: bpy.props.BoolProperty( 354 | name='Link Location', 355 | description='Link location to the source bone', 356 | get=_get_use_location, set=_set_use_location, 357 | ) 358 | 359 | source_to_target_rest: bpy.props.FloatVectorProperty( 360 | description='-private-data-', 361 | size=16, 362 | ) 363 | delta_transform: bpy.props.FloatVectorProperty( 364 | description='-private-data-', 365 | size=16, 366 | ) 367 | 368 | def update_link(self): 369 | ''' 370 | Holy Python! Today I spent hours to realize what the magic is happening here and there. 371 | So, to not spend the time once again, the magic lays in the following equation/expression 372 | which is being calculated in `_get_transform()`: 373 | /--source_to_target_rest--\\ 374 | | | 375 | | /target_rest\\ | /delta_transform\\ 376 | | | / | | / 377 | Ta_x = (!(Tw * !Ta_0) * Sw * !Sa_0) * Sa_x * (!Sw * Tw) 378 | where: 379 | Sw - source world-basis matrix 380 | Sa_0, Sa_x - source local animation data matrix, e.g. calculated from curve values 381 | `Sa_0` - the initial, `Sa_x` - the current state/pose matrix 382 | Tw - target world-basis matrix 383 | Ta_x - the desired target local animation data matrix 384 | 385 | The Proof (in the initial state, don't care of edge cases): 386 | 1. replacing Sa_x => Sa_0, Ta_x => Ta_0: 387 | Ta_0 = (!(Tw * !Ta_0) * Sw * !Sa_0) * Sa_0 * (!Sw * Tw) 388 | 2. Remove braces due to associative property of matrix multiplication 389 | Ta_0 = !(Tw * !Ta_0) * Sw * !Sa_0 * Sa_0 * !Sw * Tw 390 | 3. Multiply by `!Ta_0` at right 391 | Ta_0 * !Ta_0 = !(Tw * !Ta_0) * Sw * !Sa_0 * Sa_0 * !Sw * Tw * !Ta_0 392 | 4. Reduce the expression 393 | 1 = !(Tw * !Ta_0) * Sw * 1 * !Sw * Tw * !Ta_0 394 | 1 = !(Tw * !Ta_0) * 1 * Tw * !Ta_0 395 | 1 = 1 396 | ''' 397 | source_obj = bpy.data.objects[self.id_data.animation_retarget.source] 398 | source_bone = source_obj.pose.bones[self.source] 399 | target_obj = self.id_data 400 | target_bone = _prop_to_pose_bone(target_obj, self) 401 | 402 | source = source_obj.matrix_world @ source_bone.matrix 403 | source_rest = source @ source_bone.matrix_basis.inverted() 404 | target = target_obj.matrix_world @ target_bone.matrix 405 | target_rest = target @ target_bone.matrix_basis.inverted() 406 | 407 | source_to_target_rest = target_rest.inverted() @ source_rest 408 | delta_transform = source.inverted() @ target 409 | 410 | self.source_to_target_rest = ( 411 | *source_to_target_rest.row[0], 412 | *source_to_target_rest.row[1], 413 | *source_to_target_rest.row[2], 414 | *source_to_target_rest.row[3] 415 | ) 416 | self.delta_transform = ( 417 | *delta_transform.row[0], 418 | *delta_transform.row[1], 419 | *delta_transform.row[2], 420 | *delta_transform.row[3] 421 | ) 422 | self._invalidate() 423 | 424 | frame_cache: bpy.props.FloatVectorProperty( 425 | description='-private-data-', 426 | size=8, 427 | ) 428 | 429 | def _invalidate(self): 430 | self.invalidate_cache() 431 | bpy.context.view_layer.update() 432 | 433 | def invalidate_cache(self): 434 | self.frame_cache[7] = 0 435 | 436 | def _get_transform(self): 437 | frame = bpy.context.scene.frame_current + \ 438 | 1 # to workaround the default 0 value 439 | cache = tuple(self.frame_cache) 440 | if cache[7] == frame: 441 | return cache 442 | source = bpy.data.objects[self.id_data.animation_retarget.source] 443 | source_bone = source.pose.bones[self.source] 444 | target_bone = _prop_to_pose_bone(self.id_data, self) 445 | 446 | source_to_target_rest = _fvec16_to_matrix4(self.source_to_target_rest) 447 | delta_transform = _fvec16_to_matrix4(self.delta_transform) 448 | transform = source_to_target_rest @ source_bone.matrix_basis @ delta_transform 449 | 450 | location = transform.to_translation() 451 | quaternion = transform.to_quaternion() 452 | rotation = None 453 | if target_bone.rotation_mode == 'QUATERNION': 454 | rotation = quaternion 455 | elif target_bone.rotation_mode == 'AXIS_ANGLE': 456 | rotation = (quaternion.angle, *quaternion.axis) 457 | else: 458 | rotation = (*quaternion.to_euler(target_bone.rotation_mode), 0) 459 | 460 | self.frame_cache = cache = (*location, *rotation, frame) 461 | return cache 462 | 463 | transform: bpy.props.FloatVectorProperty(size=8, get=_get_transform) 464 | 465 | 466 | __CLASSES__ = ( 467 | RelativeObjectTransform, 468 | RelativeBoneTransform, 469 | ) 470 | 471 | 472 | def register(): 473 | for clas in __CLASSES__: 474 | bpy.utils.register_class(clas) 475 | b_type = getattr(clas, 'b_type', None) 476 | if b_type: 477 | b_type.animation_retarget = bpy.props.PointerProperty(type=clas) 478 | 479 | 480 | def unregister(): 481 | for clas in reversed(__CLASSES__): 482 | b_type = getattr(clas, 'b_type', None) 483 | if b_type: 484 | del b_type.animation_retarget 485 | bpy.utils.unregister_class(clas) 486 | -------------------------------------------------------------------------------- /animation_retarget/ops.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from .core import auto_mapping, mapping_to_text, text_to_mapping, clear_mapping 4 | from .core import trick_blender28, need_to_trick_blender28 5 | 6 | 7 | class OBJECT_OT_AutoMapping(bpy.types.Operator): 8 | bl_idname = "animation_retarget.auto_mapping" 9 | bl_label = "Auto Mapping" 10 | bl_description = "Map bones automatically" 11 | bl_options = {'UNDO'} 12 | 13 | def execute(self, context): 14 | target_obj = context.active_object 15 | source_obj = bpy.data.objects[target_obj.animation_retarget.source] 16 | auto_mapping(source_obj, target_obj) 17 | return {'FINISHED'} 18 | 19 | @classmethod 20 | def poll(cls, context): 21 | target_obj = context.active_object 22 | if (not target_obj) or (target_obj.type not in {'ARMATURE'}): 23 | return False 24 | if not target_obj.animation_retarget.source: 25 | return False 26 | return True 27 | 28 | 29 | class OBJECT_OT_CopyMapping(bpy.types.Operator): 30 | bl_idname = "animation_retarget.copy_mapping" 31 | bl_label = "Copy Mapping" 32 | bl_description = "Copy the current mapping to the clipboard" 33 | bl_options = {'UNDO'} 34 | 35 | def execute(self, context): 36 | target_obj = context.active_object 37 | bpy.context.window_manager.clipboard = mapping_to_text(target_obj) 38 | return {'FINISHED'} 39 | 40 | @classmethod 41 | def poll(cls, context): 42 | target_obj = context.active_object 43 | if (not target_obj) or (target_obj.type not in {'ARMATURE'}): 44 | return False 45 | if not target_obj.animation_retarget.source: 46 | return False 47 | return True 48 | 49 | 50 | class OBJECT_OT_PasteMapping(bpy.types.Operator): 51 | bl_idname = "animation_retarget.paste_mapping" 52 | bl_label = "Paste Mapping" 53 | bl_description = "Paste the current mapping from the clipboard" 54 | bl_options = {'UNDO'} 55 | 56 | def execute(self, context): 57 | target_obj = context.active_object 58 | text_to_mapping(bpy.context.window_manager.clipboard, target_obj) 59 | return {'FINISHED'} 60 | 61 | @classmethod 62 | def poll(cls, context): 63 | target_obj = context.active_object 64 | if (not target_obj) or (target_obj.type not in {'ARMATURE'}): 65 | return False 66 | if not bpy.context.window_manager.clipboard: 67 | return False 68 | return True 69 | 70 | 71 | class OBJECT_OT_ClearMapping(bpy.types.Operator): 72 | bl_idname = "animation_retarget.clear_mapping" 73 | bl_label = "Clear Mapping" 74 | bl_description = "Clear the current mapping" 75 | bl_options = {'UNDO'} 76 | 77 | def execute(self, context): 78 | target_obj = context.active_object 79 | clear_mapping(target_obj) 80 | return {'FINISHED'} 81 | 82 | @classmethod 83 | def poll(cls, context): 84 | target_obj = context.active_object 85 | if (not target_obj) or (target_obj.type not in {'ARMATURE'}): 86 | return False 87 | return True 88 | 89 | 90 | class OBJECT_OT_TrickBlender(bpy.types.Operator): 91 | bl_idname = "animation_retarget.trick_blender" 92 | bl_label = "Fix Refreshing" 93 | bl_description = "Trick Blender 2.8x 'depsgraph' to force driver variables refresh each frame" 94 | 95 | def execute(self, context): 96 | target_obj = context.active_object 97 | trick_blender28(target_obj) 98 | return {'FINISHED'} 99 | 100 | @classmethod 101 | def poll(cls, context): 102 | target_obj = context.active_object 103 | if (not target_obj) or (target_obj.type not in {'ARMATURE'}): 104 | return False 105 | 106 | return need_to_trick_blender28(target_obj) 107 | 108 | 109 | __CLASSES__ = ( 110 | OBJECT_OT_AutoMapping, 111 | OBJECT_OT_CopyMapping, 112 | OBJECT_OT_PasteMapping, 113 | OBJECT_OT_ClearMapping, 114 | OBJECT_OT_TrickBlender, 115 | ) 116 | 117 | 118 | def register(): 119 | for clas in __CLASSES__: 120 | bpy.utils.register_class(clas) 121 | 122 | 123 | def unregister(): 124 | for clas in reversed(__CLASSES__): 125 | bpy.utils.unregister_class(clas) 126 | -------------------------------------------------------------------------------- /animation_retarget/ui.py: -------------------------------------------------------------------------------- 1 | import bgl 2 | import gpu 3 | import gpu_extras.batch 4 | import bpy 5 | 6 | 7 | class AbstractBasePanel(bpy.types.Panel): 8 | bl_label = 'Animation Retargeting' 9 | bl_space_type = 'PROPERTIES' 10 | bl_region_type = 'WINDOW' 11 | 12 | def draw_header(self, _context): 13 | self.layout.label(icon='PLUGIN') 14 | 15 | 16 | class OBJECT_PT_ObjectPanel(AbstractBasePanel): 17 | bl_context = 'object' 18 | 19 | @classmethod 20 | def poll(cls, context): 21 | obj = context.active_object 22 | return obj and (obj.type == 'ARMATURE') 23 | 24 | def draw(self, context): 25 | layout = self.layout 26 | data = context.object.animation_retarget 27 | 28 | if bpy.ops.animation_retarget.trick_blender.poll(): 29 | layout.operator('animation_retarget.trick_blender', icon='ERROR') 30 | 31 | row = layout.row(align=True) 32 | row.operator('animation_retarget.auto_mapping', 33 | icon='SHADERFX', text='Auto') 34 | row.operator('animation_retarget.copy_mapping', 35 | icon='COPYDOWN', text='Copy') 36 | row.operator('animation_retarget.paste_mapping', 37 | icon='PASTEDOWN', text='Paste') 38 | row.operator('animation_retarget.clear_mapping', 39 | icon='X', text='Clear') 40 | 41 | layout.prop_search(data, 'source', bpy.data, 'objects') 42 | 43 | source = bpy.data.objects.get(data.source) 44 | if source: 45 | layout.prop(data, 'draw_links', toggle=True) 46 | col = layout.column(align=True) 47 | for bone in context.object.pose.bones: 48 | row = col.row(align=True) 49 | row.label(text=bone.name) 50 | bone_data = bone.animation_retarget 51 | row.prop_search(bone_data, 'source', 52 | source.pose, 'bones', text='') 53 | alert = BONE_PT_PoseBonePanel.draw_buttons( 54 | row, source, bone_data, hide_texts=True) 55 | row.active = alert or bool(bone.animation_retarget.source) 56 | 57 | 58 | class BONE_PT_PoseBonePanel(AbstractBasePanel): 59 | bl_context = 'bone' 60 | 61 | @classmethod 62 | def poll(cls, context): 63 | return ( 64 | OBJECT_PT_ObjectPanel.poll(context) 65 | and context.active_pose_bone 66 | ) 67 | 68 | def draw(self, context): 69 | layout = self.layout 70 | data = context.active_pose_bone.animation_retarget 71 | 72 | source_object = bpy.data.objects.get( 73 | context.active_object.animation_retarget.source) 74 | if not source_object: 75 | layout.label( 76 | text='Select the source object on the Object Properties panel', 77 | icon='INFO') 78 | return 79 | 80 | layout.prop_search(data, 'source', source_object.pose, 'bones') 81 | row = layout.row(align=True) 82 | BONE_PT_PoseBonePanel.draw_buttons(row, source_object, data) 83 | 84 | @classmethod 85 | def draw_buttons(cls, layout, source_object, data, hide_texts=False): 86 | no_source = source_object.pose.bones.get(data.source) is None 87 | 88 | def modified_layout(layout, use_flag): 89 | if no_source: 90 | layout = layout.split(align=True) 91 | if use_flag: 92 | layout.alert = True 93 | else: 94 | layout.enabled = False 95 | return layout 96 | 97 | kwargs = {} 98 | if hide_texts: 99 | kwargs['text'] = '' 100 | modified_layout( 101 | layout, 102 | data.use_rotation, 103 | ).prop(data, 'use_rotation', toggle=True, icon='CON_ROTLIKE', **kwargs) 104 | modified_layout( 105 | layout, 106 | data.use_location, 107 | ).prop(data, 'use_location', toggle=True, icon='CON_LOCLIKE', **kwargs) 108 | return no_source and (data.use_rotation or data.use_location) 109 | 110 | 111 | __CLASSES__ = ( 112 | OBJECT_PT_ObjectPanel, 113 | BONE_PT_PoseBonePanel, 114 | ) 115 | 116 | COLOR_LINKED = (1.0, 0.0, 0.0, 1.0) 117 | 118 | 119 | def overlay_view_3d(): 120 | vertices, colors = [], [] 121 | for object_target in bpy.data.objects: 122 | if object_target.type != 'ARMATURE': 123 | continue 124 | 125 | prop_object = object_target.animation_retarget 126 | if not prop_object.draw_links: 127 | continue 128 | 129 | object_source = bpy.data.objects.get(prop_object.source) 130 | if not object_source: 131 | continue 132 | 133 | for bone_target in object_target.pose.bones: 134 | prop_bone = bone_target.animation_retarget 135 | bone_source = object_source.pose.bones.get(prop_bone.source) 136 | if not bone_source: 137 | continue 138 | loc_source = object_source.matrix_world @ bone_source.matrix 139 | loc_target = object_target.matrix_world @ bone_target.matrix 140 | vertices.append(loc_source.translation) 141 | vertices.append(loc_target.translation) 142 | colors.append(COLOR_LINKED) 143 | colors.append(COLOR_LINKED) 144 | 145 | if not vertices: 146 | return None 147 | 148 | shader = gpu.shader.from_builtin('3D_FLAT_COLOR') 149 | batch = gpu_extras.batch.batch_for_shader( 150 | shader, 'LINES', { 151 | 'pos': vertices, 152 | 'color': colors, 153 | } 154 | ) 155 | bgl.glEnable(bgl.GL_BLEND) 156 | bgl.glEnable(bgl.GL_LINE_SMOOTH) 157 | shader.bind() 158 | return batch.draw(shader) 159 | 160 | 161 | DRAW_HANDLES = [] 162 | 163 | 164 | def register(): 165 | for clas in __CLASSES__: 166 | bpy.utils.register_class(clas) 167 | DRAW_HANDLES.append(bpy.types.SpaceView3D.draw_handler_add( 168 | overlay_view_3d, (), 169 | 'WINDOW', 'POST_VIEW' 170 | )) 171 | 172 | 173 | def unregister(): 174 | for handle in DRAW_HANDLES: 175 | bpy.types.SpaceView3D.draw_handler_remove(handle, 'WINDOW') 176 | DRAW_HANDLES.clear() 177 | for clas in reversed(__CLASSES__): 178 | bpy.utils.unregister_class(clas) 179 | -------------------------------------------------------------------------------- /mk_release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import path, walk 4 | from zipfile import ZipFile, ZIP_DEFLATED 5 | 6 | from animation_retarget import bl_info 7 | 8 | with ZipFile('animation-retarget-' + ('.'.join(map(str, bl_info['version']))) + '.zip', 'w') as z: 9 | z.write('LICENSE', 'animation_retarget/LICENSE', compress_type=ZIP_DEFLATED) 10 | for root, _, files in walk('animation_retarget'): 11 | for file in files: 12 | if not file.endswith('.py'): 13 | continue 14 | z.write(path.join(root, file), compress_type=ZIP_DEFLATED) 15 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igelbox/blender-retarget/9ab6889795903478d7bb4f0a0ff3ef056d3c86b7/tests/__init__.py -------------------------------------------------------------------------------- /tests/cases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igelbox/blender-retarget/9ab6889795903478d7bb4f0a0ff3ef056d3c86b7/tests/cases/__init__.py -------------------------------------------------------------------------------- /tests/cases/test_addon.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | import animation_retarget 4 | from tests import utils 5 | 6 | 7 | class TestAddon(utils.BaseTestCase): 8 | def test_blinfo(self): 9 | self.assertIsNotNone(animation_retarget.bl_info) 10 | 11 | def test_enabled(self): 12 | self.assertIn('animation_retarget', bpy.context.preferences.addons) 13 | -------------------------------------------------------------------------------- /tests/cases/test_ops.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import bpy 4 | 5 | from tests.utils import BaseTestCase, create_armature, ContextMock 6 | 7 | 8 | class TestOperations(BaseTestCase): 9 | @patch.object(bpy, 'context', ContextMock()) 10 | def test_copy(self): 11 | operator = bpy.ops.animation_retarget.copy_mapping 12 | # no armature 13 | self.assertFalse(operator.poll()) 14 | 15 | src = create_armature('src') 16 | tgt = create_armature('tgt') 17 | # no source 18 | self.assertFalse(operator.poll()) 19 | 20 | tgt.animation_retarget.source = src.name 21 | prop = tgt.pose.bones['root'].animation_retarget 22 | prop.source = 'root' 23 | prop.use_location = True 24 | # all ok 25 | self.assertTrue(operator.poll()) 26 | 27 | operator() 28 | self.assertEqual(bpy.context.window_manager.clipboard, """[object] 29 | source = src 30 | 31 | [bone:root] 32 | source = root 33 | use_location = True 34 | source_to_target_rest = (1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 35 | delta_transform = (1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 36 | 37 | """) 38 | 39 | @patch.object(bpy, 'context', ContextMock()) 40 | def test_paste(self): 41 | operator = bpy.ops.animation_retarget.paste_mapping 42 | # no armature 43 | self.assertFalse(operator.poll()) 44 | 45 | create_armature('src') 46 | tgt = create_armature('tgt') 47 | bpy.context.window_manager.clipboard = '' 48 | # the clipboard is empty 49 | self.assertFalse(operator.poll()) 50 | bpy.context.window_manager.clipboard = """ 51 | [object] 52 | source=src 53 | [bone:root] 54 | source = root 55 | use_rotation = True 56 | source_to_target_rest = (1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 57 | [bone:unknown] 58 | [bone:child] 59 | """ 60 | # all ok 61 | self.assertTrue(operator.poll()) 62 | 63 | operator() 64 | self.assertEqual(tgt.animation_retarget.source, 'src') 65 | prop = tgt.pose.bones['root'].animation_retarget 66 | self.assertEqual(prop.source, 'root') 67 | self.assertFalse(prop.use_location) 68 | self.assertTrue(prop.use_rotation) 69 | 70 | def test_clear(self): 71 | operator = bpy.ops.animation_retarget.clear_mapping 72 | # no armature 73 | self.assertFalse(operator.poll()) 74 | 75 | src = create_armature('src') 76 | tgt = create_armature('tgt') 77 | tgt.animation_retarget.source = src.name 78 | prop = tgt.pose.bones['root'].animation_retarget 79 | prop.source = 'root' 80 | prop.use_location = True 81 | prop.use_rotation = True 82 | # all ok 83 | self.assertTrue(operator.poll()) 84 | 85 | operator() 86 | self.assertEqual(prop.source, '') 87 | self.assertFalse(prop.use_location) 88 | self.assertFalse(prop.use_rotation) 89 | 90 | def test_automap(self): 91 | operator = bpy.ops.animation_retarget.auto_mapping 92 | self.assertFalse(operator.poll(), 'no armature') 93 | 94 | src = create_armature('src') 95 | tgt = create_armature('tgt') 96 | self.assertFalse(operator.poll(), 'no source') 97 | tgt.animation_retarget.source = src.name 98 | self.assertTrue(operator.poll(), 'all ok') 99 | prop_child = tgt.pose.bones['child'].animation_retarget 100 | prop_child.source = 'unknown' 101 | 102 | operator() 103 | prop_root = tgt.pose.bones['root'].animation_retarget 104 | self.assertEqual( 105 | (prop_root.source, prop_root.use_location, prop_root.use_rotation), 106 | ('root', True, True) 107 | ) 108 | self.assertEqual( 109 | (prop_child.source, prop_child.use_location, prop_child.use_rotation), 110 | ('unknown', False, False) 111 | ) 112 | 113 | prop_child.source = '' 114 | operator() 115 | self.assertEqual( 116 | (prop_child.source, prop_child.use_location, prop_child.use_rotation), 117 | ('child', True, True) 118 | ) 119 | 120 | def test_trick(self): 121 | operator = bpy.ops.animation_retarget.trick_blender 122 | # no armature 123 | self.assertFalse(operator.poll()) 124 | 125 | src = create_armature('src') 126 | tgt = create_armature('tgt') 127 | tgt.animation_retarget.source = src.name 128 | prop = tgt.pose.bones['root'].animation_retarget 129 | prop.source = 'root' 130 | 131 | self.assertFalse(operator.poll()) 132 | prop.use_location = True 133 | prop.use_rotation = True 134 | 135 | # tricked automatically 136 | self.assertFalse(operator.poll()) 137 | 138 | for fcurve in tgt.animation_data.drivers: 139 | variables = fcurve.driver.variables 140 | variables.remove(variables['Just-a-Trick-to-Refresh']) 141 | self.assertTrue(operator.poll()) 142 | 143 | operator() 144 | self.assertFalse(operator.poll()) 145 | -------------------------------------------------------------------------------- /tests/cases/test_ui.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import bpy 4 | 5 | from animation_retarget.ui import BONE_PT_PoseBonePanel, overlay_view_3d, OBJECT_PT_ObjectPanel 6 | from tests.utils import BaseTestCase, ContextMock, create_armature 7 | 8 | 9 | class ShaderMock: 10 | def __init__(self, *args) -> None: 11 | self.args = args 12 | 13 | def bind(self) -> None: 14 | pass 15 | 16 | 17 | class BatchMock: 18 | def __init__(self, shader, *args) -> None: 19 | self.shader = shader 20 | self.args = args 21 | 22 | def draw(self, shader) -> None: 23 | self.shader = shader 24 | return self 25 | 26 | 27 | class LayoutMock: 28 | def __init__(self, kwargs=None) -> None: 29 | self.calls = [] 30 | self.kwargs = kwargs or {} 31 | 32 | @property 33 | def active(self): 34 | return self.kwargs.get('active', False) 35 | 36 | @active.setter 37 | def active(self, value): 38 | self.kwargs['active'] = value 39 | 40 | @property 41 | def enabled(self): 42 | return self.kwargs.get('enabled', False) 43 | 44 | @enabled.setter 45 | def enabled(self, value): 46 | self.kwargs['enabled'] = value 47 | 48 | def operator(self, *args, **kwargs): 49 | self.calls.append(('operator', args, kwargs)) 50 | 51 | def prop(self, *args, **kwargs): 52 | self.calls.append(('prop', args, kwargs)) 53 | 54 | def prop_search(self, *args, **kwargs): 55 | self.calls.append(('prop_search', args, kwargs)) 56 | 57 | def label(self, *args, **kwargs): 58 | self.calls.append(('label', args, kwargs)) 59 | 60 | def split(self, *args, **kwargs): 61 | layout = LayoutMock(kwargs) 62 | self.calls.append(('split', args, kwargs, layout.calls)) 63 | return layout 64 | 65 | def column(self, *args, **kwargs): 66 | layout = LayoutMock(kwargs) 67 | self.calls.append(('column', args, kwargs, layout.calls)) 68 | return layout 69 | 70 | def row(self, *args, **kwargs): 71 | layout = LayoutMock(kwargs) 72 | self.calls.append(('row', args, kwargs, layout.calls)) 73 | return layout 74 | 75 | 76 | class PanelMock: 77 | def __init__(self) -> None: 78 | self.layout = LayoutMock() 79 | 80 | 81 | class TestUI(BaseTestCase): 82 | def test_object_panel(self): 83 | panel = OBJECT_PT_ObjectPanel 84 | self.assertFalse(panel.poll(bpy.context)) 85 | 86 | src = create_armature('src') 87 | tgt = create_armature('tgt') 88 | self.assertTrue(panel.poll(bpy.context)) 89 | 90 | expected_operators_row = ('row', (), {'align': True}, [ 91 | ('operator', ('animation_retarget.auto_mapping',), 92 | {'icon': 'SHADERFX', 'text': 'Auto'}), 93 | ('operator', ('animation_retarget.copy_mapping',), 94 | {'icon': 'COPYDOWN', 'text': 'Copy'}), 95 | ('operator', ('animation_retarget.paste_mapping',), 96 | {'icon': 'PASTEDOWN', 'text': 'Paste'}), 97 | ('operator', ('animation_retarget.clear_mapping',), 98 | {'icon': 'X', 'text': 'Clear'}) 99 | ]) 100 | 101 | panel_mock = PanelMock() 102 | panel.draw(panel_mock, bpy.context) 103 | self.assertEqual(panel_mock.layout.calls, [ 104 | expected_operators_row, 105 | ('prop_search', 106 | (tgt.animation_retarget, 'source', bpy.data, 'objects'), {}), 107 | ]) 108 | 109 | panel_mock = PanelMock() 110 | tgt.animation_retarget.source = src.name 111 | prop_root = tgt.pose.bones['root'].animation_retarget 112 | prop_root.source = 'root' 113 | prop_root.use_location = True 114 | prop_child = tgt.pose.bones['child'].animation_retarget 115 | prop_child.source = 'child' 116 | prop_child.use_rotation = True 117 | prop_child.source = '' 118 | self.assertTrue(panel.poll(bpy.context)) 119 | panel.draw(panel_mock, bpy.context) 120 | self.assertEqual(panel_mock.layout.calls, [ 121 | expected_operators_row, 122 | ('prop_search', 123 | (tgt.animation_retarget, 'source', bpy.data, 'objects'), {}), 124 | ('prop', (bpy.data.objects['tgt'].animation_retarget, 'draw_links'), { 125 | 'toggle': True}), 126 | ('column', (), {'align': True}, [ 127 | ('row', (), {'align': True, 'active': True}, [ 128 | ('label', (), {'text': 'root'}), 129 | ('prop_search', (prop_root, 'source', 130 | src.pose, 'bones'), {'text': ''}), 131 | ('prop', (prop_root, 'use_rotation'), { 132 | 'toggle': True, 'icon': 'CON_ROTLIKE', 'text': ''}), 133 | ('prop', (prop_root, 'use_location'), { 134 | 'toggle': True, 'icon': 'CON_LOCLIKE', 'text': ''}), 135 | ]), 136 | ('row', (), {'align': True, 'active': True}, [ 137 | ('label', (), {'text': 'child'}), 138 | ('prop_search', (prop_child, 'source', src.pose, 'bones'), { 139 | 'text': ''}), 140 | ('split', (), {'align': True}, [ 141 | ('prop', (prop_child, 'use_rotation'), { 142 | 'toggle': True, 'icon': 'CON_ROTLIKE', 'text': ''}) 143 | ]), 144 | ('split', (), {'align': True, 'enabled': False}, [ 145 | ('prop', (prop_child, 'use_location'), { 146 | 'toggle': True, 'icon': 'CON_LOCLIKE', 'text': ''}) 147 | ]) 148 | ]) 149 | ]), 150 | ]) 151 | 152 | @patch.object(bpy, 'context', ContextMock()) 153 | def test_pose_bone_panel(self): 154 | panel = BONE_PT_PoseBonePanel 155 | self.assertFalse(panel.poll(bpy.context)) 156 | 157 | src = create_armature('src') 158 | tgt = create_armature('tgt') 159 | bpy.context.active_pose_bone = tgt.pose.bones['root'] 160 | self.assertTrue(panel.poll(bpy.context)) 161 | 162 | panel_mock = PanelMock() 163 | panel.draw(panel_mock, bpy.context) 164 | self.assertEqual(panel_mock.layout.calls, [ 165 | ('label', (), { 166 | 'icon': 'INFO', 'text': 'Select the source object on the Object Properties panel'}) 167 | ]) 168 | 169 | panel_mock = PanelMock() 170 | tgt.animation_retarget.source = src.name 171 | prop_root = tgt.pose.bones['root'].animation_retarget 172 | prop_root.source = 'root' 173 | prop_root.use_location = True 174 | self.assertTrue(panel.poll(bpy.context)) 175 | panel.draw(panel_mock, bpy.context) 176 | self.assertEqual(panel_mock.layout.calls, [ 177 | ('prop_search', (prop_root, 'source', src.pose, 'bones'), {}), 178 | ('row', (), {'align': True}, [ 179 | ('prop', (prop_root, 'use_rotation'), { 180 | 'icon': 'CON_ROTLIKE', 'toggle': True}), 181 | ('prop', (prop_root, 'use_location'), { 182 | 'icon': 'CON_LOCLIKE', 'toggle': True}), 183 | ]), 184 | ]) 185 | 186 | @patch('gpu.shader.from_builtin', ShaderMock) 187 | @patch('gpu_extras.batch.batch_for_shader', BatchMock) 188 | @patch('bgl.glEnable', lambda _flags: None) 189 | def test_overlay(self): 190 | self.assertIsNone(overlay_view_3d()) 191 | 192 | tgt = create_armature('tgt') 193 | self.assertIsNone(overlay_view_3d()) 194 | prop = tgt.animation_retarget 195 | prop.draw_links = True 196 | 197 | create_armature('src') 198 | self.assertIsNone(overlay_view_3d()) 199 | 200 | prop.source = 'src' 201 | self.assertIsNone(overlay_view_3d()) 202 | 203 | prop_root = tgt.pose.bones['root'].animation_retarget 204 | prop_root.source = 'root' 205 | batch = overlay_view_3d() 206 | self.assertEqual(batch.args[0], 'LINES') 207 | self.assertEqual(len(batch.args[1]['pos']), 2) 208 | self.assertEqual(batch.shader.args, ('3D_FLAT_COLOR',)) 209 | -------------------------------------------------------------------------------- /tests/ci-prepare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ ! -e "$BLENDER_PATH" ]; then 5 | VER=$(echo "$BLENDER_VERSION" | sed -e 's/\.[0-9]*$//') 6 | URL="https://download.blender.org/release/Blender$VER/blender-$BLENDER_VERSION-linux-x64.tar.xz" 7 | 8 | echo "Downloading $URL to $BLENDER_PATH ..." 9 | mkdir -p "$BLENDER_PATH" 10 | curl -L "$URL" | tar -xJ -C "$BLENDER_PATH" --strip-components 1 11 | 12 | TGT="$HOME/.config/blender/$BLENDER_VERSION/scripts/addons" 13 | echo "Installing the Addon to $TGT ..." 14 | mkdir -p $TGT 15 | ln -s tests $TGT/ 16 | 17 | URL="https://files.pythonhosted.org/packages/74/fb/f481628033d42f6f6021af8a9a13d913707221e139567f39b09b337421b9/coverage-6.3.2.tar.gz" 18 | TGT="$BLENDER_PATH/$VER/python/lib/python$BLENDER_PYTHON_VERSION/" 19 | echo "Downloading $URL to $TGT ..." 20 | test -d "$TGT" # assert the target is exist 21 | curl -L "$URL" | tar -xz 22 | mv coverage-6.3.2/coverage "$TGT" 23 | rm -rf coverage-6.3.2 24 | fi 25 | -------------------------------------------------------------------------------- /tests/runner.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | 4 | import coverage 5 | 6 | cov = coverage.Coverage( 7 | branch=True, 8 | source=['animation_retarget'], 9 | ) 10 | cov.start() 11 | 12 | suite = unittest.defaultTestLoader.discover('.') 13 | if not unittest.TextTestRunner().run(suite).wasSuccessful(): 14 | sys.exit(1) 15 | 16 | cov.stop() 17 | cov.xml_report() 18 | 19 | if '--save-html-report' in sys.argv: 20 | cov.html_report() 21 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | import shutil 4 | import sys 5 | import tempfile 6 | 7 | import bpy 8 | from bpy import context 9 | import addon_utils 10 | 11 | 12 | class WMock: 13 | clipboard = '' 14 | 15 | 16 | class ContextMock: 17 | base = None 18 | window_manager = WMock 19 | active_pose_bone = None 20 | 21 | def __getattribute__(self, name: str): 22 | if name in ('window_manager', 'base', 'active_pose_bone'): 23 | return super().__getattribute__(name) 24 | return context.__getattribute__(name) 25 | 26 | 27 | class BaseTestCase(unittest.TestCase): 28 | __save_test_data = '--save-test-data' in sys.argv 29 | __tmp_base = os.path.join(tempfile.gettempdir(), 30 | 'animation_retarget-tests') 31 | __tmp = __tmp_base + '/out' 32 | 33 | @classmethod 34 | def outpath(cls, path=''): 35 | if not os.path.exists(cls.__tmp): 36 | os.makedirs(cls.__tmp) 37 | return os.path.join(cls.__tmp, path) 38 | 39 | def setUp(self): 40 | self._reports = [] 41 | bpy.ops.wm.read_homefile() 42 | addon_utils.enable('animation_retarget', default_set=True) 43 | 44 | def tearDown(self): 45 | if os.path.exists(self.__tmp): 46 | if self.__save_test_data: 47 | bpy.ops.wm.save_mainfile( 48 | filepath=os.path.join(self.__tmp, 'result.blend')) 49 | new_path = os.path.join( 50 | self.__tmp_base, 51 | self.__class__.__name__, 52 | self._testMethodName 53 | ) 54 | os.renames(self.__tmp, new_path) 55 | else: 56 | shutil.rmtree(self.__tmp) 57 | addon_utils.disable('animation_retarget') 58 | 59 | 60 | def create_armature(name): 61 | arm = bpy.data.armatures.new(name) 62 | obj = bpy.data.objects.new(name, arm) 63 | bpy.context.scene.collection.objects.link(obj) 64 | bpy.context.view_layer.objects.active = obj 65 | 66 | bpy.ops.object.mode_set(mode='EDIT') 67 | try: 68 | root = arm.edit_bones.new('root') 69 | root.tail = (0, 0, 1) 70 | child = arm.edit_bones.new('child') 71 | child.parent = root 72 | child.head = root.tail 73 | child.tail = (0, 1, 1) 74 | finally: 75 | bpy.ops.object.mode_set(mode='OBJECT') 76 | return obj 77 | --------------------------------------------------------------------------------