import bpy import bpy_types import re import numpy as np from mathutils import Matrix, Vector, Color def get_addon_prefs(): ''' function to read current addon preferences properties access with : get_addon_prefs().super_special_option ''' import os addon_name = os.path.splitext(__name__)[0] preferences = bpy.context.preferences 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 ''' if not get_addon_prefs().debug: return import inspect func = inspect.currentframe().f_back name = name or f'def {func.f_code.co_name}' args = inspect.getargvalues(func).locals 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(Vector()),type(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(Matrix()): return (np.matrix(Attr).tolist()) else: return(Attr) def get_gnd(): gnd = bpy.context.scene.anim_cycle_settings.gnd if not gnd: for o in bpy.context.scene.objects: if o.type == 'MESH' and o.name.lower() in ('ground', 'gnd'): return o if not gnd: # nothing found print('ERROR', 'No "gnd" object found') return gnd def get_follow_curve_from_armature(arm): """Return curve and constraint or a tuple of string ('error', 'message')""" name = get_root_name() parents = [] 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: parents.append(c.target) if not const: for p in parents: for c in p.constraints: if c.type == 'FOLLOW_PATH': print('INFO', f'follow_path found on "{p.name}" parent object') const = c break """ if not const: return ('ERROR', 'No constraints founds') curve = const.target if not curve: return ('ERROR', f'no target set for {curve.name}') return curve, const # --- ACTIONS def get_nla_strip(ob): # get all strips in all tracks (can only be one active) strips = [s for t in ob.animation_data.nla_tracks for s in t.strips] if len(strips) == 1: return strips[0].action if len(strips) > 1: # return active strip for s in strips: if s.active: return s.action def get_obj_action(ob): print(helper()) act = ob.animation_data if not act: print('ERROR', f'no animation data on {ob.name}') return act = act.action if not act: # check NLA strip = get_nla_strip(ob) if strip: return strip # there are multiple strips but no active if len([s for t in ob.animation_data.nla_tracks for s in t.strips]): print('ERROR', f'no active strip on NLA for {ob.name}') return print('ERROR', f'no action on {ob.name}') return return act def set_generated_action(obj): '''Backup object action and return a new action suffixed '_pinned' associated with the object ''' print(helper()) act = get_obj_action(obj) if not act: return regen = re.compile(r'_pinned\.?\d{0,3}$') if regen.search(act.name): # is an autogenerated one org_action_name = regen.sub('', act.name) org_action = bpy.data.actions.get(org_action_name) if not org_action: print('ERROR', f'{org_action_name} not found') return obj.animation_data.action = org_action bpy.data.actions.remove(act) act = org_action # backup action before doing anything crazy act.use_fake_user = True new_act = act.copy() new_act.name = act.name + '_pinned' obj.animation_data.action = new_act return new_act def set_baked_action(obj): '''Backup object action and return a new action associated with the object ''' print(helper()) re_baked = re.compile(r'_baked\.?\d{0,3}$') act = obj.animation_data.action if re_baked.search(act.name): # is an autogenerated one org_action_name = re_baked.sub('', act.name) org_action = bpy.data.actions.get(org_action_name) if not org_action: print('ERROR', f'{org_action_name} not found') return obj.animation_data.action = org_action bpy.data.actions.remove(act) act = org_action # backup action before doing anything crazy act.use_fake_user = True new_act = act.copy() new_act.name = act.name + '_baked' obj.animation_data.action = new_act return new_act def get_origin_action(act): '''Return original action if found, else return same''' 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})') return act def get_curve_length(ob): '''Get a curve object, return a float representing world space length''' dg = bpy.context.evaluated_depsgraph_get() obeval = ob.evaluated_get(dg)#.copy() baked_me = obeval.to_mesh(preserve_all_data_layers=False, depsgraph=dg) mat = obeval.matrix_world total_length = 0 for i, _v in enumerate(baked_me.vertices): if i == 0: continue total_length += ((mat @ baked_me.vertices[i-1].co) - (mat @ baked_me.vertices[i].co)).length print("total_length", total_length)#Dbg return total_length def scale_matrix_from_vector(scale): # recreate a neutral mat scale matscale_x = Matrix.Scale(scale[0], 4,(1,0,0)) matscale_y = Matrix.Scale(scale[1], 4,(0,1,0)) matscale_z = Matrix.Scale(scale[2], 4,(0,0,1)) matscale = matscale_x @ matscale_y @ matscale_z return matscale def compose_matrix(loc, rot, scale): loc_mat = Matrix.Translation(loc) rot_mat = rot.to_matrix().to_4x4() scale_mat = scale_matrix_from_vector(scale) return loc_mat @ rot_mat @ scale_mat def get_direction_vector_from_enum(string) -> Vector: orient_vectors = { 'FORWARD_X' : Vector((1,0,0)), 'FORWARD_Y' : Vector((0,1,0)), 'FORWARD_Z' : Vector((0,0,1)), 'TRACK_NEGATIVE_X' : Vector((-1,0,0)), 'TRACK_NEGATIVE_Y' : Vector((0,-1,0)), 'TRACK_NEGATIVE_Z' : Vector((0,0,-1)) } return orient_vectors[string] def orentation_track_from_vector(input_vector) -> str: '''return closest world track orientation name from passed vector direction''' orient_vectors = { 'FORWARD_X' : Vector((1,0,0)), 'FORWARD_Y' : Vector((0,1,0)), 'FORWARD_Z' : Vector((0,0,1)), 'TRACK_NEGATIVE_X' : Vector((-1,0,0)), 'TRACK_NEGATIVE_Y' : Vector((0,-1,0)), 'TRACK_NEGATIVE_Z' : Vector((0,0,-1)) } orient = None min_angle = 10000 for track, v in orient_vectors.items(): angle = input_vector.angle(v) if angle < min_angle: min_angle = angle orient = track return orient def get_root_name(context=None): '''return name of rig root name''' context = context or bpy.context prefs = get_addon_prefs() # 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 ## --- curve funcs 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''' context = context or bpy.context ## using ops (dirty) # bpy.ops.curve.primitive_bezier_curve_add(radius=1, enter_editmode=enter_edit, align='WORLD', location=location, scale=(1, 1, 1)) # curve = context.object # curve.name = 'curve_path' # # fast straighten # bpy.ops.curve.handle_type_set(type='VECTOR') # bpy.ops.curve.handle_type_set(type='ALIGNED') # bpy.ops.transform.translate(value=(1, 0, 0), orient_type='LOCAL', # orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), orient_matrix_type='LOCAL', # constraint_axis=(True, False, False), mirror=True, use_proportional_edit=False) ## using data curve_data = bpy.data.curves.new(name, 'CURVE') # ('CURVE', 'SURFACE', 'FONT') curve_data.dimensions = '3D' curve_data.use_path = True curve = bpy.data.objects.new(name, curve_data) spl = curve_data.splines.new('BEZIER') # ('POLY', 'BEZIER', 'NURBS') spl.bezier_points.add(1) # one point already exists for i in range(2): spl.bezier_points[i].handle_left_type = 'VECTOR' # ('FREE', 'VECTOR', 'ALIGNED', 'AUTO') spl.bezier_points[i].handle_right_type = 'VECTOR' spl.bezier_points[1].co = direction # Back to aligned mode for i in range(2): spl.bezier_points[i].handle_right_type = spl.bezier_points[i].handle_left_type = 'ALIGNED' # Select second point spl.bezier_points[1].select_control_point = True spl.bezier_points[1].select_left_handle = True spl.bezier_points[1].select_right_handle = True # link context.scene.collection.objects.link(curve) # curve object settings curve.location = location curve.show_in_front = True # enter edit if enter_edit and context.mode == 'OBJECT': curve.select_set(True) context.view_layer.objects.active = curve bpy.ops.object.mode_set(mode='EDIT', toggle=False) # EDIT_CURVE ## set viewport overlay visibility for better view if context.space_data.type == 'VIEW_3D': context.space_data.overlay.show_curve_normals = True context.space_data.overlay.normals_length = 0.2 return curve def create_follow_path_constraint(ob, curve, follow_curve=True): prefs = get_addon_prefs() root_name = prefs.tgt_bone root = ob.pose.bones.get(root_name) if not root: return ('ERROR', f'posebone {root_name} not found in armature {ob.name} check addon preferences to change name') # Clear bone follow path constraint exiting_fp_constraints = [c for c in root.constraints if c.type == 'FOLLOW_PATH'] for c in exiting_fp_constraints: root.constraints.remove(c) # loc = ob.matrix_world @ root.matrix.to_translation() if root.name == ('world', 'root') and root.location != (0,0,0): old_loc = root.location root.location = (0,0,0) print(f'root moved from {old_loc} to (0,0,0) to counter follow curve offset') const = root.constraints.new('FOLLOW_PATH') const.target = curve # axis only in this case, should be in addon to prefs ## determine which axis to use... maybe found orientation in world space from matrix_basis ? root_world_base_direction = root.bone.matrix_local @ get_direction_vector_from_enum(bpy.context.scene.anim_cycle_settings.forward_axis) const.forward_axis = orentation_track_from_vector(root_world_base_direction) # 'TRACK_NEGATIVE_Y' # bpy.context.scene.anim_cycle_settings.forward_axis # 'FORWARD_X' print('const.forward_axis: ', const.forward_axis) const.use_curve_follow = True return curve, const def shrinkwrap_on_object(source, target, apply=True): # shrinkwrap or cast on ground mod = source.modifiers.new('Shrinkwrap', 'SHRINKWRAP') # 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 = target if apply: # Apply and decimate switch = False if bpy.context.mode == 'EDIT_CURVE': switch = True bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.modifier_apply({'object': source}, modifier="Shrinkwrap", report=False) if switch: bpy.ops.object.mode_set(mode='EDIT') def snap_curve(create_copy=False): obj = bpy.context.object gnd = get_gnd() if not gnd: return curve = const = None if obj.type == 'ARMATURE': curve, const = get_follow_curve_from_armature(obj) to_follow = bpy.context.scene.anim_cycle_settings.path_to_follow curve_act = None anim_frame = None if create_copy: # get curve from field if not curve and not to_follow: return ('ERROR', f'No curve pointed by "Path" filed') if not curve: curve, const = create_follow_path_constraint(obj, to_follow) if isinstance(curve, str): return (curve, const) # those are error message # if obj.type == 'CURVE': # return ('ERROR', f'Select the armature related to curve {obj.name}') # else: # return ('ERROR', 'Not an armature object') # 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) org_curve = bpy.context.scene.objects.get(org_name) 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 curve = org_curve nc = curve.copy() name = re.sub(r'\.\d{3}$', '', curve.name) + '_snap' const.target = nc 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) else: if not curve: return ('ERROR', 'Path not found') nc = curve ## If object mode is Curve subdivide it (TODO if nurbs needs conversion) #-# subdivide the curve (if curve is not nurbs) # bpy.ops.object.mode_set(mode='EDIT') # bpy.ops.curve.select_all(action='SELECT') # bpy.ops.curve.subdivide(number_cuts=4) # bpy.ops.object.mode_set(mode='OBJECT') shrinkwrap_on_object(nc, gnd) bpy.context.scene.anim_cycle_settings.path_to_follow = nc # return 0, nc ## --- action funcs def update_action(act): '''update fcurves (often broken after generation through API)''' # update fcurves for fcu in act.fcurves: fcu.update() # redraw graph area for window in bpy.context.window_manager.windows: screen = window.screen for area in screen.areas: 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] 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 class attr_set(): '''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 = [] # item = (prop, attr, [new_val]) for item in attrib_list: prop, attr = item[:2] self.store.append( (prop, attr, getattr(prop, attr)) ) 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) # fcurve modifiers def remove_all_cycles_modifier(ob=None): ob = ob or bpy.context.object ct = 0 for fc in ob.animation_data.action.fcurves: # if fc.data_path.split('"')[1] in selected_names: for m in reversed(fc.modifiers): if m.type == 'CYCLES': ct += 1 # print(f'Remove cycle mod on fcurve: {fc.data_path}') fc.modifiers.remove(m) fc.update() print(f'Remove cyclic modifiers on {ct} fcurve(s)') return ct def get_only_pose_keyable_fcurves(ob, action=None): '''Can action providing another action (must be for the same object)''' act = action or ob.animation_data.action ## 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) fcus = [] re_prefix = re.compile(r'^(mch|def|org|vis|fld|ctp)[\._-]', flags=re.I) for fc in act.fcurves: # skip offset if fc.data_path.endswith('.offset') and 'constraint' in fc.data_path: continue # skip fcus that are not bones if not '"' in fc.data_path: continue b_name = fc.data_path.split('"')[1] if b_name not in name_list: continue if re_prefix.match(b_name): continue fcus.append(fc) return fcus def create_cycle_modifiers(ob=None): ob = ob or bpy.context.object ct = 0 keyable_fcurves = get_only_pose_keyable_fcurves(ob) for fc in keyable_fcurves: if [m for m in fc.modifiers if m.type == 'CYCLES']: # skip if already existing modifier continue # print(f'Adding cycle modifier {fc.data_path}') _m = fc.modifiers.new(type='CYCLES') ct += 1 fc.update() print(f'Added cyclic modifiers on {ct} fcurve(s)') return ct ## Get collection, create if necessary def get_col(name, parent=None, create=True): parent = parent or bpy.context.scene.collection col = bpy.data.collections.get(name) if not col and create: col = bpy.data.collections.new(name) if col not in parent.children[:]: parent.children.link(col) return col def go_edit_mode(ob, context=None): '''set mode to object, set passed obhject as active and go Edit''' context = context or bpy.context bpy.ops.object.mode_set(mode='OBJECT', toggle=False) ob.select_set(True) context.view_layer.objects.active = ob bpy.ops.object.mode_set(mode='EDIT', toggle=False) def get_visible_bones(ob): '''Get name of all editable bones (unhided *and* on a visible bone layer''' # visible bone layer index visible_layers_indexes = [i for i, l in enumerate(ob.data.layers) if l] # check if layers overlaps visible_bones = [b for b in ob.data.bones \ if not b.hide \ if any(i for i, l in enumerate(b.layers) if l and i in visible_layers_indexes)] return visible_bones def get_x_pos_of_visible_keys(ob, act): '''Get an object and associated action return x.coordinate of all fcurves.keys of all visible bones ''' ## just skip offset # return [k.co.x for fc in act.fcurves if not '.offset' in fc.data_path for k in fc.keyframe_points] ## skip offset + fcu related to invisible bones viz_bones = get_visible_bones(ob) visible_bone_names = [b.name for b in viz_bones] keys = [] for fc in act.fcurves: if '.offset' in fc.data_path: continue if not '"' in fc.data_path: continue if not fc.data_path.split('"')[1] in visible_bone_names: continue keys += [k.co.x for k in fc.keyframe_points] return keys # def clear_duplicated_keys_in_fcurves() def clean_fcurve(action=None): '''clear duplicated keys at same frame in fcurves''' cleaned = 0 problems = 0 if action is None: bpy.context.object.animation_data.action for fcu in action.fcurves: prev = None for k in reversed(fcu.keyframe_points): if not prev: prev = k continue if prev.co.x == k.co.x: if prev.co.y == k.co.y: print(f'autoclean: 2 idential keys at {k.co.x} ', fcu.data_path, fcu.array_index) fcu.keyframe_points.remove(prev) cleaned += 1 else: print(f'/!\ 2 keys with different value at {k.co.x} ! : ', fcu.data_path, fcu.array_index) problems += 1 prev = k if problems: return ('ERROR', f'{problems} keys are overlapping (see console)') if cleaned: return ('WARNING', f'{cleaned} keys autocleaned')