auto_walk/fn.py

806 lines
26 KiB
Python

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')