diff --git a/OP_expand_cycle_step.py b/OP_expand_cycle_step.py index 2fdcd78..1e1840a 100644 --- a/OP_expand_cycle_step.py +++ b/OP_expand_cycle_step.py @@ -1,12 +1,14 @@ -import bpy +import bpy, re from . import fn - +from time import time ## step 3 # - Bake cycle modifier keys -chained with- step the animation path # - Pin the feet (separated ops) def bake_cycle(on_selection=True): print(fn.helper()) + debug = fn.get_addon_prefs().debug + print('debug: ', debug) obj = bpy.context.object if obj.type != 'ARMATURE': print('ERROR', 'active is not an armature type') @@ -15,17 +17,23 @@ def bake_cycle(on_selection=True): act = fn.set_expanded_action(obj) if not act: return - print('action', act.name) + + if debug: print('action', act.name) - act = obj.animation_data.action + # obj.animation_data.action = act + ct_fcu = len(act.fcurves) ct = 0 + ct_no_cycle = 0 + for fcu in act.fcurves: ## if a curve is not cycled don't touch if not [m for m in fcu.modifiers if m.type == 'CYCLES']: + ct_no_cycle += 1 continue - + + if debug: print(fcu.data_path, 'has cycle') #-# only on location : # if not fcu.data_path.endswith('.location'): # continue @@ -33,6 +41,7 @@ def bake_cycle(on_selection=True): # prop = fcu.data_path.split('.')[-1] b_name = fcu.data_path.split('"')[1] + if debug: print(b_name, 'has cycle') pb = obj.pose.bones.get(b_name) if not pb: print(f'{b_name} is invalid') @@ -41,11 +50,14 @@ def bake_cycle(on_selection=True): #-# limit on selection if passed if on_selection and not pb.bone.select: continue - + + if debug: print(b_name, 'seems ok') + #-# only on selected and visible curve # if not fcu.select or fcu.hide: # continue + fcu_kfs = [] for k in fcu.keyframe_points: k_dic = {} @@ -62,13 +74,24 @@ def bake_cycle(on_selection=True): # first_offset = second - first current_offset = offset = last - first - print('offset: ', offset) + keys_num = len(fcu_kfs) + if debug: print(keys_num) + + if keys_num <= 1: + if debug: print(b_name, f'{keys_num} key') + continue + + ## ! important: delete last after computing offset IF cycle have first frame repeatead as last ! + fcu_kfs.pop() + # print('offset: ', offset) + + if debug: print('keys', len(fcu_kfs)) ## expand to end frame end = bpy.context.scene.frame_end # maybe add possibility define target manually iterations = ((end - last) // offset) + 1 - print('iterations: ', iterations) + if debug: print('iterations: ', iterations) for _i in range(int(iterations)): for kf in fcu_kfs: # create a new key, adding offset to keys @@ -80,9 +103,20 @@ def bake_cycle(on_selection=True): else: setattr(new, att, val) current_offset += offset - + ct += 1 + if ct_fcu == ct_no_cycle: # skipped because no cycle exists + rexpand = re.compile(r'_expanded\.?\d{0,3}$') + if rexpand.search(act.name): + # is an autogenerated one + org_action_name = rexpand.sub('', act.name) + org_action = bpy.data.actions.get(org_action_name) + if not org_action: + return ('ERROR', 'No fcurve with anim cycle found (on expanded action)') + obj.animation_data.action = org_action + return ('ERROR', 'No fcurve with anim cycle found (back to unexpanded)') + if not ct: return ('ERROR', 'No fcurve treated (! action duplicated to _expand !)') @@ -162,12 +196,31 @@ class UAC_OT_bake_cycle_and_step(bpy.types.Operator): if err[0] == 'ERROR': return {"CANCELLED"} - # CHAINED ACTION : step the path of the curve path - err = step_path() - if err: - self.report({err[0]}, err[1]) - if err[0] == 'ERROR': - return {"CANCELLED"} + if not context.scene.anim_cycle_settings.linear: + # CHAINED ACTION : step the path of the curve path + err = step_path() + if err: + self.report({err[0]}, err[1]) + if err[0] == 'ERROR': + return {"CANCELLED"} + else: + # Delete points in curve action between first and last and go LINEAR + curve = context.scene.anim_cycle_settings.path_to_follow + if curve: + act = fn.get_obj_action(curve.data) + if act: + timef = next((fcu for fcu in act.fcurves if fcu.data_path == 'eval_time'), None) + if timef: + keys_ct = len(timef.keyframe_points) + if keys_ct > 2: + for k in reversed(timef.keyframe_points[1:-2]): + timef.keyframe_points.remove(k) + for k in timef.keyframe_points: + k.interpolation = 'LINEAR' + print(f'Anim path to linear : Deleted all keys ({keys_ct - 2}) on anim path except first and last') + + + # CHAINED ACTION pin feet ?? : Step the path of the curve path @@ -198,6 +251,28 @@ def pin_down_feets(): # TODO autodetect contact frame ? + ## Link armature in a new collection and exclude all the others + + ## STORE AND MODIFY /- + tmp_col = bpy.data.collections.new('TMP_COLLECTION_PINNING') + bpy.context.scene.collection.children.link(tmp_col) + tmp_col.objects.link(obj) + + simplify = bpy.context.scene.render.use_simplify + simplify_subdiv = bpy.context.scene.render.simplify_subdivision + bpy.context.scene.render.use_simplify = True + bpy.context.scene.render.simplify_subdivision = 0 + + showed_col = [] + for vlc in bpy.context.view_layer.layer_collection.children: + if vlc.collection == tmp_col: + continue + showed_col.append([vlc, vlc.exclude]) + vlc.exclude = True + #-/ + + t0 = time() + ct = 0 done = {} for fcu in act.fcurves: @@ -217,7 +292,7 @@ def pin_down_feets(): start_contact = None for k in fcu.keyframe_points: - + if k.type == 'EXTREME': bpy.context.scene.frame_set(k.co[0]) if start_contact is None: @@ -257,6 +332,7 @@ def pin_down_feets(): # pb.keyframe_insert('rotation_euler') k.type = 'JITTER' # 'BREAKDOWN' 'MOVING_HOLD' 'JITTER' + ct += 1 else: if start_contact is not None: @@ -273,11 +349,25 @@ def pin_down_feets(): #k.handle_left_type = 'AUTO_CLAMPED' #k.handle_right_type = 'AUTO_CLAMPED' + print(f'--\n{ct} keys changed in {time()-t0:.2f}s\n--') # fcurves treated in + + ## RESTORE + # without >> 433 keys changed in 29.15s + # with all collection excluded >> 433 keys changed in 25.00s + # with simplify >> 9.57s + + bpy.context.scene.render.use_simplify = simplify + bpy.context.scene.render.simplify_subdivision = simplify_subdiv + + for exclude_pairs in showed_col: + exclude_pairs[0].exclude = exclude_pairs[1] + tmp_col.objects.unlink(obj) + bpy.data.collections.remove(tmp_col) + bpy.context.scene.frame_current = org_frame # detect last key in contact - class UAC_OT_pin_feets(bpy.types.Operator): bl_idname = "anim.pin_feets" bl_label = "Pin Feets" diff --git a/OP_setup_curve_path.py b/OP_setup_curve_path.py index a150808..d67186d 100644 --- a/OP_setup_curve_path.py +++ b/OP_setup_curve_path.py @@ -55,6 +55,8 @@ def snap_curve(): if not gnd: return + curve_act = None + anim_frame = None # if it's on a snap curve, fetch original if '_snap' in curve.name: org_name = re.sub(r'_snap\.?\d{0,3}$', '', curve.name) @@ -62,6 +64,11 @@ def snap_curve(): if org_curve: const.target = org_curve + # keep action + if curve.data.animation_data and curve.data.animation_data.action: + curve_act = curve.data.animation_data.action + + anim_frame = curve.data.path_duration # delete old snap bpy.data.objects.remove(curve) # assign old curve as main one @@ -73,6 +80,11 @@ def snap_curve(): nc.name = name nc.data = curve.data.copy() nc.data.name = name + '_data' + if curve_act: + nc.data.animation_data_create() + nc.data.animation_data.action = curve_act + if anim_frame: + nc.data.path_duration = anim_frame curve.users_collection[0].objects.link(nc) @@ -86,7 +98,12 @@ def snap_curve(): # shrinkwrap or cast on ground mod = nc.modifiers.new('Shrinkwrap', 'SHRINKWRAP') - mod.wrap_method = 'TARGET_PROJECT' + # mod.wrap_method = 'TARGET_PROJECT' + mod.wrap_method = 'PROJECT' + mod.wrap_mode = 'ON_SURFACE' + mod.use_project_z = True + mod.use_negative_direction = True + mod.use_positive_direction = True mod.target = gnd # Apply and decimate diff --git a/OP_snap_contact.py b/OP_snap_contact.py index 9922f2c..602af05 100644 --- a/OP_snap_contact.py +++ b/OP_snap_contact.py @@ -125,16 +125,16 @@ def snap_foot(pb, gnd): m.show_viewport = False dg = bpy.context.evaluated_depsgraph_get() - obeval = ob.evaluated_get(dg).copy() + obeval = ob.evaluated_get(dg) #.copy() print('object: ', ob.name) ## bpy.context.object.proxy_collection.instance_collection.all_objects['body_deform'] ## bpy.context.object.proxy_collection.instance_collection.all_objects['body'] ## Hide modifier - for m in obeval.modifiers: - if m.type == 'SUBSURF': - m.show_viewport = False # m.levels = 0 + # for m in obeval.modifiers: + # if m.type == 'SUBSURF': + # m.show_viewport = False # m.levels = 0 bake_mesh = obeval.to_mesh(preserve_all_data_layers=True, depsgraph=dg) @@ -207,7 +207,9 @@ def snap_foot(pb, gnd): if m.type == 'SUBSURF': # if m.type in ('SUBSURF', 'TRIANGULATE'): m.show_viewport = True - # obeval.to_mesh_clear() + + obeval.to_mesh_clear() + def snap_feet(): diff --git a/OP_world_copy_paste.py b/OP_world_copy_paste.py new file mode 100644 index 0000000..993279d --- /dev/null +++ b/OP_world_copy_paste.py @@ -0,0 +1,101 @@ +import bpy +from . import fn + + +class UAC_OT_world_space_copy(bpy.types.Operator): + bl_idname = "anim.world_space_copy" + bl_label = "World Copy" + bl_description = "Copy world space transforms. Store active bone matrix" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.object and context.mode == 'POSE' and context.active_pose_bone + + def execute(self, context): + bpy.types.ViewLayer.world_space_store = context.object.matrix_world @ context.active_pose_bone.matrix + + return {"FINISHED"} + +class UAC_OT_world_space_paste(bpy.types.Operator): + bl_idname = "anim.world_space_paste" + bl_label = "World Paste" + bl_description = "Paste world space transforms. Apply stored matrix to active bone" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.object and context.mode == 'POSE' and context.active_pose_bone + + + def execute(self, context): + context.active_pose_bone.matrix = context.object.matrix_world.inverted() @ bpy.context.view_layer.world_space_store + + if context.scene.tool_settings.use_keyframe_insert_auto: + bpy.ops.anim.keyframe_insert_menu(type='Available') + + return {"FINISHED"} + +class UAC_OT_world_space_paste_next(bpy.types.Operator): + bl_idname = "anim.world_space_paste_next" + bl_label = "World Paste Jump" + bl_description = "Paste world space transforms and keyframe available chanels\nThen jump to prev/next key" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.object and context.mode == 'POSE' and context.active_pose_bone + + prev: bpy.props.BoolProperty() + + def execute(self, context): + # apply matrix + context.active_pose_bone.matrix = context.object.matrix_world.inverted() @ bpy.context.view_layer.world_space_store + + # insert keyframe at value + # context.object.keyframe_insert(data_path, index=-1, frame=bpy.context.scene.frame_current, group="", options={'INSERTKEY_AVAILABLE'}) + bpy.ops.anim.keyframe_insert_menu(type='Available') + + # jump to next key + act = fn.get_obj_action(context.object) + if not act: + self.report({'ERROR'}, 'No action on armature') + return {'CANCELLED'} + + kx = [k.co.x for fcu in act.fcurves + if fcu.data_path.split('"')[1] == context.active_pose_bone.bone.name + for k in fcu.keyframe_points] + + if not kx: + self.report({'ERROR'}, 'No keys on action available (no keyframe added)') + return {'CANCELLED'} + + # for fcu in act.fcurves: + # if fcu.data_path.split('"')[1] == context.active_pose_bone.bone.name: + + if self.prev: + new_frame = next((k for k in reversed(kx) if k < context.scene.frame_current), None) + else: + new_frame = next((k for k in kx if k > context.scene.frame_current), None) + + if not new_frame: + self.report({'WARNING'}, 'No next frame to jump on') + return {'FINISHED'} + + context.scene.frame_current = new_frame + return {"FINISHED"} + + +classes=( +UAC_OT_world_space_copy, +UAC_OT_world_space_paste, +UAC_OT_world_space_paste_next, +) + +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/README.md b/README.md index 6a89adf..a471c98 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ sequencial set of tools: - Pin feet on ground (use contact keys marked as 'EXTREME' for each feets) + +# TODO : + +- create nurb path instead of curve +- align curve to root ? +- Smoothing keys after last freezed to avoid too much gap "pose click". + + ### Where ? Sidebar > Anim > unfold anim cycle @@ -31,6 +39,21 @@ Sidebar > Anim > unfold anim cycle ## Changelog: +0.3.0 + +- Rework interface +- Add manual bone pinning: simple world space copy/paste + pose and jump prev/next +- Faster pin feets +- bool prop to disable end/curve stepping (interpolation as linear if needed) +- Switch action back to new curve when re-snapping to ground +- fix shrinkwrap +- fix rebake with linear +- error message when no fcurve has anim cycle + +0.2.0: + +- first working version + 0.1.0: - initial commit, halfway there \ No newline at end of file diff --git a/__init__.py b/__init__.py index 9cee7fa..29faef0 100644 --- a/__init__.py +++ b/__init__.py @@ -2,7 +2,7 @@ bl_info = { "name": "Unfold Anim Cycle", "description": "Anim utility to develop walk/run cycles along a curve", "author": "Samuel Bernou", - "version": (0, 2, 0), + "version": (0, 3, 0), "blender": (2, 92, 0), "location": "View3D", "warning": "WIP", @@ -13,12 +13,22 @@ bl_info = { import bpy +import importlib as imp from . import OP_setup_curve_path from . import OP_animate_path from . import OP_expand_cycle_step from . import OP_snap_contact +from . import OP_world_copy_paste from . import panels +if 'bpy' in locals(): + imp.reload(OP_setup_curve_path) + imp.reload(OP_animate_path) + imp.reload(OP_expand_cycle_step) + imp.reload(OP_snap_contact) + imp.reload(OP_world_copy_paste) + imp.reload(panels) + class UAC_PGT_settings(bpy.types.PropertyGroup) : ## HIDDEN to hide the animatable dot thing path_to_follow : bpy.props.PointerProperty(type=bpy.types.Object, @@ -27,16 +37,13 @@ class UAC_PGT_settings(bpy.types.PropertyGroup) : gnd : bpy.props.PointerProperty(type=bpy.types.Object, name="Ground", description="Choose the ground object to use") - ## As strings - # path_to_follow : bpy.props.StringProperty( - # name="Path to follow", description="Curve object used") - - # gnd : bpy.props.StringProperty( - # name="Ground object", description="Choose the ground object to use") - expand_on_selected_bones : bpy.props.BoolProperty( name="On selected", description="Expand on selected bones", default=True, options={'HIDDEN'}) + + linear : bpy.props.BoolProperty( + name="Linear", description="keep the animation path linear (Else step the path usings cycle keys)", + default=False, options={'HIDDEN'}) start_frame : bpy.props.IntProperty( name="Start Frame", description="Starting frame for animation path", @@ -85,6 +92,7 @@ def register(): OP_animate_path.register() OP_expand_cycle_step.register() OP_snap_contact.register() + OP_world_copy_paste.register() panels.register() # if not bpy.app.background: @@ -95,6 +103,7 @@ def unregister(): # if not bpy.app.background: #unregister_keymaps() panels.unregister() + OP_world_copy_paste.unregister() OP_snap_contact.unregister() OP_expand_cycle_step.unregister() OP_animate_path.unregister() diff --git a/fn.py b/fn.py index bec12e3..102be19 100644 --- a/fn.py +++ b/fn.py @@ -1,5 +1,6 @@ import bpy import re +import numpy as np def get_addon_prefs(): ''' @@ -25,6 +26,22 @@ def helper(name: str = '') -> str: arguments = ', '.join([f'{k}={v}' for k, v in args.items()]) return(f'{name}({arguments})') +def convertAttr(Attr): + '''Convert given value to a Json serializable format''' + if type(Attr) in [type(mathutils.Vector()),type(mathutils.Color())]: + if len(Attr) == 3: + return([Attr[0],Attr[1],Attr[2]]) + elif len(Attr) == 2: + return([Attr[0],Attr[1]]) + elif len(Attr) == 1: + return([Attr[0]]) + elif len(Attr) == 4: + return([Attr[0],Attr[1],Attr[2],Attr[3]]) + elif type(Attr) == type(mathutils.Matrix()): + return (np.matrix(Attr).tolist()) + else: + return(Attr) + def get_gnd(): for o in bpy.context.scene.objects: diff --git a/panels.py b/panels.py index f541d7d..220d86e 100644 --- a/panels.py +++ b/panels.py @@ -37,17 +37,18 @@ class UAC_PT_walk_cycle_anim_panel(bpy.types.Panel): layout.operator('anim.snap_curve_to_ground', text='Snap curve to ground', icon='SNAP_ON') - row=layout.row() - row.operator('anim.animate_path', text='Animate Path (select foot)', icon='ANIM') - row.prop(context.scene.anim_cycle_settings, "start_frame", text='start') + # row=layout.row() + layout.prop(context.scene.anim_cycle_settings, "start_frame", text='Start') + layout.operator('anim.animate_path', text='Animate Path (select foot)', icon='ANIM') row=layout.row() row.operator('anim.adjust_animation_length', icon='MOD_TIME') ## Bake cycle (on selected) row=layout.row() - row.operator('anim.bake_cycle_and_step', text='Bake key and step path', icon='SHAPEKEY_DATA') + row.prop(context.scene.anim_cycle_settings, "linear", text='Linear') row.prop(context.scene.anim_cycle_settings, "expand_on_selected_bones") + layout.operator('anim.bake_cycle_and_step', text='Bake key and step path', icon='SHAPEKEY_DATA') # Pin feet layout.operator('anim.pin_feets', text='Pin feets', icon='PINNED') @@ -58,11 +59,18 @@ class UAC_PT_anim_tools_panel(bpy.types.Panel): bl_space_type = "VIEW_3D" bl_region_type = "UI" bl_category = "Anim" - bl_label = "Unicorn Tools" + bl_label = "Tools" def draw(self, context): layout = self.layout layout.operator('anim.contact_to_ground', text='Ground selected feet', icon='SNAP_OFF') + row = layout.row() + row.operator('anim.world_space_copy', text='Copy Pose', icon='COPYDOWN') + row.operator('anim.world_space_paste', text='Paste', icon='PASTEDOWN') + row = layout.row() + row.operator('anim.world_space_paste_next', text='Paste Prev', icon='PASTEDOWN').prev = True + row.operator('anim.world_space_paste_next', text='Paste Next', icon='PASTEDOWN').prev = False + classes=(