import bpy from . import fn from mathutils import Vector, Euler from mathutils.geometry import intersect_line_plane ## step 2 : Auto animate the path (with feet selection) and modal to adjust speed def get_bone_transform_at_frame(b, act, frame): '''Find every loc, rot, scale values at given frame''' transform = {} for channel in ('location', 'rotation_euler', 'scale'): chan_list = [] for i in range(3): f = act.fcurves.find(f'pose.bones["{b.name}"].{channel}', index=i) if not f: print(frame, channel, 'Not animated ! Using current value') # Dbg chan_list.append(getattr(b, channel)[i]) # get current value since not animated continue chan_list.append(f.evaluate(frame)) # print('chan_list: ', chan_list) # print(frame, b.name, channel, chan_list) # Dbg if channel == 'rotation_euler': transform[channel] = Euler(chan_list) else: transform[channel] = Vector(chan_list) 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 not '"' in fcu.data_path: continue 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 '"' in fcu.data_path and fcu.data_path.split('"')[1] == b.bone.name] # for f in b_fcurves: # print('fc:', f.data_path, f.array_index) curves = [] for fcu in b_fcurves: start_frame = end_frame = None e_ct = 0 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 e_ct += 1 continue # it's a normal keyframe if encountered_marks: if e_ct == 1: # reset (continue scan to next frame range) e_ct = 0 start_frame = end_frame = None encountered_marks = False else: ## means back to other frame type after passed sufficient key ## (stop for this fcu) break print(fcu.data_path, fcu.array_index, start_frame, end_frame) if start_frame is None or end_frame is None: continue if end_frame - start_frame == 0: # same key continue print('ok') curves.append(end_frame - start_frame) if not curves: return print('curves: ', curves) curves.sort() return curves[-1] 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 act = fn.get_origin_action(act) 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) print('flipped_contact_range: ', flipped_contact_range) bone_contact_range = get_extreme_range(ob, act, b) print('bone_contact_range: ', bone_contact_range) if bone_contact_range: if flipped_contact_range and (bone_contact_range < flipped_contact_range): return flipped else: return b elif flipped_contact_range: return flipped return ('ERROR', f'No Extreme (red keys) on bone "{b.name}" for action "{act.name}"') def anim_path_from_translate(): '''Calculate step size from selected foot and forward axis and animate curve''' print(fn.helper()) ob = bpy.context.object debug = fn.get_addon_prefs().debug settings = bpy.context.scene.anim_cycle_settings axis = settings.forward_axis curve = None if settings.path_to_follow: curve = settings.path_to_follow if curve and not bpy.context.scene.objects.get(curve.name): return 'ERROR', f'Curve {curve.name} is not in scene' # if curve is not defined try to track it from constraints on armature if not curve: curve, _const = fn.get_follow_curve_from_armature(ob) if isinstance(curve, str): return curve, _const act = fn.get_obj_action(ob) if not act: return ('ERROR', f'No action active on {ob.name}') base_act = None # use original action as ref if '_baked' in act.name: base_act_name = act.name.split('_baked')[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})') # 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) # axis : fcu.array_index (to get axis letter : {0:'X', 1:'Y', 2:'Z'}[fcu.array_index]) ## get only fcurves relative to selected bone b_fcurves = [fcu for fcu in act.fcurves if '"' in fcu.data_path and fcu.data_path.split('"')[1] == b.bone.name] print('b_fcurves: ', len(b_fcurves)) # find best fcurve for fcu in b_fcurves: start_frame = end_frame = None # 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 e_ct = 0 for k in fcu.keyframe_points: 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 e_ct += 1 continue # this is a normal keyframe if encountered_marks: if e_ct == 1: # mean only one extreme has been scanned # reset and continue de keys scan e_ct = 0 start_frame = end_frame = None encountered_marks = False else: ## 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: continue if start_frame == end_frame: continue # we have a range and were probably happy with this one. break if start_frame is None or end_frame is None: return ('ERROR', f'No / All or 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') print(f'Offset from key range. start: {start_frame} - end: {end_frame}') move_frame = end_frame - start_frame ## 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']) end_transform = get_bone_transform_at_frame(b, act, end_frame) end_mat = fn.compose_matrix(end_transform['location'], end_transform['rotation_euler'], end_transform['scale']) ## Determine direction vector of the charater (root) root = ob.pose.bones.get(fn.get_root_name()) if not root: print('No root found') return {"CANCELLED"} root_axis_vec = fn.get_direction_vector_from_enum(axis) # world space root_axis_vec = root.bone.matrix_local @ root_axis_vec # aligned with object # bpy.context.scene.cursor.location = root_axis_vec # Dbg root direction ## Get difference between start_loc and ends loc on forward axis start_loc = (b.bone.matrix_local @ start_mat).to_translation() end_loc = (b.bone.matrix_local @ end_mat).to_translation() # bpy.context.scene.cursor.location = start_loc # Dbg foot start position 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('Detected move value: ', move_val) length = fn.get_curve_length(curve) ## steps: number of repetitions (how many times the move distance fit in the lenght of the curve) steps = length / move_val ## frame_duration: number of frame for the move multiplied by the step frame_duration = int(steps * move_frame) 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) anim_frame = settings.start_frame # curve.data.path_duration = frame_duration # set only 100 curve.data.path_duration = 100 const.offset = 0 const.keyframe_insert('offset', frame=anim_frame) # , options={'INSERTKEY_AVAILABLE'} ## negative (offset time rewinding so character move forward) # const.offset = -frame_duration # Valid when duration is set same as curve's path_duration # const.offset = -(((move_frame * 100) / frame_duration) * steps) # works and slightly more precise but super convoluted const.offset = -100 const.keyframe_insert('offset', frame=anim_frame + frame_duration) ## All to linear (will be set to CONSTANT at the moment of sampling) fcu = ob.animation_data.action.fcurves.find(offset_data_path) if fcu: for k in fcu.keyframe_points: k.interpolation = 'LINEAR' # Set extrapolation to linear to avoid a stop # TODO: maybe expose choice ? fcu.extrapolation = 'LINEAR' # default is 'CONSTANT' # defaut to linear (avoid mothion blur problem if start is set exactly on scene.frame_start) ## set all to constant # for k in t_fcu.keyframe_points: # k.interpolation = 'CONSTANT' # speed indicator if animator wants to adapt the cycle ## Show a marker to determine cycle range if needed to reach a ''' # Does not work yet if settings.end_frame > settings.start_frame: all_keys_list = [k.co.x for fc in act.fcurves if 'foot' in fc.data_path for k in fc.keyframe_points] base_start = int(min(all_keys_list)) base_end = int(max(all_keys_list)) base_range = base_end - base_start wanted_duration = settings.end_frame - settings.start_frame # defined in "autowalk > motion" interface range_needed_to_go_full_curve = base_range * (wanted_duration / frame_duration) # create marker mark_name = 'range to fill path' speed_mark = bpy.context.scene.timeline_markers.get(mark_name) if not speed_mark: speed_mark = bpy.context.scene.timeline_markers.new(mark_name) speed_mark.frame = int(base_start + range_needed_to_go_full_curve) ''' if debug: print('end of set_follow_path_anim') class AW_OT_animate_path(bpy.types.Operator): bl_idname = "autowalk.animate_path" bl_label = "Animate Path" 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):\ \nSelect keyframe and use R > Extreme)" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return context.object and context.object.type == 'ARMATURE' # def invoke(self, context, event): # self.shift = event.shift # return self.execute(context) 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]) if err[0] == 'ERROR': return {"CANCELLED"} return {"FINISHED"} class AW_OT_adjust_animation_length(bpy.types.Operator): bl_idname = "autowalk.adjust_animation_length" bl_label = "Adjust Anim speed" bl_description = "Adjust speed\nOnce pressed, move up/down to move animation path last key value" bl_options = {"REGISTER"} # , "UNDO" @classmethod def poll(cls, context): 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() 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"} # 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 {ob.name} data') return {"CANCELLED"} # if '_baked' in self.act.name: # self.report({'WARNING'}, f'Action is expanded') 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' 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'{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 self.init_my = event.mouse_y context.window_manager.modal_handler_add(self) return {'RUNNING_MODAL'} def modal(self, context, event): # added reduction factor self.val = self.init_ky + ((event.mouse_y - self.init_my) * 0.1) offset = self.val - self.init_ky display_text = f"Path animation end key value {self.val:.3f}, offset {offset:.3f}" context.area.header_text_set(display_text) self.k.co.y = self.val if event.type == 'LEFTMOUSE' and event.value == "PRESS": context.area.header_text_set(None) self.execute(context) return {"FINISHED"} if event.type in ('RIGHTMOUSE', 'ESC') and event.value == "PRESS": self.k.co.y = self.init_ky context.area.header_text_set(None) return {"CANCELLED"} if event.type in ('MIDDLEMOUSE', 'SPACE'): # Mmaybe not mid mouse ? return {'PASS_THROUGH'} return {"RUNNING_MODAL"} def execute(self, context): self.k.co.y = self.val return {"FINISHED"} # def draw(self, context): # layout = self.layout # layout.prop(self, "val") classes=( AW_OT_animate_path, AW_OT_adjust_animation_length, ) def register(): for cls in classes: bpy.utils.register_class(cls) def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls)