From 57aae8af75d3dc0cbffcb090656571b7bb27d2ee Mon Sep 17 00:00:00 2001 From: Pullusb Date: Mon, 11 Apr 2022 19:46:22 +0200 Subject: [PATCH] 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 --- OP_animate_path.py | 128 +++++++++++++++++++++++++++++++++++++---- OP_setup_curve_path.py | 102 ++++++++++++++++++++++++++++++++ README.md | 5 ++ __init__.py | 6 +- fn.py | 63 ++++++++++++++++++-- panels.py | 86 +++++++++++++++------------ preferences.py | 2 +- properties.py | 9 ++- 8 files changed, 342 insertions(+), 59 deletions(-) diff --git a/OP_animate_path.py b/OP_animate_path.py index e81851f..918678f 100644 --- a/OP_animate_path.py +++ b/OP_animate_path.py @@ -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"} diff --git a/OP_setup_curve_path.py b/OP_setup_curve_path.py index 87ae0a1..6b7c162 100644 --- a/OP_setup_curve_path.py +++ b/OP_setup_curve_path.py @@ -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(): diff --git a/README.md b/README.md index 8f4b73e..e07deab 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__init__.py b/__init__.py index cc7831e..e4bbf00 100644 --- a/__init__.py +++ b/__init__.py @@ -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) diff --git a/fn.py b/fn.py index 65d219c..dcc9dcf 100644 --- a/fn.py +++ b/fn.py @@ -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) + diff --git a/panels.py b/panels.py index 4da9c01..7e0296b 100644 --- a/panels.py +++ b/panels.py @@ -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') diff --git a/preferences.py b/preferences.py index 6b0ff6c..baa6a8e 100644 --- a/preferences.py +++ b/preferences.py @@ -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', diff --git a/properties.py b/properties.py index b975242..5b19560 100644 --- a/properties.py +++ b/properties.py @@ -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', ''),