First Commit
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
|
||||
from asset_library.pose import (
|
||||
operators)
|
||||
|
||||
if 'bpy' in locals():
|
||||
import importlib
|
||||
|
||||
importlib.reload(operators)
|
||||
|
||||
def register():
|
||||
operators.register()
|
||||
|
||||
def unregister():
|
||||
operators.unregister()
|
||||
@@ -0,0 +1,63 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
||||
"""
|
||||
Pose Library - Conversion of old pose libraries.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from collections.abc import Collection
|
||||
|
||||
if "pose_creation" not in locals():
|
||||
from . import pose_creation
|
||||
else:
|
||||
import importlib
|
||||
|
||||
pose_creation = importlib.reload(pose_creation)
|
||||
|
||||
import bpy
|
||||
from bpy.types import (
|
||||
Action,
|
||||
TimelineMarker,
|
||||
)
|
||||
|
||||
|
||||
def convert_old_poselib(old_poselib: Action) -> Collection[Action]:
|
||||
"""Convert an old-style pose library to a set of pose Actions.
|
||||
|
||||
Old pose libraries were one Action with multiple pose markers. Each pose
|
||||
marker will be converted to an Action by itself and marked as asset.
|
||||
"""
|
||||
|
||||
pose_assets = [
|
||||
action
|
||||
for marker in old_poselib.pose_markers
|
||||
if (action := convert_old_pose(old_poselib, marker))
|
||||
]
|
||||
|
||||
# Mark all Actions as assets in one go. Ideally this would be done on an
|
||||
# appropriate frame in the scene (to set up things like the background
|
||||
# colour), but the old-style poselib doesn't contain such information. All
|
||||
# we can do is just render on the current frame.
|
||||
bpy.ops.asset.mark({'selected_ids': pose_assets})
|
||||
|
||||
return pose_assets
|
||||
|
||||
|
||||
def convert_old_pose(old_poselib: Action, marker: TimelineMarker) -> Optional[Action]:
|
||||
"""Convert an old-style pose library pose to a pose action."""
|
||||
|
||||
frame: int = marker.frame
|
||||
action: Optional[Action] = None
|
||||
|
||||
for fcurve in old_poselib.fcurves:
|
||||
key = pose_creation.find_keyframe(fcurve, frame)
|
||||
if not key:
|
||||
continue
|
||||
|
||||
if action is None:
|
||||
action = bpy.data.actions.new(marker.name)
|
||||
|
||||
pose_creation.create_single_key_fcurve(action, fcurve, key)
|
||||
|
||||
return action
|
||||
@@ -0,0 +1,615 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
||||
"""
|
||||
Pose Library - operators.
|
||||
"""
|
||||
|
||||
from math import radians
|
||||
from mathutils import Vector
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
from typing import Optional, Set
|
||||
|
||||
from asset_library.pose import pose_creation, pose_usage
|
||||
|
||||
import bpy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
import time
|
||||
|
||||
from bpy.props import BoolProperty, CollectionProperty, EnumProperty, PointerProperty, StringProperty
|
||||
from bpy.types import (
|
||||
Action,
|
||||
Context,
|
||||
Event,
|
||||
FileSelectEntry,
|
||||
Object,
|
||||
Operator,
|
||||
PropertyGroup,
|
||||
)
|
||||
from bpy_extras import asset_utils
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
from asset_library.action.functions import (
|
||||
get_marker,
|
||||
get_keyframes,
|
||||
)
|
||||
|
||||
from asset_library.common.bl_utils import (
|
||||
get_view3d_persp,
|
||||
load_assets_from,
|
||||
split_path
|
||||
)
|
||||
|
||||
|
||||
class POSELIB_OT_create_pose_asset(Operator):
|
||||
bl_idname = "poselib.create_pose_asset"
|
||||
bl_label = "Create Pose Asset"
|
||||
bl_description = (
|
||||
"Create a new Action that contains the pose of the selected bones, and mark it as Asset. "
|
||||
"The asset will be stored in the current blend file"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
pose_name: StringProperty(name="Pose Name") # type: ignore
|
||||
activate_new_action: BoolProperty(name="Activate New Action", default=True) # type: ignore
|
||||
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
# Make sure that if there is an asset browser open, the artist can see the newly created pose asset.
|
||||
asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
|
||||
if not asset_browse_area:
|
||||
# No asset browser is visible, so there also aren't any expectations
|
||||
# that this asset will be visible.
|
||||
return True
|
||||
|
||||
asset_space_params = asset_browser.params(asset_browse_area)
|
||||
if asset_space_params.asset_library_ref != 'LOCAL':
|
||||
cls.poll_message_set("Asset Browser must be set to the Current File library")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
#pose_name = self.pose_name or context.object.name
|
||||
pose_name = False
|
||||
if context.object.animation_data:
|
||||
if context.object.animation_data.action:
|
||||
pose_name = get_marker(context.object.animation_data.action)
|
||||
|
||||
if pose_name:
|
||||
prefix = True
|
||||
asset_name = Path(bpy.data.filepath).stem.split('_')[0]
|
||||
|
||||
action_asset_name = re.search(f'^{asset_name}.', pose_name)
|
||||
if action_asset_name:
|
||||
pose_name = pose_name.replace(action_asset_name.group(0), '')
|
||||
|
||||
side = re.search('_\w$', pose_name)
|
||||
if side:
|
||||
pose_name = pose_name.replace(side.group(0), '')
|
||||
|
||||
if 'hands' in context.object.animation_data.action.name.lower():
|
||||
pose_name = f'hand_{pose_name}'
|
||||
|
||||
if pose_name.startswith('lips_'):
|
||||
pose_name.replace('lips_', '')
|
||||
split = pose_name.split('_')
|
||||
pose_name = '-'.join([s for s in split if s.isupper()])
|
||||
pose_name = f'{pose_name}_{split[-1]}'
|
||||
prefix = False
|
||||
|
||||
if prefix and not pose_name.startswith(asset_name):
|
||||
pose_name = f'{asset_name}_{pose_name}'
|
||||
|
||||
else:
|
||||
pose_name = self.pose_name or context.object.name
|
||||
|
||||
asset = pose_creation.create_pose_asset_from_context(context, pose_name)
|
||||
if not asset:
|
||||
self.report({"WARNING"}, "No keyframes were found for this pose")
|
||||
return {"CANCELLED"}
|
||||
|
||||
### ADD ADM
|
||||
data = asset.asset_data
|
||||
data.catalog_id = str(uuid.UUID(int=0))
|
||||
|
||||
data_dict = dict(
|
||||
is_single_frame=True,
|
||||
)
|
||||
if context.scene.camera:
|
||||
data_dict.update(dict(camera=context.scene.camera.name))
|
||||
|
||||
|
||||
for k, v in data_dict.items():
|
||||
data[k] = v
|
||||
###
|
||||
|
||||
if self.activate_new_action:
|
||||
self._set_active_action(context, asset)
|
||||
self._activate_asset_in_browser(context, asset)
|
||||
return {'FINISHED'}
|
||||
|
||||
def _set_active_action(self, context: Context, asset: Action) -> None:
|
||||
self._prevent_action_loss(context.object)
|
||||
|
||||
anim_data = context.object.animation_data_create()
|
||||
context.scene.actionlib.previous_action = anim_data.action
|
||||
anim_data.action = asset
|
||||
|
||||
def _activate_asset_in_browser(self, context: Context, asset: Action) -> None:
|
||||
"""Activate the new asset in the appropriate Asset Browser.
|
||||
|
||||
This makes it possible to immediately check & edit the created pose asset.
|
||||
"""
|
||||
|
||||
asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
|
||||
if not asset_browse_area:
|
||||
return
|
||||
|
||||
# After creating an asset, the window manager has to process the
|
||||
# notifiers before editors should be manipulated.
|
||||
pose_creation.assign_from_asset_browser(asset, asset_browse_area)
|
||||
|
||||
# Pass deferred=True, because we just created a new asset that isn't
|
||||
# known to the Asset Browser space yet. That requires the processing of
|
||||
# notifiers, which will only happen after this code has finished
|
||||
# running.
|
||||
asset_browser.activate_asset(asset, asset_browse_area, deferred=True)
|
||||
|
||||
def _prevent_action_loss(self, object: Object) -> None:
|
||||
"""Mark the action with Fake User if necessary.
|
||||
|
||||
This is to prevent action loss when we reduce its reference counter by one.
|
||||
"""
|
||||
|
||||
if not object.animation_data:
|
||||
return
|
||||
|
||||
action = object.animation_data.action
|
||||
if not action:
|
||||
return
|
||||
|
||||
if action.use_fake_user or action.users > 1:
|
||||
# Removing one user won't GC it.
|
||||
return
|
||||
|
||||
action.use_fake_user = True
|
||||
self.report({'WARNING'}, "Action %s marked Fake User to prevent loss" % action.name)
|
||||
|
||||
|
||||
class POSELIB_OT_restore_previous_action(Operator):
|
||||
bl_idname = "poselib.restore_previous_action"
|
||||
bl_label = "Restore Previous Action"
|
||||
bl_description = "Switch back to the previous Action, after creating a pose asset"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return bool(
|
||||
context.scene.actionlib.previous_action
|
||||
and context.object
|
||||
and context.object.animation_data
|
||||
and context.object.animation_data.action
|
||||
and context.object.animation_data.action.asset_data is not None
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
# This is the Action that was just created with "Create Pose Asset".
|
||||
# It has to be re-applied after switching to the previous action,
|
||||
# to ensure the character keeps the same pose.
|
||||
self.pose_action = context.object.animation_data.action
|
||||
|
||||
prev_action = context.scene.actionlib.previous_action
|
||||
context.object.animation_data.action = prev_action
|
||||
context.scene.actionlib.previous_action = None
|
||||
|
||||
# Wait a bit for the action assignment to be handled, before applying the pose.
|
||||
wm = context.window_manager
|
||||
self._timer = wm.event_timer_add(0.001, window=context.window)
|
||||
wm.modal_handler_add(self)
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def modal(self, context, event):
|
||||
if event.type != 'TIMER':
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
wm = context.window_manager
|
||||
wm.event_timer_remove(self._timer)
|
||||
|
||||
context.object.pose.apply_pose_from_action(self.pose_action)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class ASSET_OT_assign_action(Operator):
|
||||
bl_idname = "asset.assign_action"
|
||||
bl_label = "Assign Action"
|
||||
bl_description = "Set this pose Action as active Action on the active Object"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
assign: BoolProperty(name="Assign", default=True) # type: ignore
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return bool(
|
||||
isinstance(getattr(context, "id", None), Action)
|
||||
and context.object
|
||||
and context.object.mode == "POSE" # This condition may not be desired.
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
if self.assign:
|
||||
context.object.animation_data_create().action = context.id
|
||||
else:
|
||||
if context.object.animation_data.action:
|
||||
context.object.animation_data.action = None
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class POSELIB_OT_copy_as_asset(Operator):
|
||||
bl_idname = "poselib.copy_as_asset"
|
||||
bl_label = "Copy Pose As Asset"
|
||||
bl_description = "Create a new pose asset on the clipboard, to be pasted into an Asset Browser"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
CLIPBOARD_ASSET_MARKER = "ASSET-BLEND="
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return bool(
|
||||
# There must be an object.
|
||||
context.object
|
||||
# It must be in pose mode with selected bones.
|
||||
and context.object.mode == "POSE"
|
||||
and context.object.pose
|
||||
and context.selected_pose_bones_from_active_object
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
asset = pose_creation.create_pose_asset_from_context(
|
||||
context,
|
||||
context.object.name,
|
||||
)
|
||||
if asset is None:
|
||||
self.report({"WARNING"}, "No animation data found to create asset from")
|
||||
return {"CANCELLED"}
|
||||
|
||||
filepath = self.save_datablock(asset)
|
||||
|
||||
context.window_manager.clipboard = "%s%s" % (
|
||||
self.CLIPBOARD_ASSET_MARKER,
|
||||
filepath,
|
||||
)
|
||||
asset_browser.tag_redraw(context.screen)
|
||||
self.report({"INFO"}, "Pose Asset copied, use Paste As New Asset in any Asset Browser to paste")
|
||||
|
||||
# The asset has been saved to disk, so to clean up it has to loose its asset & fake user status.
|
||||
asset.asset_clear()
|
||||
asset.use_fake_user = False
|
||||
|
||||
# The asset can be removed from the main DB, as it was purely created to
|
||||
# be stored to disk, and not to be used in this file.
|
||||
if asset.users > 0:
|
||||
# This should never happen, and indicates a bug in the code. Having a warning about it is nice,
|
||||
# but it shouldn't stand in the way of actually cleaning up the meant-to-be-temporary datablock.
|
||||
self.report({"WARNING"}, "Unexpected non-zero user count for the asset, please report this as a bug")
|
||||
|
||||
bpy.data.actions.remove(asset)
|
||||
return {"FINISHED"}
|
||||
|
||||
def save_datablock(self, action: Action) -> Path:
|
||||
tempdir = Path(bpy.app.tempdir)
|
||||
filepath = tempdir / "copied_asset.blend"
|
||||
bpy.data.libraries.write(
|
||||
str(filepath),
|
||||
datablocks={action},
|
||||
path_remap="NONE",
|
||||
fake_user=True,
|
||||
compress=True, # Single-datablock blend file, likely little need to diff.
|
||||
)
|
||||
return filepath
|
||||
|
||||
|
||||
class POSELIB_OT_paste_asset(Operator):
|
||||
bl_idname = "poselib.paste_asset"
|
||||
bl_label = "Paste As New Asset"
|
||||
bl_description = "Paste the Asset that was previously copied using Copy As Asset"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
|
||||
cls.poll_message_set("Current editor is not an asset browser")
|
||||
return False
|
||||
|
||||
asset_lib_ref = context.space_data.params.asset_library_ref
|
||||
if asset_lib_ref != 'LOCAL':
|
||||
cls.poll_message_set("Asset Browser must be set to the Current File library")
|
||||
return False
|
||||
|
||||
# Delay checking the clipboard as much as possible, as it's CPU-heavier than the other checks.
|
||||
clipboard: str = context.window_manager.clipboard
|
||||
if not clipboard:
|
||||
cls.poll_message_set("Clipboard is empty")
|
||||
return False
|
||||
|
||||
marker = POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER
|
||||
if not clipboard.startswith(marker):
|
||||
cls.poll_message_set("Clipboard does not contain an asset")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
clipboard = context.window_manager.clipboard
|
||||
marker_len = len(POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER)
|
||||
filepath = Path(clipboard[marker_len:])
|
||||
|
||||
assets = load_assets_from(filepath)
|
||||
if not assets:
|
||||
self.report({"ERROR"}, "Did not find any assets on clipboard")
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.report({"INFO"}, "Pasted %d assets" % len(assets))
|
||||
|
||||
bpy.ops.asset.library_refresh()
|
||||
|
||||
asset_browser_area = asset_browser.area_from_context(context)
|
||||
if not asset_browser_area:
|
||||
return {"FINISHED"}
|
||||
|
||||
# Assign same catalog_global as in asset browser.
|
||||
catalog_id = asset_browser.active_catalog_id(asset_browser_area)
|
||||
for asset in assets:
|
||||
asset.asset_data.catalog_id = catalog_id
|
||||
asset_browser.activate_asset(assets[0], asset_browser_area, deferred=True)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class POSELIB_OT_pose_asset_select_bones(Operator):
|
||||
bl_idname = "poselib.pose_asset_select_bones"
|
||||
bl_label = "Select Bones"
|
||||
#bl_description = "Select those bones that are used in this pose"
|
||||
bl_description = "Click: Select used Bones\nAlt+Click: Select Flipped Bones\nCtrl+Click: Select Both sides."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
#bl_property = "selected_side"
|
||||
|
||||
selected_side: EnumProperty(
|
||||
name='Selected Side',
|
||||
items=(
|
||||
('CURRENT', "Current", ""),
|
||||
('FLIPPED', "Flipped", ""),
|
||||
('BOTH', "Both", ""),
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
if not (
|
||||
context.object
|
||||
and context.object.mode == "POSE" # This condition may not be desired.
|
||||
and context.asset_library_ref
|
||||
and context.asset_file_handle
|
||||
):
|
||||
return False
|
||||
return context.asset_file_handle.id_type == 'ACTION'
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
asset: FileSelectEntry = context.asset_file_handle
|
||||
if asset.local_id:
|
||||
return self.use_pose(context, asset.local_id)
|
||||
return self._load_and_use_pose(context)
|
||||
|
||||
def use_pose(self, context: Context, asset: bpy.types.ID) -> Set[str]:
|
||||
# Implement in subclass.
|
||||
pass
|
||||
|
||||
def _load_and_use_pose(self, context: Context) -> Set[str]:
|
||||
asset_library_ref = context.asset_library_ref
|
||||
asset = context.asset_file_handle
|
||||
asset_lib_path = bpy.types.AssetHandle.get_full_library_path(asset, asset_library_ref)
|
||||
|
||||
if not asset_lib_path:
|
||||
self.report( # type: ignore
|
||||
{"ERROR"},
|
||||
# TODO: Add some way to get the library name from the library reference (just asset_library_ref.name?).
|
||||
f"Selected asset {asset.name} could not be located inside the asset library",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
if asset.id_type != 'ACTION':
|
||||
self.report( # type: ignore
|
||||
{"ERROR"},
|
||||
f"Selected asset {asset.name} is not an Action",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
with bpy.types.BlendData.temp_data() as temp_data:
|
||||
with temp_data.libraries.load(asset_lib_path) as (data_from, data_to):
|
||||
data_to.actions = [asset.name]
|
||||
|
||||
action: Action = data_to.actions[0]
|
||||
return self.use_pose(context, action)
|
||||
|
||||
def use_pose(self, context: Context, pose_asset: Action) -> Set[str]:
|
||||
arm_object: Object = context.object
|
||||
#pose_usage.select_bones(arm_object, pose_asset, select=self.select, flipped=self.flipped)
|
||||
pose_usage.select_bones(arm_object, pose_asset, selected_side=self.selected_side)
|
||||
return {"FINISHED"}
|
||||
|
||||
# This operator takes the Window Manager's `actionlib_flipped` property, and
|
||||
# passes it to the `POSELIB_OT_blend_pose_asset` operator. This makes it
|
||||
# possible to bind a key to the operator and still have it respect the global
|
||||
# "Flip Pose" checkbox.
|
||||
class POSELIB_OT_blend_pose_asset_for_keymap(Operator):
|
||||
bl_idname = "poselib.blend_pose_asset_for_keymap"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
_rna = bpy.ops.poselib.blend_pose_asset.get_rna_type()
|
||||
bl_label = _rna.name
|
||||
bl_description = _rna.description
|
||||
del _rna
|
||||
|
||||
flipped: BoolProperty(name="Flipped", default=False) # type: ignore
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return bpy.ops.poselib.blend_pose_asset.poll(context.copy())
|
||||
|
||||
"""
|
||||
def invoke(self, context, event):
|
||||
if event.type == 'LEFTMOUSE':
|
||||
self.flipped = True if event.alt else False
|
||||
|
||||
if event.ctrl:
|
||||
bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=not self.flipped)
|
||||
bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=self.flipped)
|
||||
|
||||
return {'FINISHED'}
|
||||
"""
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||
return bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=self.flipped)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
return bpy.ops.poselib.blend_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=self.flipped)
|
||||
|
||||
|
||||
# This operator takes the Window Manager's `actionlib_flipped` property, and
|
||||
# passes it to the `POSELIB_OT_apply_pose_asset` operator. This makes it
|
||||
# possible to bind a key to the operator and still have it respect the global
|
||||
# "Flip Pose" checkbox.
|
||||
|
||||
class POSELIB_OT_apply_pose_asset_for_keymap(Operator):
|
||||
bl_idname = "poselib.apply_pose_asset_for_keymap"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
_rna = bpy.ops.poselib.apply_pose_asset.get_rna_type()
|
||||
bl_label = _rna.name
|
||||
#bl_description = _rna.description
|
||||
bl_description = 'Apply Pose to Bones'
|
||||
del _rna
|
||||
|
||||
flipped: BoolProperty(name="Flipped", default=False) # type: ignore
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
|
||||
return False
|
||||
return bpy.ops.poselib.apply_pose_asset.poll(context.copy())
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
if self.flipped:
|
||||
action = bpy.data.actions.get(context.active_file.name)
|
||||
|
||||
store_bones = {}
|
||||
|
||||
bones = [
|
||||
'blendshape-eyes', 'blendshape-eye.L', 'blendshape-eye.R',
|
||||
'blendshape-corner-mouth', 'blendshape-corner-mouth.L',
|
||||
'blendshape-corner-down-mouth.L', 'blendshape-corner-up-mouth.L',
|
||||
'blendshape-corner-mouth-add.L','blendshape-corner-mouth.R',
|
||||
'blendshape-corner-down-mouth.R', 'blendshape-corner-up-mouth.R',
|
||||
'blendshape-corner-mouth-add.R', 'blendshape-center-up-mouth',
|
||||
'blendshape-center-down-mouth',
|
||||
'hat1.R', 'hat2.R', 'hat3.R', 'hat1.L', 'hat2.L', 'hat3.L',
|
||||
]
|
||||
|
||||
attributes = [
|
||||
'location', 'rotation_quaternion',
|
||||
'rotation_euler', 'rotation_axis_angle', 'scale'
|
||||
]
|
||||
|
||||
if action:
|
||||
for fc in action.fcurves:
|
||||
bone_name, prop_name = split_path(fc.data_path)
|
||||
if bone_name not in bones:
|
||||
continue
|
||||
|
||||
if not bone_name in store_bones.keys():
|
||||
store_bones[bone_name] = {}
|
||||
|
||||
for attr in attributes:
|
||||
if prop_name == attr:
|
||||
if not prop_name in store_bones[bone_name].keys():
|
||||
store_bones[bone_name][prop_name] = []
|
||||
|
||||
val = getattr(context.object.pose.bones[bone_name], prop_name)
|
||||
|
||||
store_bones[bone_name][prop_name].append(fc.evaluate(context.scene.frame_current))
|
||||
|
||||
bpy.ops.poselib.apply_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=True)
|
||||
|
||||
for bone, v in store_bones.items():
|
||||
for attr, attr_val in v.items():
|
||||
flipped_vector = 1
|
||||
|
||||
### TODO FAIRE ÇA PROPREMENT AVEC UNE COMPREHENSION LIST OU AUTRE
|
||||
if re.search(r'\.[RL]$', bone):
|
||||
flipped_bone = pose_usage.flip_side_name(bone)
|
||||
if attr == 'location':
|
||||
flipped_vector = Vector((-1, 1, 1))
|
||||
# print('-----', store_bones.get(flipped_bone)[attr])
|
||||
attr_val = Vector(store_bones.get(flipped_bone)[attr]) * flipped_vector
|
||||
|
||||
setattr(context.object.pose.bones[bone], attr, attr_val)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
else:
|
||||
return bpy.ops.poselib.apply_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=False)
|
||||
|
||||
|
||||
class POSELIB_OT_convert_old_poselib(Operator):
|
||||
bl_idname = "poselib.convert_old_poselib"
|
||||
bl_label = "Convert Old-Style Pose Library"
|
||||
bl_description = "Create a pose asset for each pose marker in the current action"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
action = context.object and context.object.animation_data and context.object.animation_data.action
|
||||
if not action:
|
||||
cls.poll_message_set("Active object has no Action")
|
||||
return False
|
||||
if not action.pose_markers:
|
||||
cls.poll_message_set("Action %r is not a old-style pose library" % action.name)
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
from . import conversion
|
||||
|
||||
old_poselib = context.object.animation_data.action
|
||||
new_actions = conversion.convert_old_poselib(old_poselib)
|
||||
|
||||
if not new_actions:
|
||||
self.report({'ERROR'}, "Unable to convert to pose assets")
|
||||
return {'CANCELLED'}
|
||||
|
||||
self.report({'INFO'}, "Converted %d poses to pose assets" % len(new_actions))
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
|
||||
classes = (
|
||||
POSELIB_OT_apply_pose_asset_for_keymap,
|
||||
POSELIB_OT_blend_pose_asset_for_keymap,
|
||||
POSELIB_OT_convert_old_poselib,
|
||||
POSELIB_OT_copy_as_asset,
|
||||
POSELIB_OT_create_pose_asset,
|
||||
POSELIB_OT_paste_asset,
|
||||
POSELIB_OT_pose_asset_select_bones,
|
||||
POSELIB_OT_restore_previous_action
|
||||
)
|
||||
|
||||
register, unregister = bpy.utils.register_classes_factory(classes)
|
||||
@@ -0,0 +1,423 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
||||
"""
|
||||
Pose Library - creation functions.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
import re
|
||||
|
||||
from typing import Optional, FrozenSet, Set, Union, Iterable, cast
|
||||
|
||||
|
||||
import bpy
|
||||
from bpy.types import (
|
||||
Action,
|
||||
Bone,
|
||||
Context,
|
||||
FCurve,
|
||||
Keyframe,
|
||||
)
|
||||
|
||||
FCurveValue = Union[float, int]
|
||||
|
||||
pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]')
|
||||
"""RegExp for matching FCurve data paths."""
|
||||
|
||||
|
||||
@dataclasses.dataclass(unsafe_hash=True, frozen=True)
|
||||
class PoseCreationParams:
|
||||
armature_ob: bpy.types.Object
|
||||
src_action: Optional[Action]
|
||||
src_frame_nr: float
|
||||
bone_names: FrozenSet[str]
|
||||
new_asset_name: str
|
||||
|
||||
|
||||
class UnresolvablePathError(ValueError):
|
||||
"""Raised when a data_path cannot be resolved to a current value."""
|
||||
|
||||
|
||||
@dataclasses.dataclass(unsafe_hash=True)
|
||||
class PoseActionCreator:
|
||||
"""Create an Action that's suitable for marking as Asset.
|
||||
|
||||
Does not mark as asset yet, nor does it add asset metadata.
|
||||
"""
|
||||
|
||||
params: PoseCreationParams
|
||||
|
||||
# These were taken from Blender's Action baking code in `anim_utils.py`.
|
||||
# Items are (name, array_length) tuples.
|
||||
_bbone_props = [
|
||||
("bbone_curveinx", None),
|
||||
("bbone_curveoutx", None),
|
||||
("bbone_curveinz", None),
|
||||
("bbone_curveoutz", None),
|
||||
("bbone_rollin", None),
|
||||
("bbone_rollout", None),
|
||||
("bbone_scalein", 3),
|
||||
("bbone_scaleout", 3),
|
||||
("bbone_easein", None),
|
||||
("bbone_easeout", None),
|
||||
]
|
||||
|
||||
def create(self) -> Optional[Action]:
|
||||
"""Create a single-frame Action containing only the given bones, or None if no anim data was found."""
|
||||
|
||||
try:
|
||||
dst_action = self._create_new_action()
|
||||
self._store_pose(dst_action)
|
||||
finally:
|
||||
# Prevent next instantiations of this class from reusing pointers to
|
||||
# bones. They may not be valid by then any more.
|
||||
self._find_bone.cache_clear()
|
||||
|
||||
if len(dst_action.fcurves) == 0:
|
||||
bpy.data.actions.remove(dst_action)
|
||||
return None
|
||||
|
||||
return dst_action
|
||||
|
||||
def _create_new_action(self) -> Action:
|
||||
dst_action = bpy.data.actions.new(self.params.new_asset_name)
|
||||
if self.params.src_action:
|
||||
dst_action.id_root = self.params.src_action.id_root
|
||||
dst_action.user_clear() # actions.new() sets users=1, but marking as asset also increments user count.
|
||||
return dst_action
|
||||
|
||||
def _store_pose(self, dst_action: Action) -> None:
|
||||
"""Store the current pose into the given action."""
|
||||
self._store_bone_pose_parameters(dst_action)
|
||||
self._store_animated_parameters(dst_action)
|
||||
self._store_parameters_from_callback(dst_action)
|
||||
|
||||
def _store_bone_pose_parameters(self, dst_action: Action) -> None:
|
||||
"""Store loc/rot/scale/bbone values in the Action."""
|
||||
|
||||
for bone_name in sorted(self.params.bone_names):
|
||||
self._store_location(dst_action, bone_name)
|
||||
self._store_rotation(dst_action, bone_name)
|
||||
self._store_scale(dst_action, bone_name)
|
||||
self._store_bbone(dst_action, bone_name)
|
||||
|
||||
def _store_animated_parameters(self, dst_action: Action) -> None:
|
||||
"""Store the current value of any animated bone properties."""
|
||||
if self.params.src_action is None:
|
||||
return
|
||||
|
||||
armature_ob = self.params.armature_ob
|
||||
for fcurve in self.params.src_action.fcurves:
|
||||
match = pose_bone_re.match(fcurve.data_path)
|
||||
if not match:
|
||||
# Not animating a bone property.
|
||||
continue
|
||||
|
||||
bone_name = match.group(1)
|
||||
if bone_name not in self.params.bone_names:
|
||||
# Bone is not our export set.
|
||||
continue
|
||||
|
||||
if dst_action.fcurves.find(fcurve.data_path, index=fcurve.array_index):
|
||||
# This property is already handled by a previous _store_xxx() call.
|
||||
continue
|
||||
|
||||
# Only include in the pose if there is a key on this frame.
|
||||
if not self._has_key_on_frame(fcurve):
|
||||
continue
|
||||
|
||||
try:
|
||||
value = self._current_value(armature_ob, fcurve.data_path, fcurve.array_index)
|
||||
except UnresolvablePathError:
|
||||
# A once-animated property no longer exists.
|
||||
continue
|
||||
|
||||
dst_fcurve = dst_action.fcurves.new(
|
||||
fcurve.data_path, index=fcurve.array_index, action_group=bone_name
|
||||
)
|
||||
dst_fcurve.keyframe_points.insert(self.params.src_frame_nr, value=value)
|
||||
dst_fcurve.update()
|
||||
|
||||
def _store_parameters_from_callback(self, dst_action: Action) -> None:
|
||||
"""Store extra parameters in the pose based on arbitrary callbacks.
|
||||
|
||||
Not implemented yet, needs a proper design & some user stories.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _store_location(self, dst_action: Action, bone_name: str) -> None:
|
||||
"""Store bone location."""
|
||||
self._store_bone_array(dst_action, bone_name, "location", 3)
|
||||
|
||||
def _store_rotation(self, dst_action: Action, bone_name: str) -> None:
|
||||
"""Store bone rotation given current rotation mode."""
|
||||
bone = self._find_bone(bone_name)
|
||||
if bone.rotation_mode == "QUATERNION":
|
||||
self._store_bone_array(dst_action, bone_name, "rotation_quaternion", 4)
|
||||
elif bone.rotation_mode == "AXIS_ANGLE":
|
||||
self._store_bone_array(dst_action, bone_name, "rotation_axis_angle", 4)
|
||||
else:
|
||||
self._store_bone_array(dst_action, bone_name, "rotation_euler", 3)
|
||||
|
||||
def _store_scale(self, dst_action: Action, bone_name: str) -> None:
|
||||
"""Store bone scale."""
|
||||
self._store_bone_array(dst_action, bone_name, "scale", 3)
|
||||
|
||||
def _store_bbone(self, dst_action: Action, bone_name: str) -> None:
|
||||
"""Store bendy-bone parameters."""
|
||||
for prop_name, array_length in self._bbone_props:
|
||||
if array_length:
|
||||
self._store_bone_array(dst_action, bone_name, prop_name, array_length)
|
||||
else:
|
||||
self._store_bone_property(dst_action, bone_name, prop_name)
|
||||
|
||||
def _store_bone_array(
|
||||
self, dst_action: Action, bone_name: str, property_name: str, array_length: int
|
||||
) -> None:
|
||||
"""Store all elements of an array property."""
|
||||
for array_index in range(array_length):
|
||||
self._store_bone_property(dst_action, bone_name, property_name, array_index)
|
||||
|
||||
def _store_bone_property(
|
||||
self,
|
||||
dst_action: Action,
|
||||
bone_name: str,
|
||||
property_path: str,
|
||||
array_index: int = -1,
|
||||
) -> None:
|
||||
"""Store the current value of a single bone property."""
|
||||
|
||||
bone = self._find_bone(bone_name)
|
||||
value = self._current_value(bone, property_path, array_index)
|
||||
|
||||
# Get the full 'pose.bones["bone_name"].blablabla' path suitable for FCurves.
|
||||
rna_path = bone.path_from_id(property_path)
|
||||
|
||||
fcurve: Optional[FCurve] = dst_action.fcurves.find(rna_path, index=array_index)
|
||||
if fcurve is None:
|
||||
fcurve = dst_action.fcurves.new(rna_path, index=array_index, action_group=bone_name)
|
||||
|
||||
fcurve.keyframe_points.insert(self.params.src_frame_nr, value=value)
|
||||
fcurve.update()
|
||||
|
||||
@classmethod
|
||||
def _current_value(
|
||||
cls, datablock: bpy.types.ID, data_path: str, array_index: int
|
||||
) -> FCurveValue:
|
||||
"""Resolve an RNA path + array index to an actual value."""
|
||||
value_or_array = cls._path_resolve(datablock, data_path)
|
||||
|
||||
# Both indices -1 and 0 are used for non-array properties.
|
||||
# -1 cannot be used in arrays, whereas 0 can be used in both arrays and non-arrays.
|
||||
|
||||
if array_index == -1:
|
||||
return cast(FCurveValue, value_or_array)
|
||||
|
||||
if array_index == 0:
|
||||
value_or_array = cls._path_resolve(datablock, data_path)
|
||||
try:
|
||||
# MyPy doesn't understand this try/except is to determine the type.
|
||||
value = value_or_array[array_index] # type: ignore
|
||||
except TypeError:
|
||||
# Not an array after all.
|
||||
return cast(FCurveValue, value_or_array)
|
||||
return cast(FCurveValue, value)
|
||||
|
||||
# MyPy doesn't understand that array_index>0 implies this is indexable.
|
||||
return cast(FCurveValue, value_or_array[array_index]) # type: ignore
|
||||
|
||||
@staticmethod
|
||||
def _path_resolve(
|
||||
datablock: bpy.types.ID, data_path: str
|
||||
) -> Union[FCurveValue, Iterable[FCurveValue]]:
|
||||
"""Wrapper for datablock.path_resolve(data_path).
|
||||
|
||||
Raise UnresolvablePathError when the path cannot be resolved.
|
||||
This is easier to deal with upstream than the generic ValueError raised
|
||||
by Blender.
|
||||
"""
|
||||
try:
|
||||
return datablock.path_resolve(data_path) # type: ignore
|
||||
except ValueError as ex:
|
||||
raise UnresolvablePathError(str(ex)) from ex
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def _find_bone(self, bone_name: str) -> Bone:
|
||||
"""Find a bone by name.
|
||||
|
||||
Assumes the named bone exists, as the bones this class handles comes
|
||||
from the user's selection, and you can't select a non-existent bone.
|
||||
"""
|
||||
|
||||
bone: Bone = self.params.armature_ob.pose.bones[bone_name]
|
||||
return bone
|
||||
|
||||
def _has_key_on_frame(self, fcurve: FCurve) -> bool:
|
||||
"""Return True iff the FCurve has a key on the source frame."""
|
||||
|
||||
points = fcurve.keyframe_points
|
||||
if not points:
|
||||
return False
|
||||
|
||||
frame_to_find = self.params.src_frame_nr
|
||||
margin = 0.001
|
||||
high = len(points) - 1
|
||||
low = 0
|
||||
while low <= high:
|
||||
mid = (high + low) // 2
|
||||
diff = points[mid].co.x - frame_to_find
|
||||
if abs(diff) < margin:
|
||||
return True
|
||||
if diff < 0:
|
||||
# Frame to find is bigger than the current middle.
|
||||
low = mid + 1
|
||||
else:
|
||||
# Frame to find is smaller than the current middle
|
||||
high = mid - 1
|
||||
return False
|
||||
|
||||
|
||||
def create_pose_asset(
|
||||
params: PoseCreationParams,
|
||||
) -> Optional[Action]:
|
||||
"""Create a single-frame Action containing only the pose of the given bones.
|
||||
|
||||
DOES mark as asset, DOES NOT configure asset metadata.
|
||||
"""
|
||||
|
||||
creator = PoseActionCreator(params)
|
||||
pose_action = creator.create()
|
||||
if pose_action is None:
|
||||
return None
|
||||
|
||||
pose_action.asset_mark()
|
||||
pose_action.asset_generate_preview()
|
||||
return pose_action
|
||||
|
||||
#def create_pose_asset_from_context(context: Context, new_asset_name: str, selection=True) -> Optional[Action]:
|
||||
def create_pose_asset_from_context(context: Context, new_asset_name: str) -> Optional[Action]:
|
||||
"""Create Action asset from active object & selected bones."""
|
||||
|
||||
bones = context.selected_pose_bones_from_active_object
|
||||
bone_names = {bone.name for bone in bones}
|
||||
|
||||
params = PoseCreationParams(
|
||||
context.object,
|
||||
getattr(context.object.animation_data, "action", None),
|
||||
context.scene.frame_current,
|
||||
frozenset(bone_names),
|
||||
new_asset_name,
|
||||
)
|
||||
|
||||
return create_pose_asset(params)
|
||||
|
||||
|
||||
def copy_fcurves(
|
||||
dst_action: Action,
|
||||
src_action: Action,
|
||||
src_frame_nr: float,
|
||||
bone_names: Set[str],
|
||||
) -> int:
|
||||
"""Copy FCurves, returning number of curves copied."""
|
||||
num_fcurves_copied = 0
|
||||
for fcurve in src_action.fcurves:
|
||||
match = pose_bone_re.match(fcurve.data_path)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
bone_name = match.group(1)
|
||||
if bone_name not in bone_names:
|
||||
continue
|
||||
|
||||
# Check if there is a keyframe on this frame.
|
||||
keyframe = find_keyframe(fcurve, src_frame_nr)
|
||||
if keyframe is None:
|
||||
continue
|
||||
create_single_key_fcurve(dst_action, fcurve, keyframe)
|
||||
num_fcurves_copied += 1
|
||||
return num_fcurves_copied
|
||||
|
||||
|
||||
def create_single_key_fcurve(
|
||||
dst_action: Action, src_fcurve: FCurve, src_keyframe: Keyframe
|
||||
) -> FCurve:
|
||||
"""Create a copy of the source FCurve, but only for the given keyframe.
|
||||
|
||||
Returns a new FCurve with just one keyframe.
|
||||
"""
|
||||
|
||||
dst_fcurve = copy_fcurve_without_keys(dst_action, src_fcurve)
|
||||
copy_keyframe(dst_fcurve, src_keyframe)
|
||||
return dst_fcurve
|
||||
|
||||
|
||||
def copy_fcurve_without_keys(dst_action: Action, src_fcurve: FCurve) -> FCurve:
|
||||
"""Create a new FCurve and copy some properties."""
|
||||
|
||||
src_group_name = src_fcurve.group.name if src_fcurve.group else ""
|
||||
dst_fcurve = dst_action.fcurves.new(
|
||||
src_fcurve.data_path, index=src_fcurve.array_index, action_group=src_group_name
|
||||
)
|
||||
for propname in {"auto_smoothing", "color", "color_mode", "extrapolation"}:
|
||||
setattr(dst_fcurve, propname, getattr(src_fcurve, propname))
|
||||
return dst_fcurve
|
||||
|
||||
|
||||
def copy_keyframe(dst_fcurve: FCurve, src_keyframe: Keyframe) -> Keyframe:
|
||||
"""Copy a keyframe from one FCurve to the other."""
|
||||
|
||||
dst_keyframe = dst_fcurve.keyframe_points.insert(
|
||||
src_keyframe.co.x, src_keyframe.co.y, options={'FAST'}, keyframe_type=src_keyframe.type
|
||||
)
|
||||
|
||||
for propname in {
|
||||
"amplitude",
|
||||
"back",
|
||||
"easing",
|
||||
"handle_left",
|
||||
"handle_left_type",
|
||||
"handle_right",
|
||||
"handle_right_type",
|
||||
"interpolation",
|
||||
"period",
|
||||
}:
|
||||
setattr(dst_keyframe, propname, getattr(src_keyframe, propname))
|
||||
dst_fcurve.update()
|
||||
return dst_keyframe
|
||||
|
||||
|
||||
def find_keyframe(fcurve: FCurve, frame: float) -> Optional[Keyframe]:
|
||||
# Binary search adapted from https://pythonguides.com/python-binary-search/
|
||||
keyframes = fcurve.keyframe_points
|
||||
low = 0
|
||||
high = len(keyframes) - 1
|
||||
mid = 0
|
||||
|
||||
# Accept any keyframe that's within 'epsilon' of the requested frame.
|
||||
# This should account for rounding errors and the likes.
|
||||
epsilon = 1e-4
|
||||
frame_lowerbound = frame - epsilon
|
||||
frame_upperbound = frame + epsilon
|
||||
while low <= high:
|
||||
mid = (high + low) // 2
|
||||
keyframe = keyframes[mid]
|
||||
if keyframe.co.x < frame_lowerbound:
|
||||
low = mid + 1
|
||||
elif keyframe.co.x > frame_upperbound:
|
||||
high = mid - 1
|
||||
else:
|
||||
return keyframe
|
||||
return None
|
||||
|
||||
|
||||
def assign_from_asset_browser(asset: Action, asset_browser_area: bpy.types.Area) -> None:
|
||||
"""Assign some things from the asset browser to the asset.
|
||||
|
||||
This sets the current catalog ID, and in the future could include tags
|
||||
from the active dynamic catalog, etc.
|
||||
"""
|
||||
|
||||
cat_id = asset_browser.active_catalog_id(asset_browser_area)
|
||||
asset.asset_data.catalog_id = cat_id
|
||||
@@ -0,0 +1,180 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
|
||||
"""
|
||||
Pose Library - usage functions.
|
||||
"""
|
||||
|
||||
from typing import Set
|
||||
import re
|
||||
|
||||
from bpy.types import (
|
||||
Action,
|
||||
Object,
|
||||
)
|
||||
|
||||
|
||||
#def select_bones(arm_object: Object, action: Action, *, select: bool, flipped: bool, both=False) -> None:
|
||||
def select_bones(arm_object: Object, action: Action, *, selected_side, toggle=True):
|
||||
pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]')
|
||||
pose = arm_object.pose
|
||||
|
||||
seen_bone_names: Set[str] = set()
|
||||
bones_to_select = set()
|
||||
|
||||
for fcurve in action.fcurves:
|
||||
data_path: str = fcurve.data_path
|
||||
match = pose_bone_re.match(data_path)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
bone_name = match.group(1)
|
||||
bone_name_flip = flip_side_name(bone_name)
|
||||
|
||||
if bone_name in seen_bone_names:
|
||||
continue
|
||||
seen_bone_names.add(bone_name)
|
||||
|
||||
if selected_side == 'FLIPPED':
|
||||
bones_to_select.add(bone_name_flip)
|
||||
elif selected_side == 'BOTH':
|
||||
bones_to_select.add(bone_name_flip)
|
||||
bones_to_select.add(bone_name)
|
||||
elif selected_side == 'CURRENT':
|
||||
bones_to_select.add(bone_name)
|
||||
|
||||
|
||||
for bone in bones_to_select:
|
||||
pose_bone = pose.bones.get(bone)
|
||||
if pose_bone:
|
||||
if toggle:
|
||||
pose_bone.bone.select = not pose_bone.bone.select
|
||||
else:
|
||||
pose_bone.bone.select = True
|
||||
|
||||
|
||||
_FLIP_SEPARATORS = set(". -_")
|
||||
|
||||
# These are single-character replacements, others are handled differently.
|
||||
_FLIP_REPLACEMENTS = {
|
||||
"l": "r",
|
||||
"L": "R",
|
||||
"r": "l",
|
||||
"R": "L",
|
||||
}
|
||||
|
||||
|
||||
def flip_side_name(to_flip: str) -> str:
|
||||
"""Flip left and right indicators in the name.
|
||||
|
||||
Basically a Python implementation of BLI_string_flip_side_name.
|
||||
|
||||
>>> flip_side_name('bone_L.004')
|
||||
'bone_R.004'
|
||||
>>> flip_side_name('left_bone')
|
||||
'right_bone'
|
||||
>>> flip_side_name('Left_bone')
|
||||
'Right_bone'
|
||||
>>> flip_side_name('LEFT_bone')
|
||||
'RIGHT_bone'
|
||||
>>> flip_side_name('some.bone-RIGHT.004')
|
||||
'some.bone-LEFT.004'
|
||||
>>> flip_side_name('some.bone-right.004')
|
||||
'some.bone-left.004'
|
||||
>>> flip_side_name('some.bone-Right.004')
|
||||
'some.bone-Left.004'
|
||||
>>> flip_side_name('some.bone-LEFT.004')
|
||||
'some.bone-RIGHT.004'
|
||||
>>> flip_side_name('some.bone-left.004')
|
||||
'some.bone-right.004'
|
||||
>>> flip_side_name('some.bone-Left.004')
|
||||
'some.bone-Right.004'
|
||||
>>> flip_side_name('.004')
|
||||
'.004'
|
||||
>>> flip_side_name('L.004')
|
||||
'R.004'
|
||||
"""
|
||||
import string
|
||||
|
||||
if len(to_flip) < 3:
|
||||
# we don't flip names like .R or .L
|
||||
return to_flip
|
||||
|
||||
# We first check the case with a .### extension, let's find the last period.
|
||||
number = ""
|
||||
replace = to_flip
|
||||
if to_flip[-1] in string.digits:
|
||||
try:
|
||||
index = to_flip.rindex(".")
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if to_flip[index + 1] in string.digits:
|
||||
# TODO(Sybren): this doesn't handle "bone.1abc2" correctly.
|
||||
number = to_flip[index:]
|
||||
replace = to_flip[:index]
|
||||
|
||||
if not replace:
|
||||
# Nothing left after the number, so no flips necessary.
|
||||
return replace + number
|
||||
|
||||
if len(replace) == 1:
|
||||
replace = _FLIP_REPLACEMENTS.get(replace, replace)
|
||||
return replace + number
|
||||
|
||||
# First case; separator . - _ with extensions r R l L.
|
||||
if replace[-2] in _FLIP_SEPARATORS and replace[-1] in _FLIP_REPLACEMENTS:
|
||||
replace = replace[:-1] + _FLIP_REPLACEMENTS[replace[-1]]
|
||||
return replace + number
|
||||
|
||||
# Second case; beginning with r R l L, with separator after it.
|
||||
if replace[1] in _FLIP_SEPARATORS and replace[0] in _FLIP_REPLACEMENTS:
|
||||
replace = _FLIP_REPLACEMENTS[replace[0]] + replace[1:]
|
||||
return replace + number
|
||||
|
||||
lower = replace.lower()
|
||||
prefix = suffix = ""
|
||||
if lower.startswith("right"):
|
||||
bit = replace[0:2]
|
||||
if bit == "Ri":
|
||||
prefix = "Left"
|
||||
elif bit == "RI":
|
||||
prefix = "LEFT"
|
||||
else:
|
||||
prefix = "left"
|
||||
replace = replace[5:]
|
||||
elif lower.startswith("left"):
|
||||
bit = replace[0:2]
|
||||
if bit == "Le":
|
||||
prefix = "Right"
|
||||
elif bit == "LE":
|
||||
prefix = "RIGHT"
|
||||
else:
|
||||
prefix = "right"
|
||||
replace = replace[4:]
|
||||
elif lower.endswith("right"):
|
||||
bit = replace[-5:-3]
|
||||
if bit == "Ri":
|
||||
suffix = "Left"
|
||||
elif bit == "RI":
|
||||
suffix = "LEFT"
|
||||
else:
|
||||
suffix = "left"
|
||||
replace = replace[:-5]
|
||||
elif lower.endswith("left"):
|
||||
bit = replace[-4:-2]
|
||||
if bit == "Le":
|
||||
suffix = "Right"
|
||||
elif bit == "LE":
|
||||
suffix = "RIGHT"
|
||||
else:
|
||||
suffix = "right"
|
||||
replace = replace[:-4]
|
||||
|
||||
return prefix + replace + suffix + number
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import doctest
|
||||
|
||||
print(f"Test result: {doctest.testmod()}")
|
||||
Reference in New Issue
Block a user