First Commit

This commit is contained in:
“christopheseux”
2022-12-24 15:30:32 +01:00
parent 3c7f133a85
commit e3d24aa4e3
49 changed files with 8466 additions and 4 deletions
+14
View File
@@ -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()
+63
View File
@@ -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
+615
View File
@@ -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)
+423
View File
@@ -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
+180
View File
@@ -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()}")