diff --git a/OP_animate_path.py b/OP_animate_path.py index 98c0f56..288c856 100644 --- a/OP_animate_path.py +++ b/OP_animate_path.py @@ -63,10 +63,10 @@ def anim_path_from_translate(): # use original action as ref if '_expanded' in act.name: base_act_name = act.name.split('_expanded')[0] - base_act = bpy.data.action.get(base_act_name) + base_act = bpy.data.actions.get(base_act_name) if base_act: act = base_act - print(f'Using for {base_act_name} as reference') + print(f'Using for action {base_act_name} as reference') else: print(f'No base action found (searching for {base_act_name})') @@ -157,26 +157,39 @@ def anim_path_from_translate(): frame_duration = int(steps * move_frame) - ## Clear 'eval_time' keyframe before creating new ones # curve.data.animation_data_clear() # too much.. delete only eval_time - if curve.data.animation_data and curve.data.animation_data.action: - for fcu in curve.data.animation_data.action.fcurves: - if fcu.data_path == 'eval_time': - curve.data.animation_data.action.fcurves.remove(fcu) - break + + const = root.constraints.get("Follow Path") + if not const: + return 'ERROR', f'No "Follow Path" constraint on bone "{root.name}"' + + offset_data_path = f'pose.bones["{root.name}"].constraints["{const.name}"].offset' + + fcu = ob.animation_data.action.fcurves.find(offset_data_path) + if fcu: + ob.animation_data.action.fcurves.remove(fcu) + + ## add eval time animation on curve anim_frame = settings.start_frame curve.data.path_duration = frame_duration - curve.data.eval_time = 0 - curve.data.keyframe_insert('eval_time', frame=anim_frame) # , options={'INSERTKEY_AVAILABLE'} + + # curve.data.eval_time = 0 + # curve.data.keyframe_insert('eval_time', frame=anim_frame) # , options={'INSERTKEY_AVAILABLE'} + + const.offset = 0 + const.keyframe_insert('offset', frame=anim_frame) - curve.data.eval_time = frame_duration - curve.data.keyframe_insert('eval_time', frame=anim_frame + frame_duration) + # curve.data.eval_time = frame_duration + # curve.data.keyframe_insert('eval_time', frame=anim_frame + frame_duration) + + const.offset = -frame_duration # negative (offset time rewinding so character move forward) + const.keyframe_insert('offset', frame=anim_frame + frame_duration) ## all to linear (will be set to CONSTANT at the moment of sampling) - for fcu in curve.data.animation_data.action.fcurves: - if fcu.data_path == 'eval_time': - for k in fcu.keyframe_points: - k.interpolation = 'LINEAR' + fcu = ob.animation_data.action.fcurves.find(offset_data_path) + if fcu: + for k in fcu.keyframe_points: + k.interpolation = 'LINEAR' ## set all to constant # for k in t_fcu.keyframe_points: @@ -219,44 +232,53 @@ class UAC_OT_adjust_animation_length(bpy.types.Operator): @classmethod def poll(cls, context): - return context.object and context.object.type in ('ARMATURE', 'CURVE') + return context.object and context.object.type == 'ARMATURE' # in ('ARMATURE', 'CURVE') val : bpy.props.FloatProperty(name='End key value') def invoke(self, context, event): # check animation data of curve # self.pref = fn.get_addon_prefs() - curve = bpy.context.scene.anim_cycle_settings.path_to_follow - if not curve: - if context.object.type != 'ARMATURE': - self.report({'ERROR'}, 'no curve targeted in "Path" field') - return {"CANCELLED"} - curve, _const = fn.get_follow_curve_from_armature(context.object) - if isinstance(curve, str): - self.report({curve}, _const) - return {"CANCELLED"} + ob = context.object + root_name = fn.get_root_name() + root = ob.pose.bones.get(root_name) + if not root: + self.report({'ERROR'}, f'no bone {root_name} found in {ob.name}') + return {"CANCELLED"} - self.act = fn.get_obj_action(curve.data) + # TODO replace fcurve getter by fn.get_offset_fcu(root) + # self.fcu = fn.get_offset_fcu(root) + # if isinstance(self.fcu, str): + # self.report({'ERROR'}, self.fcu) + # return {"CANCELLED"} + + + self.act = fn.get_obj_action(ob) if not self.act: - self.report({'ERROR'}, f'No action on {curve.name} data') + self.report({'ERROR'}, f'No action on {ob.name} data') return {"CANCELLED"} # if '_expanded' in self.act.name: # self.report({'WARNING'}, f'Action is expanded') - self.fcu = None - for fcu in self.act.fcurves: - if fcu.data_path == 'eval_time': - self.fcu = fcu - break + const = root.constraints.get("Follow Path") + if not const: + self.report({'ERROR'}, f'No "Follow Path" constraint on bone "{root.name}"') + return {"CANCELLED"} + offset_data_path = f'pose.bones["{root.name}"].constraints["{const.name}"].offset' - if not self.fcu or not len(self.fcu.keyframe_points): - self.report({'ERROR'}, f'No eval_time animated on {curve.name} data action (or no keys)') + self.fcu = self.act.fcurves.find(offset_data_path) + if not self.fcu: + self.report({'ERROR'}, f'No fcurve at datapath: {offset_data_path}') + return {"CANCELLED"} + + if not len(self.fcu.keyframe_points): + self.report({'ERROR'}, f'No keys on fcurve {self.fcu.data_path}') return {"CANCELLED"} if len(self.fcu.keyframe_points) > 2: - self.report({'WARNING'}, f'{curve.name} eval_time has {len(self.fcu.keyframe_points)} keyframe (should just have 2 to redefine speed)') + self.report({'WARNING'}, f'{ob.name} offset anim has {len(self.fcu.keyframe_points)} keyframe (should just have 2 to redefine speed)') self.k = self.fcu.keyframe_points[-1] self.val = self.init_ky = self.k.co.y diff --git a/OP_expand_cycle_step.py b/OP_expand_cycle_step.py index 4163a91..050e832 100644 --- a/OP_expand_cycle_step.py +++ b/OP_expand_cycle_step.py @@ -76,22 +76,22 @@ def bake_cycle(on_selection=True): current_offset = offset = last - first keys_num = len(fcu_kfs) - if debug: print(keys_num) + if debug >= 2: print(keys_num) if keys_num <= 1: - if debug: print(b_name, f'{keys_num} key') + if debug >= 2: 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)) + if debug >= 2: 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 - if debug: print('iterations: ', iterations) + if debug >= 2: print('iterations: ', iterations) for _i in range(int(iterations)): for kf in fcu_kfs: # create a new key, adding offset to keys @@ -240,18 +240,21 @@ def pin_down_feets(): if obj.type != 'ARMATURE': print('ERROR', 'active is not an armature type') return - + debug = fn.get_addon_prefs().debug + scn = bpy.context.scene # Delete current action if its not the main one # create a new '_autogen' one act = fn.set_generated_action(obj) if not act: return ('ERROR', f'No action on {obj.name}') - print('action', act.name) + + if debug: + print('action', act.name) act = obj.animation_data.action to_change_list = [ - (bpy.context.scene, 'frame_current', '_undefined'), # use '_undefined' when no value to assign for now + (bpy.context.scene, 'frame_current'), (bpy.context.scene.render, 'use_simplify', True), (bpy.context.scene.render, 'simplify_subdivision', 0), ] @@ -277,51 +280,81 @@ def pin_down_feets(): t0 = time() ct = 0 done = {} + viewed_data_paths = [] for fcu in act.fcurves: - + # check only location if not fcu.data_path.endswith('.location'): continue + - # prop = fcu.data_path.split('.')[-1] + # skip same data path with different array index to avoid multiple iteration + if fcu.data_path in viewed_data_paths: + continue - b_name = fcu.data_path.split('"')[1] - # print('b_name: ', b_name, fcu.is_valid) + viewed_data_paths.append(fcu.data_path) + + prop = fcu.data_path.split('.')[-1] + + b_name = fcu.data_path.split('"')[1] # print('b_name: ', b_name, fcu.is_valid) + + ## TEST : only foot bones + if not 'foot' in b_name: + continue pb = obj.pose.bones.get(b_name) if not pb: print(f'{b_name} is invalid') continue - + + contact_ranges = [] start_contact = None + prev = None for k in fcu.keyframe_points: - if k.type == 'EXTREME': - bpy.context.scene.frame_set(int(k.co[0])) + prev = k if start_contact is None: - start_contact=k.co[0] + start_contact = int(k.co.x) + continue + else: + if start_contact is not None: + # print(f'contact range {start_contact} - {k.co.x:.0f}') + if prev: + contact_ranges.append((start_contact, int(prev.co.x))) + start_contact = None + prev = None + + if not contact_ranges: + if debug >= 2: print(f'SKIP (no extreme keys): {b_name} > {prop}') + continue + + if debug: print(f'fcurve: {b_name} > {prop}') + + for r in reversed(contact_ranges): # iterate in reverse ranges (not really necessary) + print(f'range: {r}') + first = True + for i in range(r[0], r[1]+1)[::-1]: # start from the end of the range + # for i in range(r[0], r[1]+1): + bpy.context.scene.frame_set(i) + if first: # record coordinate relative to referent object (or world coord) bone_mat = pb.matrix.copy() # bone_mat = obj.matrix_world @ pb.matrix.copy() + first = False continue - - if b_name in done.keys(): - if k.co[0] in done[b_name]: - continue - else: - # mark as treated (all curve of this bone at this time) - done[b_name] = [k.co[0]] - #-# Insert keyframe to match Hold position - # print(f'Apply on {b_name} at {k.co[0]}') - + # print(f'Apply on {b_name} at {i}') + #-# assign previous matrix - pbl = pb.location.copy() - # l, _r, _s = bone_mat.decompose() + # pbl = pb.location.copy() pb.matrix = bone_mat # Exact same position + + ## maybe align on a specific axis # pb.location.x = pbl.x # dont touch x either - pb.location.z = pbl.z + + #pb.location.z = pbl.z # Z is not necessarily up in local axis, need to check first + # pb.location.y = l.y (weirdly not working) # bpy.context.view_layer.update() @@ -331,33 +364,23 @@ def pin_down_feets(): ## insert keyframe pb.keyframe_insert('location') + # only touched Y location - # pb.keyframe_insert('rotation_euler') + pb.keyframe_insert('rotation_euler') - k.type = 'JITTER' # 'BREAKDOWN' 'MOVING_HOLD' 'JITTER' + # if i == r[1]+1: # (last key) in normal + # if i == r[0]: # (last key) in reverse + # continue + + # k.type = 'JITTER' # 'BREAKDOWN' 'MOVING_HOLD' 'JITTER' ct += 1 - else: - if start_contact is not None: - # print('fcu.data_path: ', fcu.data_path, fcu.array_index) - # print(f'{b_name} contact range {start_contact} - {k.co[0]}') - start_contact = None - - # print(i, fcu.data_path, fcu.array_index) - # print('time', k.co[0], '- value', k.co[1]) - - #k.handle_left - #k.handle_right - ##change handler type ([‘FREE’, ‘VECTOR’, ‘ALIGNED’, ‘AUTO’, ‘AUTO_CLAMPED’], default ‘FREE’) - #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 + print(f'--\n{ct} keys changed/added 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 + # with simplify set to 0 >> 9.57s tmp_col.objects.unlink(obj) bpy.data.collections.remove(tmp_col) diff --git a/README.md b/README.md index 40346f1..d4c852b 100644 --- a/README.md +++ b/README.md @@ -22,23 +22,44 @@ sequencial set of tools: - Expand anim cycle and step curve animation - 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 +### TODO + +- pin feets: + - iterate in reverse in keys when pinning so last foot position is correct + - create intermediate keys (at each frame when necessary) to prevent lateral sliding on curved path + (maybe expose as an option... not needed if path is straight for example... or auto detect if path is full straight at the moment of pin ops) + +- Expose methods to go back in action history + + +*things to consider*: + + - Expose foot ? + - Store path animation on a separate action (but that mean NLA hadto be used every time) + +Bonus: + - Use position A-B method to generate curve (with retimed animation to prioritize speed over fidelity) + - auto-determine foot bone to use for distance reference + - create nurb path instead of curve + - Smoothing keys after last freezed to avoid too much gap "pose click". + + --- ## Changelog: +0.5.0 + +- pin feet working + 0.4.2 - context manager for `expand cycle step` store / restore diff --git a/__init__.py b/__init__.py index 9e36b73..9de9199 100644 --- a/__init__.py +++ b/__init__.py @@ -2,7 +2,7 @@ bl_info = { "name": "Unfold Anim Cycle", "description": "Anim tools to develop walk/run cycles along a curve", "author": "Samuel Bernou", - "version": (0, 4, 3), + "version": (0, 5, 0), "blender": (3, 0, 0), "location": "View3D", "warning": "WIP", diff --git a/fn.py b/fn.py index c93befb..0e6faad 100644 --- a/fn.py +++ b/fn.py @@ -1,4 +1,5 @@ import bpy +import bpy_types import re import numpy as np from mathutils import Matrix, Vector, Color @@ -55,8 +56,7 @@ def get_gnd(): def get_follow_curve_from_armature(arm): """Return curve and constraint or a tuple of string ('error', 'message') """ - pref = get_addon_prefs() - name = pref.tgt_bone + name = get_root_name() parents = [] # root = b.id_data.pose.bones.get(name) @@ -312,26 +312,95 @@ def update_action(act): if area.type == 'GRAPH_EDITOR': area.tag_redraw() + +def get_offset_fcu(act=None): + '''Get an action, object, pose_bone or a bone constraint (if nothing is passed use active object) + return offset fcurve or a string describing error + ''' + ob = None + bone_name = None + const_name = None + if act is None: + if not bpy.context.object: + return 'No active object' + act = bpy.context.object + + if isinstance(act, bpy.types.FollowPathConstraint): + ob = act.id_data + const = act + bones = [b for b in ob.pose.bones for c in b.constraints if b.constraints if c == act] + if not bones: + return f'no bone found with constraint {ob.name}' + bone = bones[0] + bone_name = bone.name + const_name = const.name + + if isinstance(act, bpy_types.PoseBone): + bone = act + bone_name = act.name + ob = act = act.id_data # fall_back to armature object + if not const_name: + consts = [c for c in bone.constraints if isinstance(c, bpy.types.FollowPathConstraint)] + if not consts: + return f'no follow path constraint on bone {bone_name}' + const_name = consts[0].name + + if isinstance(act, bpy.types.Object): + ob = act + if not ob.animation_data: + return f'{ob.name} has no animation_data' + act = ob.animation_data + + if isinstance(act, bpy.types.AnimData): + ob = act.id_data + if not act.action: + return f'{ob.name} has animation_data but no action' + act = act.action + + + if bone_name and const_name: + offset_data_path = f'pose.bones["{bone_name}"].constraints["{const_name}"].offset' + fcu = act.fcurves.find(offset_data_path) + if not fcu: + return f'No fcurve found with data_path {offset_data_path}' + return fcu + + # bone_name = get_root_name() + + # find from determined action + fcus = [fcu for fcu in act.fcurves if all(x in fcu.data_path for x in ('pose.bones', 'constraints', 'offset'))] + if not fcus: + return f'no offset fcurves found for: {act.name}' + + if len(fcus) > 1: + print(f'/!\ multiple fcurves seem to have a follow path constraint') + + return fcus[0] + + + + + ### --- context manager - store / restore - class attr_set(): - '''Receive a list of tuple [(data_path:python_obj, "attribute":str, "wanted value":str)] - before with statement : Store existing values, assign wanted value - after with statement: Restore values to their old values + '''Receive a list of tuple [(data_path, "attribute" [, wanted value)] ] + entering with-statement : Store existing values, assign wanted value (if any) + exiting with-statement: Restore values to their old values ''' def __init__(self, attrib_list): self.store = [] - for prop, attr, new_val in attrib_list: + # item = (prop, attr, [new_val]) + for item in attrib_list: + prop, attr = item[:2] self.store.append( (prop, attr, getattr(prop, attr)) ) - if new_val == '_undefined': # None -> what if we want to apply None state ? - continue - setattr(prop, attr, new_val) - + if len(item) >= 3: + setattr(prop, attr, item[2]) + def __enter__(self): return self - + 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/preferences.py b/preferences.py index bd56c27..6b0ff6c 100644 --- a/preferences.py +++ b/preferences.py @@ -6,10 +6,13 @@ class UAC_addon_prefs(bpy.types.AddonPreferences): bl_idname = __name__.split('.')[0] # or with: os.path.splitext(__name__)[0] # some_bool_prop to display in the addon pref - debug : bpy.props.BoolProperty( + debug : bpy.props.IntProperty( name='Debug', - description="Enable Debug prints", - default=False) + description="Enable Debug prints\n\ + 0 = no prints\n\ + 1 = basic\n\ + 2 = full prints", + default=1) tgt_bone : bpy.props.StringProperty( name="Constrained Pose bone name", default='world',