diff --git a/CHANGELOG.md b/CHANGELOG.md index 948e063..147d314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +0.9.0 + +- fix problem with translation calculation when all keys are marked +- add button to create cycle un tested +- added addon pref button + 0.8.0 - Easy jump to previous action @@ -9,6 +15,7 @@ 0.7.1 - customizable panel category name +- changed tab category name to `Walk` - Change generated action name: - `expanded` -> `baked` - `autogen` -> `pinned` diff --git a/OP_animate_path.py b/OP_animate_path.py index 394855b..40f2318 100644 --- a/OP_animate_path.py +++ b/OP_animate_path.py @@ -139,7 +139,7 @@ def anim_path_from_translate(): ob = bpy.context.object - + debug = fn.get_addon_prefs().debug settings = bpy.context.scene.anim_cycle_settings axis = settings.forward_axis @@ -187,10 +187,20 @@ 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] + print('b_fcurves: ', len(b_fcurves)) start_frame = end_frame = None - for fcu in b_fcurves: + # skip problematic keys + if not len(fcu.keyframe_points): + if debug: print(fcu.data_path, fcu.array_index, '>> no keys !') + continue + + if all(k.type == 'EXTREME' for k in fcu.keyframe_points): + # True if all are extreme or no keyframe in fcu + if debug: print(fcu.data_path, fcu.array_index, '>> all keys are marked as extremes !') + continue + 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 ? @@ -215,7 +225,7 @@ def anim_path_from_translate(): break if start_frame is None or end_frame is None: - return ('ERROR', f'No (or not enough) keyframe marked Extreme {ob.name} > {b.name}') + return ('ERROR', f'No / All / not enough keyframe marked Extreme {ob.name} > {b.name}') if start_frame == end_frame: return ('ERROR', f'Only one key detected as extreme (at frame {start_frame}) !\nNeed at least two chained marked keys') @@ -224,6 +234,9 @@ def anim_path_from_translate(): ## Find move_val from diff position at start and end frame wihtin character forward axis + ## FIXME: problem when cycle axis is not Forward compare to character + ## apply rotations in real world ? quat_diff = b.matrix_basis.to_quaternion().rotation_difference(b.matrix.to_quaternion()) + start_transform = get_bone_transform_at_frame(b, act, start_frame) start_mat = fn.compose_matrix(start_transform['location'], start_transform['rotation_euler'], start_transform['scale']) @@ -246,12 +259,14 @@ def anim_path_from_translate(): end_loc = (b.bone.matrix_local @ end_mat).to_translation() # bpy.context.scene.cursor.location = start_loc # Dbg foot start position - print('root vec : ', root_axis_vec) - print('start loc: ', start_loc) + if debug: + print('root vec : ', root_axis_vec) + print('start loc: ', start_loc) + print('end loc: ', end_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 - print('move_val: ', move_val) + print('Detected move value: ', move_val) length = fn.get_curve_length(curve) @@ -297,7 +312,7 @@ def anim_path_from_translate(): # for k in t_fcu.keyframe_points: # k.interpolation = 'CONSTANT' - print('end of set_follow_path_anim') + if debug: print('end of set_follow_path_anim') class UAC_OT_animate_path(bpy.types.Operator): bl_idname = "anim.animate_path" @@ -318,7 +333,10 @@ class UAC_OT_animate_path(bpy.types.Operator): def execute(self, context): # TODO clear previous animation (keys) if there is any - + if context.mode == 'OBJECT': + # Go in pose mode + bpy.ops.object.mode_set(mode='POSE') + err = anim_path_from_translate() if err: self.report({err[0]}, err[1]) diff --git a/OP_expand_cycle_step.py b/OP_expand_cycle_step.py index 3d482dd..035c779 100644 --- a/OP_expand_cycle_step.py +++ b/OP_expand_cycle_step.py @@ -89,7 +89,8 @@ def bake_cycle(on_selection=True): if debug >= 2: print('keys', len(fcu_kfs)) ## expand to end frame - end = bpy.context.scene.frame_end # maybe add possibility define target manually + # maybe add possibility define target manually ? + end = bpy.context.scene.frame_end + 10 # add a margin iterations = ((end - last) // offset) + 1 if debug >= 2: print('iterations: ', iterations) for _i in range(int(iterations)): @@ -185,7 +186,8 @@ def step_path(): class UAC_OT_bake_cycle_and_step(bpy.types.Operator): bl_idname = "anim.bake_cycle_and_step" bl_label = "Bake key and step path " - bl_description = "Bake the key and step the animation path according to those key" + bl_description = "Bake the key and step the animation path according to those key\ + \n(duplicate to a new 'baked' action)" bl_options = {"REGISTER", "UNDO"} @classmethod @@ -258,6 +260,8 @@ def pin_down_feets(): to_change_list = [ (bpy.context.scene, 'frame_current'), + (bpy.context.scene, 'frame_start', bpy.context.scene.frame_start-100), + (bpy.context.scene, 'frame_end', bpy.context.scene.frame_end+100), (bpy.context.scene.render, 'use_simplify', True), (bpy.context.scene.render, 'simplify_subdivision', 0), ] @@ -333,7 +337,8 @@ def pin_down_feets(): if debug: print(f'fcurve: {b_name} > {prop}') - for r in reversed(contact_ranges): # iterate in reverse ranges (not really necessary) + # iterate in reverse ranges (not really necessary) + for r in reversed(contact_ranges): print(f'range: {r}') first = True for i in range(r[0], r[1]+1)[::-1]: # start from the end of the range @@ -392,7 +397,7 @@ def pin_down_feets(): class UAC_OT_pin_feets(bpy.types.Operator): bl_idname = "anim.pin_feets" bl_label = "Pin Feets" - bl_description = "Pin feets on keys marked as extreme" + bl_description = "Pin feets on keys marked as extreme\n(duplicate to a new 'pinned' action)" bl_options = {"REGISTER", "UNDO"} @classmethod @@ -410,6 +415,8 @@ class UAC_OT_pin_feets(bpy.types.Operator): return {"FINISHED"} +# --- Quick action management + class UAC_OT_set_action(bpy.types.Operator): bl_idname = "uac.set_action" bl_label = "Set action by name" @@ -430,7 +437,7 @@ class UAC_OT_set_action(bpy.types.Operator): class UAC_OT_step_back_actions(bpy.types.Operator): bl_idname = "uac.step_back_actions" bl_label = "Actions Step Back" - bl_description = "Step back to a previous action when 'baked' or 'pinned' action are not ok" + bl_description = "Step back to a previous action if 'baked' or 'pinned' action are not ok" bl_options = {"REGISTER", "INTERNAL", "UNDO"} @classmethod @@ -438,6 +445,10 @@ class UAC_OT_step_back_actions(bpy.types.Operator): return context.object and context.object.type == 'ARMATURE' def invoke(self, context, event): + if context.object.animation_data.use_tweak_mode: + self.report({'ERROR'}, f'Cannot access animation in NLA') + return {"CANCELLED"} + act = context.object.animation_data.action base_name = act.name.replace('_baked', '').replace('_pinned', '') base_name = re.sub(r'\.\d{3}', '', base_name) # remove duplicate to search everything that has the same base diff --git a/OP_nla_tweak.py b/OP_nla_tweak.py new file mode 100644 index 0000000..8a5050c --- /dev/null +++ b/OP_nla_tweak.py @@ -0,0 +1,74 @@ +import bpy +from . import fn + +def get_active_nla_strip(all_nla=False): + '''Return object active strip + :all_nla: return first active strip found on all objects > NLA tracks + ''' + + if all_nla: + objs = [o for o in bpy.data.objects if ob.animation_data] + else: + if not bpy.context.object: + return + objs = [bpy.context.object] + + for ob in objs: + if not (anim := ob.animation_data): + continue + for nla in anim.nla_tracks: + for strip in nla.strips: + if strip.active: + print(f'{strip.name} on Track {nla.name}') + return strip + +class UAC_OT_nla_key_speed(bpy.types.Operator): + bl_idname = "anim.nla_key_speed" + bl_label = "NLA Key Speed" + bl_description = "Activate animate strip time and Keyframe linear for first and last animation frame" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.object and context.object.type == 'ARMATURE' + + def execute(self, context): + nla_strip = get_active_nla_strip() + if not nla_strip: + self.report({'ERROR'}, 'no active NLA strip') + return {"CANCELLED"} + + # Clear if exists (or just move first and last point ?) + fcu = nla_strip.fcurves.find('strip_time') + if fcu: + for k in reversed(fcu.keyframe_points): + fcu.keyframe_points.remove(k) + + nla_strip.use_animated_time = True + nla_strip.strip_time = nla_strip.action_frame_start + nla_strip.keyframe_insert('strip_time', frame=nla_strip.frame_start) + + nla_strip.strip_time = nla_strip.action_frame_end + nla_strip.keyframe_insert('strip_time', frame=nla_strip.frame_end) + + fcu = nla_strip.fcurves.find('strip_time') + if not fcu: + return {"CANCELLED"} + + # Go linear + for k in fcu.keyframe_points: + k.interpolation = 'LINEAR' + + return {"FINISHED"} + +classes=( +UAC_OT_nla_key_speed, +) + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + +def unregister(): + for cls in reversed(classes): + bpy.utils.unregister_class(cls) \ No newline at end of file diff --git a/OP_setup.py b/OP_setup.py index a3b9701..5cac136 100644 --- a/OP_setup.py +++ b/OP_setup.py @@ -2,6 +2,7 @@ import bpy from mathutils import Vector, Quaternion from math import sin, cos, radians import numpy as np +from . import fn ## all action needed to setup the walk @@ -73,10 +74,31 @@ class UAC_OT_autoset_axis(bpy.types.Operator): context.scene.anim_cycle_settings.forward_axis = best_axis return {"FINISHED"} +class UAC_OT_create_cycles_modifiers(bpy.types.Operator): + bl_idname = "uac.create_cycles_modifiers" + bl_label = "Add Cycles Modifiers" + bl_description = "Add cycles modifier on all bones not starting with [mch, org, def]\ + \nand that are non-deforming" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.object and context.object.type == 'ARMATURE' + + def execute(self, context): + fn.create_cycle_modifiers(context.object) + return {"FINISHED"} + + +classes=( +UAC_OT_autoset_axis, +UAC_OT_create_cycles_modifiers, +) def register(): - bpy.utils.register_class(UAC_OT_autoset_axis) + for cls in classes: + bpy.utils.register_class(cls) def unregister(): - bpy.utils.unregister_class(UAC_OT_autoset_axis) - + for cls in reversed(classes): + bpy.utils.unregister_class(cls) \ No newline at end of file diff --git a/OP_setup_curve_path.py b/OP_setup_curve_path.py index 1fe441c..30bb213 100644 --- a/OP_setup_curve_path.py +++ b/OP_setup_curve_path.py @@ -178,8 +178,8 @@ class UAC_OT_create_follow_path(bpy.types.Operator): class UAC_OT_snap_curve_to_ground(bpy.types.Operator): bl_idname = "anim.snap_curve_to_ground" - bl_label = "snap_curve_to_ground" - bl_description = "Snap Curve" + bl_label = "Snap Curve" + bl_description = "snap curve to ground determine in field" bl_options = {"REGISTER", "UNDO"} @classmethod diff --git a/__init__.py b/__init__.py index 7bb5846..fe15de2 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,7 @@ bl_info = { "name": "Unfold Anim Cycle", "description": "Anim tools to develop walk/run cycles along a curve", "author": "Samuel Bernou", - "version": (0, 8, 0), + "version": (0, 9, 0), "blender": (3, 0, 0), "location": "View3D", "warning": "WIP", @@ -21,6 +21,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_nla_tweak) imp.reload(panels) else: from . import properties @@ -31,6 +32,7 @@ else: from . import OP_expand_cycle_step from . import OP_snap_contact from . import OP_world_copy_paste + from . import OP_nla_tweak from . import panels import bpy @@ -45,22 +47,40 @@ mods = ( OP_expand_cycle_step, OP_snap_contact, OP_world_copy_paste, + OP_nla_tweak, panels, ) +from bpy.app.handlers import persistent +## Not +# @persistent +# def set_target_bone(scene): +# # prefill constrained bone field +# settings = bpy.context.scene.anim_cycle_settings +# if not settings.tgt_bone: +# settings.tgt_bone = fn.get_addon_prefs().tgt_bone + + def register(): if bpy.app.background: return + for module in mods: module.register() - - panels.update_panel(fn.get_addon_prefs(), bpy.context) + + prefs = fn.get_addon_prefs() + panels.update_panel(prefs, bpy.context) + + # bpy.app.handlers.load_post.append(set_target_bone) def unregister(): if bpy.app.background: return + + # bpy.app.handlers.load_post.remove(set_target_bone) + for module in reversed(mods): module.unregister() diff --git a/fn.py b/fn.py index d41dd34..c616e58 100644 --- a/fn.py +++ b/fn.py @@ -15,6 +15,18 @@ def get_addon_prefs(): addon_prefs = preferences.addons[addon_name].preferences return (addon_prefs) +def open_addon_prefs(): + '''Open addon prefs windows with focus on current addon''' + from .__init__ import bl_info + wm = bpy.context.window_manager + wm.addon_filter = 'All' + if not 'COMMUNITY' in wm.addon_support: # reactivate community + wm.addon_support = set([i for i in wm.addon_support] + ['COMMUNITY']) + wm.addon_search = bl_info['name'] + bpy.context.preferences.active_section = 'ADDONS' + bpy.ops.preferences.addon_expand(module=__package__) + bpy.ops.screen.userpref_show('INVOKE_DEFAULT') + def helper(name: str = '') -> str: '''Return name and arguments from calling obj as str :name: - replace definition name by your own str @@ -232,16 +244,19 @@ def orentation_track_from_vector(input_vector) -> str: def get_root_name(context=None): '''return name of rig root name''' - ## auto-detect ? - ## TODO root need to be user defined (or at least quickly adjustable) - ## need to expose a scene prop from anim_cycle_settings - if context is None: context = bpy.context - # settings = context.scene.anim_cycle_settings - ## maybe use a field on interface + prefs = get_addon_prefs() - return prefs.tgt_bone + + # Auto set bone with pref default value if nothing entered + settings = context.scene.anim_cycle_settings + if not settings.tgt_bone: + settings.tgt_bone = prefs.tgt_bone + + ## auto-detect mode ? + + return settings.tgt_bone def generate_curve(location=(0,0,0), direction=(1,0,0), name='curve_path', enter_edit=True, context=None): '''Create curve at provided location and direction vector''' @@ -458,3 +473,38 @@ class attr_set(): for prop, attr, old_val in self.store: setattr(prop, attr, old_val) +# fcurve modifiers + +def remove_all_cycles_modifier(): + for fc in bpy.context.object.animation_data.action.fcurves: + # if fc.data_path.split('"')[1] in selected_names: + for m in reversed(fc.modifiers): + if m.type == 'CYCLES': + fc.modifiers.remove(m) + +def create_cycle_modifiers(ob=None): + if not ob: + ob = bpy.context.object + + # skip bones that are on protected layers ? + # protected = [i for i, l in enumerate(ob.data.layers_protected) if l] + # for b in ob.data.bones: + # if b.use_deform: # don't affect deform bones + # continue + ## b_layers = [i for i, l in enumerate(b.layers) if l] + + name_list = [b.name for b in ob.data.bones] # if not b.use_deform (too limiting) + + for fc in ob.animation_data.action.fcurves: + if [m for m in fc.modifiers if m.type == 'CYCLES']: + # skip already existing modifier + continue + if not '"' in fc.data_path: + continue + b_name = fc.data_path.split('"')[1] + if b_name.lower().startswith(('mch', 'def', 'org')): + continue + if b_name not in name_list: + continue + print(f'Adding cycle modifier {fc.data_path}') + _m = fc.modifiers.new(type='CYCLES') \ No newline at end of file diff --git a/panels.py b/panels.py index 6aa1509..d77352b 100644 --- a/panels.py +++ b/panels.py @@ -19,10 +19,13 @@ class UAC_PT_walk_cycle_anim_panel(bpy.types.Panel): ## Define Constraint axis (depend on root orientation) # layout.prop(settings, "forward_axis") # plain prop - row = layout.row() + col = layout.column() + row = col.row() row.label(text='Forward Axis') row.prop(settings, "forward_axis", text='') - layout.operator("uac.autoset_axis", text='Auto-Set Axis') + row.operator('uac.open_addon_prefs', icon='PREFERENCES', text='') + col.operator("uac.autoset_axis", text='Auto-Set Axis') # maybe check for fcruve cycle at the end of autoset axis ? (like a check) + col.operator("uac.create_cycles_modifiers", text='Add Cycles Modifiers', icon='GRAPH') pb = None constrained = False @@ -47,8 +50,10 @@ class UAC_PT_walk_cycle_anim_panel(bpy.types.Panel): 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(settings, "tgt_bone", text='Bone') box.prop_search(settings, "path_to_follow", context.scene, "objects") box.prop_search(settings, "gnd", context.scene, "objects") @@ -86,7 +91,8 @@ class UAC_PT_walk_cycle_anim_panel(bpy.types.Panel): ## Bake cycle (on selected) box = layout.box() col=box.column() - col.label(text='Actions:') + col.label(text='Action:') + row=col.row() row.prop(settings, "linear", text='Linear') row.prop(settings, "expand_on_selected_bones") @@ -99,12 +105,12 @@ class UAC_PT_walk_cycle_anim_panel(bpy.types.Panel): ## show a dropdown allowing to go back to unpinned, unbaked version of the animation if ob and ob.type == 'ARMATURE': - if ob.animation_data and ob.animation_data.action: - if 'baked' in ob.animation_data.action.name or 'pinned' in ob.animation_data.action.name: + anim = ob.animation_data + if anim and anim.action and not anim.use_tweak_mode: + # skipped if in NLA tweak mode because anim.is_property_readonly('action') = True + if 'baked' in anim.action.name or 'pinned' in anim.action.name: + col=box.column() col.operator('uac.step_back_actions', text='Use Previous Actions', icon= 'ACTION') - - - class UAC_PT_anim_tools_panel(bpy.types.Panel): bl_space_type = "VIEW_3D" @@ -137,20 +143,38 @@ class UAC_PT_anim_tools_panel(bpy.types.Panel): row.operator('anim.world_space_paste_next_frame', text='', icon='FRAME_NEXT').prev = False row.scale_x = 2 +class UAC_PT_nla_tools_panel(bpy.types.Panel): + bl_space_type = "NLA_EDITOR" + bl_region_type = "UI" + bl_category = "Strip" + bl_label = "Walk Tools" + + def draw(self, context): + layout = self.layout + # layout.label(text='Retime Tools') + layout.operator('anim.nla_key_speed', text='Set Time Keys', icon='TIME') + + classes=( UAC_PT_walk_cycle_anim_panel, UAC_PT_anim_tools_panel, +UAC_PT_nla_tools_panel, +) + +classes_override_category =( +UAC_PT_walk_cycle_anim_panel, +UAC_PT_anim_tools_panel, ) ## Addons Preferences Update Panel def update_panel(self, context): - for cls in classes: + for cls in classes_override_category: # classes try: bpy.utils.unregister_class(cls) except: pass - cls.bl_category = self.category#fn.get_addon_prefs().category + cls.bl_category = self.category # fn.get_addon_prefs().category bpy.utils.register_class(cls) def register(): diff --git a/preferences.py b/preferences.py index 95b89a6..b50ed14 100644 --- a/preferences.py +++ b/preferences.py @@ -1,6 +1,15 @@ import bpy from .panels import update_panel +from . import fn +class UAC_OT_open_addon_prefs(bpy.types.Operator): + bl_idname = "uac.open_addon_prefs" + bl_label = "Open Addon Prefs" + bl_description = "Open user preferences window in addon tab and prefill the search with addon name" + bl_options = {"REGISTER", "INTERNAL"} + def execute(self, context): + fn.open_addon_prefs() + return {'FINISHED'} class UAC_addon_prefs(bpy.types.AddonPreferences): ## can be just __name__ if prefs are in the __init__ mainfile # Else need the splitext '__name__ = addonname.subfile' (or use a static name) @@ -15,16 +24,18 @@ class UAC_addon_prefs(bpy.types.AddonPreferences): # some_bool_prop to display in the addon pref debug : bpy.props.IntProperty( name='Debug', + default=0, description="Enable Debug prints\n\ 0 = no prints\n\ 1 = basic\n\ 2 = full prints", - default=0) + ) tgt_bone : bpy.props.StringProperty( name="Constrained Pose bone name", default='world', description="name of the bone that suppose to hold the constraint") + """ default_forward_axis : bpy.props.EnumProperty( name='Forward Axis', default='TRACK_NEGATIVE_Y', # Modifier default is FORWARD_X @@ -54,8 +65,15 @@ class UAC_addon_prefs(bpy.types.AddonPreferences): layout.prop(self, "debug") +classes = ( +UAC_addon_prefs, +UAC_OT_open_addon_prefs, +) + def register(): - bpy.utils.register_class(UAC_addon_prefs) + for cls in classes: + bpy.utils.register_class(cls) def unregister(): - bpy.utils.unregister_class(UAC_addon_prefs) \ No newline at end of file + for cls in reversed(classes): + bpy.utils.unregister_class(cls) \ No newline at end of file diff --git a/properties.py b/properties.py index 5b19560..266aec0 100644 --- a/properties.py +++ b/properties.py @@ -14,6 +14,10 @@ class UAC_PG_settings(bpy.types.PropertyGroup) : name="Tweak", description="Show Tweaking options", default=False, options={'HIDDEN'}) + tgt_bone : bpy.props.StringProperty( + name="Bone to constrain", default='', + description="Name of the bone to constrain with follow path (usually the root bone)") + path_to_follow : bpy.props.PointerProperty(type=bpy.types.Object, name="Path", description="Curve object used") @@ -48,7 +52,6 @@ class UAC_PG_settings(bpy.types.PropertyGroup) : ), ) - """ ## foot axis not needed (not always aligned with character direction) foot_axis : EnumProperty( @@ -63,22 +66,6 @@ class UAC_PG_settings(bpy.types.PropertyGroup) : # ('-Z', '-Z', '', '', 5), )) """ - # someBool : BoolProperty( - # name="", description="", - # default=True, - # options={'HIDDEN'}) - - # keyframe_type : EnumProperty( - # name="Keyframe Filter", description="Only jump to defined keyframe type", - # default='ALL', options={'HIDDEN', 'SKIP_SAVE'}, - # items=( - # ('ALL', 'All', '', 0), # 'KEYFRAME' - # ('KEYFRAME', 'Keyframe', '', 'KEYTYPE_KEYFRAME_VEC', 1), - # ('BREAKDOWN', 'Breakdown', '', 'KEYTYPE_BREAKDOWN_VEC', 2), - # ('MOVING_HOLD', 'Moving Hold', '', 'KEYTYPE_MOVING_HOLD_VEC', 3), - # ('EXTREME', 'Extreme', '', 'KEYTYPE_EXTREME_VEC', 4), - # ('JITTER', 'Jitter', '', 'KEYTYPE_JITTER_VEC', 5), - # )) def register(): bpy.utils.register_class(UAC_PG_settings)