Wrap animation tool
1.8.0 - added: `wrap animation` tool. On selected pose bones on each location keyframe, apply offset from a reference bone to a root bone.master
parent
a1d7aff92c
commit
f75d785370
|
@ -1,5 +1,9 @@
|
|||
# Changelog
|
||||
|
||||
1.8.0
|
||||
|
||||
- added: `wrap animation` tool. On selected pose bones on each location keyframe, apply offset from a reference bone to a root bone.
|
||||
|
||||
1.7.0
|
||||
|
||||
- added: `Custom Pinning` Possibility to pin selectively lobc/rot x/y/z components
|
||||
|
|
|
@ -0,0 +1,398 @@
|
|||
## Wrap an expanded animation to an on place loop
|
||||
## Define a bone that will be offseted to stay fix above root
|
||||
|
||||
import bpy
|
||||
import math
|
||||
from mathutils import Vector, Matrix, Euler
|
||||
|
||||
def get_bone_transform_at_frame(b, act, frame):
|
||||
'''Find every loc, rot, scale values at given frame'''
|
||||
transform = {}
|
||||
for channel in ('location', 'rotation_euler', 'scale'):
|
||||
chan_list = []
|
||||
for i in range(3):
|
||||
f = act.fcurves.find(f'pose.bones["{b.name}"].{channel}', index=i)
|
||||
if not f:
|
||||
# print(frame, channel, 'not animated ! using current value') # Dbg
|
||||
chan_list.append(getattr(b, channel)) # get current value since not animated
|
||||
continue
|
||||
chan_list.append(f.evaluate(frame))
|
||||
|
||||
# print(frame, b.name, channel, chan_list) # Dbg
|
||||
if channel == 'rotation_euler':
|
||||
transform[channel] = Euler(chan_list)
|
||||
else:
|
||||
transform[channel] = Vector(chan_list)
|
||||
|
||||
return transform # loc, rot, scale
|
||||
|
||||
def dopesheet_summary(obj_list=None):
|
||||
if obj_list is None:
|
||||
obj_list = bpy.context.selected_objects
|
||||
elif isinstance(obj_list, bpy.types.Object):
|
||||
obj_list = [obj_list]
|
||||
|
||||
start = bpy.context.scene.frame_start
|
||||
end = bpy.context.scene.frame_end
|
||||
frames = []
|
||||
|
||||
for obj in obj_list:
|
||||
for fcurve in obj.animation_data.action.fcurves:
|
||||
for keyframe_point in fcurve.keyframe_points:
|
||||
x, y = keyframe_point.co
|
||||
if x >= start and x <= end and x not in frames:
|
||||
frames.append(x)
|
||||
## for returning an int (import math)
|
||||
#frames.append((math.ceil(x)))
|
||||
return sorted(frames)
|
||||
|
||||
def pose_bone_frame_summary(bone_list=None, filter_channel=None) -> list[int]:
|
||||
'''Return frame numbers where bone(s) have keys
|
||||
:bone_list: a single pose bone or a list of pose bone, if not porvided, context.selected_pose_bone
|
||||
:filter_channel: str or tuple of str to filter channel (ex: 'location')
|
||||
'''
|
||||
if bone_list is None:
|
||||
bone_list = bpy.context.selected_pose_bone
|
||||
elif isinstance(bone_list, bpy.types.PoseBone):
|
||||
bone_list = [bone_list]
|
||||
|
||||
|
||||
start, end = bpy.context.scene.frame_start, bpy.context.scene.frame_end
|
||||
frames = []
|
||||
for bone in bone_list:
|
||||
for fcurve in bone.id_data.animation_data.action.fcurves:
|
||||
if not fcurve.data_path.startswith(f'pose.bones["{bone.name}"]'):
|
||||
continue
|
||||
|
||||
if filter_channel:
|
||||
if not fcurve.data_path.endswith(filter_channel):
|
||||
continue
|
||||
# print('fcurve.data_path: ', fcurve.data_path)
|
||||
|
||||
for keyframe_point in fcurve.keyframe_points:
|
||||
x, _y = keyframe_point.co
|
||||
if x >= start and x <= end and x not in frames:
|
||||
frames.append(x)
|
||||
## for returning an int (import math)
|
||||
#frames.append((math.ceil(x)))
|
||||
return sorted(frames)
|
||||
|
||||
def get_all_keyframe(use_only = True):
|
||||
sum = set()
|
||||
for action in D.actions:
|
||||
if use_only and action.use_fake_user and action.users == 1:
|
||||
#avoid saved (fake user) but unused actions
|
||||
pass
|
||||
elif use_only and action.users == 0:
|
||||
#avoid 0 user actions
|
||||
pass
|
||||
|
||||
else:
|
||||
for fcurve in action.fcurves:
|
||||
for key in fcurve.keyframe_points:
|
||||
sum.add(key.co[0])
|
||||
return sum
|
||||
|
||||
|
||||
def get_keyframes(obj_list):
|
||||
keyframes = []
|
||||
for obj in obj_list:
|
||||
anim = obj.animation_data
|
||||
if anim is not None and anim.action is not None:
|
||||
for fcu in anim.action.fcurves:
|
||||
for keyframe in fcu.keyframe_points:
|
||||
x, y = keyframe.co
|
||||
if x not in keyframes:
|
||||
keyframes.append((math.ceil(x)))
|
||||
return keyframes
|
||||
|
||||
|
||||
def scale_matrix_from_vector(scale):
|
||||
'''recreate a neutral mat scale'''
|
||||
matscale_x = Matrix.Scale(scale[0], 4,(1,0,0))
|
||||
matscale_y = Matrix.Scale(scale[1], 4,(0,1,0))
|
||||
matscale_z = Matrix.Scale(scale[2], 4,(0,0,1))
|
||||
matscale = matscale_x @ matscale_y @ matscale_z
|
||||
return matscale
|
||||
|
||||
|
||||
def has_key_at_frame(item, act=None, frame=None, channel='location', verbose=False):
|
||||
'''Return True if pose bone has a key at passed frame'''
|
||||
|
||||
if frame is None:
|
||||
frame = bpy.context.scene.frame_current
|
||||
|
||||
if isinstance(item, bpy.types.Object):
|
||||
## Object
|
||||
if act is None:
|
||||
act = item.animation_data.action
|
||||
data_path = channel
|
||||
else:
|
||||
## Consider it's a Pose bone
|
||||
if act is None:
|
||||
act = item.id_data.animation_data.action
|
||||
data_path = f'pose.bones["{item.name}"].{channel}'
|
||||
|
||||
for i in range(0,3):
|
||||
f = act.fcurves.find(data_path, index=i)
|
||||
if not f:
|
||||
if verbose:
|
||||
print(f'{item.name} has not {data_path}')
|
||||
continue
|
||||
|
||||
if f.is_empty:
|
||||
if verbose:
|
||||
print(f'fcurve has not keyframes: {f.data_path} {i}')
|
||||
continue
|
||||
|
||||
if not f.is_valid:
|
||||
if verbose:
|
||||
print(f'fcurve is invalid {f.data_path} {i}')
|
||||
continue
|
||||
|
||||
## ? int frame ?
|
||||
if next((k for k in f.keyframe_points if k.co.x == frame), None) is not None:
|
||||
if verbose:
|
||||
print(f'{item.name} {channel} is keyframed')
|
||||
return True
|
||||
return False
|
||||
|
||||
"""
|
||||
def has_channel_key_at_frame(pb, act, frame, channel='location'):
|
||||
'''Return Rrue if pose bone has a key at passed frame'''
|
||||
for i in range(0,3):
|
||||
f = act.fcurves.find(f'pose.bones["{pb.name}"].{channel}', index=i)
|
||||
if not f:
|
||||
continue
|
||||
if f.is_empty:
|
||||
print(f'fcurve has not keyframes: {f.data_path} {i}')
|
||||
continue
|
||||
if not f.is_valid:
|
||||
print(f'fcurve is invalid {f.data_path} {i}')
|
||||
continue
|
||||
|
||||
## ? int frame ?
|
||||
if next((k for k in f.keyframe_points if k.co.x == frame), None) is not None:
|
||||
return True
|
||||
return False
|
||||
"""
|
||||
|
||||
def assign_world_location_to_pose_bone(pb, newloc):
|
||||
# mat = scn.cursor.matrix
|
||||
|
||||
# get world space matrix
|
||||
mat = pb.id_data.matrix_world @ pb.matrix
|
||||
loc, rot, _scale = mat.decompose()
|
||||
|
||||
# compose
|
||||
# loc_mat = Matrix.Translation(loc)
|
||||
loc_mat = Matrix.Translation(newloc)
|
||||
rot_mat = rot.to_matrix().to_4x4()
|
||||
scale_mat = scale_matrix_from_vector(pb.scale)
|
||||
new_mat = loc_mat @ rot_mat @ scale_mat
|
||||
|
||||
# assign
|
||||
pb.matrix = pb.id_data.matrix_world.inverted() @ new_mat
|
||||
|
||||
|
||||
def sort_bones_by_hierarchy_depth(bones):
|
||||
hierarchy_depth = {}
|
||||
|
||||
def get_depth(bone):
|
||||
if bone.parent is None:
|
||||
return 0
|
||||
if bone.name in hierarchy_depth:
|
||||
return hierarchy_depth[bone.name]
|
||||
else:
|
||||
depth = get_depth(bone.parent) + 1
|
||||
hierarchy_depth[bone.name] = depth
|
||||
return depth
|
||||
|
||||
for bone in bones:
|
||||
get_depth(bone)
|
||||
|
||||
# print('hierarchy_depth: ', hierarchy_depth) # Dbg
|
||||
# Ex: hierarchy_depth -> {'world': 1, 'walk': 2, 'scale-all': 3, 'rotate-hip': 4, 'spine01': 5, 'spine02':6, 'foot.R': 1, 'foot.L': 1}
|
||||
sorted_bones = sorted(bones, key=lambda b: hierarchy_depth[b.name])
|
||||
return sorted_bones
|
||||
|
||||
def wrap_animation(ref, root=None, verbose=False):
|
||||
scn = bpy.context.scene
|
||||
org_frame = scn.frame_current
|
||||
|
||||
ob = bpy.context.object
|
||||
ac = ob.animation_data.action
|
||||
|
||||
## Auto set root with classic names
|
||||
if not root:
|
||||
root = ob.pose.bones.get('root')
|
||||
if not root:
|
||||
root = ob.pose.bones.get('world')
|
||||
# if not root:
|
||||
# root = ob.pose.bones.get('walk')
|
||||
|
||||
target_bones = bpy.context.selected_pose_bones
|
||||
|
||||
## During the loop, move upper hiercharchy bones first
|
||||
## (Not 100% sure that's needed, as it's usually not targetting dependent bones)
|
||||
target_bones = sort_bones_by_hierarchy_depth(target_bones)
|
||||
|
||||
for i in range(len(target_bones))[::-1]:
|
||||
## Remove the root:
|
||||
if target_bones[i] == root:
|
||||
target_bones.pop(i)
|
||||
|
||||
## Always put the ref bone at the end:
|
||||
if target_bones[i] == ref:
|
||||
target_bones.append(target_bones.pop(i))
|
||||
|
||||
print('Targeted bones', [b.name for b in target_bones]) #Dbg
|
||||
|
||||
'''
|
||||
### Iterate in whole summary:
|
||||
### -> Frame -> Bones
|
||||
frames = dopesheet_summary(ob)
|
||||
|
||||
## remove duplicates
|
||||
if verbose:
|
||||
print('-Keyframes-')
|
||||
print(frames)
|
||||
|
||||
### Note: /!\ Still some weird error, work when select and place bones one by one !
|
||||
### Not optimized but need to iterate in bone first then frame for each...
|
||||
for f in reversed(frames):
|
||||
frame = int(f)
|
||||
if not verbose:
|
||||
print(f'{frame:04d}', end='\r')
|
||||
scn.frame_set(frame)
|
||||
|
||||
## calc distance
|
||||
ref_loc = (ref.id_data.matrix_world @ ref.matrix).translation.copy()
|
||||
ref_loc = Vector((ref_loc.x, ref_loc.y, 0)) # Remove Z
|
||||
|
||||
## On a moving root, recalculate root_loc on each iteration
|
||||
root_loc = (root.id_data.matrix_world @ root.matrix).translation.copy()
|
||||
root_loc = Vector((root_loc.x, root_loc.y, 0)) # Remove Z
|
||||
|
||||
# get reset_vector
|
||||
reset_vec = root_loc - ref_loc
|
||||
|
||||
for pb in sorted_selected_pose_bones:
|
||||
# if not has_channel_key_at_frame(pb, ac, frame, channel='location'):
|
||||
if not has_key_at_frame(pb, ac, frame, channel='location', verbose=verbose):
|
||||
if verbose:
|
||||
print(f'{frame:03d}: {pb.name} no location keys')
|
||||
continue
|
||||
|
||||
#### assign the matrix from world
|
||||
## bone world position
|
||||
bone_world_loc = (pb.id_data.matrix_world @ pb.matrix).translation.copy()
|
||||
new_pos = bone_world_loc + reset_vec
|
||||
# scn.cursor.location = new_pos #Dbg
|
||||
|
||||
assign_world_location_to_pose_bone(pb, new_pos)
|
||||
|
||||
## create the keyframe
|
||||
pb.keyframe_insert('location', frame=frame)
|
||||
'''
|
||||
|
||||
### Iterate in individual bone summary:
|
||||
### -> bone -> frames
|
||||
|
||||
for pb in target_bones:
|
||||
# Calculate single bone frame summary
|
||||
frames = pose_bone_frame_summary(pb, filter_channel='location')
|
||||
print(f'{pb.name} : {len(frames)} location frames')
|
||||
|
||||
for f in reversed(frames):
|
||||
frame = int(f)
|
||||
if not verbose:
|
||||
print(f'{pb.name} : frame {frame:04d}', end='\r')
|
||||
scn.frame_set(frame)
|
||||
|
||||
## calc distance
|
||||
ref_loc = (ref.id_data.matrix_world @ ref.matrix).translation.copy()
|
||||
ref_loc = Vector((ref_loc.x, ref_loc.y, 0)) # Remove Z
|
||||
|
||||
## On a moving root, recalculate root_loc on each iteration
|
||||
root_loc = (root.id_data.matrix_world @ root.matrix).translation.copy()
|
||||
root_loc = Vector((root_loc.x, root_loc.y, 0)) # Remove Z
|
||||
|
||||
# get reset_vector
|
||||
reset_vec = root_loc - ref_loc
|
||||
|
||||
if not has_key_at_frame(pb, ac, frame, channel='location', verbose=verbose):
|
||||
if verbose:
|
||||
print(f'{frame:03d}: {pb.name} no location keys')
|
||||
continue
|
||||
|
||||
#### assign the matrix from world
|
||||
## bone world position
|
||||
bone_world_loc = (pb.id_data.matrix_world @ pb.matrix).translation.copy()
|
||||
new_pos = bone_world_loc + reset_vec
|
||||
|
||||
assign_world_location_to_pose_bone(pb, new_pos)
|
||||
|
||||
## create the keyframe
|
||||
pb.keyframe_insert('location', frame=frame)
|
||||
|
||||
if not verbose:
|
||||
print()
|
||||
scn.frame_current = org_frame
|
||||
|
||||
|
||||
class AW_OT_wrap_animation(bpy.types.Operator):
|
||||
bl_idname = "autowalk.wrap_animation"
|
||||
bl_label = "Wrap Animation"
|
||||
bl_description = "Wrap the current animation on selected bones.\
|
||||
\nApply Reference -> root offset to selected bones on each frames"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return context.object and context.object.type == 'ARMATURE'
|
||||
|
||||
def execute(self, context):
|
||||
ob = context.object
|
||||
|
||||
settings = context.scene.anim_cycle_settings
|
||||
if not settings.wrap_ref_bone or not settings.wrap_root_bone:
|
||||
self.report({'ERROR'}, 'Need to specify both reference and root bone fields')
|
||||
return {"CANCELLED"}
|
||||
|
||||
if not ob.animation_data or not ob.animation_data.action:
|
||||
self.report({'ERROR'}, f'No animation data on {ob.name}')
|
||||
return {"CANCELLED"}
|
||||
|
||||
ref = settings.wrap_ref_bone
|
||||
root = settings.wrap_root_bone
|
||||
|
||||
## as strings
|
||||
ref = ob.pose.bones.get(ref)
|
||||
root = ob.pose.bones.get(root)
|
||||
|
||||
if not ref:
|
||||
self.report({'ERROR'}, f'Reference bone not found in object: {context.object.name}')
|
||||
return {"CANCELLED"}
|
||||
if not root:
|
||||
self.report({'ERROR'}, f'Root bone not found in object: {context.object.name}')
|
||||
return {"CANCELLED"}
|
||||
|
||||
wrap_animation(ref, root)
|
||||
return {"FINISHED"}
|
||||
|
||||
classes=(
|
||||
AW_OT_wrap_animation,
|
||||
)
|
||||
|
||||
def register():
|
||||
# bpy.types.Object.pose_bone = bpy.props.PointerProperty(type=bpy.types.PoseBone)
|
||||
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
|
||||
# del bpy.types.Object.pose_bone
|
|
@ -4,7 +4,7 @@ bl_info = {
|
|||
"name": "Auto Walk",
|
||||
"description": "Develop a walk/run cycles along a curve and pin feets",
|
||||
"author": "Samuel Bernou",
|
||||
"version": (1, 7, 0),
|
||||
"version": (1, 8, 0),
|
||||
"blender": (3, 0, 0),
|
||||
"location": "View3D",
|
||||
"warning": "",
|
||||
|
@ -22,6 +22,7 @@ if 'bpy' in locals():
|
|||
imp.reload(OP_expand_cycle_step)
|
||||
imp.reload(OP_snap_contact)
|
||||
imp.reload(OP_world_copy_paste)
|
||||
imp.reload(OP_wrap_anim)
|
||||
imp.reload(OP_nla_tweak)
|
||||
imp.reload(panels)
|
||||
else:
|
||||
|
@ -34,6 +35,7 @@ else:
|
|||
from . import OP_expand_cycle_step
|
||||
from . import OP_snap_contact
|
||||
from . import OP_world_copy_paste
|
||||
from . import OP_wrap_anim
|
||||
from . import OP_nla_tweak
|
||||
from . import panels
|
||||
|
||||
|
@ -50,6 +52,7 @@ mods = (
|
|||
OP_expand_cycle_step,
|
||||
OP_snap_contact,
|
||||
OP_world_copy_paste,
|
||||
OP_wrap_anim,
|
||||
OP_nla_tweak,
|
||||
panels,
|
||||
)
|
||||
|
|
58
panels.py
58
panels.py
|
@ -144,6 +144,60 @@ class AW_PT_walk_cycle_anim_panel(bpy.types.Panel):
|
|||
col=box.column()
|
||||
col.operator('autowalk.step_back_actions', text='Use Previous Actions', icon= 'ACTION')
|
||||
|
||||
class AW_MT_wrap_animation_help(bpy.types.Menu):
|
||||
# bl_idname = "OBJECT_MT_custom_menu"
|
||||
bl_label = "Wrap Animation Infos"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
col = layout.column()
|
||||
# col.label(text='Wrap animation:', icon='INFO')
|
||||
col.label(text='Apply offset from ref bone to root bone')
|
||||
col.label(text='on all selected pose bones')
|
||||
|
||||
col.separator()
|
||||
col.label(text='Example:')
|
||||
col.label(text='Root is the bone to return to. Often root/walk/world bone')
|
||||
col.label(text='In most cases, Ref bone will be the bottom spine bone')
|
||||
col.label(text='Applying with spine selected will align spine with root on X-Y axis')
|
||||
|
||||
col.separator()
|
||||
col.label(text='Note: If resulted animation is broken', icon='ERROR')
|
||||
col.label(text='try applying with only one selected bone at a time')
|
||||
|
||||
|
||||
class AW_PT_wrap_animation_panel(bpy.types.Panel):
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "UI"
|
||||
bl_category = "Walk"
|
||||
bl_label = "Wrap Anim"
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.use_property_split = True
|
||||
col = layout.column()
|
||||
if not context.object or context.object.type != 'ARMATURE':
|
||||
col.label(text='Active object must be of Armature type')
|
||||
return
|
||||
|
||||
settings = context.scene.anim_cycle_settings
|
||||
|
||||
## As pose bone search picker
|
||||
# col.prop_search(context.object, "pose_bone", context.object.pose, "bones")
|
||||
# col.prop_search(settings, "wrap_root_bone", context.object.pose, "bones")
|
||||
# col.prop_search(settings, "wrap_ref_bone", context.object.pose, "bones")
|
||||
|
||||
## As strings
|
||||
# col.label(text='offset selection from reference bone to Root')
|
||||
row = col.row()
|
||||
row.label(text='Enter bones names')
|
||||
row.operator("wm.call_menu", text="", icon='QUESTION').name = "AW_MT_wrap_animation_help"
|
||||
|
||||
col.prop(settings, "wrap_root_bone", text='Root')
|
||||
col.prop(settings, "wrap_ref_bone", text='Ref')
|
||||
col.operator('autowalk.wrap_animation', text='Wrap Animation') # , icon=''
|
||||
|
||||
class AW_PT_anim_tools_panel(bpy.types.Panel):
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "UI"
|
||||
|
@ -187,16 +241,18 @@ class AW_PT_nla_tools_panel(bpy.types.Panel):
|
|||
def draw(self, context):
|
||||
layout = self.layout
|
||||
# layout.label(text='Retime Tools')
|
||||
settings = context.scene.anim_cycle_settings
|
||||
row = layout.row(align=True)
|
||||
row.operator('autowalk.nla_key_speed', text='Set/Update Time Keys', icon='TIME')
|
||||
row.operator('autowalk.nla_remove_key_speed', text='', icon='X')
|
||||
|
||||
|
||||
|
||||
classes=(
|
||||
AW_PT_walk_cycle_anim_panel,
|
||||
AW_PT_anim_tools_panel,
|
||||
AW_PT_nla_tools_panel,
|
||||
AW_MT_wrap_animation_help,
|
||||
AW_PT_wrap_animation_panel,
|
||||
)
|
||||
|
||||
classes_override_category =(
|
||||
|
|
|
@ -76,6 +76,33 @@ class AW_PG_settings(bpy.types.PropertyGroup) :
|
|||
pin_rot_z : bpy.props.BoolProperty(
|
||||
name="Pin Rot Z", description="Pin bones rotation Z", default=True, options={'HIDDEN'})
|
||||
|
||||
## Wrap properties
|
||||
|
||||
# wrap_ref_bone : bpy.props.PointerProperty(
|
||||
# name="Reference Bone",
|
||||
# type=bpy.types.PoseBone,
|
||||
# description="Reference bone to replace aligned with root bone"
|
||||
# )
|
||||
|
||||
# wrap_root_bone : bpy.props.PointerProperty(
|
||||
# name="Root Bone",
|
||||
# type=bpy.types.PoseBone,
|
||||
# description="Root bone, on each keyframe\
|
||||
# \nthe offset between ref bon eand root bone will be applied to selected pose bones"
|
||||
# )
|
||||
|
||||
wrap_ref_bone : bpy.props.StringProperty(
|
||||
name="Reference Bone",
|
||||
description="Reference bone to calculate offset towards Root bone"
|
||||
)
|
||||
|
||||
wrap_root_bone : bpy.props.StringProperty(
|
||||
name="Root Bone",
|
||||
default="world", # should be root or walk
|
||||
description="Root bone, on each keyframe\
|
||||
\nthe offset between ref bon eand root bone will be applied to selected pose bones"
|
||||
)
|
||||
|
||||
"""
|
||||
## foot axis not needed (not always aligned with character direction)
|
||||
foot_axis : EnumProperty(
|
||||
|
|
Loading…
Reference in New Issue