auto detect foot and curve edit switch

0.7.0

- auto-detect foot to use for path animation
- button to go back and forth between curve edit and armature pose mode
- UI revamp showing better separation of tool categories
master
Pullusb 2022-04-11 19:46:22 +02:00
parent c6a75f25f8
commit 57aae8af75
8 changed files with 342 additions and 59 deletions

View File

@ -27,17 +27,119 @@ def get_bone_transform_at_frame(b, act, frame):
return transform # loc, rot, scale
def has_extremes(b, act=None):
'''tell if a bone has extreme marked '''
if not act:
act = fn.get_obj_action(b.id_data)
if not act:
return False
for fcu in act.fcurves:
if fcu.data_path.split('"')[1] == b.name: # name of the bone
if [k for k in fcu.keyframe_points if k.type == 'EXTREME']:
return True
return False
def get_extreme_range(ob, act, b):
b_fcurves = [fcu for fcu in act.fcurves if fcu.data_path.split('"')[1] == b.bone.name]
start_frame = end_frame = None
for fcu in b_fcurves:
encountered_marks = False # flag to stop after last extreme of each fcu
for k in fcu.keyframe_points:
# if k.select_control_point: # based on selection ?
if k.type == 'EXTREME':
encountered_marks = True
f = k.co.x
if start_frame is None:
start_frame = f
if start_frame > f:
start_frame = f
if end_frame is None:
end_frame = f
if end_frame < f:
end_frame = f
else:
if encountered_marks:
## means back to other frame type after passed breakdown we stop
## (for this fcu)
break
if start_frame is None or end_frame is None:
return
return end_frame - start_frame
def find_best_foot(ob):
'''Get an armature object and do some wizardry to return best match for foot bone'''
b = bpy.context.active_pose_bone
act = fn.get_obj_action(ob)
if not act:
return ('ERROR', f'No action active on {ob.name}')
# use original action as ref
if '_expanded' in act.name:
base_act_name = act.name.split('_expanded')[0]
base_act = bpy.data.actions.get(base_act_name)
if base_act:
act = base_act
print(f'Using for action {base_act_name} as reference')
else:
print(f'No base action found (searching for {base_act_name})')
if 'foot' in b.name.lower():
# if best is selected
return b
## auto detect reference foot
ref = 'foot.R'
target_bones = [pb for pb in ob.pose.bones if not b.name.lower().startswith(('mch', 'org', 'def'))\
and has_extremes(pb, act=act)]
if not target_bones:
return ('ERROR', f'No keys in action "{act.name}" are marked as Extreme (red keys)')
target_bones.sort(key=lambda x: fn.fuzzy_match_ratio(ref, x.name))
# print('target_bones: ', [b.name for b in target_bones]) #Dbg
# analyse best bone for contact length
b = target_bones[0]
print(f'auto-detected best bone as {b.name}')
# determine contact chain length
flip_name = fn.get_flipped_name(b.name)
flipped = next((b for b in target_bones if b.name == flip_name), None)
if not flipped:
print(f'No flipped name found (using "{flip_name}")')
return b
flipped_contact_range = get_extreme_range(ob, act, flipped)
if not flipped_contact_range:
return b
bone_contact_range = get_extreme_range(ob, act, b)
if bone_contact_range:
if bone_contact_range < flipped_contact_range:
return flipped
else:
return ('ERROR', f'No Extreme (red keys) on bone "{b.name}" for action "{act.name}"')
return b
def anim_path_from_translate():
'''Calculate step size from selected foot and forward axis and animate curve'''
print(fn.helper())
ob = bpy.context.object
# found curve through constraint
b = bpy.context.active_pose_bone
# if not 'foot' in b.bone.name:
# return ('ERROR', 'No "foot" in active bone name\n-> Select foot that has the most reliable contact')
settings = bpy.context.scene.anim_cycle_settings
axis = settings.forward_axis
@ -68,12 +170,16 @@ def anim_path_from_translate():
else:
print(f'No base action found (searching for {base_act_name})')
# CHANGE - retiré le int de la frame
# keyframes = [int(k.co[0]) for fcu in act.fcurves for k in fcu.keyframe_points]
# b = bpy.context.active_pose_bone
b = find_best_foot(ob)
if isinstance(b, tuple):
return b # return error and message
print('best: ', b.name)
# if not 'foot' in b.bone.name:
# return ('ERROR', 'No "foot" in active bone name\n-> Select foot that has the most reliable contact')
## calculate offset from bones by evaluating distance at extremes
# fcurve parsing:
# name : fcu.data_path.split('"')[1] (bone_name)
# properties: fcu.data_path.split('.')[-1] ('location', rotation_euler)
@ -81,7 +187,6 @@ def anim_path_from_translate():
## get only fcurves relative to selected bone
b_fcurves = [fcu for fcu in act.fcurves if fcu.data_path.split('"')[1] == b.bone.name]
start_frame = end_frame = None
@ -143,7 +248,6 @@ def anim_path_from_translate():
print('root vec : ', root_axis_vec)
print('start loc: ', start_loc)
## get distance on forward axis
move_val = (intersect_line_plane(start_loc, start_loc + root_axis_vec, end_loc, root_axis_vec) - start_loc).length
@ -198,9 +302,9 @@ def anim_path_from_translate():
class UAC_OT_animate_path(bpy.types.Operator):
bl_idname = "anim.animate_path"
bl_label = "Animate Path"
bl_description = "Select the most representative 'in contact' feet of the cycle\
bl_description = "Use most representative 'in contact' feet of the cycle\
\nSelect foot bone to use as reference\
\nSelected bone should have two keyframe marked as type extreme (red):\
\nA key for foot first ground contact and other key for foot last contact frame\
\nSelect keyframe and use R > Extreme)"
bl_options = {"REGISTER", "UNDO"}

View File

@ -194,10 +194,112 @@ class UAC_OT_snap_curve_to_ground(bpy.types.Operator):
return {"CANCELLED"}
return {"FINISHED"}
class UAC_OT_edit_curve(bpy.types.Operator):
bl_idname = "uac.edit_curve"
bl_label = "Edit Curve"
bl_description = "Edit curve used as constraint for foot"
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
@classmethod
def poll(cls, context):
return context.object and context.object.type == 'ARMATURE'
def execute(self, context):
b = context.active_pose_bone
curve = None
# test with selected bone
if b and b.constraints:
curve = next((c.target for c in b.constraints if c.type == 'FOLLOW_PATH' and c.target), None)
# get from 'root' bone
if not curve:
curve, _const = fn.get_follow_curve_from_armature(context.object)
if isinstance(curve, str):
self.report({curve}, _const)
if curve == 'ERROR':
return {"CANCELLED"}
# set mode to object set curve as active and go Edit
bpy.ops.object.mode_set(mode='OBJECT', toggle=False)
curve.select_set(True)
context.view_layer.objects.active = curve
bpy.ops.object.mode_set(mode='EDIT', toggle=False) # EDIT_CURVE
return {"FINISHED"}
class UAC_OT_set_choice_id(bpy.types.Operator):
bl_idname = "uac.set_choice_id"
bl_label = "Chosen ID"
bl_description = "Set passed id to a custom prop in window manager"
bl_options = {"REGISTER", "INTERNAL"}
idx : bpy.props.IntProperty(default=0, options={'SKIP_SAVE'})
def execute(self, context):
context.window_manager['back_to_armature_idx_prop'] = self.idx
return {"FINISHED"}
class UAC_OT_object_from_curve(bpy.types.Operator):
bl_idname = "uac.object_from_curve"
bl_label = "Back To Armature"
bl_description = "Go in armature pose mode from current curve"
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
@classmethod
def poll(cls, context):
return context.object and context.object.type == 'CURVE'
def invoke(self, context, event):
self.armatures = []
curve = context.object
for o in context.scene.objects:
if o.type != 'ARMATURE':
continue
for pb in o.pose.bones:
for c in pb.constraints:
if c.type == 'FOLLOW_PATH' and c.target and c.target == curve:
self.armatures.append((o, pb))
break
if not self.armatures:
self.report({'ERROR'}, 'No armature using this curve found')
return {"CANCELLED"}
if len(self.armatures) > 1:
# context.window_manager['back_to_armature_idx_prop']
# return context.window_manager.invoke_props_dialog(self, width=450) # execute on ok
return context.window_manager.invoke_props_popup(self, event) # execute on change
return self.execute(context)
def draw(self, context):
layout = self.layout
for i, item in enumerate(self.armatures):
arm, pb = item
layout.operator('uac.set_choice_id', text=f'{arm.name} > {pb.name}', icon='OUTLINER_OB_ARMATURE').idx = i
def execute(self, context):
if len(self.armatures) > 1:
# use user chosen index
obj, pb = self.armatures[context.window_manager['back_to_armature_idx_prop']]
else:
obj, pb = self.armatures[0]
bpy.ops.object.mode_set(mode='OBJECT', toggle=False)
obj.select_set(True)
context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='POSE', toggle=False)
self.report({'INFO'}, f'Back to pose mode {obj.name} (constraint on {pb.name})')
return {"FINISHED"}
classes=(
UAC_OT_create_curve_path,
UAC_OT_create_follow_path,
UAC_OT_snap_curve_to_ground,
UAC_OT_edit_curve,
UAC_OT_set_choice_id,
UAC_OT_object_from_curve, # use set_choice_id is used to set an index in object_from_curve pop up menu
)
def register():

View File

@ -56,6 +56,11 @@ Bonus:
## Changelog:
0.7.0
- auto-detect foot to use for path animation
- button to go back and forth between curve edit and armature pose mode
- UI revamp showing better separation of tool categories
0.6.0

View File

@ -1,16 +1,16 @@
# SPDX-License-Identifier: GPL-2.0-or-later
bl_info = {
"name": "Unfold Anim Cycle",
"description": "Anim tools to develop walk/run cycles along a curve",
"author": "Samuel Bernou",
"version": (0, 6, 0),
"version": (0, 7, 0),
"blender": (3, 0, 0),
"location": "View3D",
"warning": "WIP",
"doc_url": "https://gitlab.com/autour-de-minuit/blender/unfold_anim_cycle",
"category": "Object"}
# from . import other_file
if 'bpy' in locals():
import importlib as imp
imp.reload(properties)

63
fn.py
View File

@ -54,17 +54,21 @@ def get_gnd():
def get_follow_curve_from_armature(arm):
"""Return curve and constraint or a tuple of string ('error', 'message')
"""
name = get_root_name()
"""Return curve and constraint or a tuple of string ('error', 'message')"""
name = get_root_name()
parents = []
const = False
const = None
# root = b.id_data.pose.bones.get(name)
root = arm.pose.bones.get(name)
if not root:
return ('ERROR', f'No bone named {name}')
for c in root.constraints:
if c.type == 'FOLLOW_PATH':
const = c
""" # old method compatible with child of (using animation on parented object)
if c.type == 'CHILD_OF':
print(f'found child-of on {name}')
if c.target:
@ -77,7 +81,7 @@ def get_follow_curve_from_armature(arm):
print('INFO', f'follow_path found on "{p.name}" parent object')
const = c
break
"""
if not const:
return ('ERROR', 'No constraints founds')
@ -379,8 +383,56 @@ def get_offset_fcu(act=None):
return fcus[0]
def fuzzy_match(s1, s2, tol=0.8, case_sensitive=False):
'''Tell if two strings are similar using a similarity ratio (0 to 1) value passed as third arg'''
from difflib import SequenceMatcher
# can also use difflib.get_close_matches(word, possibilities, n=3, cutoff=0.6)
if case_sensitive:
similarity = SequenceMatcher(None, s1, s2)
else:
similarity = SequenceMatcher(None, s1.lower(), s2.lower())
return similarity.ratio() > tol
def fuzzy_match_ratio(s1, s2, case_sensitive=False):
'''Tell how much two passed strings are similar 1.0 being exactly similar'''
from difflib import SequenceMatcher
if case_sensitive:
similarity = SequenceMatcher(None, s1, s2)
else:
similarity = SequenceMatcher(None, s1.lower(), s2.lower())
return similarity.ratio()
def flip_suffix_side_name(name):
return re.sub(r'([-._])(R|L)', lambda x: x.group(1) + ('L' if x.group(2) == 'R' else 'R'), name)
def get_flipped_name(name):
import re
def flip(match, start=False):
if not match.group(1) or not match.group(2):
return
sides = {
'R' : 'L',
'r' : 'l',
'L' : 'R',
'l' : 'r',
}
if start:
side, sep = match.groups()
return sides[side] + sep
else:
sep, side, num = match.groups()
return sep + sides[side] + (num or '')
start_reg = re.compile(r'^(l|r)([-_.])', flags=re.I)
if start_reg.match(name):
return start_reg.sub(lambda x: flip(x, True), name)
else:
return re.sub(r'([-_.])(l|r)(\.\d+)?$', flip, name, flags=re.I)
### --- context manager - store / restore
@ -405,3 +457,4 @@ class attr_set():
def __exit__(self, exc_type, exc_value, exc_traceback):
for prop, attr, old_val in self.store:
setattr(prop, attr, old_val)

View File

@ -9,7 +9,11 @@ class UAC_PT_walk_cycle_anim_panel(bpy.types.Panel):
def draw(self, context):
layout = self.layout
prefs = fn.get_addon_prefs()
ob = context.object
settings = context.scene.anim_cycle_settings
tweak = settings.tweak
# need to know root orientation forward)
## know direction to evaluate feet moves
## Define Constraint axis (depend on root orientation)
@ -20,57 +24,67 @@ class UAC_PT_walk_cycle_anim_panel(bpy.types.Panel):
row.prop(settings, "forward_axis", text='')
layout.operator("uac.autoset_axis", text='Auto-Set Axis')
box = layout.box()
if not settings.path_to_follow:
box.operator('anim.create_curve_path', text='Create Curve at Root Position', icon='CURVE_BEZCURVE')
layout.operator('anim.create_curve_path', text='Create Curve at Root Position', icon='CURVE_BEZCURVE')
#-# path and ground objects
box.prop_search(settings, "path_to_follow", context.scene, "objects")
box.prop_search(settings, "gnd", context.scene, "objects")
box = layout.box()
expand_icon = 'TRIA_DOWN' if tweak else 'TRIA_RIGHT'
box.prop(settings, 'tweak', text='Curve Options', icon=expand_icon)
if tweak:
#-# path and ground objects
box.prop_search(settings, "path_to_follow", context.scene, "objects")
box.prop_search(settings, "gnd", context.scene, "objects")
row = box.row()
row.operator('anim.snap_curve_to_ground', text='Snap curve to ground', icon='SNAP_ON')
row.active = bool(settings.gnd)
prefs = fn.get_addon_prefs()
ob = context.object
# Determine if already has a constraint (a bit too much condition in a panel...)
constrained = False
if ob and ob.type == 'ARMATURE':
pb = ob.pose.bones.get(prefs.tgt_bone)
if pb:
follow = pb.constraints.get('Follow Path')
if follow and follow.target:
box.label(text=f'{pb.name} -> {follow.target.name}', icon='CON_FOLLOWPATH')
constrained = True
## Put this in a setting popup or submenu
row = box.row()
row.operator('anim.snap_curve_to_ground', text='Snap curve to ground', icon='SNAP_ON')
row.active = bool(settings.gnd)
# Determine if already has a constraint (a bit too much condition in a panel...)
constrained = False
if ob:
if ob.type == 'ARMATURE':
pb = ob.pose.bones.get(prefs.tgt_bone)
if pb:
follow = pb.constraints.get('Follow Path')
if follow and follow.target:
box.label(text=f'{pb.name} -> {follow.target.name}', icon='CON_FOLLOWPATH')
constrained = True
## Put this in a setting popup or submenu
# if context.mode == 'POSE':
if not constrained:
box.operator('anim.create_follow_path', text='Add follow path constraint', icon='CON_FOLLOWPATH')
box.operator('uac.edit_curve', text='Edit Curve', icon='OUTLINER_DATA_CURVE') # FORCE_CURVE
elif ob.type == 'CURVE':
if context.mode in ('OBJECT', 'EDIT_CURVE') \
and settings.path_to_follow \
and ob == settings.path_to_follow:
box.operator('uac.object_from_curve', text='Back To Object', icon='LOOP_BACK')
if not constrained:
## Créer automatiquement le follow path TODO et l'anim de base
box.operator('anim.create_follow_path', text='Add follow path constraint', icon='CON_FOLLOWPATH')
col=layout.column()
box = layout.box()
col=box.column()
col.label(text='Motion:')
col.prop(settings, "start_frame", text='Start')
# col.prop(settings, "foot_axis", text='Foot Axis')
col.operator('anim.animate_path', text='Animate Path (select foot)', icon='ANIM')
col.operator('anim.animate_path', text='Animate Forward Motion', icon='ANIM')
row=layout.row()
row.operator('anim.adjust_animation_length', icon='MOD_TIME')
row=col.row()
row.operator('anim.adjust_animation_length', text='Adjust Forward Speed', icon='MOD_TIME')
## Bake cycle (on selected)
row=layout.row()
box = layout.box()
col=box.column()
col.label(text='Keys:')
row=col.row()
row.prop(settings, "linear", text='Linear')
row.prop(settings, "expand_on_selected_bones")
txt = 'Bake keys' if settings.linear else 'Bake keys and step path'
layout.operator('anim.bake_cycle_and_step', text=txt, icon='SHAPEKEY_DATA')
col.operator('anim.bake_cycle_and_step', text=txt, icon='SHAPEKEY_DATA')
# Pin feet
layout.operator('anim.pin_feets', text='Pin feets', icon='PINNED')
col.operator('anim.pin_feets', text='Pin feets', icon='PINNED')

View File

@ -12,7 +12,7 @@ class UAC_addon_prefs(bpy.types.AddonPreferences):
0 = no prints\n\
1 = basic\n\
2 = full prints",
default=1)
default=0)
tgt_bone : bpy.props.StringProperty(
name="Constrained Pose bone name", default='world',

View File

@ -9,6 +9,11 @@ from bpy.props import (
class UAC_PG_settings(bpy.types.PropertyGroup) :
## HIDDEN to hide the animatable dot thing
tweak : bpy.props.BoolProperty(
name="Tweak", description="Show Tweaking options",
default=False, options={'HIDDEN'})
path_to_follow : bpy.props.PointerProperty(type=bpy.types.Object,
name="Path", description="Curve object used")
@ -26,13 +31,13 @@ class UAC_PG_settings(bpy.types.PropertyGroup) :
start_frame : bpy.props.IntProperty(
name="Start Frame", description="Starting frame for animation path",
default=100,
default=101,
min=0, max=2**31-1, soft_min=0, soft_max=2**31-1, step=1, options={'HIDDEN'})#, subtype='PIXEL'
forward_axis : bpy.props.EnumProperty(
name='Forward Axis',
default='FORWARD_Z', # Modifier default is FORWARD_X (should be TRACK_NEGATIVE_Y for a good rig)
description='Local axis of the "root" bone that point forward',
description='Local axis of the "root" bone that point forward in rest pose',
items=(
('FORWARD_X', 'X', ''),
('FORWARD_Y', 'Y', ''),