Files
2026-03-17 15:25:32 -06:00

4969 lines
214 KiB
Python

# ***** BEGIN GPL LICENSE BLOCK *****
#
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ***** END GPL LICENCE BLOCK *****
import bpy
import gpu
from gpu_extras.batch import batch_for_shader
import bpy_extras.view3d_utils
from . import Tools
import math
from mathutils import Vector
from mathutils import Matrix
from mathutils import Quaternion
import numpy as np
import copy
import bisect
import traceback
import json
import blf
import inspect
import time
def coords_2d_update(self, context):
'''update the 2d coordinates from the 3d coordinates, when the screen moves'''
self.coords2d_ranges = []
self.coords2d_bones_frames = dict()
self.coords2d_handles_r = dict()
self.coords2d_handles_l = dict()
#when pressing ctrl, use all points to hover, for adding new keyframe points
if self.ctrl:
self.coords2d_bones_frames = coords_2d_add(self, context, self.bones_allframes_coords)
# bones_frames_coords = self.bones_allframes_coords
else:
#otherwise get only the keyframes
self.coords2d_bones_frames = coords_2d_add(self, context, self.bones_keyframes_coords)
#and coordinates of the handles
if self.bones_handles_right:
self.coords2d_handles_r = coords_2d_add(self, context, self.bones_handles_right)
self.coords2d_handles_l = coords_2d_add(self, context, self.bones_handles_left)
def coords_2d_add(self, context, bones_frames_coords):
'''Get all the coordinates withing the range value from keys or handles'''
range_value = self.range_value
coords2d_bones_frames = dict()
for obj_bonename, keyframes_coords in bones_frames_coords.items():
for frame, coord_3d in keyframes_coords.items():
coord_2d = bpy_extras.view3d_utils.location_3d_to_region_2d(self.region, self.region_data, coord_3d)
if not coord_2d:
continue
coord_2d = tuple(coord_2d)
coord_x = int(coord_2d[0])
coord_y = int(coord_2d[1])
#continue if the coordinates are out of the screen
if coord_x < 0 or coord_y < 0:
continue
# adding all the range values from each point based on the range values
self.coords2d_ranges += [(x, y) for x in range(coord_x - range_value, coord_x + range_value + 1)
for y in range(coord_y - range_value, coord_y + range_value + 1)]
if coord_2d in coords2d_bones_frames:
bone_frame = coords2d_bones_frames[coord_2d]
bone_frame.update({obj_bonename : frame})
coords2d_bones_frames.update({coord_2d : bone_frame})
else:
bone_frame = {obj_bonename: frame}
coords2d_bones_frames.update({coord_2d : bone_frame})
return coords2d_bones_frames
def get_keyframe_coord2d(self):
'''get the coordinates frame and bone where the mouse is currently hovering'''
if (int(self.mouse_x), int(self.mouse_y)) in self.coords2d_ranges:
#get both coordinates for the keyframes and for the handles
coords2d_bones_frames = self.coords2d_bones_frames.copy()
if self.coords2d_handles_r or self.coords2d_handles_l:
coords2d_bones_frames.update(self.coords2d_handles_r.copy())
coords2d_bones_frames.update(self.coords2d_handles_l.copy())
#creating a dict with the coords and difference from mouse, using the difference as the key to find the coord
coords_2d_dict = dict()
for coord_2d, bone_frame in coords2d_bones_frames.items():
x_diff = abs(int(self.mouse_x) - int(coord_2d[0]))
y_diff = abs(int(self.mouse_y) - int(coord_2d[1]))
#get all the coords that are inside the range
if x_diff <= self.range_value and y_diff <= self.range_value:
coords_2d_dict.update({(x_diff, y_diff) : coord_2d})
if coords_2d_dict:
#compare the coords in the range value to see which one is the closest to the mouse
coord_2d_key = min(coords_2d_dict.keys())
coord_2d = coords_2d_dict[coord_2d_key]
return coord_2d, coords2d_bones_frames[coord_2d]
return None, None
def mouse_hover_keyframe(self, event):
'''get the property that recognizes when the mouse is hovering on a frame'''
if self.press or self.scale or self.rotate:
return
if (int(self.mouse_x), int(self.mouse_y)) not in self.coords2d_ranges:
if any(self.hover_bone_frame.values()):
#give None to all the hover values when the mouse is out of range
self.hover_bone_frame = {key : None for key in self.hover_bone_frame.keys()}
self.current_hover_frame = (None, None)
self.update = True
Tools.redraw_areas(['VIEW_3D'])
return
coords_2d, bone_frame = get_keyframe_coord2d(self)
if bone_frame is None:
return
if coords_2d in self.coords2d_bones_frames:
self.hover_bone_frame = {key : bone_frame if key == 'keyframe' else None for key in self.hover_bone_frame.keys()}
key = 'keyframe'
elif coords_2d in self.coords2d_handles_r:
self.hover_bone_frame = {key : bone_frame if key == 'handle_r' else None for key in self.hover_bone_frame.keys()}
key = 'handle_r'
elif coords_2d in self.coords2d_handles_l:
self.hover_bone_frame = {key : bone_frame if key == 'handle_l' else None for key in self.hover_bone_frame.keys()}
key = 'handle_l'
frame = next(iter(bone_frame.values()))
# Make sure to update the motion path ONLY when leaving the current hovered frame
# Or getting closer to another frame, or switching from keyframe to handle
if self.current_hover_frame[0] != key or self.current_hover_frame[1] != frame:
self.current_hover_frame = (key, frame)
self.update = True
Tools.redraw_areas(['VIEW_3D'])
def filter_axis_distance(self, points, initial_coords):
'''Filtering the X Y Z from the location while scaling or moving the keyframes'''
if not self.scale and not self.press and not self.rotate:
return points
if self.filter_axis == '':
('cancel filtering')
return points
new_points = initial_coords.copy()
axis_array = {'X' : 0, 'Y' : 1, 'Z' : 2}
filter_axis = self.text.split(' along world ')[1]
for axis in filter_axis:
i = axis_array[axis]
new_points[i] = points[i]
return new_points
def filter_axis_keyframes(self, event):
if not self.press and not self.scale and not self.rotate:
return False
# if self.filter_axis is not None:
# return False
if event.type not in {'X', 'Y', 'Z'}:
return False
if event.value != 'PRESS':
return False
if not self.bones_selected_keyframes:
return False
if not any(self.hover_bone_frame.values()):
return False
axis = 'XYZ'.replace(event.type, '') if self.shift else event.type
filter_axis = ' along world ' + axis
#Remove filter axis when double click on the axis
self.filter_axis = '' if self.filter_axis == filter_axis else filter_axis
if ' along world ' in self.text:
self.text = self.text.split(' along world ')[0]
self.text = self.text + self.filter_axis
def move_rotate_scale_keyframes(self, event):
'''Applying the G R S hotkeys'''
# Reseting before movement, need to check if this is really necessery
# esc = True if not any([self.press, self.rotate, self.scale]) and hasattr(self, 'draw_handle') else False
# Tools.update_notification(self, bpy.context, event, esc, fade_out = False)
if event.type not in {'G','R', 'S'}:
return False
# if self.scale or self.rotate:
# return False
if event.value != 'PRESS':
return False
if not self.bones_selected_keyframes:
return False
if not any(self.hover_bone_frame.values()):
return False
obj_bonename, key, frame, selection, coord_3d = unpack_hover_frame(self)
#if it's not in the selected keyframes then skip
if obj_bonename in selection:
if frame not in selection[obj_bonename]:
return False
else:
return False
#scaling and rotating need minimum of 2 selected keyframes
if len(self.bones_selected_keyframes[obj_bonename]) < 2 and event.type in {'R', 'S'}:
return False
initialize_g_r_s(self, event.type)
# Need to check if this is necessery since its already happenning on mouse presse
initialize_points(self)
initialize_prop_edit_coord2d(self, bpy.context, obj_bonename)
self.initial_coord = Vector(coord_3d)
self.prev_event_value = None
def initialize_g_r_s(self, transform):
'''set the attribute for the operator and reset the others'''
op = {'G' : 'press', 'R' : 'rotate', 'S' : 'scale'}
text_dict = {'G' : 'Move Keyframes ', 'R' : 'Rotate Keyframes ', 'S' : 'Scale Keyframes '}
if getattr(self, op[transform]):
return
# Setting self.press, self.rotate or self.press to be True
setattr(self, op[transform], True)
def scale_points(points, pivot, scale_factor):
# for i, point in enumerate(points):
direction = points - pivot
scaled_points = pivot + direction * scale_factor
return scaled_points
def project_onto_view_plane(vector, view_matrix):
# Get the view direction from the view matrix
view_direction = view_matrix.to_quaternion() @ Vector((0.0, 0.0, -1.0))
# Project the vector onto the plane perpendicular to the view direction
projection = vector - vector.project(view_direction)
return projection
def get_rotation_2d(self, context, initial_coord, new_coord, pivot_2d):
'''rotation the points in 2d'''
new_coord_2d = bpy_extras.view3d_utils.location_3d_to_region_2d(self.region, self.region_data, new_coord)
initial_coord_2d = bpy_extras.view3d_utils.location_3d_to_region_2d(self.region, self.region_data, initial_coord)
vector1_2d = initial_coord_2d - pivot_2d
vector2_2d = new_coord_2d - pivot_2d
angle = vector1_2d.angle(vector2_2d)
#check the direction it should rotate
cross_product = vector1_2d.x * vector2_2d.y - vector1_2d.y * vector2_2d.x
if cross_product > 0:
angle *= -1
rotation_matrix = Matrix.Rotation(angle, 2, 'Z')
return rotation_matrix, angle
def get_transform_channel(posemode, context, posebone):
'''Get the transform channel for the motion path'''
if posemode:
# Getting path depending on the channel settings in the motion path
if context.scene.emp.channels == 'HEADS':
transform = 'location'
else:
# Get the rotation channel depending the rotation mode
rot_mod = posebone.rotation_mode
transform = Tools.rot_mode_to_channel(rot_mod)
else:
transform = 'location'
return transform
def get_fcu_path(context, posemode, obj, bonename):
'''get the datapath of the location, check if it's a bone or object'''
if posemode:
posebone = obj.pose.bones[bonename]
bone_path = posebone.path_from_id()
# Getting path depending on the channel settings in the motion path
transform = get_transform_channel(posemode, context, posebone)
path = bone_path + '.' + transform
else:
path = 'location'
array_len = 4 if path.endswith('rotation_quaternion') or path.endswith('rotation_axis_angle') else 3
return path, array_len
def get_keyframe_locations(self, keyframe_coords):
''' get a list with the average of all the keyframe world locations'''
locations = []
for obj_bonename, frames in self.bones_selected_keyframes.items():
for frame in frames:
locations.append(keyframe_coords[obj_bonename][frame])
return locations
def zoom_keyframes(self, context, event):
'''Zoom into selected keyframes'''
if event.type != 'NUMPAD_PERIOD':
return False
if not self.bones_selected_keyframes:
return False
if (int(self.mouse_x), int(self.mouse_y)) not in self.coords2d_ranges:
return False
locations = get_keyframe_locations(self, self.bones_keyframes_coords)
avg_loc = sum(locations) / len(locations)
self.region_data.view_location = avg_loc
max_loc = max([Vector(loc) for loc in locations])
min_loc = min([Vector(loc) for loc in locations])
self.region_data.view_distance = (max_loc - min_loc).length
if self.region_data.view_distance < 1:
self.region_data.view_distance = 1
self.region_data.view_perspective = 'PERSP'
return True
def unpack_hover_frame(self):
for key, value in self.hover_bone_frame.items():
if value is None:
continue
obj_bonename, frame = next(iter(value.items()))
if key == 'keyframe':
selection = self.bones_selected_keyframes
coord_3d = self.bones_keyframes_coords[obj_bonename].get(frame)
elif key == 'handle_r':
selection = self.bones_selected_handles_r
coord_3d = self.bones_handles_right[obj_bonename].get(frame)
elif key == 'handle_l':
selection = self.bones_selected_handles_l
coord_3d = self.bones_handles_left[obj_bonename].get(frame)
break
return obj_bonename, key, frame, selection, coord_3d
def left_mouse_press(self, context):
'''behavior when pressing on the left mouse either to select or initialize moving the point'''
if self.ctrl or self.scale or self.rotate:
return False
if self.press:
return True
if not any(self.hover_bone_frame.values()):
return False
#apply the selection either to the keyframes or handles
obj_bonename, key, frame, selection, coord_3d = unpack_hover_frame(self)
# Initial coords when the mouse was clicking
self.initial_coord = Vector(coord_3d)
#Checking if shift was pressed for multiple selections
if self.shift:
#ADD or remove selection using shift
if obj_bonename in selection:
#if holding SHIFT then DESELECT the frame, remove selection and handles
if frame in selection[obj_bonename]:
selection[obj_bonename].remove(frame)
if key == 'keyframe':
handles_frame_remove(self, obj_bonename, frame)
if not len(selection[obj_bonename]):
del selection[obj_bonename]
# When removing selection returning instead of initialzing the movement
self.update = True
Tools.redraw_areas(['VIEW_3D'])
# Update selection into the undo system
context.scene.emp.selected_keyframes = serialize_dict(self.bones_selected_keyframes)
bpy.ops.ed.undo_push(message = 'Select Motion Path Keyframe')
return True
else:
# Add the new keyframe to selection
selection[obj_bonename].append(frame)
else:
# Add the keyframe and object to the selection
selection.update({obj_bonename : [frame]})
else:
if obj_bonename in selection and frame in selection[obj_bonename]:
# In case the frame is already selected, then it doesn't need to reset the selection
# This is especially useful when selecting multiple keyframes and pressing to move them
pass
else:
# If shift was not pressed then clear selection and create a new one
clear_reselection(self, obj_bonename, key, frame, selection)
# Initialize in order to start moving the keyframes when dragging the mouse
get_handles(self, context)
coords_2d_update(self, context)
initialize_g_r_s(self, 'G')
initialize_points(self)
initialize_prop_edit_coord2d(self, context, obj_bonename)
self.prev_event_value = 'PRESS'
# self.initial_coord = Vector(coord_3d)
self.update = True
Tools.redraw_areas(['VIEW_3D'])
# Update selection into the undo system
context.scene.emp.selected_keyframes = serialize_dict(self.bones_selected_keyframes)
bpy.ops.ed.undo_push(message = 'Select Motion Path Keyframe')
return True
def initialize_prop_edit_coord2d(self, context, obj_bonename):
# Get the position for the circle during proportional editing
# Flipping the 2d coords dictionary to get the coords based on frames for the edit prop frames
if not context.scene.emp.prop_edit:
return
self.bones_frames_coords2d = dict()
for coords2d, bones_frames in self.coords2d_bones_frames.items():
for bone, frame in bones_frames.items():
if bone not in self.bones_frames_coords2d:
self.bones_frames_coords2d[bone] = dict()
self.bones_frames_coords2d[bone][frame] = coords2d
# Getting the coord2d where the circle is going to be displayed
frame = self.current_hover_frame[1]
coords2d = self.bones_frames_coords2d[obj_bonename][frame]
self.prop_edit_mouse = coords2d
def initialize_points(self):
"""Storing all the motion path points before moving them"""
# if key == 'keyframe':
self.initial_keyframe_coords = copy.deepcopy(self.bones_keyframes_coords)
self.initial_bones_points = copy.deepcopy(self.bones_points)
self.initial_handles_right = copy.deepcopy(self.bones_handles_right)
self.initial_handles_left = copy.deepcopy(self.bones_handles_left)
def clear_reselection(self, obj_bonename, key, frame, selection):
if key == 'keyframe':
selection.clear()
handles_selection_clear(self)
selection[obj_bonename] = [frame]
def coords_cursor_offset(item, coord):
if 'mp_cursor_offset' not in item.keys():
return
coord += Vector(item['mp_cursor_offset'])
def add_cursor_offset(self, context, obj_bonename, item):
'''Currently still not applied since the motion path is having only the information of location
Can maybe add it after including also rotations'''
if not context.scene.emp.cursor_offset:
if 'mp_cursor_offset' in item.keys():
del item['mp_cursor_offset']
return
if 'mp_cursor_offset' not in item.keys():
c_loc = context.scene.cursor.matrix.translation
if type(item) == bpy.types.PoseBone:
if context.scene.emp.channels == 'HEADS':
item_loc = item.head
else:
item_loc = item.tail
else:
item_loc = item.matrix_world.translation
item['mp_cursor_offset'] = c_loc - item_loc
if self.camera_space:
cam = context.scene.camera
M_cam = np.array(cam.matrix_world, dtype=np.float32)
# Getting the camera orientation from its matrix
R = np.array(M_cam[:3, :3], dtype=np.float32)
if self.cursor_offset and 'mp_cursor_offset' in item:
# Add the camera space to the cursor offset
cursor_offset_cam = item['mp_cursor_offset'] @ R
# Adding the cursor offset to the camera space points
self.camspace_points[obj_bonename] += cursor_offset_cam
distance = item['mp_cursor_offset']
self.bones_points[obj_bonename] = self.bones_points[obj_bonename] + distance
def edit_motion_path_update(self, context, event):
'''update mouse interaction with editable motion paths'''
scene = context.scene
if not self.coords2d_ranges:
coords_2d_update(self, context)
#if view angle is changed then update the 2d coords of the keyframess
if self.prev_view_matrix != self.region_data.view_matrix or self.prev_window_matrix != self.region_data.window_matrix:
self.prev_view_matrix = self.region_data.view_matrix.copy()
self.prev_window_matrix = self.region_data.window_matrix.copy()
coords_2d_update(self, context)
mouse_hover_keyframe(self, event)
if scene.emp.smooth:
return False
# g_move_keyframes(self, event)
move_rotate_scale_keyframes(self, event)
filter_axis_keyframes(self, event)
if event.type == self.select_mouse: #'LEFTMOUSE'
frame_current = scene.frame_current_final
#add or remove keyframes using Ctrl hotkey
if event.value == 'RELEASE' and self.ctrl:
if not self.hover_bone_frame['keyframe']:
return True
obj_bonename, frame = next(iter(self.hover_bone_frame['keyframe'].items()))
key_frames = self.mp_bones_keys[obj_bonename]
obj = context.view_layer.objects[obj_bonename[0]]
# posebone = obj.pose.bones[obj_bonename[1]]
path, array_len = get_fcu_path(context, self.posemode, obj, obj_bonename[1])
fcurves = Tools.get_fcurves(obj, obj.animation_data.action)
#if there is a keyframe then remove it
if frame in key_frames:
#removing keyframes
for i in range(array_len):
fcu = fcurves.find(data_path = path, index = i)
if fcu is None:
continue
for keyframe in fcu.keyframe_points:
if round(add_frame_offset(obj, keyframe.co[0])) == frame:
fcu.keyframe_points.remove(keyframe)
#deselect if selected
if obj_bonename in self.bones_selected_keyframes:
if frame in self.bones_selected_keyframes[obj_bonename]:
self.bones_selected_keyframes[obj_bonename].remove(frame)
handles_frame_remove(self, obj_bonename, frame)
self.mp_bones_keys[obj_bonename].remove(frame)
#reset the motion path
initialize_points(self)
update_mp_points(self, context, [frame], [obj_bonename])
else: #if there is no keyframe then add a new one
if frame != frame_current:
scene.frame_set(int(frame), subframe = frame%1)
#Offset is already applied to the frame
obj.keyframe_insert(data_path = path, frame = frame)
self.mp_bones_keys[obj_bonename].append(frame)
self.mp_bones_keys[obj_bonename].sort()
get_handles(self, context)
update_keyframe_coords(self)
calculate_velocities(self, context)
coords_2d_update(self, context)
self.current_bones_keys = get_current_bones_keys(self, context)
scene.frame_set(int(frame_current), subframe = frame_current % 1)
#update selected keyframes property serialization for undo purpose
context.scene.emp.selected_keyframes = serialize_dict(self.bones_selected_keyframes)
bpy.ops.ed.undo_push(message = 'Add / Remove Keyframe')
self.update = True
return True
if event.value == 'PRESS':
if left_mouse_press(self, context):
return True
if event.value == 'RELEASE' and (self.press or self.scale or self.rotate):
obj_bonename, key, frame, selection, coord_3d = unpack_hover_frame(self)
# When releasing directly after press it will cancel the movement and reselect
if self.prev_event_value == 'PRESS':
if not self.shift:
# Reselect a single frame if shift is not on
clear_reselection(self, obj_bonename, key, frame, selection)
get_handles(self, context)
coords_2d_update(self, context)
cancel_update(self)
self.prev_event_value = event.value
return True
apply_keyframe_movement(self, context, event, key, coord_3d)
remove_notification(self, context)
return True
elif event.type == 'MOUSEMOVE' and (self.press or self.scale or self.rotate):
#updating only the moving keyframe
check_notification(self, text = get_notification_text(self, context.scene.emp), size = 15)
if add_distance_on_mouse_change(self, context, event):
return True
#Cancel the operation, usually with RIGHTMOUSE go back to initial coordinates
elif event.type in {self.cancel_mouse, 'ESC'} and (self.press or self.scale or self.rotate):
cancel_update(self)
return True
elif (event.type == 'LEFT_CTRL' or event.type == 'RIGHT_CTRL'):
if event.value == 'RELEASE' and self.ctrl:
#Go out of Ctrl mode
self.bones_allframes_coords = dict()
self.ctrl = False
coords_2d, bone_frame = get_keyframe_coord2d(self)
if coords_2d in self.coords2d_bones_frames:
#remove extra keyframes that were added during hovering with ctrl
self.hover_bone_frame['keyframe'] = bone_frame
bone, frame = next(iter(bone_frame.items()))
#if the keyframe was added only during hovering with ctrl but not actually added, then remove it
if frame in self.bones_keyframes_coords[bone] and frame not in self.mp_bones_keys[bone]:
self.bones_keyframes_coords[bone].pop(frame)
self.coords2d_bones_frames[coords_2d].pop(bone)
coords_2d_update(self, context)
self.update = True
Tools.redraw_areas(['VIEW_3D'])
return True
elif event.value == 'PRESS' and not self.ctrl:
#Setting up adding keyframe mode
self.ctrl = True
self.bones_allframes_coords = dict()
#Create self.bones_allframes_coords that is similiar to self.bones_keyframes_coords
for obj_bonename, points in self.bones_points.items():
for i in range(len(points)):
# frame = ((i+3) / 3) + self.avz_frame_start[obj_bonename] - 1
frame = i + self.avz_frame_start[obj_bonename]
if obj_bonename in self.bones_allframes_coords:
# if frame in self.bones_allframes_coords[obj_bonename]:
self.bones_allframes_coords[obj_bonename].update({frame : points[i]})
else:
self.bones_allframes_coords.update({obj_bonename : {frame : points[i]}})
coords_2d_update(self, context)
coords_2d, bone_frame = get_keyframe_coord2d(self)
if coords_2d in self.coords2d_bones_frames:
self.hover_bone_frame['keyframe'] = bone_frame
self.update = True
Tools.redraw_areas(['VIEW_3D'])
return True
return False
def apply_keyframe_movement(self, context, event, key, coord_3d):
###Apply the keyframe movement, normally after release ###
#get the new coordinates and distance from the selected frame
#Using obj_bonename for hovered object to get the distance, and the selected_obj_bonename
# for the rest of the selected bones
update_frames = set()
for obj_bonename, frames_distance in self.frames_distance.items():
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
# if we are using an object then treat the object as a bone when getting the distance
posebone = obj.pose.bones[bonename] if self.posemode else obj
# get the distance from the initial coords
frames_local_distance = dict()
for frame in frames_distance.keys():
if context.scene.emp.channels == 'HEADS':
local_distance = get_distance(key, self, context, posebone, obj_bonename, frame)
else:
local_distance = get_rotation_difference(key, self, context, posebone, obj_bonename, frame)
frames_local_distance[frame] = local_distance
#apply the coordinates and distance to all the selected keyframes and handles
mp_update_keyframes(self, context, obj_bonename, frames_local_distance, key) #
update_frames.update(frames_distance.keys())
if context.scene.frame_current_final != self.frame_current:
context.scene.frame_set(int(self.frame_current), subframe = self.frame_current % 1)
update_frames = sorted(update_frames)
# update_frames = [frame for frames in self.bones_selected_keyframes.values() for frame in frames]
if update_frames:
update_mp_points(self, context, update_frames, self.bones_selected_keyframes.keys(), key, coord_3d)
update_keyframe_coords(self)
calculate_velocities(self, context)
self.coords2d_bones_frames = None
coords_2d_update(self, context)
# self.hover_bone_frame = {'keyframe': None, 'handle_r' : None, 'handle_l' : None}
# self.moving_frame = dict()
self.initial_coord = None
self.initial_keyframe_coords = dict()
self.prev_event_value = event.value
self.press = False
self.scale = False
self.rotate = False
self.filter_axis = ''
self.current_bones_keys = get_current_bones_keys(self, context)
self.update = True
context.scene.emp.selected_keyframes = serialize_dict(self.bones_selected_keyframes)
bpy.ops.ed.undo_push(message = 'Move Motion Path Keyframe')
def smooth_motion_path_point(p0, p1, p2, p3, t=0.5):
"""Catmull-Rom interpolation between p1 and p2"""
t2 = t * t
t3 = t2 * t
return 0.5 * (
2.0 * p1 +
(-p0 + p2) * t +
(2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 +
(-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3
)
def smooth_points(self, context, event):
'''Running the Smooth Brush'''
emp = context.scene.emp
if not emp.smooth:
if hasattr(self, 'text') and self.text == 'Smooth Brush Mode':
remove_notification(self, context, text = 'Smooth Brush Mode')
return False
if not self.hover_bone_frame['keyframe']:
return False
#Add the smoothing While pressing and dragging
obj_bonename, frame = next(iter(self.hover_bone_frame["keyframe"].items()))
if event.type == self.select_mouse:
if event.value == 'PRESS' and not self.smooth:
self.frames_distance[obj_bonename] = dict()
initialize_points(self)
self.smooth = True
return True
elif event.value == 'RELEASE'and self.smooth:
obj_bonename, key, frame, selection, coord_3d = unpack_hover_frame(self)
apply_keyframe_movement(self, context, event, key, coord_3d)
self.smooth = False
return True
if not self.smooth:
return False
frames_n_dist = get_smooth_frames_n_dist(self, context, obj_bonename)
# Updating all the points while the mouse is moving and smooth is turned on
for frame, n_dist in frames_n_dist.items():
coord = self.bones_keyframes_coords[obj_bonename][frame]
frames = self.mp_bones_keys[obj_bonename]
# Use min keyframe in case of negative frames
first_keyframe = min(self.bones_keyframes_coords[obj_bonename].keys())
p1_frame = max(first_keyframe, get_previous_frame(frame, frames))
p2_frame = get_next_frame(frame, frames)
p0_frame = max(first_keyframe, get_previous_frame(p1_frame, frames))
p3_frame = get_next_frame(p2_frame, frames)
p0_coords = self.bones_keyframes_coords[obj_bonename][p0_frame]
p1_coords = self.bones_keyframes_coords[obj_bonename][p1_frame]
p2_coords = self.bones_keyframes_coords[obj_bonename][p2_frame]
p3_coords = self.bones_keyframes_coords[obj_bonename][p3_frame]
initial_coord = self.initial_keyframe_coords[obj_bonename][frame]
# Using the catmull clarck algorithm
t = (frame - p1_frame) / (p2_frame - p1_frame)
# getting the final smooth point
smooth_coord = smooth_motion_path_point(p0_coords, p1_coords, p2_coords, p3_coords, t)
# Getting the new coordinates based on strength and distance from center
new_coord = (smooth_coord - coord) * emp.smooth_strength * (1 - n_dist)
i = round(frame - self.avz_frame_start[obj_bonename])
self.bones_points[obj_bonename][i] += new_coord
distance = Vector(self.bones_points[obj_bonename][i] - initial_coord)
if obj_bonename in self.bones_handles_right:
#get the distance from the keyframes
if frame in self.bones_handles_right[obj_bonename]:
self.bones_handles_right[obj_bonename][frame] = self.initial_handles_right[obj_bonename][frame] + distance
self.bones_handles_left[obj_bonename][frame] = self.initial_handles_left[obj_bonename][frame] + distance
self.frames_distance[obj_bonename][frame] = distance
add_distance_to_points(self, obj_bonename, self.frames_distance[obj_bonename])
self.update = True
self.area.tag_redraw()
return True
def get_smooth_frames_n_dist(self, context, obj_bonename):
'''Get all the frames in the range of the smooth brush and their normlized distance'''
emp = context.scene.emp
# Getting the range around the mouse
radius = emp.radius
all_coords2d = np.array(list(self.coords2d_bones_frames.keys()))
mouse_pos = np.array([self.mouse_x, self.mouse_y])
diff = all_coords2d - mouse_pos
dist = np.linalg.norm(diff, axis=1)
in_range_mask = dist <= radius
points_in_range = all_coords2d[in_range_mask]
dist_in_range = dist[in_range_mask]
# Normalize distances (0 at center, 1 at edge)
norm_dist = dist_in_range / radius
# Get the points that are in range
points_in_range = all_coords2d[in_range_mask]
# Build dict: frame -> normalized distance
frames_n_dist = {
self.coords2d_bones_frames[tuple(coord)][obj_bonename]: d
for coord, d in zip(points_in_range, norm_dist)
}
return frames_n_dist
def circle_select_keyframes(self, context, event):
'''Running the Circle select Brush'''
emp = context.scene.emp
if not emp.select_circle:
return False
if not any(self.hover_bone_frame.values()):
return False
if any((self.press, self.rotate, self.scale)):
return False
#Add the smoothing While pressing and dragging
# obj_bonename, frame = next(iter(self.hover_bone_frame["keyframe"].items()))
key, frame = self.current_hover_frame
obj_bonename, frame = next(iter(self.hover_bone_frame[key].items()))
if event.type == self.select_mouse:
if event.value == 'PRESS' and not self.select_circle:
self.select_circle = True
return True
# Turn off on release
elif event.value == 'RELEASE'and self.select_circle:
get_handles(self, context)
self.select_circle = False
return True
if not self.select_circle:
return False
frames_n_dist = get_smooth_frames_n_dist(self, context, obj_bonename)
for frame in frames_n_dist.keys():
if self.shift:
if frame in self.bones_selected_keyframes[obj_bonename]:
self.bones_selected_keyframes[obj_bonename].remove(frame)
self.update = True
# Remove frame from handles if existing there
self.bones_handles_left.get(obj_bonename, {}).pop(frame, None)
self.bones_handles_right.get(obj_bonename, {}).pop(frame, None)
else:
if obj_bonename in self.bones_selected_keyframes:
if frame not in self.bones_selected_keyframes[obj_bonename]:
self.bones_selected_keyframes[obj_bonename].append(frame)
self.update = True
else:
self.bones_selected_keyframes.update({obj_bonename : [frame]})
self.update = True
if self.update:
context.scene.emp.selected_keyframes = serialize_dict(self.bones_selected_keyframes)
bpy.ops.ed.undo_push(message = 'Add / Remove Keyframe')
self.area.tag_redraw()
return True
def add_distance_to_cyclic(self, frame, i, obj_bonename, distance):
if obj_bonename in self.bones_cyclic:
return
if frame == self.mp_bones_keys[obj_bonename][-1]:
self.bones_points[obj_bonename][i:] = self.initial_bones_points[obj_bonename][i:] + distance
elif frame == self.mp_bones_keys[obj_bonename][0]:
self.bones_points[obj_bonename][:i] = self.initial_bones_points[obj_bonename][:i] + distance
def add_distance_on_mouse_change(self, context, event):
#add the distance to all the selected keyframes or the selected handles
obj_bonename, key, frame, selection, coord_3d = unpack_hover_frame(self)
#get the new coordinates and distance from the selected moving keyframe
loc_coord = Vector(bpy_extras.view3d_utils.region_2d_to_location_3d(self.region, self.region_data, (self.mouse_x, self.mouse_y), coord_3d))
# distance = loc_coord - Vector(coord_3d)
# context.scene.cursor.location = loc_coord
if not self.press:
locations = get_keyframe_locations(self, self.initial_keyframe_coords)
pivot = Vector(sum(locations) / len(locations))
if self.press:
#adding text with all the detail
axis_array = {0 : 'X', 1 : 'Y', 2 : 'Z'}
if self.filter_axis == '':
move_distance = loc_coord - self.initial_coord
else:
move_distance = (value if axis_array[i] in self.filter_axis else 0.0 for i, value in enumerate (loc_coord - self.initial_coord))
distance_text = ' '.join(axis_array[i] + ': ' + str(round(value, 2)) for i, value in enumerate(move_distance))
self.text = 'Move Keyframes ' + distance_text + self.filter_axis
elif self.rotate:
pivot_2d = bpy_extras.view3d_utils.location_3d_to_region_2d(self.region, self.region_data, pivot)
rotation_matrix, angle = get_rotation_2d(self, context, self.initial_coord, loc_coord, pivot_2d)
self.text = 'Rotate Keyframes ' + str(round(math.degrees(angle), 2)) + self.filter_axis
elif self.scale:
try:
scale_factor = (loc_coord - pivot).length / (self.initial_coord - pivot).length
self.text = 'Scale Keyframes ' + str(round(scale_factor, 2)) + self.filter_axis
except ZeroDivisionError:
return True
# Check the direction using the dot product
dot_product = (loc_coord - pivot).dot(self.initial_coord - pivot)
dot = -1 if dot_product < 0 else 1
#add the distance to all the selected keyframes or the selected handles
for obj_bonename in selection.keys():
if key == 'keyframe':
keyframes_coords = self.bones_keyframes_coords[obj_bonename]
#get all the points from the curve of this bone
self.frames_distance[obj_bonename] = dict()
for frame, coord_3d in keyframes_coords.items():
if frame not in selection[obj_bonename]:
continue
initial_coords = Vector(self.initial_keyframe_coords[obj_bonename][frame])
#get the index of the point based on the frame number
i = round(frame - self.avz_frame_start[obj_bonename])
if self.press:
# Initial coords are from the current frame, and self.initial_coords are where the mouse is hovering
new_coord = initial_coords + (Vector(loc_coord) - self.initial_coord)
elif self.scale:
#scale only the initial coords and add it as a distance
new_coord = scale_points(initial_coords, pivot, scale_factor * dot)
elif self.rotate:
initial_coord_2d = bpy_extras.view3d_utils.location_3d_to_region_2d(self.region, self.region_data, initial_coords)
rotated_vector = (initial_coord_2d - pivot_2d) @ rotation_matrix + pivot_2d
new_coord = Vector(bpy_extras.view3d_utils.region_2d_to_location_3d(self.region, self.region_data, rotated_vector, coord_3d))
new_coord = filter_axis_distance(self, new_coord, initial_coords)
#get the distance of the handles from the keyframe last position
distance = Vector(new_coord - initial_coords)
self.frames_distance[obj_bonename][frame] = distance
#apply the keyframe scaled position
self.bones_points[obj_bonename][i] = new_coord
add_distance_to_cyclic(self, frame, i, obj_bonename, distance)
if obj_bonename in self.bones_handles_right:
#get the distance from the keyframes
self.bones_handles_right[obj_bonename][frame] = self.initial_handles_right[obj_bonename][frame] + distance
self.bones_handles_left[obj_bonename][frame] = self.initial_handles_left[obj_bonename][frame] + distance
add_distance_to_prop_edit(self, obj_bonename)
add_distance_to_points(self, obj_bonename, self.frames_distance[obj_bonename])
else:
# distance = loc_coord - Vector(coord_3d)
distance = loc_coord - self.initial_coord
for side in ['r', 'l']:
move_handle_side(self, obj_bonename, distance, side, key)
self.prev_event_value = None
self.update = True
self.area.tag_redraw()
# Tools.redraw_areas(['VIEW_3D'])
return True
def move_handle_side(self, obj_bonename, distance, side, key):
"""
Move handles for one side using getattr for dynamic attribute access.
Args:
obj_bonename: Name of the bone object
distance: Movement distance vector
side: 'r' for right, 'l' for left
key: Key parameter for get_side_auto function
"""
# Dynamic attribute access using getattr
handle_coords = getattr(self, f'bones_handles_{["left", "right"][side == "r"]}')[obj_bonename]
init_handles = getattr(self, f'initial_handles_{["left", "right"][side == "r"]}')[obj_bonename]
selected_handles = getattr(self, f'bones_selected_handles_{side}')
opposite_handle_coords = getattr(self, f'bones_handles_{["right", "left"][side == "r"]}')[obj_bonename]
opposite_init_handles = getattr(self, f'initial_handles_{["right", "left"][side == "r"]}')[obj_bonename]
# Side-specific configuration
auto_side_check = f'handle_{["l", "r"][side == "r"]}'
# Check if any frames are selected for this bone and side
if obj_bonename not in selected_handles:
return
selected_frames = set(selected_handles[obj_bonename])
# Process only selected frames
for frame in selected_frames:
if frame not in handle_coords:
continue
# Skip if automatic handle should move the other side
if get_side_auto(self, obj_bonename, frame, key) == auto_side_check:
continue
coord_3d = handle_coords[frame]
keyframe_coord = self.bones_keyframes_coords[obj_bonename][frame]
# Calculate distance length ratio
try:
dist_len = abs((init_handles[frame] + distance - Vector(keyframe_coord)).length /
(init_handles[frame] - Vector(keyframe_coord)).length)
except ZeroDivisionError:
dist_len = None
# Move the current side handle
handle_coords[frame] = init_handles[frame] + distance
# Handle AUTO type - align opposite handle
if self.bones_handles_types[obj_bonename][frame] == 'AUTO':
opposite_handle_coords[frame] = align_handles(
opposite_init_handles[frame],
coord_3d,
Vector(keyframe_coord),
dist_len
)
if obj_bonename not in self.frames_distance:
self.frames_distance[obj_bonename] = dict()
self.frames_distance[obj_bonename][frame] = distance
def move_handles_optimized(self, obj_bonename, distance, key):
"""
Optimized version using getattr and sets for efficient frame processing.
"""
# Move both sides
for side in ['r', 'l']:
move_handle_side(self, obj_bonename, distance, side, key)
def cancel_update(self):
'''Cancelling the operation, moving the points to their initial position'''
obj_bonename, key, frame, selection, coord_3d = unpack_hover_frame(self)
if key == 'keyframe':
self.bones_points = copy.deepcopy(self.initial_bones_points)
update_keyframe_coords(self)
self.bones_handles_right = self.initial_handles_right
self.bones_handles_left = self.initial_handles_left
del self.initial_handles_right, self.initial_handles_left, self.initial_bones_points
self.frames_distance = dict()
self.press = False
self.scale = False
self.rotate = False
self.filter_axis = ''
self.initial_coord = None
self.initial_keyframe_coords = None
self.update = True
remove_notification(self, bpy.context)
def add_frame_offset(obj, frame, add = False):
'''Adding strip offset'''
anim_data = obj.animation_data
if not anim_data.use_nla or not anim_data.use_tweak_mode:
return frame
if not len(anim_data.nla_tracks):
return frame
offset = frame - anim_data.nla_tweak_strip_time_to_scene(frame)
frame = frame + offset if add else frame - offset
return frame
def get_previous_frame(frame, frames):
# Getting the previous Previous frame
prev_i = bisect.bisect_left(frames, frame)
if prev_i:
prev_i -= 1
prev_neighbor = frames[prev_i]
return prev_neighbor
def get_next_frame(frame, frames):
# Getting the previous neighbor frame
next_i = bisect.bisect_right(frames, frame)
if next_i >= len(frames):
next_neighbor = frame
else:
next_neighbor = frames[next_i]
return next_neighbor
def edit_prop_frames(self, obj_bonename, frame, prev_k_dist, next_k_dist):
'''Finds the previous and next last frames of proportional editing (optimized).'''
emp = bpy.context.scene.emp
if not emp.prop_edit:
return None, None
frames_coords2d = self.bones_frames_coords2d[obj_bonename]
if frame not in frames_coords2d:
return None, None
if not hasattr(self, 'prop_editor'):
self.prop_editor = ProportionalEditor()
def reset_frame_points(frame):
i = round(frame - self.avz_frame_start[obj_bonename])
self.bones_points[obj_bonename][i] = self.initial_bones_points[obj_bonename][i]
frames = np.array(list(frames_coords2d.keys()))
index = np.where(frames == frame)[0][0]
# Precompute 2D coords as array
coords = np.array([frames_coords2d[f] for f in frames])
# Compute Euclidean distance between consecutive frames (vectorized)
diffs = np.diff(coords, axis=0) # Shape (N-1, 2)
distances = np.linalg.norm(diffs, axis=1) # Shape (N-1,)
# ================================
# Next neighbors
# ================================
total_dist = 0.0
for i in range(index + 1, len(frames)):
# If distance is already known, stop early
if frames[i] in self.frames_distance[obj_bonename]:
break
# Add Euclidean step distance
total_dist += distances[i - 1] # distance between frames[i-1] and frames[i]
if total_dist > emp.radius:
reset_frame_points(frames[i])
break
n_dist = total_dist / emp.radius
next_k_dist[frames[i]] = (frame, n_dist)
# ================================
# Previous neighbors
# ================================
total_dist = 0.0
for i in range(index - 1, -1, -1):
if frames[i] in self.frames_distance[obj_bonename]:
break
total_dist += distances[i] # distance between frames[i] and frames[i+1]
if total_dist > emp.radius:
reset_frame_points(frames[i])
break
n_dist = total_dist / emp.radius
prev_k_dist[frames[i]] = (frame, n_dist)
return prev_k_dist, next_k_dist
def add_distance_to_prop_edit(self, obj_bonename):
'''Add distance to proportional editing keyframes and add the keyframes to frames_distance'''
emp = bpy.context.scene.emp
if not emp.prop_edit:
return
prev_k_dist = dict()
next_k_dist = dict()
added_frames_distance = dict()
frames_distance = self.frames_distance[obj_bonename]
selected_frames = sorted(frames_distance.keys())
for frame in selected_frames:
edit_prop_frames(self, obj_bonename, frame, prev_k_dist, next_k_dist)
# Unify the two dictionaries based on the higher value (The frame that is closer)
for neighbor_frame in prev_k_dist.keys() | next_k_dist.keys():
# The dict values are tuples of original frame and normalized distance, we get the tuple with the lowest distance, so the original keyframe
frame, n_dist = min(prev_k_dist.get(neighbor_frame, (0, 1)), next_k_dist.get(neighbor_frame, (0, 1)), key=lambda x: x[1])
if not n_dist:
continue
interpolated_factor = self.prop_editor.apply_interpolation(n_dist, emp.prop_edit_falloff)
# Applying the distance to the next keyframe
distance = frames_distance[frame]
neighbor_distance = distance * (interpolated_factor)
added_frames_distance[neighbor_frame] = neighbor_distance
# Add the values to bones points, the actual motion path points
for added_key, added_dist in added_frames_distance.items():
added_i = round(added_key - self.avz_frame_start[obj_bonename])
self.bones_points[obj_bonename][added_i] = self.initial_bones_points[obj_bonename][added_i] + added_dist
frames_distance.update(added_frames_distance)
def add_distance_to_points(self, obj_bonename, frames_distance):
'''Adding the distance to the inbetween frames '''
for frame, distance in frames_distance.items():
frame_index = self.mp_bones_keys[obj_bonename].index(frame)
current_i = round(frame - self.avz_frame_start[obj_bonename])
if frame_index: # Make sure it's a positive index
# Get previous keyframe if it was not assigned during prop
prev_key = round(self.mp_bones_keys[obj_bonename][frame_index -1], 2)
if prev_key < 0:
continue
prev_dist = frames_distance[prev_key] if prev_key in frames_distance else Vector((0,0,0))
prev_frames = int(frame - prev_key)
prev_i = round(prev_key - self.avz_frame_start[obj_bonename])
#Linear distribution of the points until the previous keyframe
added_array = np.linspace(prev_dist, distance, prev_frames, endpoint=False)
#apply the keyframe position
if len(self.bones_points[obj_bonename][prev_i :current_i]) != len(added_array):
continue
self.bones_points[obj_bonename][prev_i :current_i] = self.initial_bones_points[obj_bonename][prev_i :current_i] + added_array
#Interpolatin for the next keyframe
if (frame_index + 1) >= len(self.mp_bones_keys[obj_bonename]):
continue
next_key = round(self.mp_bones_keys[obj_bonename][frame_index + 1], 2)
#if the next keyframe is also being moved then skip it
if next_key in frames_distance : #and frame in self.bones_selected_keyframes[obj_bonename]
continue
next_frames = int(next_key - frame)
next_i = round(next_key - self.avz_frame_start[obj_bonename])
#Linear distribution of the points until the next keyframe
added_array = np.linspace(distance, Vector((0,0,0)), next_frames, endpoint=False)
if len(self.bones_points[obj_bonename][current_i : next_i]) != len(added_array):
continue
self.bones_points[obj_bonename][current_i : next_i] = self.initial_bones_points[obj_bonename][current_i : next_i] + added_array
def draw_frames_callback_px(self, context):
'''Drawing keyframe numbers'''
font_id = 0 # XXX, need to find out how best to get this.
alpha = 0.75
emp = context.scene.emp
try:
if context.area != self.area:
return
for obj_bonename, keyframes_coords in self.bones_keyframes_coords.items():
for frame, coord_3d in keyframes_coords.items():
if frame < self.frame_start[obj_bonename]:
continue
# Checking if it's currently out of range when using around frame range
if emp.frame_range == 'AROUND':
current_frame = context.scene.frame_current_final
before = current_frame - emp.before
after = current_frame + emp.after
if frame < before or frame > after-1:
continue
# for region in self.regions:
coords2d = bpy_extras.view3d_utils.location_3d_to_region_2d(self.region, self.region.data, coord_3d)
blf.position(font_id, coords2d[0],coords2d[1] + 10 ,0)
blf.size(font_id, 15)
blf.color(0, 1, 1, 0, alpha)
blf.draw(font_id, str(int(round(frame))) )
# self.area.tag_redraw()
except ReferenceError:
return
# Function to draw the motion path
def draw_motionpath_callback_px(self, context):
'''draw the motion path'''
# start_time = time.perf_counter()
emp = context.scene.emp
check_frame_change(self, context)
if self.update or emp.update:
#Reset the lines and points
# for k, v in self.batch_handles_points.items():
self.batch_handles_points = []
self.batch_handles_lines = []
handles_vertices = []
col_handles_points = []
handles_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
pref = context.preferences.addons[__package__].preferences
def set_alternate_vis_type(col_lines, col_points, frame):
'''Setting the Alternating colors for the motion path'''
if not frame % 2:
col_lines.append(emp.color_after)
col_points.append(emp.color_after)
else:
col_lines.append(emp.color_before)
col_points.append(emp.color_before)
def set_before_after_vis_type(col_lines, col_points, frame):
'''Setting the colors for the motion path based on before and after current frame'''
if frame > self.frame_current:
col_lines.append(emp.color_after)
col_points.append(emp.color_after)
elif frame < self.frame_current:
col_lines.append(emp.color_before)
col_points.append(emp.color_before)
else:
col_lines.append((emp.color_after + emp.color_before) * 0.5)
col_points.append((1.0, 1.0, 1.0))
def set_velocity_vis_type(col_lines, col_points, frame):
'''Applying the velocity colors'''
col_lines.append(self.bones_interpolated_colors[obj_bonename][frame])
col_points.append(self.bones_interpolated_colors[obj_bonename][frame])
def update_handles(frame, coords, hover_handle_r, hover_handle_l):
# Add the handles selection points
if not emp.handles or not self.bones_handles_right:
return
#Add the handles to the selected keyframes
if frame not in self.bones_handles_right[obj_bonename]:
return
handles_vertices.append(self.bones_handles_right[obj_bonename][frame])
if hover_handle_r == frame:
# Orange-red color when hovering over the handle points
col_handles_points.append(pref.mp_hover_color)
else:
# Orange light color when not hovering
col_handles_points.append(pref.mp_handle_color)
#add the control points to the right handles
if obj_bonename in self.bones_selected_handles_r:
if frame in self.bones_selected_handles_r[obj_bonename]:
col_sel_keys.append(pref.mp_handle_selection_color)
sel_vertices.append(self.bones_handles_right[obj_bonename][frame])
handles_vertices.append(self.bones_handles_left[obj_bonename][frame])
if hover_handle_l == frame:
# Orange-red color when hovering over the handle points
col_handles_points.append(pref.mp_hover_color) # (1.0, 0.4, 0.2, 1.0)
else:
# Orange light color when not hovering
col_handles_points.append(pref.mp_handle_color) # (1.0, 0.8, 0.2, 1.0)
#add the control cage to the left handles
if obj_bonename in self.bones_selected_handles_l:
if frame in self.bones_selected_handles_l[obj_bonename]:
# Adding color to selected handles
col_sel_keys.append(pref.mp_handle_selection_color)
sel_vertices.append(self.bones_handles_left[obj_bonename][frame])
#draw the line of the handles
handles_line = [self.bones_handles_right[obj_bonename][frame]] + [coords] + [self.bones_handles_left[obj_bonename][frame]]
self.batch_handles_lines.append(batch_for_shader(handles_shader, 'LINE_STRIP', {"pos": handles_line}))
def update_lines_points():
'''Updating the vertex and color information of motion path during iteration'''
if not self.update and not emp.update:
return
#setup the hover attribute with the frame value. Either hover_keyframe, hover_handle_r or hover_handle_l
hover_keyframe = None
hover_handle_r = None
hover_handle_l = None
for key, value in self.hover_bone_frame.items():
if value is None:
continue
if obj_bonename in value:
if key == 'keyframe':
hover_keyframe = value[obj_bonename]
elif key == 'handle_r':
hover_handle_r = value[obj_bonename]
elif key == 'handle_l':
hover_handle_l = value[obj_bonename]
#iterate every 3 points, since every point has a 3 coordinate system
for i in range(len(points)):
#every 3 points are a vector of a frame
frame = i + self.avz_frame_start[obj_bonename]
#skip frames that are previous to the visual frames
if frame < self.frame_start[obj_bonename]:
continue
if emp.frame_range == 'AROUND':
before = self.frame_current - emp.before
after = self.frame_current + emp.after-1
if frame < before or frame > after:
continue
coords = tuple(points[i])
vertices.append(coords)
#append keyframes for a separate shader
if frame in keyframes or frame == hover_keyframe:
key_vertices.append(coords)
#paint the current frame white
if frame == self.frame_current:
keyframe_color = (1.0, 1.0, 1.0, 1.0)
else:
keyframe_color = pref.mp_keyframe_color #(1.0, 1.0, 0.0, 1.0)
if hover_keyframe == frame:
if frame in keyframes and self.ctrl:
#color the keyframe blue for removal
keyframe_color = pref.mp_remove_color # (0.0, 0.5, 1.0, 1.0)
else:
#color the frame that is going to be added or selected as orange using hover color
keyframe_color = pref.mp_hover_color # (1.0, 0.4, 0.0, 1.0)
col_keys.append(keyframe_color)
# Add the keyframe selection points
if obj_bonename in self.bones_selected_keyframes:
if frame in self.bones_selected_keyframes[obj_bonename]:
# Color of the points of the selected keyframes
col_sel_keys.append(pref.mp_key_selection_color)
# col_sel_keys.append(emp.key_selection_color)
sel_vertices.append(coords)
# Add the handles selection points
update_handles(frame, coords, hover_handle_r, hover_handle_l)
#add the colors for the motion path lines and points
if emp.vis_type == 'ALTERNATE':
set_alternate_vis_type(col_lines, col_points, frame)
elif emp.vis_type == 'BEFORE_AFTER':
set_before_after_vis_type(col_lines, col_points, frame)
elif emp.vis_type == 'VELOCITY':
set_velocity_vis_type(col_lines, col_points, frame)
def update_shaders():
if not self.update and not emp.update:
return
if emp.lines:
self.batch_line[obj_bonename] = batch_for_shader(shader, 'LINE_STRIP', {"pos": vertices, "color": col_lines})
if emp.points:
self.batch_points[obj_bonename] = batch_for_shader(shader_points, 'POINTS', {"pos": vertices, "color": col_points})
self.batch_keyframes[obj_bonename] = batch_for_shader(shader_points, 'POINTS', {"pos": key_vertices, "color": col_keys})
self.batch_selected[obj_bonename] = batch_for_shader(shader_points, 'POINTS', {"pos": sel_vertices, "color": col_sel_keys})
if handles_vertices:
self.batch_handles_points = batch_for_shader(shader_points, 'POINTS', {"pos": handles_vertices, "color": col_handles_points})
for obj_bonename, points in self.bones_points.items():
#get the set of the keyframes frame numbers
keyframes = self.mp_bones_keys[obj_bonename]
vertices = []
key_vertices = []
sel_vertices = []
# keyframes_coords = dict()
col_lines = []
col_points = []
col_keys = []
col_sel_keys = []
update_lines_points()
# Make the motion path occluding object or not using the infront option
if emp.infront:
gpu.state.depth_test_set('NONE')
else:
gpu.state.depth_test_set('LESS_EQUAL')
#Create all the shaders
if emp.vis_type == 'BEFORE_AFTER':
shader = gpu.shader.from_builtin("POLYLINE_SMOOTH_COLOR")
else:
shader = gpu.shader.from_builtin("POLYLINE_FLAT_COLOR")
#From 4.5 shaders are changed, probably because of Vulkan
shader_type = "FLAT_COLOR" if bpy.app.version < (4, 5, 0) else "POINT_FLAT_COLOR"
shader_points = gpu.shader.from_builtin(shader_type)
update_shaders()
#draw the path and lines
shader.bind()
shader_points.bind()
# gpu.state.line_width_set(10)
gpu.state.point_size_set(emp.frame_size)
region = self.region
viewport_size = (region.width, region.height)
# Set width of the lines
shader.uniform_float("viewportSize", viewport_size)
shader.uniform_float("lineWidth", emp.thickness)
if emp.lines:
self.batch_line[obj_bonename].draw(shader)
if emp.points:
self.batch_points[obj_bonename].draw(shader_points)
gpu.state.point_size_set(emp.keyframe_size*2)
self.batch_selected[obj_bonename].draw(shader_points)
gpu.state.point_size_set(emp.keyframe_size)
self.batch_keyframes[obj_bonename].draw(shader_points)
#draw selected keyframes box controller and handles
handles_shader.bind()
handles_shader.uniform_float("viewportSize", viewport_size)
handles_shader.uniform_float("color", (1, 1, 1, 1))
handles_shader.uniform_float("lineWidth", 1.0)
# if obj_bonename in self.bones_selected_keyframes:
# for batch_selection in batch_selected_keyframes:
# batch_selection.draw(selected_shader)
if self.batch_handles_lines:
for batch_handle in self.batch_handles_lines:
batch_handle.draw(handles_shader)
# draw the points of the handles
if self.batch_handles_points:
self.batch_handles_points.draw(shader_points)
self.update = emp.update = False
# end_time = time.perf_counter()
# draw_time = (end_time - start_time) * 1000 # Convert to milliseconds
# print(f"Draw function took: {draw_time:.2f}ms")
def calculate_velocities(self, context):
emp = context.scene.emp
if not emp.vis_type == 'VELOCITY':
return
self.bones_interpolated_colors = dict()
for obj_bonename, points in self.bones_points.items():
# Reshaping all the points into array of vectors
vectors = points.copy()
# Calculating all the velocities between all the vectors
velocity_vectors = vectors[1:] - vectors[:-1]
# Calculate speeds (magnitudes) for all vectors at once
velocities = np.linalg.norm(velocity_vectors, axis = 1)
if not np.any(velocities):
continue
normal_color_values = map_to_colors_percentile(velocities, context)
# normal_color_values *= emp.velocity_factor
# Convert the color values into the color vectors based on the selected colors
interpolated_colors = interpolate_colors_vectorized(normal_color_values, emp.color_before, emp.color_after)
# Duplicating the first color to match the number of points
interpolated_colors = np.insert(interpolated_colors, 0, interpolated_colors[0], axis = 0)
# Creating the frame numbers
frames = np.arange(len(interpolated_colors)) + self.avz_frame_start[obj_bonename]
# Mapping the frames to the colors using a dictionary
self.bones_interpolated_colors.update({obj_bonename : dict(zip(frames, interpolated_colors))})
def map_to_colors_percentile(velocities, context):
"""Use percentiles instead of absolute min/max to clamp/limit values and also normalizing the values"""
if len(velocities) == 0:
return []
lower_percentile = context.scene.emp.clamp_min
upper_percentile = context.scene.emp.clamp_max
# Remove velocity vecotors that are 0, usually caused with empty frames in scene frame range
filtered_velocities = velocities[velocities != 0]
min_speed = np.percentile(filtered_velocities, lower_percentile)
max_speed = np.percentile(filtered_velocities, upper_percentile)
#Clamp velocity to not get hard spikes that will tone down the rest of the velocities
clamped = np.clip(velocities, min_speed, max_speed)
if max_speed != min_speed:
color_values = (clamped - min_speed) / (max_speed - min_speed)
else:
color_values = np.full_like(velocities, 0.5)
velocity_factor = context.scene.emp.velocity_factor
if velocity_factor != 1.0:
# Factor > 1.0: increases contrast (sharper transitions)
# Factor < 1.0: decreases contrast (smoother transitions)
color_values = np.power(color_values, velocity_factor)
return color_values
def interpolate_colors_vectorized(color_values, color_before, color_after):
"""Vectorized color interpolation"""
color_before = np.array(color_before)
color_after = np.array(color_after)
# Expand color_values to match RGB dimensions
t = color_values[:, np.newaxis] # Shape: (N, 1)
# Linear interpolation for all colors at once
interpolated = (1 - t) * color_after + t * color_before
return interpolated
def update_prop_edit(self, context, event, emp):
# Use hotkey to toggle
if event.type == 'O' and event.value == 'PRESS':
emp.prop_edit = not emp.prop_edit
if emp.prop_edit and any((self.press, self.rotate, self.scale)):
key, frame = self.current_hover_frame
obj_bonename, frame = next(iter(self.hover_bone_frame[key].items()))
initialize_prop_edit_coord2d(self, context, obj_bonename)
if not emp.prop_edit:
if hasattr(self, 'text') and self.text == "Proportional Editing Mode":
remove_notification(self, context, text = "Proportional Editing Mode")
return False
if update_circle_radius(self, context, event, emp):
if any((self.press, self.rotate, self.scale)):
add_distance_on_mouse_change(self, context, event)
# else:
# self.area.tag_redraw()
return True
return False
def update_smooth(self, context, event, emp):
if self.hover_bone_frame["keyframe"]:
if self.shift and event.type == 'S' and event.value == 'PRESS':
emp.smooth = not emp.smooth
return True
if not emp.smooth:
return False
if update_circle_radius(self, context, event, emp):
# self.update = True
# context.region.tag_redraw()
return True
return False
def update_select_circle(self, context, event, emp):
if self.hover_bone_frame["keyframe"]:
if event.type == 'C' and event.value == 'PRESS':
emp.select_circle = not emp.select_circle
return True
if not emp.select_circle:
if hasattr(self, 'text') and self.text == "Select Brush Mode":
remove_notification(self, context, text = "Select Brush Mode")
return False
if update_circle_radius(self, context, event, emp):
return True
return False
def update_circle_radius(self, context, event, emp):
'''Updating radius when F hotkey is pressed'''
self.middlemouse = event.type
region = self.region
if self.mouse_x != (event.mouse_x - region.x) or self.mouse_y != (event.mouse_y - region.y):
self.region.tag_redraw()
if event.type in {'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}:
if any((self.press, self.rotate, self.scale, self.smooth)):
if event.type == 'WHEELUPMOUSE':
emp.radius -= 2
return True
elif event.type == 'WHEELDOWNMOUSE':
emp.radius += 2
return True
if event.type == 'F' and event.value == 'PRESS' and not self.change_radius:
# Entering change radius mode
self.prev_mouse_x = event.mouse_x
# In case of cancelling
self.init_radius = emp.radius
self.change_radius = True
elif event.type == self.select_mouse and event.value == 'RELEASE' and self.change_radius:
# Confirm the Radius size
self.change_radius = False
del self.prev_mouse_x
del self.init_radius
self.area.tag_redraw()
elif event.type == self.cancel_mouse and event.value == 'RELEASE' and self.change_radius:
# Cancel the Radius size
emp.radius = self.init_radius
self.change_radius = False
del self.prev_mouse_x
del self.init_radius
self.update = True
self.region.tag_redraw()
if not self.change_radius:
return False
if self.prev_mouse_x > event.mouse_x:
emp.radius -= 2
if self.prev_mouse_x < event.mouse_x:
emp.radius += 2
self.prev_mouse_x = event.mouse_x
return True
def draw_circle(self, context):
"""Create a 2D circle batch"""
# Drawing only during keyframe operation
try:
emp = context.scene.emp
if not any({emp.prop_edit, emp.smooth, emp.select_circle}):
return
if self.middlemouse == 'MIDDLEMOUSE':
return
if emp.prop_edit and any((self.press, self.rotate, self.scale)):
circle_x = self.prop_edit_mouse[0]
circle_y =self.prop_edit_mouse[1]
else:
circle_x = self.mouse_x
circle_y = self.mouse_y
pref = context.preferences.addons[__package__].preferences
if any((self.press, self.rotate, self.scale, self.smooth, self.change_radius)):
color = pref.mp_brush_active
elif self.hover_bone_frame.get('keyframe') is not None:
color = pref.mp_brush_hover
else:
color = pref.mp_brush_disabled
segments=16
vertices = []
for i in range(segments+1):
angle = 2.0 * math.pi * i / segments
x = circle_x + emp.radius * math.cos(angle)
y = circle_y + emp.radius * math.sin(angle)
vertices.append((x, y))
self.circle_shader = gpu.shader.from_builtin('UNIFORM_COLOR')
self.circle_batch = batch_for_shader(self.circle_shader, 'LINE_LOOP', {"pos": vertices})
# Enable blending and set line width
gpu.state.blend_set('ALPHA')
gpu.state.line_width_set(2.0)
# Draw circle
self.circle_shader.bind()
self.circle_shader.uniform_float("color", color)
self.circle_batch.draw(self.circle_shader)
except ReferenceError:
remove_draw_circle()
return
def draw_notification_callback(self, context):
'''Write a text notification during modal operators'''
font_id = 0 # need to find out how best to get this.
as_height = Tools.asset_shelf_height(context)
# draw some text
blf.position(font_id, 70, 20 + as_height, 0)
blf.size(font_id, self.size)
blf.color(0, 1, 1, 0, 1)
blf.draw(font_id, self.text)
def remove_notification(self, context, text = None):
''' Check if notification was on and remove it '''
if not hasattr(self, 'draw_notification'):
return
emp = context.scene.emp
# Instead of removing switch to a new mode
if any({emp.prop_edit, emp.smooth, emp.select_circle}) and emp.motion_path:
self.text = get_notification_text(self, emp)
self.size = 20
self.area.tag_redraw()
return
if not hasattr(self, 'text'):
return
if text is None or self.text == text:
bpy.types.SpaceView3D.draw_handler_remove(self.draw_notification, 'WINDOW')
del self.draw_notification, self.text, self.size
self.area.tag_redraw()
def check_notification(self, text, size = 15):
'''Check if notification already exist, if yes then just update text'''
self.text = text
self.size = size
if not hasattr(self, 'draw_notification') or not hasattr(self, 'text'):
self.draw_notification = bpy.types.SpaceView3D.draw_handler_add(draw_notification_callback, (self, bpy.context), 'WINDOW', 'POST_PIXEL')
#self.area.tag_redraw()
def get_notification_text(self,emp):
# print('get notification text')
mapping = {
"Move Keyframes ": self.press,
"Rotate Keyframes ": self.rotate,
"Scale Keyframes ": self.scale,
"Proportional Editing Mode": emp.prop_edit,
"Smooth Brush Mode": emp.smooth,
"Select Brush Mode": emp.select_circle
}
text = next(key for key, value in mapping.items() if value)
return text
def remove_draw_circle():
dns = bpy.app.driver_namespace
if 'emp_draw_circle' in dns:
draw_prop_edit_handler = dns['emp_draw_circle']
bpy.types.SpaceView3D.draw_handler_remove(draw_prop_edit_handler, 'WINDOW')
dns.pop('emp_draw_circle')
def add_remove_draw_circle(self, context):
"""Start the mouse circle drawing"""
emp = context.scene.emp
dns = bpy.app.driver_namespace
should_draw = any({emp.prop_edit, emp.smooth, emp.select_circle})
if self.draw_circle is None and should_draw:
remove_draw_circle()
# Add draw handler for 2D drawing
self.draw_circle = bpy.types.SpaceView3D.draw_handler_add(
draw_circle, (self, context, ), 'WINDOW', 'POST_PIXEL'
)
check_notification(self, text = get_notification_text(self, emp), size = 20)
# Tools.notification_invoke(self, context, text = get_notification_text(emp), size = 20)
dns['emp_draw_circle'] = self.draw_circle
self.area.tag_redraw()
elif self.draw_circle is not None and not should_draw:
remove_draw_circle()
self.draw_circle = None
self.area.tag_redraw()
if not emp.smooth:
self.smooth = False
def draw_cube(coords, size):
# size = (sum(context.view_layer.objects[self.obj_name].dimensions) / 3) * 0.1
selected_coords = (
(coords[0]-size, coords[1]-size, coords[2]-size), (coords[0]+size, coords[1]-size, coords[2]-size),
(coords[0]-size, coords[1]+size, coords[2]-size), (coords[0]+size, coords[1]+size, coords[2]-size),
(coords[0]-size, coords[1]-size, coords[2]+size), (coords[0]+size, coords[1]-size, coords[2]+size),
(coords[0]-size, coords[1]+size, coords[2]+size), (coords[0]+size, coords[1]+size, coords[2]+size))
indices = (
(0, 1), (0, 2), (1, 3), (2, 3),
(4, 5), (4, 6), (5, 7), (6, 7),
(0, 4), (1, 5), (2, 6), (3, 7))
return selected_coords, indices
def draw_sphere(coords, size):
vertices = ((0.0, 0.40137654542922974, 0.0), (-0.10388384014368057, 0.3876999318599701, 0.0), (-0.20068827271461487, 0.34760206937789917, 0.0), (-0.2838158905506134, 0.2838158905506134, 0.0),
(-0.34760230779647827, 0.20068813860416412, 0.0), (-0.3876999318599701, 0.10388384014368057, 0.0), (-0.40137654542922974, 3.030309159157696e-08, 0.0), (-0.3876999318599701, -0.10388379544019699, 0.0),
(-0.34760230779647827, -0.20068813860416412, 0.0), (-0.2838161289691925, -0.2838158905506134, 0.0), (-0.20068827271461487, -0.34760206937789917, 0.0), (-0.10388395935297012, -0.3876999318599701, 0.0),
(-1.5630175198566576e-07, -0.40137654542922974, 0.0), (0.10388367623090744, -0.3876999318599701, 0.0), (0.20068803429603577, -0.34760230779647827, 0.0), (0.2838158905506134, -0.2838161289691925, 0.0),
(0.34760206937789917, -0.20068837702274323, 0.0), (0.387699693441391, -0.10388403385877609, 0.0), (0.40137654542922974, -1.8660485068267008e-07, 0.0), (0.3876999318599701, 0.10388367623090744, 0.0),
(0.34760230779647827, 0.20068803429603577, 0.0), (0.2838161289691925, 0.2838158905506134, 0.0), (0.20068851113319397, 0.34760206937789917, 0.0), (0.10388415306806564, 0.387699693441391, 0.0),
(0.0, 2.990487502074757e-08, 0.40137654542922974), (-0.10388384014368057, 2.990487502074757e-08, 0.3876999318599701), (-0.20068827271461487, 2.990487502074757e-08, 0.34760206937789917),
(-0.2838158905506134, 2.990487502074757e-08, 0.2838158905506134), (-0.34760230779647827, 1.4952437510373784e-08, 0.20068813860416412), (-0.3876999318599701, 7.476218755186892e-09, 0.10388384014368057),
(-0.40137654542922974, 3.564938905328222e-15, 3.030309159157696e-08), (-0.3876999318599701, -7.476218755186892e-09, -0.10388379544019699), (-0.34760230779647827, -1.4952437510373784e-08, -0.20068813860416412),
(-0.2838161289691925, -2.990487502074757e-08, -0.2838158905506134), (-0.20068827271461487, -2.990487502074757e-08, -0.34760206937789917), (-0.10388395935297012, -2.990487502074757e-08, -0.3876999318599701),
(-1.5630175198566576e-07, -2.990487502074757e-08, -0.40137654542922974), (0.10388367623090744, -2.990487502074757e-08, -0.3876999318599701), (0.20068803429603577, -2.990487502074757e-08, -0.34760230779647827),
(0.2838158905506134, -2.990487502074757e-08, -0.2838161289691925), (0.34760206937789917, -1.4952437510373784e-08, -0.20068837702274323), (0.387699693441391, -7.476218755186892e-09, -0.10388403385877609),
(0.40137654542922974, -1.425975562131289e-14, -1.8660485068267008e-07), (0.3876999318599701, 7.476218755186892e-09, 0.10388367623090744), (0.34760230779647827, 1.4952437510373784e-08, 0.20068803429603577),
(0.2838161289691925, 2.990487502074757e-08, 0.2838158905506134), (0.20068851113319397, 2.990487502074757e-08, 0.34760206937789917), (0.10388415306806564, 2.990487502074757e-08, 0.387699693441391),
(-2.990487502074757e-08, 1.782469452664111e-15, 0.40137654542922974), (-3.738109199957762e-08, -0.10388384014368057, 0.3876999318599701), (-4.485731253112135e-08, -0.20068827271461487, 0.34760206937789917),
(-5.980975004149514e-08, -0.2838158905506134, 0.2838158905506134), (-2.990487502074757e-08, -0.34760230779647827, 0.20068813860416412), (-2.990487502074757e-08, -0.3876999318599701, 0.10388384014368057),
(-2.990487502074757e-08, -0.40137654542922974, 3.030309159157696e-08), (-2.990487502074757e-08, -0.3876999318599701, -0.10388379544019699), (0.0, -0.34760230779647827, -0.20068813860416412),
(0.0, -0.2838161289691925, -0.2838158905506134), (1.4952437510373784e-08, -0.20068827271461487, -0.34760206937789917), (2.2428656265560676e-08, -0.10388395935297012, -0.3876999318599701),
(2.990486080989285e-08, -1.5630175198566576e-07, -0.40137654542922974), (3.738109199957762e-08, 0.10388367623090744, -0.3876999318599701), (4.485731253112135e-08, 0.20068803429603577, -0.34760230779647827),
(5.980975004149514e-08, 0.2838158905506134, -0.2838161289691925), (2.990487502074757e-08, 0.34760206937789917, -0.20068837702274323), (2.990487502074757e-08, 0.387699693441391, -0.10388403385877609),
(2.990487502074757e-08, 0.40137654542922974, -1.8660485068267008e-07), (2.990487502074757e-08, 0.3876999318599701, 0.10388367623090744), (0.0, 0.34760230779647827, 0.20068803429603577),
(0.0, 0.2838161289691925, 0.2838158905506134), (-1.4952437510373784e-08, 0.20068851113319397, 0.34760206937789917), (-2.2428656265560676e-08, 0.10388415306806564, 0.387699693441391))
selected_coords = [(coords+Vector(vert)*size*2.5) for vert in vertices]
indices = [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 8], [8, 9], [9, 10], [10, 11], [11, 12], [12, 13], [13, 14], [14, 15], [15, 16], [16, 17], [17, 18], [18, 19], [19, 20], [20, 21], [21, 22], [22, 23], [0, 23], [24, 25], [25, 26], [26, 27], [27, 28], [28, 29], [29, 30], [30, 31], [31, 32], [32, 33], [33, 34], [34, 35], [35, 36], [36, 37], [37, 38], [38, 39], [39, 40], [40, 41], [41, 42], [42, 43], [43, 44], [44, 45], [45, 46], [46, 47], [24, 47], [48, 49], [49, 50], [50, 51], [51, 52], [52, 53], [53, 54], [54, 55], [55, 56], [56, 57], [57, 58], [58, 59], [59, 60], [60, 61], [61, 62], [62, 63], [63, 64], [64, 65], [65, 66], [66, 67], [67, 68], [68, 69], [69, 70], [70, 71], [48, 71]]
return selected_coords, indices
def serialize_dict(data):
"""Convert the dictionary to a JSON string, encoding tuple keys as strings."""
converted_data = {str(key): value for key, value in data.items()}
return json.dumps(converted_data)
def deserialize_dict(serialized):
"""Convert the JSON string back into a dictionary, decoding tuple keys."""
converted_data = json.loads(serialized)
return {eval(key): value for key, value in converted_data.items()}
# @persistent
def mp_value_update(scene, depsgraph):
'''dependency handler to use to avoid modal operator repeating on every mouse move,
updating only on action change'''
global anim_update
global layers_update
# Check if any animation data was updated
for update in depsgraph.updates:
# Check if this update is specifically about animation data
if hasattr(update.id, 'bl_rna'):
# Check for Action updates
if isinstance(update.id, bpy.types.Action):
anim_update = True
if hasattr(update.id, 'animation_data') and update.id.animation_data:
if update.id.animation_data.nla_tracks:
layers_update = True
# def mp_frame_change(self, context):
# '''dependency handler to use to avoid modal operator repeating on every mouse move'''
# global frame_change
# frame_change = True
def mp_redo_update(self, context):
'''dependency handler to use during redo'''
global undo
undo = True
def mp_undo_update(self, context):
'''dependency handler to use during undo'''
global undo
undo = True
def mp_undo_check(self, context):
global undo
if not undo:
return False
undo = False
if len(context.scene.emp.selected_keyframes):
self.bones_selected_keyframes = deserialize_dict(context.scene.emp.selected_keyframes)
else:
self.bones_selected_keyframes = {}
if not self.mp_bone_names:
return False
#get all the items of the motion path using the bone or object names, since it's getting broken when using undo
get_mp_items(self)
get_mp_points(self, context)
update_keyframe_coords(self)
calculate_velocities(self, context)
self.update = True
return True
def remove_draw_handlers(self):
'''Remove motion path drawing and frames drawing'''
dns = bpy.app.driver_namespace
if hasattr(self, '_handle'):
bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW')
del self._handle
if hasattr(self, 'draw_frames'):
bpy.types.SpaceView3D.draw_handler_remove(self.draw_frames, 'WINDOW')
del self.draw_frames
if 'mp_dh' in bpy.app.driver_namespace:
dns.pop('mp_dh')
if 'mp_df' in bpy.app.driver_namespace:
dns.pop('mp_df')
remove_draw_circle()
self.mp_vis = False
# Operator with modal and draw handler
class MotionPathOperator(bpy.types.Operator):
"""Creates an editable motion path, Ctrl click to refresh"""
bl_idname = "object.motion_path_operator"
bl_label = "Motion Path Operator"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if context.scene.emp.motion_path:
return True
return True if context.selected_objects else False
# return len(context.selected_pose_bones)
@property
def range_value(self):
#the range distance around the keyframes for hovering, getting a dynamic value
return bpy.context.preferences.addons[__package__].preferences.keyframes_range
def invoke(self, context, event):
scene = context.scene
emp = scene.emp
emp.refresh = False
if emp.motion_path:
if event.ctrl:
# Refresh the motion path
emp.refresh = True
else:
emp.motion_path = False
return {'CANCELLED'}
if context.selected_pose_bones is None and context.selected_objects is None:
emp.motion_path = False
return {'CANCELLED'}
emp.motion_path = True
global undo, handle_type, interpolation
undo = False
handle_type = None
interpolation = None
bpy.app.handlers.depsgraph_update_post.append(mp_value_update)
# bpy.app.handlers.frame_change_post.append(mp_frame_change)
bpy.app.handlers.undo_pre.append(mp_undo_update)
bpy.app.handlers.redo_pre.append(mp_redo_update)
self.window = context.window
self.show_overlays = True
#Check if we use motion path in pose mode or object mode
self.posemode = True if context.selected_pose_bones else False
#Notify when it's turned off because of overlay or object mode
self.mp_vis = check_mp_vis_off(self, context)
self.area = context.area
self.region = context.region
self.region_data = context.region_data
# Create multiple regions incase of multiple viewports for the frame number drawing
self.regions = []
self.window = context.window
self.press = False #boolean turned on while pressing and moving a keyframe
self.shift = False
self.ctrl = False
self.scale = False
self.rotate = False
self.filter_axis = ''
self.smooth = False
self.select_circle = False
self.change_radius = False
region = context.region
self.mouse_x = event.mouse_x - region.x
self.mouse_y = event.mouse_y - region.y
# Draw only during update
self.update = False
self.channels = emp.channels if self.posemode else 'HEADS'
self.cursor_offset = emp.cursor_offset
self.camera_space = emp.camera_space
# Using this variable to update the draw only once during hover
self.current_hover_frame = (None, None)
self.hover_bone_frame = {'keyframe': None, 'handle_r' : None, 'handle_l' : None} #get the bone and frame of the keyframe that change color while hovering
#check for right or left mouse selection
preferences = context.window_manager.keyconfigs.default.preferences
if preferences:
self.select_mouse = preferences.select_mouse + 'MOUSE'
self.cancel_mouse = 'RIGHTMOUSE' if self.select_mouse == 'LEFTMOUSE' else 'LEFTMOUSE'
else:
self.select_mouse, self.cancel_mouse = 'LEFTMOUSE', 'RIGHTMOUSE'
self.prev_event_value = None # records the previous mouse event to see if it was just a click and release for selecting
self.initial_coord = None #the starting point of the coords when starting to move them with g or mouse press
self.prev_view_matrix = context.region_data.view_matrix.copy() #caluclating 2d coordinates only when view and window matrixchange
self.prev_window_matrix = context.region_data.window_matrix.copy()
# self.timer_tick_counter = 0 #using a timer while motion paths are updated
# self.timer = None
self.items = context.selected_pose_bones if self.posemode else context.selected_objects
#get rid of the items that don't have animation data
self.items = [item for item in self.items if item.id_data.animation_data]
if not self.items:
emp.motion_path = False
return {'CANCELLED'}
self.frame_range_type = emp.frame_range
self.use_preview_range = scene.use_preview_range
# Using this to compare with the properties if they changed
self.frame_start = dict()
self.frame_end = dict()
# extra start property incase we need to calculate the path before the official start
# if frame start is between two keyframes, then it will start from the previous keyframe
self.avz_frame_start = dict()
#All the points of the motion path per bone
self.bones_points = dict()
# Points in camera space
self.camspace_points = dict()
#All the coordinates of the keyframes per bone, it has a sub dictionary of keyframes_coords with the frame number and it's coordinates
self.bones_keyframes_coords = dict()
#a copy of the keyframe coords before they start to move
self.initial_keyframe_coords = None
#Dictionary to use the frames and distance from initial coords per bone/object
self.frames_distance = dict()
#check mark which bones are cyclic
self.bones_cyclic = dict()
#coordinates for all the frames when using the coordinates
self.bones_allframes_coords = dict()
#The 2D coordinates of the keyframes per bone, , it has a sub dictionary of keyframes_coords with the frame number and it's coordinates
self.coords2d_bones_frames = dict()
#The 2D coordinates of the keyframes per bone, , it has a sub dictionary of handles coords with the frame number and it's coordinates
self.coords2d_handles_r = dict()
self.coords2d_handles_l = dict()
self.coords2d_ranges = []
#Dictionary of selected bones and the frame numbers of the selected keyframes
self.bones_selected_keyframes = dict()
self.bones_selected_handles_r = dict()
self.bones_selected_handles_l = dict()
#dictinary of bones, frames and the handles coordinates
self.bones_handles_right = dict()
self.bones_handles_left = dict()
self.mp_handles = emp.handles
#dictinary of bones, keyframe frames and handles frames
self.bones_handles_frames = dict()
#dictinary of bones, keyframe frames and handles types
self.bones_handles_types = dict()
#dictinary of bones, handles frames and the handles coordinates
# self.bones_frames_handles = dict()
self.mp_bones_keys = dict()
self.mp_bone_names = []
# Setting up the dictionaries for the shader containers
self.batch_line = dict()
self.batch_points = dict()
self.batch_keyframes = dict()
self.batch_selected = dict()
self.draw_circle = None
# self.batch_handles_lines = dict()
# self.batch_handles_points = dict()
nfr = context.preferences.edit.use_negative_frames
for item in self.items:
obj_bonename = (item.id_data.name, item.name)
self.mp_bone_names.append(obj_bonename)
obj = item.id_data
if self.posemode:
avz_mp = obj.pose.animation_visualization.motion_path
# Notify that motion path is turned on in case of using temp ctrls
else:
avz_mp = obj.animation_visualization.motion_path
avz_mp.range ='KEYS_ALL' if emp.frame_range == 'AROUND' else emp.frame_range
#Camera space relevant only from version 4.1 - currently disabled
if hasattr(avz_mp, 'bake_in_camera_space'):
if avz_mp.bake_in_camera_space:
avz_mp.bake_in_camera_space = False
#assign the frame range per object
frame_start, frame_end = get_frame_range(scene, obj)
self.frame_start.update({obj_bonename : frame_start})
self.frame_end.update({obj_bonename : frame_end})
update_avz_frame_start(self, context, obj_bonename)
#Negative frame range, needs to be checked in case frame start is minus
turn_nfr_on(context.preferences, self.avz_frame_start[obj_bonename])
avz_mp.frame_start, avz_mp.frame_end = self.avz_frame_start[obj_bonename], frame_end
#store bone names in case they were changed
item['atb_mp_name'] = item.name
item.id_data['atb_mp_name'] = item.id_data.name
get_cyclic_modifiers(self, context, obj, obj_bonename)
self.current_selected_items = context.selected_pose_bones if self.posemode else context.selected_objects
self.frame_current = scene.frame_current_final
matrix_differece(self, context)
self.current_bones_keys = get_current_bones_keys(self, context)
self.obj_layer_properties = get_layer_properties(self.items)
global anim_update, frame_range_update, names_update, layers_update
anim_update = True
layers_update = False
frame_range_update = False
names_update = False
subscribe_to_names(emp)
if emp.frame_range == 'SCENE':
subscribe_to_scene_frame_range(scene)
# update_scene_frame_range()
get_mp_points(self, context)
update_keyframe_coords(self)
if emp.vis_type == 'VELOCITY':
global velocity_update
velocity_update = False
calculate_velocities(self, context)
#return negative frame range to what it was
if context.preferences.edit.use_negative_frames != nfr:
context.preferences.edit.use_negative_frames = nfr
#If overlays are turned off then skip drawing
if self.mp_vis:
self._handle = bpy.types.SpaceView3D.draw_handler_add(draw_motionpath_callback_px, (self, context,), 'WINDOW', 'POST_VIEW')
dns = bpy.app.driver_namespace
dns['mp_dh'] = self._handle
if emp.display_frames:
self.draw_frames = bpy.types.SpaceView3D.draw_handler_add(draw_frames_callback_px, (self, context,), 'WINDOW', 'POST_PIXEL')
dns['mp_df'] = self.draw_frames
coords_2d_update(self, context)
self.update = True
self.area.tag_redraw()
# Tools.redraw_areas(['VIEW_3D'])
#assign the draw handler
context.window_manager.modal_handler_add(self)
bpy.ops.ed.undo_push(message = 'Initialize Motion Path')
return {'RUNNING_MODAL'}
def modal(self, context, event):
# using a timer when creating a motion path so that the hotkey G doesnt respond before the path is created
# if event.type == 'TIMER' and self.timer:
# if self.timer_tick_counter < 3:
# self.timer_tick_counter += 1
# # print('timer counter')
# return {'RUNNING_MODAL'}
# else:
# context.window_manager.event_timer_remove(self.timer)
# self.timer_tick_counter = 0
# self.timer = None
#quit the modal operators
# if event.type in {'ESC'}:
# context.scene.animtoolbox.motion_path = False
emp = context.scene.emp
#Quit the draw handler
if not emp.motion_path:
quit_mp(self, context)
return {'FINISHED'}
if emp.refresh:
get_mp_points(self, context)
emp.refresh = False
return {'PASS_THROUGH'}
if not self.mp_vis:
check_mp_vis_on(self, context)
return {'PASS_THROUGH'}
# if check_removed_items(self, context, emp):
# return {'FINISHED'}
try:
global anim_update
if mp_undo_check(self, context):
return {'PASS_THROUGH'}
if check_bone_names(self, context):
return {'PASS_THROUGH'}
self.info(context, event)
if compare_bone_keys(self,context, event):
return {'PASS_THROUGH'}
compare_obj_layers(self, context, event)
if mp_frame_range_change(self, context):
return {'PASS_THROUGH'}
if not self.find_area_under_mouse(context, event):
return {'PASS_THROUGH'}
if update_prop_edit(self, context, event, emp):
return{'RUNNING_MODAL'}
if update_smooth(self, context, event, emp):
return{'RUNNING_MODAL'}
if update_select_circle(self, context, event, emp):
return{'RUNNING_MODAL'}
region = self.region
self.mouse_x = event.mouse_x - region.x
self.mouse_y = event.mouse_y - region.y
if check_velocity_update(self, context):
return {'PASS_THROUGH'}
#in case the draw handler stopped when the armature went out of edit mode
dns = bpy.app.driver_namespace
if not hasattr(self, '_handle'):
self._handle = bpy.types.SpaceView3D.draw_handler_add(draw_motionpath_callback_px, (self, context,), 'WINDOW', 'POST_VIEW')
dns['mp_dh'] = self._handle
self.update = True
self.area.tag_redraw()
#Add or remove frame numbers depending on the options
if emp.display_frames and not hasattr(self, 'draw_frames'):
self.draw_frames = bpy.types.SpaceView3D.draw_handler_add(draw_frames_callback_px, (self, context,), 'WINDOW', 'POST_PIXEL')
dns['mp_df'] = self.draw_frames
if not emp.display_frames and hasattr(self, 'draw_frames'):
if hasattr(self, 'draw_frames'):
bpy.types.SpaceView3D.draw_handler_remove(self.draw_frames, 'WINDOW')
del self.draw_frames
if 'mp_df' in dns:
dns.pop('mp_df')
if not check_mp_vis_off(self, context):
self.mp_vis = False
remove_draw_handlers(self)
self.area.tag_redraw()
return{'PASS_THROUGH'}
add_remove_draw_circle(self, context)
if event.type in {'LEFT_SHIFT', 'RIGHT_SHIFT'} and event.value == 'PRESS':
self.shift = True
elif event.type in {'LEFT_SHIFT', 'RIGHT_SHIFT'} and event.value == 'RELEASE':
self.shift = False
if zoom_keyframes(self, context, event):
return {'RUNNING_MODAL'}
if circle_select_keyframes(self, context, event):
return {'RUNNING_MODAL'}
if smooth_points(self, context, event):
return {'RUNNING_MODAL'}
if edit_motion_path_update(self, context, event):
return {'RUNNING_MODAL'}
if self.press or self.scale or self.rotate:
return {'RUNNING_MODAL'}
#check if the bone selection was changed
selected_items = context.selected_pose_bones if self.posemode else context.selected_objects
if self.current_selected_items != selected_items:
#need to checek this condition
if selected_items not in self.current_selected_items:
self.current_selected_items = selected_items
self.current_bones_keys = get_current_bones_keys(self, context)
return {'PASS_THROUGH'}
self.current_selected_items = context.selected_pose_bones
return {'PASS_THROUGH'}
anim_update = False
mp_handles_on_off(self, context)
if mp_set_handle_type(self, context, event):
return {'PASS_THROUGH'}
mp_set_interpolation(self, context, event)
check_property_change(self, context)
return {'PASS_THROUGH'}
except Exception as e:
# Log the error
print("Error:", e)
context.scene.emp.motion_path = False
quit_mp(self, context)
self.report({'ERROR'}, str(e) + '. Quitting Motion Paths')
traceback.print_exc()
return {'CANCELLED'}
def info(self, context, event):
def draw_status(drawself, context):
row = drawself.layout.row(align=True)
shift_icon = "EVENT_SHIFT" if bpy.app.version < (4, 3, 0) else "KEY_SHIFT"
if any((self.press, self.rotate, self.scale)):
row.label(text=" or",icon="EVENT_ESC")
row.label(text=" Cancel operation", icon="MOUSE_RMB")
row.label(text="", icon="EVENT_X")
row.label(text="", icon="EVENT_Y")
row.label(text=" Lock an axis", icon="EVENT_Z")
row.separator()
row.label(text=" Proportional Editing", icon="EVENT_O")
elif context.scene.emp.smooth:
row.label(text="Smooth brush ")
row.label(text=" Change Radius", icon="EVENT_F")
row.separator()
row.label(icon = shift_icon)
row.label(text=" Turn Off Smooth Keyframes", icon="EVENT_S")
elif context.scene.emp.select_circle:
row.label(text="Select brush ")
row.label(text=" Select Keyframes", icon="MOUSE_LMB")
row.separator()
row.label(text=" Remove Selection", icon=shift_icon)
row.separator()
row.label(icon="EVENT_G")
row.label(icon="EVENT_R")
row.label(text=" Move/Rotate/Scale ", icon="EVENT_S")
row.separator()
row.label(text=" Change Radius", icon="EVENT_F")
row.separator()
row.label(text=" Turn Off Select brush", icon="EVENT_C")
else:
row.label(text="Hotkeys during keyframe hovering ")
row.label(text=" Move/Select Keyframes", icon="MOUSE_LMB")
row.separator()
row.label(icon="EVENT_G")
row.label(icon="EVENT_R")
row.label(text=" Move/Rotate/Scale ", icon="EVENT_S")
row.separator()
row.label(text=" Add/Remove to selection", icon=shift_icon)
row.separator()
row.label(text=" +", icon="EVENT_CTRL")
row.label(text=" Add/Remove Keyframes", icon="MOUSE_LMB")
row.separator()
row.label(text=" +", icon="EVENT_CTRL")
row.label(text=" Set Interpolation", icon="EVENT_T")
row.separator()
row.label(text=" Set Handles Type", icon="EVENT_V")
row.separator()
row.label(text=" Proportional Editing", icon="EVENT_O")
row.label(icon=shift_icon)
row.label(text=" Smooth Keyframes", icon="EVENT_S")
row.separator()
if bpy.app.version >= (4, 2, 0):
row.label(text=" Zoom Keyframes", icon="EVENT_PERIOD")
context.workspace.status_text_set(draw_status)
def update_context(self, context):
# Update context if it becomes invalid (workspace switch)
if context.area == self.area and context.region == self.region:
return
# Try to find a valid 3D viewport in the current context
for area in context.screen.areas:
if area.type == 'VIEW_3D':
self.area = area
for region in area.regions:
if region.type == 'WINDOW':
self.region = region # Get the main region
self.region_data = region.data
return
def find_area_under_mouse(self, context, event):
"""Find the 3D viewport area and region under the mouse cursor."""
# if event.type != 'MOUSEMOVE':
# return True
wm = context.window_manager
mouse_x, mouse_y = event.mouse_x, event.mouse_y
for window in wm.windows:
if not window.screen:
continue
# Check bounds
if not (0 <= mouse_x < window.x + window.width and 0 <= mouse_y < window.y + window.height):
continue
# Find area under mouse
for area in window.screen.areas:
if area.type != 'VIEW_3D':
continue
if not (area.x <= mouse_x < area.x + area.width and area.y <= mouse_y < area.y + area.height):
continue
# Find the main region
for region in area.regions:
if region.type != 'WINDOW':
continue
# if 0 <= region_x < region.width and 0 <= region_y < region.height:
if region.x <= mouse_x <= (region.x + region.width) or region.y <= mouse_y <= (region.y + region.height):
self.area = area
self.region = region # Get the main region
self.region_data = region.data
return True
return False
def check_frame_change(self, context):
# Checking for frame change, updating here because modal operator is not updated during frame change
if self.frame_current == context.scene.frame_current_final:
return
self.frame_current = context.scene.frame_current_final
if self.camera_space:
keyframe_coords = self.bones_keyframes_coords.copy()
for obj_bonename in self.mp_bone_names:
add_camera_space(self, context, obj_bonename)
update_keyframe_coords(self)
# updating handles offset
for obj_bonename, frames_coords in self.bones_handles_left.items():
for frame, coords in frames_coords.items():
offset = Vector(self.bones_keyframes_coords[obj_bonename][frame] - keyframe_coords[obj_bonename][frame])
coords += offset
self.bones_handles_right[obj_bonename][frame] += offset
coords_2d_update(self, context)
self.update = True
def check_mp_vis_on(self, context):
'''Check if the visiblity turned back on after it was off because of overlays or posemode'''
if context.mode != 'POSE' and self.posemode:
return
if hasattr(context.space_data, 'overlay'):
if not context.space_data.overlay.show_overlays and not self.show_overlays:
return
if not context.space_data.overlay.show_motion_paths and not self.show_overlays:
return
#Turn Motion Path visibilty back on
self.mp_vis = True
def check_mp_vis_off(self, context):
'''Check if the visibility need to turn off'''
#when going out of pose mode remove the draw handler, bone motion path is hidden
if context.mode != 'POSE' and self.posemode:
return False
if hasattr(context.space_data, 'overlay'):
if not context.space_data.overlay.show_overlays and self.show_overlays:
self.show_overlays = context.space_data.overlay.show_overlays
return False
#In case motion path is specifically turned off from overlays
elif not context.space_data.overlay.show_motion_paths and self.show_overlays:
self.show_overlays = context.space_data.overlay.show_motion_paths
return False
elif self.show_overlays != context.space_data.overlay.show_overlays:
self.show_overlays = context.space_data.overlay.show_overlays
return True
def ShowMessageBox(message = "", title = "", icon = 'INFO'):
def draw(self, context):
self.layout.label(text=message)
bpy.context.window_manager.popup_menu(draw, title = title, icon = icon)
def check_property_change(self, context):
'''Check for different property changes'''
properties = ['channels', 'cursor_offset', 'camera_space']
for prop in properties:
prop_val = getattr(context.scene.emp, prop)
if getattr(self, prop) == prop_val:
continue
if not self.posemode and context.scene.emp.channels != self.channels:
context.scene.emp.channels = 'HEADS'
self.area.tag_redraw()
ShowMessageBox(message = "Motion path rotations are only available for Armature bones",
title = "Rotation Channels")
return
setattr(self, prop, prop_val)
# self.cursor_offset = context.scene.emp.cursor_offset
get_mp_points(self, context)
update_keyframe_coords(self)
removed_selected_unknown_keyframes(self)
get_handles(self, context)
calculate_velocities(self, context)
coords_2d_update(self, context)
self.update = True
return
def removed_selected_unknown_keyframes(self):
'''Finding keyframes that are selected and doesn't exist, relevant when changing channels for exampls'''
for obj_bonename, frames in self.bones_selected_keyframes.items():
removed_keyframes = set(frames).difference(set(self.mp_bones_keys[obj_bonename]))
for frame in removed_keyframes:
self.bones_selected_keyframes[obj_bonename].remove(frame)
def quit_mp(self, context):
# context.scene.emp.prop_edit = False
remove_draw_handlers(self)
# for obj, self.mp_bone_names in self.mp_obj_bone_names.items():
for obj_bonename in self.mp_bone_names:
objname, bonename = obj_bonename
if objname not in context.view_layer.objects:
print(objname, 'not found in objects')
continue
obj = context.view_layer.objects[objname]
if 'atb_mp_name' in obj.keys():
del obj['atb_mp_name']
if not self.posemode:
continue
if bonename not in obj.pose.bones:
continue
bone = obj.pose.bones[bonename]
if 'atb_mp_name' in bone.keys():
del bone['atb_mp_name']
if 'mp_cursor_offset' in bone.keys():
del bone['mp_cursor_offset']
#remove dependecy handler
bpy.app.handlers.depsgraph_update_post.remove(mp_value_update)
# bpy.app.handlers.frame_change_post.remove(mp_frame_change)
bpy.app.handlers.redo_pre.remove(mp_redo_update)
bpy.app.handlers.undo_pre.remove(mp_undo_update)
clear_frame_range_owner()
clear_names_owner()
remove_notification(self, context)
# Clear the status bar info before finishing
context.workspace.status_text_set(None)
context.scene.emp.selected_keyframes = '{}'
remove_global_variables()
Tools.redraw_areas(['VIEW_3D'])
def remove_global_variables():
global undo, anim_update, frame_range_update, names_update, layers_update, velocity_update
for var in ["undo", "anim_update", "frame_range_update", "names_update", "layers_update", "velocity_update"]:
if var in globals():
del globals()[var]
def mp_set_interpolation(self, context, event):
global interpolation
if interpolation:
frames = update_mp_handle_types(self, context, interpolation)
#remove handles that are not bezier anymore
if interpolation != 'BEZIER':
for obj_bonename in self.mp_bone_names:
for frame in frames:
handles_frame_remove(self, obj_bonename, frame)
update_mp_points(self, context, frames)
calculate_velocities(self, context)
update_keyframe_coords(self)
#get current bones keys to avoid recalculation of path points
self.current_bones_keys = get_current_bones_keys(self, context)
interpolation = None
self.update = True
if event.type != 'T' or event.value != 'PRESS' or not self.ctrl:
return
self.ctrl = False
for win in context.window_manager.windows:
for area in win.screen.areas:
if area.type != 'VIEW_3D':
continue
if area.width > event.mouse_x > area.x and area.y + area.height > event.mouse_y > area.y:
context.window_manager.popup_menu(draw_interpolation_menu, title = 'Set Interpolation')
return
def mp_set_handle_type(self, context, event):
global handle_type
if handle_type:
frames = update_mp_handle_types(self, context, handle_type)
if not frames:
return False
update_mp_points(self, context, frames)
calculate_velocities(self, context)
update_keyframe_coords(self)
#get current bones keys to avoid recalculation of path points
self.current_bones_keys = get_current_bones_keys(self, context)
handle_type = None
self.update = True
return True
if event.type != 'V' or event.value != 'PRESS':
return False
if not self.bones_selected_keyframes:
return False
for win in context.window_manager.windows:
for area in win.screen.areas:
if area.type != 'VIEW_3D':
continue
if area.width > event.mouse_x > area.x and area.y + area.height > event.mouse_y > area.y:
context.window_manager.popup_menu(draw_handle_type_menu, title = 'Set Keyframe Handle Type')
return True
def draw_handle_type_menu(self, context):
'''Draw the menu for the handle types of the selected keyframes'''
layout = self.layout
layout.prop(context.scene.emp, 'handle_types', expand = True)
def draw_interpolation_menu(self, context):
layout = self.layout
layout.prop(context.scene.emp, 'interpolation', expand = True)
def update_handle_type_prop(self, context):
'''updating handle type when the property is being selected'''
global handle_type
handle_type = self.handle_types
def update_interpolation_prop(self, context):
global interpolation
interpolation = self.interpolation
def turn_nfr_on(preferences, start):
'''Motion path doesnt draw negative frames if it's turned off and we are using MANUAL'''
edit = preferences.edit
nfr = edit.use_negative_frames
#if it's already turned on
if start >= 0 or nfr:
return nfr
edit.use_negative_frames = True
return nfr
def get_frame_range(scene, obj):
'''Get the frame range values depending on the frame range type'''
emp = scene.emp
if emp.frame_range == 'SCENE':
frame_start = scene.frame_start if not scene.use_preview_range else scene.frame_preview_start
frame_end = scene.frame_end if not scene.use_preview_range else scene.frame_preview_end
frame_end = version_frame_end(frame_end)
elif emp.frame_range == 'KEYS_ALL':
frame_start = int(obj.animation_data.action.frame_range[0])
frame_end = int(obj.animation_data.action.frame_range[1])
elif emp.frame_range == 'MANUAL':
frame_start = emp.frame_start
frame_end = emp.frame_end
elif emp.frame_range == 'AROUND':
# scene_start = scene.frame_start if not scene.use_preview_range else scene.frame_preview_start
# scene_end = scene.frame_end if not scene.use_preview_range else scene.frame_preview_end
keys_start = int(obj.animation_data.action.frame_range[0])
keys_end = int(obj.animation_data.action.frame_range[1])
frame_start = keys_start # max([scene_start, keys_start])
frame_end = keys_end # min([scene_end, keys_end])
return frame_start, frame_end
def update_frame_range_type(self, context):
'''Updating depends on the frame range selected'''
scene = context.scene
emp = context.scene.emp
if self.frame_range == 'MANUAL':
update_manual_frame_range(self, context)
return
# If motion path is not turned on then skip it
if not emp.motion_path:
return
clear_frame_range_owner()
global frame_range_update
# When using scene frame range then subscribe to the scene frame range
if self.frame_range == 'SCENE':
subscribe_to_scene_frame_range(scene)
else:
frame_range_update = True
def update_manual_frame_range(self, context):
if self.frame_start > self.frame_end:
self.frame_start = self.frame_end
if self.frame_start > self.frame_end:
self.frame_end = self.frame_start
# In the first time it's activated frame end has value of 0 and it will get the scene frame range
scene = context.scene
if not self.frame_end:
self['frame_start'] = scene.frame_start if not scene.use_preview_range else scene.frame_preview_start
self['frame_end'] = scene.frame_end if not scene.use_preview_range else scene.frame_preview_end
global frame_range_update
frame_range_update = True
def update_scene_frame_range():
global frame_range_update
frame_range_update = True
def clear_frame_range_owner():
global frame_range_owner
if 'frame_range_owner' in globals():
bpy.msgbus.clear_by_owner(frame_range_owner)
del frame_range_owner
def subscribe_to_scene_frame_range(scene):
'''subscribe_to_frame_end and frame preview end'''
global frame_range_owner
frame_range_owner = object()
subscriptions = {"frame_start", "frame_end", "frame_preview_start", "frame_preview_end","use_preview_range" }
for subscribe in subscriptions:
bpy.msgbus.subscribe_rna(
key=scene.path_resolve(subscribe, False),
owner=frame_range_owner,
args=(),
notify=update_scene_frame_range,)
update_scene_frame_range()
def mp_frame_range_change(self, context):
'''Updating the motion path when changing the frame range'''
global frame_range_update
if not frame_range_update:
return False
frame_range_update = False
scene = context.scene
emp = scene.emp
nfr = context.preferences.edit.use_negative_frames
frames = []
#update all the frame ranges and types
for obj_bonename in self.mp_bone_names:
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
avz_mp = obj.pose.animation_visualization.motion_path if self.posemode else obj.animation_visualization.motion_path
frame_start, frame_end = get_frame_range(scene, obj)
#Negative frame range, needs to be checked in case frame start is minus
prev_frame_start = self.avz_frame_start.get(obj_bonename)#[obj_bonename]
prev_frame_end = self.frame_end[obj_bonename]
#apply the property to all the settings
if (frame_start != self.frame_start[obj_bonename] or frame_end != self.frame_end[obj_bonename]):
self.frame_start[obj_bonename] = frame_start # = emp['frame_start']
self.frame_end[obj_bonename] = frame_end # = emp['frame_end']
self.frame_range_type = emp.frame_range #= avz_mp.range
self.use_preview_range = scene.use_preview_range
update_avz_frame_start(self, context, obj_bonename)
turn_nfr_on(context.preferences, self.avz_frame_start[obj_bonename])
avz_mp.frame_start = frame_start
avz_mp.frame_end = frame_end
if obj_bonename not in self.bones_points:
continue
#update the begining of the motion path
if self.avz_frame_start[obj_bonename] != prev_frame_start:
if self.avz_frame_start[obj_bonename] > prev_frame_start:
remove_points = self.avz_frame_start[obj_bonename] - prev_frame_start
self.bones_points[obj_bonename] = self.bones_points[obj_bonename][remove_points:]
update_keyframe_coords(self)
elif self.avz_frame_start[obj_bonename] < prev_frame_start:
extra_points = np.zeros((abs(prev_frame_start - self.avz_frame_start[obj_bonename]), 3))
self.bones_points[obj_bonename] = np.concatenate((extra_points, self.bones_points[obj_bonename]))
frames += list(range(self.avz_frame_start[obj_bonename], (prev_frame_start)))
#update the end of the motion path
if self.frame_end[obj_bonename] != prev_frame_end:
if self.frame_end[obj_bonename] < prev_frame_end:
remove_points = prev_frame_end - self.frame_end[obj_bonename]
self.bones_points[obj_bonename] = self.bones_points[obj_bonename][:-remove_points]
update_keyframe_coords(self)
elif self.frame_end[obj_bonename] > prev_frame_end:
extra_frames = self.frame_end[obj_bonename] - prev_frame_end
extra_points = np.zeros((abs(extra_frames), 3))
self.bones_points[obj_bonename] = np.concatenate((self.bones_points[obj_bonename], extra_points))
frames += (range(prev_frame_end, self.frame_end[obj_bonename]))
if context.preferences.edit.use_negative_frames != nfr:
context.preferences.edit.use_negative_frames = nfr
if frames:
update_mp_points(self, context, frames, fr_update = True)
update_keyframe_coords(self)
calculate_velocities(self, context)
self.update = True
return True
def version_frame_end(frame_end):
'''Frame end for motion paths changed from version 4.1 in one frame'''
if bpy.app.version < (4, 1, 0):
frame_end += 1
return frame_end
def update_mp_bones_keys(self, item, obj_bonename):
'''Updating the list of motion path keyframes in a dictionary'''
transform = get_transform_channel(self.posemode, bpy.context, item)
frames_with_offset = map(lambda frame: round(add_frame_offset(item.id_data, frame)), get_bone_keyframes(item, transform)[::2])
frames_with_offset = sorted(set(frames_with_offset))
#Limiting the frames inside manual range
if self.frame_range_type == 'MANUAL':
frame_start, frame_end = self.frame_start[obj_bonename], self.frame_end[obj_bonename]
frames_with_offset = [frame for frame in frames_with_offset if frame_start <= frame <= frame_end]
self.mp_bones_keys.update({obj_bonename : frames_with_offset})
def update_avz_frame_start(self, context, obj_bonename):
'''an extra start property in case the motion path starts between keyframes and is cyclic
I need to calculate everything coming from the keyframe'''
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
item = obj.pose.bones[bonename] if self.posemode else obj
# if obj_bonename in self.mp_bones_keys:
# frames = sorted(set(self.mp_bones_keys[obj_bonename]))
# else:
update_mp_bones_keys(self, item, obj_bonename)
# update_keyframe_coords(self)
frames = self.mp_bones_keys[obj_bonename]
frame_start = self.frame_start[obj_bonename]
#in case there is no keyframes
if not frames:
self.avz_frame_start.update({obj_bonename: frame_start})
return
if self.frame_start[obj_bonename] in frames:
self.avz_frame_start.update({obj_bonename: frame_start})
return
i = bisect.bisect_left(frames, frame_start)
avz_frame_start = round(frames[i-1]) if i else round(frames[i])
#this should apply only when it's smaller then the actual frame start
if avz_frame_start > frame_start:
avz_frame_start = frame_start
self.avz_frame_start.update({obj_bonename: avz_frame_start})
def frame_range_reset(self, context):
''' Get the original frame range back to Blender's motion path '''
obj_bonenames = {obj_bonename for obj_bonename in self.mp_bones_keys.keys()}
for obj_bone in obj_bonenames:
obj = context.view_layer.objects[obj_bone[0]]
avz_mp = obj.pose.animation_visualization.motion_path if self.posemode else obj.animation_visualization.motion_path
avz_mp.frame_start = self.avz_frame_start[obj_bone]
avz_mp.frame_end = self.frame_end[obj_bone]
def available_for_update(modifiers):
'''check if the values of the modifiers are equal'''
if not len(modifiers):
return True
if len(modifiers) < 3:
return False
props = ['cycles_after', 'cycles_before', 'frame_end', 'frame_start', 'mode_after', 'mode_before','use_restricted_range']
#list of all the dictionaries
mod_dicts = []
for mod in modifiers:
mod_props = dict()
for prop in props:
value = getattr(mod, prop)
mod_props.update({prop : value})
mod_dicts.append(mod_props)
if mod_dicts[0] == mod_dicts[1] == mod_dicts[2]:
return True
else:
return False
def restrict_range(start_cyclic, end_cyclic, restrict_start_point, restrict_end_point):
if start_cyclic < restrict_start_point:
start_cyclic = restrict_start_point
if end_cyclic > restrict_end_point:
end_cyclic = restrict_end_point
if end_cyclic < restrict_start_point or restrict_start_point > restrict_end_point:
start_cyclic = end_cyclic = 0
return start_cyclic, end_cyclic
def repeat_mp_difference(self, obj_bonename, repeat, difference, length, start, end, mod):
for r in range(1, repeat + 1):
#the index of the points where the values are being added
start_repeat = start + length * r
end_repeat = end + length * r
if end_repeat < 0:
continue
if start_repeat < 0:
#the cyclic starts before 0, reseting to 0 and slicing it from the difference
difference = difference[-start_repeat:]
start_repeat = 0
# continue
diff_add = difference
# Ensure the frame range is within the restriced range
if mod.use_restricted_range:
frame_start_repeat = start_repeat + self.avz_frame_start[obj_bonename]
frame_end_repeat = end_repeat + self.avz_frame_start[obj_bonename]
adjusted_start = max(frame_start_repeat, mod.frame_start)
adjusted_end = min(frame_end_repeat, mod.frame_end + 1)
if not mod.frame_start < adjusted_start < mod.frame_end and not mod.frame_start < adjusted_end < mod.frame_end:
continue
# adding the restricted frames to the frame range
start_adjust = round(adjusted_start - frame_start_repeat)
end_adjust = round(frame_end_repeat - adjusted_end)
start_repeat = int(round(start_repeat + start_adjust))
end_repeat = int(round(end_repeat - end_adjust))
#matching the difference array length to the restricted frame range
diff_add = difference[max(0, start_adjust) : max(0, len(diff_add) - end_adjust)]
if len(self.bones_points[obj_bonename][start_repeat : end_repeat]) != len(diff_add):
#in case the cyclic is not complete and the last repeat is missing some frames from the difference array
i = len(self.bones_points[obj_bonename][start_repeat : end_repeat]) - len(diff_add)
diff_add = diff_add[:i]
# Debug logging
# print(f"Repeat #{r}: start={start_repeat}, end={end_repeat}, diff_add={len(diff_add)} end-start {end_repeat - start_repeat}")
self.bones_points[obj_bonename][start_repeat : end_repeat] += diff_add
def get_cyclic_modifiers(self, context, obj, obj_bonename):
objname, bonename = obj_bonename
fcurves = Tools.get_fcurves(obj, obj.animation_data.action)
path, array_len = get_fcu_path(context, self.posemode, obj, bonename)
modifiers = []
#get all the cyclic modifiers
for i in range(array_len):
fcu = fcurves.find(data_path = path, index = i)
if fcu is None:
continue
if not len(fcu.modifiers):
continue
for mod in fcu.modifiers:
if mod.type != 'CYCLES' or mod.mute:
continue
#Influence works in a strange way, recalcuating the whole path in this case
if mod.use_influence and mod.influence < 1:
return
#Blend mode works in a strange way, recalcuating the whole path in this case
if mod.use_restricted_range:
if mod.blend_in or mod.blend_out:
return
#Currently including only none and repeat
if mod.mode_before not in {'NONE', 'REPEAT'} or mod.mode_after not in {'NONE', 'REPEAT'}:
return
modifiers.append(mod)
self.bones_cyclic.update({obj_bonename : modifiers})
# if modifiers:# and available_for_update(modifiers):
# self.bones_cyclic.update({obj_bonename : modifiers})
# elif obj_bonename in self.bones_cyclic:
# del self.bones_cyclic[obj_bonename]
def update_mp_points(self, context, frames, included = None, key = None, coord_3d = None, fr_update = False):
'''change the frame range for the motion path to update only specific points'''
frame_range = []
# self.bones_cyclic = dict()
def get_i_keyframe_and_modifiers():
#get all the keyframes from all the arrays and check what is the last previous and next value
fcurves = Tools.get_fcurves(obj, obj.animation_data.action)
path, array_len = get_fcu_path(context, self.posemode, obj, bonename)
#get all the cyclic modifiers
for i in range(array_len):
fcu = fcurves.find(data_path = path, index = i)
if fcu is None:
continue
if len(fcu.modifiers):
for mod in fcu.modifiers:
if mod.type != 'CYCLES' or mod.mute:
continue
#Influence works in a strange way, recalcuating the whole path in this case
if mod.use_influence and mod.influence < 1:
get_mp_points(self, context)
return True
#Blend mode works in a strange way, recalcuating the whole path in this case
if mod.use_restricted_range:
if mod.blend_in or mod.blend_out:
get_mp_points(self, context)
return True
#Currently including only none and repeat
if mod.mode_before not in {'NONE', 'REPEAT'} or mod.mode_after not in {'NONE', 'REPEAT'}:
get_mp_points(self, context)
return True
modifiers.append(mod)
if not len(fcu.keyframe_points):
continue
keyframes = np.zeros(len(fcu.keyframe_points)*2)
# keyframes = [0]* (len(fcu.keyframe_points)*2)
fcu.keyframe_points.foreach_get('co', keyframes)
#get the frames with the frame offset
keyframes = list(map(lambda frame: add_frame_offset(obj, frame), keyframes[::2]))
i_keyframes.update({i : keyframes})
self.bones_cyclic.update({obj_bonename : modifiers})
# if modifiers:
#
# elif obj_bonename in self.bones_cyclic:
# del self.bones_cyclic[obj_bonename]
return False
def restore_points_before_cyclic():
# Moving points back to the initial coords before adding cyclic
if key != 'keyframe':
return
if obj_bonename not in self.bones_cyclic:
return
frames_negative_dist = dict()
for frame, distance in self.frames_distance[obj_bonename].items():
restore_point = round(frame - self.avz_frame_start[obj_bonename])
# if obj_bonename in self.bones_cyclic:
#restoring previous points before repeating them in cyclic fcurves back to the initial coordinates
self.bones_points[obj_bonename][restore_point] -= distance
# Creates a copy of frame distance going backward
frames_negative_dist.update({frame : distance * -1})
add_distance_to_points(self, obj_bonename, frames_negative_dist)
def get_prev_next_keyframes(frame_range):
# Checking for the next and previous keyframes to get the frame range that needs to be updated
for keyframes in i_keyframes.values():
keyframes = np.round(keyframes)
for frame in frames:
prev_keyframe = get_previous_frame(frame, keyframes)
#get frames that are pre first keyframe
if frame < keyframes[0]:
prev_keyframe = frame
next_keyframe = get_next_frame(frame, keyframes)
frame_range.append(prev_keyframe)
frame_range.append(next_keyframe)
return frame_range
def add_distance_before_after(self, obj_bonename):
'''Add distance before the first frame and after the last keyframe'''
if key in {'handle_r', 'handle_l'}:
return
if obj_bonename not in self.mp_bones_keys:
return
if obj_bonename in self.bones_cyclic:
return
first = self.mp_bones_keys[obj_bonename][0]
last = self.mp_bones_keys[obj_bonename][-1]
first_last = [first, last] if first != last else [first]
#all the keyframes that has distance restored used for inbetween frames
for frame in first_last:
if frame not in frames:
continue
restore_point = round(frame - self.avz_frame_start[obj_bonename])
coord = self.bones_points[obj_bonename][restore_point]
# initial_coords = stored_bones_points[obj_bonename][restore_point : restore_point+3]
if coord is None:
continue
if self.initial_keyframe_coords is None or obj_bonename not in self.initial_keyframe_coords:
continue
distance = coord - self.initial_keyframe_coords[obj_bonename][frame]
if first == last and restore_point-1:
# If there is only one keyframe which is the first and last Blender will still create
# A motion path with the previous frame, so it needs to be restored before adding all the start and end points
prev_initial = stored_bones_points[obj_bonename][restore_point-1]
self.bones_points[obj_bonename][restore_point-1] = prev_initial
#Adding the distance to the rest of the points before and after the first keys
if frame == first:
#adding distance to all frames before the first keyframe
# length = restore_point # // 3
# distance_array = np.tile(np.array(distance), round(length))
self.bones_points[obj_bonename][:restore_point] += distance #distance_array
if frame == last:
#adding distance to all frames after the last keyframe
remaining_points = self.bones_points[obj_bonename][restore_point+1:]
if len(remaining_points) > 0:
# length = len(remaining_points)#//3
# distance_array = np.tile(np.array(distance), round(length))
# remaining_points += distance_array
remaining_points += distance
for obj_bonename, keys in self.mp_bones_keys.items():
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
avz_mp = obj.pose.animation_visualization.motion_path if self.posemode else obj.animation_visualization.motion_path
#Skip cyclic when the update frames are before or after the actual animation (when updating frame range)
if fr_update:
avz_mp.frame_start = min(frames)
avz_mp.frame_end = max(frames) + 1
continue
if included:
if obj_bonename not in included:
continue
modifiers = []
# Storing previous status of modifiers for comparison
prev_modifiers = self.bones_cyclic.get(obj_bonename)
i_keyframes = dict()
if get_i_keyframe_and_modifiers():
return
#if it's only partly cyclic then recalculate the whole motion path
if not available_for_update(modifiers) or modifiers != prev_modifiers:
get_mp_points(self, context)
return
get_prev_next_keyframes(frame_range)
#usually breaks when selecting a layer with no keyframes
if not frame_range or (min(frame_range), max(frame_range)) == (self.avz_frame_start[obj_bonename], self.frame_end[obj_bonename]):
get_mp_points(self, context)
return
# restore_points_before_cyclic()
#Temporarly change the frame range around the modified keyframes
temp_start = min(frame_range)
temp_end = max(frame_range) #adding one frame to include the last frame in the calculation
temp_end = version_frame_end(temp_end)
if temp_start < self.avz_frame_start[obj_bonename]:
temp_start = self.avz_frame_start[obj_bonename]
if temp_end > self.frame_end[obj_bonename]:
temp_end = self.frame_end[obj_bonename]
avz_mp.frame_start = round(temp_start)
avz_mp.frame_end = round(temp_end)
# cyclic_points = copy.deepcopy(self.bones_points)
#creating a copy for cyclic points to be used after the distance array was added to the first and last keys
if hasattr(self, 'initial_bones_points'):
stored_bones_points = self.initial_bones_points
else:
stored_bones_points = self.bones_points.copy()
# Storing also the camera space coordinates separatly
if self.camera_space:
stored_cam_space = self.camspace_points.copy()
get_mp_points(self, context, included) #Recalculating the new points#
#Merge new frames with previous frames
for obj_bonename in self.mp_bones_keys.keys():
if included:
if obj_bonename not in included:
continue
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
avz_mp = obj.pose.animation_visualization.motion_path if self.posemode else obj.animation_visualization.motion_path
#start and end points
# Need to add solution for negative points
if bpy.app.version < (4, 1, 0):
avz_mp.frame_end -= 1
start_p = avz_mp.frame_start - self.avz_frame_start[obj_bonename]
end_p = avz_mp.frame_end - self.avz_frame_start[obj_bonename]
if bpy.app.version >= (4, 1, 0) :
end_p += 1
if end_p > len(stored_bones_points[obj_bonename]):
end_p = len(stored_bones_points[obj_bonename])
if self.bones_points[obj_bonename].shape[0] != stored_bones_points[obj_bonename][start_p : end_p].shape[0]:
#checking if length is not equal and recalculating the whole path if it's not
print(f'length is not equal for {obj_bonename}')
print(f'selected objects {context.selected_objects} bones {context.selected_pose_bones}')
print(f'self.bones_points {self.bones_points[obj_bonename].shape[0]} stored_bones_points {stored_bones_points[obj_bonename][start_p : end_p].shape[0]}')
frame_range_reset(self, context)
get_mp_points(self, context, included)
return
#Get the difference between the old points and the new points to use in cyclic modifier
# difference = self.bones_points[obj_bonename] - stored_bones_points[obj_bonename][start_p : end_p]
if self.camera_space:
stored_cam_start = stored_cam_space[obj_bonename][:start_p]
stored_cam_end = stored_cam_space[obj_bonename][end_p:]
self.camspace_points[obj_bonename] = np.concatenate((stored_cam_start, self.camspace_points[obj_bonename], stored_cam_end))
add_camera_space(self, context, obj_bonename)
else:
restored_start = stored_bones_points[obj_bonename][:start_p]
restored_end = stored_bones_points[obj_bonename][end_p:]
self.bones_points[obj_bonename] = np.concatenate((restored_start, self.bones_points[obj_bonename], restored_end))
difference = self.bones_points[obj_bonename][start_p : end_p] - stored_bones_points[obj_bonename][start_p : end_p]
add_distance_before_after(self, obj_bonename)
#skip if not cyclic
if not obj_bonename in self.bones_cyclic or fr_update:
continue
if not len(self.bones_cyclic[obj_bonename]):
continue
end_key = max(self.mp_bones_keys[obj_bonename])
start_key = min(self.mp_bones_keys[obj_bonename])
length = round(end_key - start_key)
if not length:
continue
mod = self.bones_cyclic[obj_bonename][0]
if start_key < self.avz_frame_start[obj_bonename]:
start_key = self.avz_frame_start[obj_bonename]
repeat_after = math.ceil((self.frame_end[obj_bonename] - end_key)//length)
repeat_before = math.ceil((start_key - self.avz_frame_start[obj_bonename])//length)
# checking if it starts or finish earlier based on the cyclic settings
cycles_before = mod.cycles_before
if cycles_before:
if repeat_before > cycles_before:
repeat_before = cycles_before
elif mod.mode_before == 'NONE':
repeat_before = 0
cycles_after = mod.cycles_after
if cycles_after:
if repeat_after > cycles_after:
repeat_after = cycles_after
elif mod.mode_after == 'NONE':
repeat_after = 0
#getting the point for the restrict frame range
restrict_start_point = round(mod.frame_start - self.avz_frame_start[obj_bonename])
restrict_end_point = round(mod.frame_end - self.avz_frame_start[obj_bonename]) + 1
#Restoring cyclic animation before distance array was added to all the before and after values
start_after_cyclic = round(end_key - self.avz_frame_start[obj_bonename]) + 1
end_after_cyclic = round(start_after_cyclic + length * (repeat_after -1 )) + 1
end_before_cyclic = round(start_key - self.avz_frame_start[obj_bonename])
start_before_cyclic = round(end_before_cyclic - length * (repeat_before))
#Add the difference to cyclic before
if avz_mp.frame_end != self.frame_end[obj_bonename]:
#Apply the restrict range
if mod.use_restricted_range:
start_after_cyclic, end_after_cyclic = restrict_range(start_after_cyclic, end_after_cyclic, restrict_start_point, restrict_end_point)
# cyclic_points
self.bones_points[obj_bonename][start_after_cyclic : end_after_cyclic] = stored_bones_points[obj_bonename][start_after_cyclic : end_after_cyclic]
repeat_mp_difference(self, obj_bonename, repeat_after, difference[1:], length, start_p + 1, end_p, mod)
#Add the difference to cyclic after
if avz_mp.frame_start != self.avz_frame_start[obj_bonename]:
#Apply the restrict range
if mod.use_restricted_range:
start_before_cyclic, end_before_cyclic = restrict_range(start_before_cyclic, end_before_cyclic, restrict_start_point, restrict_end_point)
#Make sure the start index is not 0
start_before_cyclic = max(0, start_before_cyclic)
#Restore the original cyclic points before adding the difference
self.bones_points[obj_bonename][start_before_cyclic : end_before_cyclic] = stored_bones_points[obj_bonename][start_before_cyclic : end_before_cyclic]
repeat_mp_difference(self, obj_bonename, repeat_before, difference[:-1], -length, start_p, end_p -1, mod)
#update the handles
get_handles(self, context)
frame_range_reset(self, context)
def get_mp_points(self, context, included = None):
'''Using Blender's motion path to get the points for the editable motion path'''
# print('get_mp_points')
#add a timer while recalculting the motion path to avoid issues with g hotkey
# self.timer_tick_counter = 0
# self.timer = context.window_manager.event_timer_add(0.1, window=context.window)
active_obj = context.view_layer.objects.active
emp = context.scene.emp
#Check for the current selection and change to the relevant selection if necessery
#check this as a set since the bone order can change when another object is active
if context.selected_pose_bones and self.posemode:
selected_items = {(bone.id_data.name, bone.name) for bone in context.selected_pose_bones}
elif context.selected_objects and not self.posemode:
selected_items = {(obj.id_data.name, obj.name) for obj in context.selected_objects}
else:
selected_items = None
# If the current frame is on subframe then switch to to a rounded frame, otherwise
# Blender will create the motion path on one step from this subframe
if context.scene.frame_subframe:
context.scene.frame_set(round(context.scene.frame_current))
#if the bone selection is changed then update the selection before running blender motion paths
if set(self.mp_bone_names) != selected_items:
if selected_items:
for obj_bonename in selected_items:
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
context.view_layer.objects.active = obj
if self.posemode:
select_bone(obj, bonename, False)
else:
obj.select_set(False)
for obj_bonename in self.mp_bone_names:
objname, bonename = obj_bonename
if included:
if obj_bonename not in included:
continue
obj = context.view_layer.objects[objname]
context.view_layer.objects.active = obj
if self.posemode:
select_bone(obj, bonename, True)
else:
obj.select_set(True)
#Iterating first over the objects and then over the bones
for obj_bonename in self.mp_bone_names:
# Check if the bone is supposed to be included
if included:
if obj_bonename not in included:
continue
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
#blender also creates motion path per active object
context.view_layer.objects.active = obj
#Always using MANUAL because we are sampling every time separate chuncks
range_type = 'MANUAL' if bpy.app.version >= (4, 0, 0) else 'SCENE'
avz_mp = obj.pose.animation_visualization.motion_path if self.posemode else obj.animation_visualization.motion_path
# Works only from blender 4.2
if hasattr(avz_mp, 'use_camera_space_bake'):
if avz_mp.use_camera_space_bake != emp.camera_space :
avz_mp.use_camera_space_bake = emp.camera_space
if self.posemode:
bpy.ops.pose.paths_calculate(range = range_type, bake_location = emp.channels)
else:
bpy.ops.object.paths_calculate(range = range_type)
#Get all the point values from all the bones in the obj from Blender's motion path
for obj_bonename in self.mp_bone_names:
if included:
if obj_bonename not in included:
continue
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
context.view_layer.objects.active = obj
if self.posemode and bonename not in obj.pose.bones:
continue
item = obj.pose.bones[bonename] if self.posemode else obj
if item.motion_path is None:
continue
length = len(item.motion_path.points)*3
points = np.zeros(length)
item.motion_path.points.foreach_get('co', points)
self.bones_points.update({obj_bonename : points.reshape(-1, 3)})
update_camera_space(self, context, obj, obj_bonename)
add_cursor_offset(self, context, obj_bonename, item)
#get the frames from the keyframes sorted
update_mp_bones_keys(self, item, obj_bonename)
if self.posemode:
bpy.ops.pose.paths_clear(only_selected=False)
else:
bpy.ops.object.paths_clear(only_selected=False)
#select back the bones
if set(self.mp_bone_names) != selected_items:
if selected_items:
for obj_bonename in selected_items:
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
if self.posemode:
select_bone(obj, bonename, True)
else:
obj.select_set(True)
for obj_bonename in self.mp_bone_names:
if selected_items and obj_bonename in selected_items:
continue
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
# context.view_layer.objects.active = obj
if self.posemode:
select_bone(obj, bonename, False)
else:
obj.select_set(False)
context.view_layer.objects.active = active_obj
if self.posemode:
bpy.ops.pose.paths_clear(only_selected=False)
else:
bpy.ops.object.paths_clear(only_selected=False)
#If it was originally on subframe then move back to the subframe
if self.frame_current != context.scene.frame_current_final:
context.scene.frame_set(int(self.frame_current), subframe = self.frame_current %1)
def select_bone(obj, bonename, select: bool):
bone = obj.data.bones[bonename] if bpy.app.version < (5, 0, 0) else obj.pose.bones[bonename]
bone.select = select
def check_camera_space(self, context, emp):
'''Switch to camera space once it's turned on'''
if self.camera_space == emp.camera_space:
return False
if not context.scene.camera:
return False
self.camera_space = emp.camera_space
get_mp_points(self, context)
update_keyframe_coords(self)
coords_2d_update(self, context)
self.update = True
return True
def add_camera_space(self, context, obj_bonename):
# Adding the camera matrix to the vectors
cam = context.scene.camera
# Adding the camera matrix to the vectors
M_cam = np.array(cam.matrix_world, dtype=np.float32)
R = np.array(M_cam[:3, :3], dtype=np.float32)
T = np.array(M_cam[:3, 3], dtype=np.float32)
self.bones_points[obj_bonename] = self.camspace_points[obj_bonename] @ R.T + T
def update_camera_space(self, context, obj, obj_bonename):
# Switching to camera space
avz_mp = obj.pose.animation_visualization.motion_path if self.posemode else obj.animation_visualization.motion_path
if not hasattr(avz_mp, 'use_camera_space_bake'):
return
if not avz_mp.use_camera_space_bake:
return
# Storing the original camera space points
self.camspace_points[obj_bonename] = self.bones_points[obj_bonename].copy()
add_camera_space(self, context, obj_bonename)
def matrix_differece(self, context):
'''get the difference between the world matrix and the basis matrix in local space, for converting point distance to keyframes distance'''
self.matrix_diff = dict()
if self.posemode:
for bone in context.selected_pose_bones:
obj = bone.id_data
parent_matrix = bone.parent.matrix if bone.parent else Matrix.Identity(4)
#make sure the matrix is not zero to be able to invert it
matrix_basis_inverted = bone.matrix_basis.inverted() if bone.matrix_basis.determinant() else Matrix.Identity(4)
matrix_diff = obj.matrix_world @ bone.matrix @ matrix_basis_inverted @ parent_matrix.inverted()
self.matrix_diff.update({(bone.id_data.name, bone.name) : matrix_diff})
#to get the new world matrix use bone.parent.matrix @ matrix_diff @ bone.matrix_basis and then get the translation
else:
for obj in context.selected_objects:
parent_matrix = obj.parent.matrix_world if obj.parent else Matrix.Identity(4)
#make sure the matrix is not zero to be able to invert it
matrix_basis_inverted = obj.matrix_basis.inverted() if obj.matrix_basis.determinant() else Matrix.Identity(4)
matrix_diff = obj.matrix_world @ matrix_basis_inverted @ parent_matrix.inverted()
self.matrix_diff.update({(obj.id_data.name, obj.name) : matrix_diff})
def handles_selection_clear(self):
self.bones_selected_handles_l.clear()
self.bones_selected_handles_r.clear()
self.bones_handles_right.clear()
self.bones_handles_left.clear()
def align_handles(handle, other_handle, keyframe_coord, dist_len = None):
'''Keep handles aligned - flip the handle distance from keyframe and use lerp to keep length'''
other_handle_distance = ((other_handle - keyframe_coord) *-1)
handle_distance = (handle - keyframe_coord)
#use lerp to keep the handle length
factor = handle_distance.length /other_handle_distance.length
if dist_len:
factor *= dist_len
handle = keyframe_coord.lerp(keyframe_coord + other_handle_distance, factor)
return handle
def get_distance(key, self, context, bone, obj_bonename, frame):
'''get the distance between the initial coordinates and the current one and convert to local matrix distance'''
# parent_frame = None
initial_coord = self.initial_coord
if key == 'keyframe':
loc_coord = Vector(self.bones_keyframes_coords[obj_bonename][frame])
initial_coord = Vector(self.initial_keyframe_coords[obj_bonename][frame])
handle_frame = None
elif key == 'handle_r':
if frame not in self.bones_handles_right[obj_bonename]:
return
loc_coord = self.bones_handles_right[obj_bonename][frame]
if obj_bonename not in self.bones_selected_handles_r:
return False
if frame not in self.bones_selected_handles_r[obj_bonename]:
return False
handle_frame = self.bones_handles_frames[obj_bonename][frame][1]
elif key == 'handle_l':
if frame not in self.bones_handles_left[obj_bonename]:
return
loc_coord = self.bones_handles_left[obj_bonename][frame]
if obj_bonename not in self.bones_selected_handles_l:
return False
if frame not in self.bones_selected_handles_l[obj_bonename]:
return False
handle_frame = self.bones_handles_frames[obj_bonename][frame][0]
if bone.parent:
if handle_frame:
# handle_frame = add_frame_offset(bone.id_data, handle_frame, add = True)
if handle_frame != context.scene.frame_current_final:
handle_subframe, handle_frame = math.modf(handle_frame)
context.scene.frame_set(int(handle_frame), subframe = handle_subframe)
parent_matrix = bone.parent.matrix if self.posemode else bone.parent.matrix_world
else:
parent_matrix = Matrix.Identity(4)
matrix_diff = self.matrix_diff[obj_bonename]
# Converting the distance loc_coord - self.initial_coord to local space distance
distance = (parent_matrix.inverted() @ matrix_diff.inverted() @ loc_coord) - (parent_matrix.inverted() @ matrix_diff.inverted() @ initial_coord)
return distance
def get_rotation_difference(key, self, context, pb, obj_bonename, frame):
'''Get the difference of the bone rotation vectors and convert to a rotation difference'''
obj = pb.id_data
scene = context.scene
if key == 'keyframe':
loc_coord = Vector(self.bones_keyframes_coords[obj_bonename][frame])
elif key == 'handle_r':
if frame not in self.bones_handles_right[obj_bonename]:
return
loc_coord = self.bones_handles_right[obj_bonename][frame]
if obj_bonename not in self.bones_selected_handles_r:
return False
if frame not in self.bones_selected_handles_r[obj_bonename]:
return False
frame = self.bones_handles_frames[obj_bonename][frame][1]
elif key == 'handle_l':
if frame not in self.bones_handles_left[obj_bonename]:
return
loc_coord = self.bones_handles_left[obj_bonename][frame]
if obj_bonename not in self.bones_selected_handles_l:
return False
if frame not in self.bones_selected_handles_l[obj_bonename]:
return False
frame = self.bones_handles_frames[obj_bonename][frame][0]
if frame != context.scene.frame_current_final:
subframe, new_frame = math.modf(frame)
scene.frame_set(int(new_frame), subframe = subframe)
# --- step 1: target orientation ---
head = obj.matrix_world @ pb.head
# Adding the offset from the cursor offset in case it was turned on
if 'mp_cursor_offset' in pb.keys():
head += Vector(pb['mp_cursor_offset'])
target = (loc_coord - head).normalized()
bone_world = obj.matrix_world @ pb.matrix
y_axis = (bone_world.to_3x3() @ Vector((0,1,0))).normalized()
q = y_axis.rotation_difference(target)
target_world = q.to_matrix().to_4x4() @ bone_world
target_world.translation = head
target_pose = obj.matrix_world.inverted() @ target_world
# --- step 2: delta rotation ---
parent_matrix = pb.parent.matrix if pb.parent else Matrix.Identity(4)
matrix_diff = self.matrix_diff[obj_bonename]
current_rot = parent_matrix.inverted() @ matrix_diff.inverted() @ pb.matrix
target_rot = parent_matrix.inverted() @ matrix_diff.inverted() @ target_pose
if len(pb.rotation_mode) == 3:
current_rot = Vector(current_rot.to_euler(pb.rotation_mode))
target_rot = Vector(target_rot.to_euler(pb.rotation_mode))
else:
current_rot = current_rot.to_quaternion()
target_rot = target_rot.to_quaternion()
# Get the difference between the rotation to the current one
diff_rot = target_rot - current_rot
return diff_rot
def update_mp_handle_types(self, context, handle_value):
'''update the values of the handles types from the motion path'''
if not self.bones_selected_keyframes:
return
update_frames = []
for obj_bonename, frames in self.bones_selected_keyframes.items():
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
fcurves = Tools.get_fcurves(obj, obj.animation_data.action)
path, array_len = get_fcu_path(context, self.posemode, obj, bonename)
for i in range(array_len):
fcu = fcurves.find(data_path = path, index = i)
if fcu is None:
continue
for keyframe_point in fcu.keyframe_points:
frame = round(keyframe_point.co[0])
frame = add_frame_offset(obj, frame)
if frame not in frames:
continue
handles = get_mp_handle_side(self, obj_bonename, frame)
if handle_value in {'LINEAR', 'BEZIER', 'CONSTANT'}:
#in this case handle_type is actually applying interpolation
keyframe_point.interpolation = handle_value
else:
if 'handle_r' in handles:
keyframe_point.handle_right_type = handle_value
if 'handle_l' in handles:
keyframe_point.handle_left_type = handle_value
update_frames.append(frame)
fcu.update()
return update_frames
def get_mp_handle_side(self, obj_bonename, frame):
'''In case of only one side is selected and need one handle type updated'''
handles = set()
if obj_bonename in self.bones_selected_handles_r:
if frame in self.bones_selected_handles_r[obj_bonename]:
handles.add('handle_r')
if obj_bonename in self.bones_selected_handles_l:
if frame in self.bones_selected_handles_l[obj_bonename]:
handles.add('handle_l')
if not handles:
handles = {'handle_r', 'handle_l'}
return handles
def mp_update_keyframes(self, context, obj_bonename, frame_distance, key):
'''updating the selected keyframes or handles after being moved and translated from world to local space'''
# for obj_bonename in self.bones_selected_keyframes.keys():
objname, bonename = obj_bonename
obj = context.view_layer.objects[objname]
fcurves = Tools.get_fcurves(obj, obj.animation_data.action)
path, array_len = get_fcu_path(context, self.posemode, obj, bonename)
for i in range(array_len):
fcu = fcurves.find(data_path = path, index = i)
if fcu is None:
continue
keyframes = set()
for keyframe_point in fcu.keyframe_points:
# frame = round(keyframe_point.co[0], 2)
frame = keyframe_point.co[0]
frame = round(add_frame_offset(obj, frame, add = False))
# if frame not in self.bones_selected_keyframes[obj_bonename]:
if frame not in frame_distance.keys():
continue
keyframes.add(frame)
distance = frame_distance[frame]
#update only the keyframes with handles
if key == 'keyframe':
keyframe_point.co[1] += distance[i]
keyframe_point.handle_right[1] += distance[i]
keyframe_point.handle_left[1] += distance[i]
elif distance: #update only the handles
#when using auto handles, update only from one side
side_auto = get_side_auto(self, obj_bonename, frame, key)
mp_update_keyframe_handles(self, obj_bonename, keyframe_point, frame, distance[i], 'handle_right', side_auto)
mp_update_keyframe_handles(self, obj_bonename, keyframe_point, frame, distance[i], 'handle_left', side_auto)
# add keyframes to channels that are missing a frame
# missing_frames = set(self.bones_selected_keyframes[obj_bonename]).difference(keyframes)
missing_frames = set(frame_distance.keys()).difference(keyframes)
if not missing_frames:
continue
for missing_frame in missing_frames:
value = fcu.evaluate(missing_frame)
if key == 'keyframe':
value += frame_distance[missing_frame][i]
# Reverting to the real frame withou offset before adding the keyframe
missing_frame = add_frame_offset(obj, missing_frame, add = True)
keyframe_point = fcu.keyframe_points.insert(missing_frame, value)
fcurves.update()
def mp_update_keyframe_handles(self, obj_bonename, keyframe_point, frame, distance, handle, side_auto):
selection = self.bones_selected_handles_r if handle == 'handle_right' else self.bones_selected_handles_l
#getting the right side handles
if obj_bonename not in selection:
return
if side_auto not in {handle[:8], None}:
return
if frame not in selection[obj_bonename]:
return
keyframe_handle = getattr(keyframe_point, handle)
prev_handle = keyframe_handle.copy()
obj = bpy.context.view_layer.objects[obj_bonename[0]]
#getting the avg frame between all the handles
avg_frame = self.bones_handles_frames[obj_bonename][frame][1] if handle == 'handle_right' else self.bones_handles_frames[obj_bonename][frame][0]
avg_frame = add_frame_offset(obj, avg_frame, add = True) #reversing frame offset
factor = abs((avg_frame - keyframe_point.co[0]) / (keyframe_handle[0] - keyframe_point.co[0]))
#getting the value at avg
handle_avg_value = keyframe_point.co.lerp(keyframe_handle, factor)
handle_avg_value[1] += distance
#move back to the previous handle position
keyframe_handle = keyframe_point.co.lerp(handle_avg_value, 1/factor)
if self.bones_handles_types[obj_bonename][frame] == 'VECTOR':
keyframe_point.handle_left_type = 'FREE'
keyframe_point.handle_right_type = 'FREE'
self.bones_handles_types[obj_bonename][frame] = 'FREE'
setattr(keyframe_point, handle, keyframe_handle)
if self.bones_handles_types[obj_bonename][frame] == 'AUTO':
dist_len = abs((keyframe_handle - keyframe_point.co).length / (prev_handle - keyframe_point.co).length)
other_handle = 'handle_left' if handle == 'handle_right' else 'handle_right'
other_keyframe_handle = getattr(keyframe_point, other_handle)
other_new_value = align_handles(other_keyframe_handle, keyframe_handle, keyframe_point.co, dist_len)
setattr(keyframe_point, other_handle, other_new_value)
keyframe_point.handle_left_type = 'ALIGNED'
keyframe_point.handle_right_type = 'ALIGNED'
def get_side_auto(self, obj_bonename, frame, key):
'''In case of using automatic handles, and both sides are selected and being moved'''
if obj_bonename not in self.bones_handles_types:
return None
if frame not in self.bones_handles_types[obj_bonename]:
return None
if self.bones_handles_types[obj_bonename][frame] != 'AUTO':
return None
if obj_bonename in self.bones_selected_handles_r and obj_bonename in self.bones_selected_handles_l:
if frame in self.bones_selected_handles_r[obj_bonename] and frame in self.bones_selected_handles_l[obj_bonename]:
return key
return None
def handles_frame_remove(self, obj_bonename, frame):
handles = [self.bones_selected_handles_l, self.bones_selected_handles_r,
self.bones_handles_right, self.bones_handles_left]
for handle in handles:
if obj_bonename not in handle:
continue
#bones_selected_handles r using lists and bones_handles are using dict
if isinstance(handle[obj_bonename], list):
if frame in handle[obj_bonename]:
handle[obj_bonename].remove(frame)
elif isinstance(handle[obj_bonename], dict):
del handle[obj_bonename][frame]
if not len(handle[obj_bonename]):
del handle[obj_bonename]
def get_avg_handles(handle_side, keys, handles):
'''Get the average frame and values of all the handles'''
#get the average of the frames from all the handles on all the location keyframes
handles_frame_avg = sum(handle[0] for handle in handles)/len(handles)
new_handles = []
for keyframe in keys:
if keyframe is None:
new_handles.append(None)
continue
handle = getattr(keyframe, handle_side)
try:
factor = (handles_frame_avg - keyframe.co[0]) / (handle[0] - keyframe.co[0])
except ZeroDivisionError:
factor = 1
# multiply_factor = round(keyframe.co[0])/keyframe.co[0]
new_value = keyframe.co.lerp(handle, factor)
new_handles.append(new_value[1])
return handles_frame_avg, new_handles
def get_handle_type(self, obj_bonename, frame, keys):
handles = []
for key in keys:
if key is None:
continue
handles.append(key.handle_left_type)
handles.append(key.handle_right_type)
handle_type = 'FREE' if any([handle in {'FREE', 'VECTOR'}for handle in handles]) else 'AUTO'
if obj_bonename in self.bones_handles_types:
self.bones_handles_types[obj_bonename].update({frame: handle_type})
else:
self.bones_handles_types.update({obj_bonename : {frame : handle_type}})
def find_fcu_group(obj, path):
'''find the fcurve group '''
channelbag = Tools.get_channelbag(obj, obj.animation_data.action)
for group in channelbag.groups:
for channel in group.channels:
if path == channel.data_path:
return group
return None
def get_frame_keys(obj, fcurves, path, array_len, frames):
'''Creating a dict with the keyframe points from all the location channels to be used for the handles'''
frame_keys = dict()
#iterate over the location arrays
key_fcurves = []
for i in range(array_len):
fcu = fcurves.find(data_path = path, index = i)
if fcu is None:
continue
for keyframe_point in fcu.keyframe_points:
frame = keyframe_point.co[0]
# Subframes will be disabled because they don't exist inside the rounded keyframes
frame = round(add_frame_offset(obj, frame),2)
if frame not in frames:
continue
if keyframe_point.interpolation != 'BEZIER':
continue
if frame not in frame_keys:
#create an empty handle, in case one of the handles doesn't exist
frame_keys.update({frame : [None] * array_len})
frame_keys[frame][i] = keyframe_point
key_fcurves.append(fcu)
return frame_keys, key_fcurves
def add_layer_to_handles(obj, path, frame, handle_right_values, handle_left_values):
if not obj.animation_data.use_nla:
return handle_right_values, handle_left_values
if not obj.animation_data.use_tweak_mode and obj.animation_data.action:
if obj.animation_data.action_influence == 1 and obj.animation_data.action_blend_type == 'REPLACE':
return handle_right_values, handle_left_values
blend_types = {'REPLACE' : '+', 'ADD' : '+', 'COMBINE' : '+', 'SUBTRACT' : '-', 'MULTIPLY' : '*'}
value_add = Vector((0, 0, 0))
handle_right_diff = Vector((0, 0, 0))
handle_left_diff = Vector((0, 0, 0))
for track in obj.animation_data.nla_tracks:
if track.mute:
continue
for strip in track.strips:
if strip.mute:
continue
fcurves = Tools.get_fcurves(obj, strip.action)
for i in range(3):
fcu = fcurves.find(data_path = path, index = i)
if fcu is None:
continue
#get the difference value between the keyframe and the handle, and add the overall values to it
if strip.action == obj.animation_data.action:
handle_right_diff[i] = handle_right_values[i] - fcu.evaluate(frame)
handle_left_diff[i] = handle_left_values[i] - fcu.evaluate(frame)
influence = strip.influence if strip.use_animated_influence else 1
value_add[i] = handles_add_to_value(value_add[i], fcu, frame, influence, strip.blend_type, blend_types)
#In case not in tweak mode and there is an action on top of all the tracks
if not obj.animation_data.use_tweak_mode and obj.animation_data.action:
fcurves = Tools.get_fcurves(obj, obj.animation_data.action)
for i in range(3):
fcu = fcurves.find(data_path = path, index = i)
if fcu is None:
continue
#get the difference value between the keyframe and the handle, and add the overall values to it
handle_right_diff[i] = handle_right_values[i] - fcu.evaluate(frame)
handle_left_diff[i] = handle_left_values[i] - fcu.evaluate(frame)
value_add[i] = handles_add_to_value(value_add[i], fcu, frame, obj.animation_data.action_influence, obj.animation_data.action_blend_type, blend_types)
handle_right_values = handle_right_diff + value_add
handle_left_values = handle_left_diff + value_add
return handle_right_values, handle_left_values
def handles_add_to_value(value_add, fcu, frame, influence, blend_type, blend_types):
if blend_type =='REPLACE':
value_add = value_add * (1 - influence) + fcu.evaluate(frame) * influence
else:
value_add = eval('value_add' + blend_types[blend_type] +' fcu.evaluate(frame)' + '*' + str(influence))
return value_add
def get_handles(self, context):
'''Getting the coordinates and frames of the handles'''
if not self.bones_selected_keyframes:
return
if not bpy.context.scene.emp.handles:
return
scene = bpy.context.scene
frame_current = scene.frame_current_final
for obj_bonename, frames in self.bones_selected_keyframes.items():
obj = bpy.context.view_layer.objects[obj_bonename[0]]
bone = obj.pose.bones[obj_bonename[1]] if self.posemode else obj
path, array_len = get_fcu_path(context, self.posemode, obj, obj_bonename[1])
fcurves = Tools.get_fcurves(obj, obj.animation_data.action)
#get all the keyframes and handles in a dictionary with rounded frames as the keys
# frame_keys = get_frame_keys(obj, fcurves, path, array_len, frames)
frame_keys, key_fcurves = get_frame_keys(obj, fcurves, path, array_len, frames)
#Get the average frame and then the value of the handles in this avg point
for frame, keys in frame_keys.items():
# frame = add_frame_offset(obj, frame)
handles_right = [keyframe.handle_right for keyframe in keys if keyframe is not None]
handles_left = [keyframe.handle_left for keyframe in keys if keyframe is not None]
handles_right_frame, handle_right_values = get_avg_handles('handle_right', keys, handles_right)
handles_left_frame, handle_left_values = get_avg_handles('handle_left', keys, handles_left)
handles_right_frame = add_frame_offset(obj, handles_right_frame, add = False)
handles_left_frame = add_frame_offset(obj, handles_left_frame, add = False)
if self.channels == 'HEADS':
if None in keys:
#if a keyframe is missing then get the array from the matrix basis
handle_right_values = [bone.matrix_basis.translation[i] if key is None else handle_right_values[i] for i, key in enumerate(keys)]
handle_left_values = [bone.matrix_basis.translation[i] if key is None else handle_left_values[i] for i, key in enumerate(keys)]
handle_right_values, handle_left_values = add_layer_to_handles(obj, path, frame, handle_right_values, handle_left_values)
get_handle_type(self, obj_bonename, frame, keys)
if self.channels == 'HEADS':
scene.frame_set(int(handles_right_frame), subframe = handles_right_frame % 1)
bone_right_matrix = bone.matrix.copy() if self.posemode else bone.matrix_world.copy()
bone_right_matrix_basis = bone.matrix_basis.copy()
if self.camera_space:
cam = context.scene.camera
bone_right_matrix = bone_right_matrix @ cam.matrix_world.inverted()
bone_right_matrix_basis = bone_right_matrix_basis @ cam.matrix_world.inverted()
handle_right_coords = bone_right_matrix @ bone_right_matrix_basis.inverted() @ Vector(handle_right_values)
else:
# In case of rotation motion path, get only the coordinates of the tail
# Can eventually do this without using frame_set, just recalculating the subframe
handle_r_i = int(handles_right_frame) - self.avz_frame_start[obj_bonename]
right_coord = Vector(self.bones_points[obj_bonename][handle_r_i])
next_coord = Vector(self.bones_points[obj_bonename][handle_r_i+1])
handle_right_coords = right_coord.lerp(next_coord, handles_right_frame % 1)
# handle_right_coords = bone.tail.copy()
if self.channels == 'HEADS':
#Getting the coordinates for the Left handles
scene.frame_set(int(handles_left_frame), subframe = handles_left_frame % 1)
bone_left_matrix = bone.matrix.copy() if self.posemode else bone.matrix_world.copy()
bone_left_matrix_basis = bone.matrix_basis.copy()
handle_left_coords = bone_left_matrix @ bone_left_matrix_basis.inverted() @ Vector(handle_left_values)
else:
# In case of rotation motion path, get only the coordinates of the tail
handle_l_i = int(handles_left_frame) - self.avz_frame_start[obj_bonename]
left_coord = Vector(self.bones_points[obj_bonename][handle_l_i])
prev_coord = Vector(self.bones_points[obj_bonename][handle_l_i+1])
handle_left_coords = left_coord.lerp(prev_coord, handles_left_frame % 1)
# handle_left_coords = bone.tail.copy()
if self.posemode:
handle_right_coords = obj.matrix_world @ handle_right_coords
handle_left_coords = obj.matrix_world @ handle_left_coords
# Add Camera or Cursor offsets
if self.channels == 'HEADS':
if self.camera_space:
i = round(frame - self.avz_frame_start[obj_bonename])
scene.frame_set(int(frame), subframe = frame % 1)
# Getting the offset from the actual world space position to remove from the handles
cam_space_offset = bone.matrix.translation - Vector(self.bones_points[obj_bonename][i])
handle_right_coords -= cam_space_offset
handle_left_coords -= cam_space_offset
elif self.cursor_offset:
coords_cursor_offset(bone, handle_right_coords)
coords_cursor_offset(bone, handle_left_coords)
#update the coordinates of the handles
if obj_bonename in self.bones_handles_left:
self.bones_handles_right[obj_bonename].update({frame : handle_right_coords})
self.bones_handles_left[obj_bonename].update({frame : handle_left_coords})
self.bones_handles_frames[obj_bonename].update({frame : (handles_left_frame, handles_right_frame)})
#get the relation between the keyframe frames and the handle frames
else:
self.bones_handles_right.update({obj_bonename : {frame : handle_right_coords}})
self.bones_handles_left.update({obj_bonename : {frame : handle_left_coords}})
self.bones_handles_frames.update({obj_bonename : {frame : (handles_left_frame, handles_right_frame)}})
if scene.frame_current_final != frame_current:
scene.frame_set(int(frame_current), subframe = frame_current%1)
coords_2d_update(self, bpy.context)
return
def mp_handles_on_off(self, context):
'''Turn on or off motion path handles'''
if self.mp_handles == context.scene.emp.handles:
return
if context.scene.emp.handles:
get_handles(self, context)
else:
#reset all the handle values when turning off
self.bones_handles_right = dict()
self.bones_handles_left = dict()
self.bones_selected_handles_r = dict()
self.bones_selected_handles_l = dict()
self.bones_handles_frames = dict()
self.coords2d_handles_r = dict()
self.coords2d_handles_l = dict()
self.update = True
Tools.redraw_areas(['VIEW_3D'])
self.mp_handles = context.scene.emp.handles
def compare_bone_keys(self,context, event):
'''check if the keyframe values were changed'''
global anim_update
if not anim_update:
return False
current_bones_keys = get_current_bones_keys(self, context)
if self.current_bones_keys == current_bones_keys:
return False
if not current_bones_keys:
return False
if not self.current_bones_keys:
return False
frames = []
skip = True
for obj_bonename, values in self.current_bones_keys.items():
if obj_bonename not in current_bones_keys:
# object might have not been selected, then just continue and eventually skip
continue
if self.current_bones_keys[obj_bonename] == current_bones_keys[obj_bonename]:
continue
skip = False
if not len(current_bones_keys[obj_bonename]) and len(self.current_bones_keys[obj_bonename]):
continue
if len(current_bones_keys[obj_bonename]) and not len(self.current_bones_keys[obj_bonename]):
continue
#creating a new 2D array, with frames and then values
old_array = np.stack((values[::2], values[1::2]))
new_array = np.stack((current_bones_keys[obj_bonename][::2], current_bones_keys[obj_bonename][1::2]))
# Detect first and last frames if they moved on the timeline update the whole path
old_first_frame, old_last_frame = round(old_array[0][0]), round(old_array[0][-1])
new_first_frame, new_last_frame = round(new_array[0][0]), round(new_array[0][-1])
if round(old_first_frame) != round(new_first_frame) or round(old_last_frame) != round(new_last_frame):
frames = []
global frame_range_update
frame_range_update = True
# mp_frame_range_change(self, context)
continue
if old_array.shape[1] != new_array.shape[1]:
#checking if frames were added or removed
old_array_indices = np.where(np.isin(old_array[0], new_array[0], invert=True))[0]
new_array_indices = np.where(np.isin(new_array[0], old_array[0], invert=True))[0]
#checking if frames were added or removed
old_array_indices = np.where(np.isin(old_array[0], new_array[0], invert=True))[0]
new_array_indices = np.where(np.isin(new_array[0], old_array[0], invert=True))[0]
#Getting all the new frames
frames = [old_array[0][i] for i in old_array_indices]
frames += [new_array[0][i] for i in new_array_indices]
else:
#Checking if any values or frames were changed
#finding the difference between the arrays
diff_values = np.round(old_array[1], decimals = 3) - np.round(new_array[1], decimals = 3)
diff_frames = np.round(old_array[0], decimals = 2) - np.round(new_array[0], decimals = 2)
#checking if there are values or frames that are not 0 after substraction
i_values = np.nonzero(diff_values)
i_frames = np.nonzero(diff_frames)
#flatten the array
frames = np.union1d(new_array[0][i_frames[0]], new_array[0][i_values[0]])
obj = context.view_layer.objects[obj_bonename[0]]
frames = list(map(lambda frame: add_frame_offset(obj, frame), frames))
if skip:
return False
self.initial_keyframe_coords = copy.deepcopy(self.bones_keyframes_coords)
if len(frames):
update_mp_points(self, context, frames, None, None)
else:
get_mp_points(self, context)
calculate_velocities(self, context)
update_keyframe_coords(self)
coords_2d_update(self, context)
self.current_bones_keys = current_bones_keys
# Update the drawing
self.update = True
#update selected keyframes in case they were removed
for obj_bonename, selected_frames in self.bones_selected_keyframes.items():
key_frames = set(self.mp_bones_keys[obj_bonename])
if not set(selected_frames).issubset(key_frames):
for frame in selected_frames:
if frame not in key_frames:
self.bones_selected_keyframes[obj_bonename].remove(frame)
handles_frame_remove(self, obj_bonename, frame)
#store selected keyframes in a property because of undo/redo
context.scene.emp.selected_keyframes = serialize_dict(self.bones_selected_keyframes)
return True
def compare_obj_layers(self, context, event):
'''redraw motion path if layer properties changed'''
def refresh_motion_path(old_bones_keys):
get_mp_points(self, context)
update_frame_offsets(self, context, old_bones_keys)
update_keyframe_coords(self)
get_handles(self, context)
calculate_velocities(self, context)
# Get the coords of the keyframes
coords_2d_update(self, context)
self.update = True
global layers_update
if not layers_update:
return
layers_update = False
obj = context.object
if not obj.animation_data:
return
if not obj.animation_data.use_nla:
if len(self.obj_layer_properties):
refresh_motion_path(self.mp_bones_keys)
self.obj_layer_properties = []
return
if not len(obj.animation_data.nla_tracks):
return
if event.value not in {'NOTHING', 'RELEASE'} and event.type not in {'INBETWEEN_MOUSEMOVE', 'MOUSEMOVE', self.select_mouse}:
return
if self.obj_layer_properties:
#Action is the first item, checking if it was changed on layer change
if context.object.animation_data.action != self.obj_layer_properties[0]:
self.bones_selected_keyframes = dict()
self.bones_selected_handles_r = dict()
self.bones_selected_handles_l = dict()
# anim_update = True
obj_layer_properties = get_layer_properties(self.items)
if self.obj_layer_properties == obj_layer_properties:
return
old_bones_keys = self.mp_bones_keys.copy()
refresh_motion_path(old_bones_keys)
self.obj_layer_properties = obj_layer_properties
def update_frame_offsets(self, context, old_bones_keys):
'''update all the frame offsets for selected keyframes'''
for obj_bonename, frames in self.bones_selected_keyframes.items():
obj = context.view_layer.objects[obj_bonename[0]]
#create a dictionary with the old as keys and new frames as values
old_new_frames = dict(zip(old_bones_keys[obj_bonename], self.mp_bones_keys[obj_bonename]))
self.bones_selected_keyframes[obj_bonename] = [old_new_frames.get(frame) for frame in frames]
keyframe_coords = self.bones_keyframes_coords[obj_bonename]
self.bones_keyframes_coords[obj_bonename] = {old_new_frames.get(frame) : coords for frame, coords in keyframe_coords.items()}
if obj_bonename in self.bones_selected_handles_l:
handles_l = self.bones_selected_handles_l[obj_bonename]
self.bones_selected_handles_l[obj_bonename] = [old_new_frames.get(frame) for frame in handles_l]
if obj_bonename in self.bones_selected_handles_r:
handles_r = self.bones_selected_handles_r[obj_bonename]
self.bones_selected_handles_r[obj_bonename] = [old_new_frames.get(frame) for frame in handles_r]
def get_layer_properties(items):
'''get all the layer properties to check if something has changed'''
for item in items:
obj = item.id_data
layer_properties = []
if not obj.animation_data.use_nla:
return layer_properties
if not obj.animation_data.use_tweak_mode and obj.animation_data.action:
if obj.animation_data.action_influence == 1 and obj.animation_data.action_blend_type == 'REPLACE':
return layer_properties
if not len(obj.animation_data.nla_tracks):
return layer_properties
layer_properties.append(obj.animation_data.action)
track_props = {'is_solo', 'mute'}
for track in obj.animation_data.nla_tracks:
layer_properties.append(track)
for prop in track_props:
layer_properties.append(prop)
if track.mute:
continue
for strip in track.strips:
layer_properties.append(strip)
for prop in dir(strip):
value = getattr(strip, prop)
if not isinstance(value, int) and not isinstance(value, float) and not isinstance(value, bool):
continue
if strip.is_property_readonly(prop):
continue
layer_properties.append(value)
#get custom frame range property from anim layers addon
if hasattr(obj, 'Anim_Layers') and hasattr(obj, 'als') and len(obj.Anim_Layers):
layer = obj.Anim_Layers[obj.als.layer_index]
if hasattr(layer, 'frame_range'):
layer_properties.append(layer.frame_range)
elif hasattr(layer, 'custom_frame_range'):
layer_properties.append(layer.custom_frame_range)
return layer_properties
def update_keyframe_coords(self):
'''Updating the keyframes coords, this comes always after updating the mp points'''
self.bones_keyframes_coords = dict()
for obj_bonename, points in self.bones_points.items():
keyframes = self.mp_bones_keys[obj_bonename]
start = self.avz_frame_start[obj_bonename]
keyframes_coords = dict()
# Generate frame numbers as a NumPy array
frames = np.arange(start, start + len(points))
# Boolean mask for frames that exist in keyframes
mask = np.isin(frames, keyframes)
# Filter frames and corresponding coords
valid_frames = frames[mask]
# valid_coords = points[mask]
# Vectorized dictionary creation using zip, converting to int values to be used later with Json
# keyframes_coords = {int(f): np.copy(coords) for f, coords in zip(valid_frames, valid_coords)}
keyframes_coords = {int(f): points[i] for f, i in zip(valid_frames, valid_frames - start)}
self.bones_keyframes_coords[obj_bonename] = keyframes_coords
def check_removed_items(self, context, emp):
''' Checking if the item was removed'''
if not hasattr(self, 'items'):
return False
for item in self.items:
obj = item.id_data
if obj not in context.view_layer.objects.values():
emp.motion_path = False
quit_mp(self, context)
return True
return False
def get_mp_items(self):
'''Get the objects or bones that are included in the motion path
since it's getting broken when using undo'''
if self.posemode:
self.items = [bpy.data.objects[objname].pose.bones[bonename] for objname, bonename in self.mp_bone_names]
else:
self.items = [bpy.data.objects[objname] for objname, bonename in self.mp_bone_names]
def get_current_bones_keys(self, context):
'''create the dictionary from the current selected bones keyframes values to compare and check if they changed.
the keyframes include all channels and bezier handle values'''
#if we are currently moving the selection with motion path, then we just need to check the location keyframes
current_selected_items = bpy.context.selected_pose_bones if self.posemode else bpy.context.selected_objects
if not current_selected_items:
return None
current_bones_keys = dict()
key_props = ['co', 'handle_left','handle_right']# 'handle_left_type', 'handle_right_type' , 'interpolation'
for item in current_selected_items:
if item in self.items and context.scene.emp.channels == 'HEADS':
# Get only the relevant transform for location motion path item
transforms = [get_transform_channel(self.posemode, context, item)]
else:
# Replace the rotation path with the actual rotation of the selection
rotation = 'rotation_euler' if len(item.rotation_mode) == 3 else 'rotation_' + item.rotation_mode.lower()
transforms = ['location', rotation, 'scale']
#create the dictionary of the bones and their keyframes
obj_bonename = (item.id_data.name, item.name)
current_bones_keys.update({obj_bonename : []})
#add all the keyframes and interpolations
for transform in transforms:
for prop in key_props:
current_bones_keys[obj_bonename] = list(get_bone_keyframes(item, transform, prop, current_bones_keys[obj_bonename]))
return current_bones_keys
def get_bone_keyframes(bone, transform = 'location', property = 'co', bones_keys = []):
'''get the keyframes of the selected bones or objects'''
def get_property(length):
# For single keyframes skip the handles
if 'handle' in property and length == 2:
return []
if property != 'interpolation':
keyframes = np.zeros(length)
# keyframes = [None]*length
fcu.keyframe_points.foreach_get(property, keyframes)
# Removing the first handle from the left
if property == 'handle_left':
keyframes = keyframes[2:]
# Removing the last handle from the right
if property == 'handle_right':
keyframes = keyframes[:-2]
else:
keyframes = []
for keyframe in fcu.keyframe_points:
keyframes.append(keyframe.interpolation)
keyframes.append(keyframe.handle_left_type)
keyframes.append(keyframe.handle_right_type)
return keyframes
if transform == 'rotation_quaternion' or transform == 'rotation_axis_angle':
array_len = 4
else:
array_len = 3
#get the keyframes
bone_path = bone.path_from_id() + '.' if type(bone) == bpy.types.PoseBone else ''
obj = bone.id_data
if obj.animation_data is None:
return []
if obj.animation_data.action is None:
return []
fcurves = Tools.get_fcurves(obj, obj.animation_data.action)
for i in range(array_len):
fcu = fcurves.find(data_path = bone_path + transform, index = i)
if fcu is None:
continue
if fcu.mute:
continue
length = len(fcu.keyframe_points) * 2
keyframes = get_property(length)
bones_keys = np.append(bones_keys, keyframes)
return bones_keys
def clear_names_owner():
global names_owner
if 'names_owner' in globals():
bpy.msgbus.clear_by_owner(names_owner)
del names_owner
def subscribe_to_names(emp):
'''subscribe_to_object and bone names in case they were changed by the user'''
global names_owner
names_owner = object()
subscribe_object = (bpy.types.Object, 'name')
subscribe_posebone = (bpy.types.PoseBone, 'name')
subscribe_bone = (bpy.types.Bone, 'name')
for subscribe in [subscribe_object, subscribe_posebone, subscribe_bone]:
bpy.msgbus.subscribe_rna(
key=subscribe,
owner=names_owner,
args=(),
notify=update_names,)
def update_names():
global names_update
names_update = True
def check_bone_names(self, context):
#check if the object or bone name was changed
global names_update
if not names_update:
return
names_update = False
changed = False
for i, obj_bonename in enumerate(self.mp_bone_names):
obj_name, bonename = obj_bonename
#find the object name if it was changed
if obj_name not in context.view_layer.objects:
for obj in context.view_layer.objects:
if 'atb_mp_name' not in obj.keys():
continue
if obj['atb_mp_name'] == obj_name:
self.mp_bone_names[i] = (obj.name, bonename)
update_dictionaries_keys(self, (obj.name, bonename), obj_bonename)
obj['atb_mp_name'] = obj.name
break
changed = True
else:
obj = context.view_layer.objects[obj_name]
if obj is None:
print('object is none')
continue
if not self.posemode:
continue
#check if bone names were changed
if bonename not in obj.pose.bones:
for bone in obj.pose.bones:
if 'atb_mp_name' not in bone.keys():
continue
if bone['atb_mp_name'] == bonename:
#if it found the old name in the property then update all the attributes with the new name
self.mp_bone_names[i] = (obj.name, bone.name)
update_dictionaries_keys(self, (obj.name, bone.name), obj_bonename)
bone['atb_mp_name'] = bone.name
break
changed = True
return changed
def update_dictionaries_keys(self, new_names, old_names):
'''update all the attributes with the new object and bone names, replacing the dict keys but keeping the values
using this when name of objects or bones was changed'''
dicts_to_update = [self.bones_points, self.mp_bones_keys, self.frame_start, self.frame_end, self.avz_frame_start, self.bones_keyframes_coords, self.matrix_diff,
self.bones_selected_keyframes,self.bones_selected_handles_r, self.bones_selected_handles_l, self.bones_handles_right,
self.bones_handles_left, self.bones_handles_frames, self.bones_handles_types, self.mp_bones_keys, self.current_bones_keys,
self.batch_line, self.batch_points, self.batch_selected, self.batch_keyframes]
# Adding the bone names from the 2d dictionaries where bone_frame is inside the values
dicts_to_update += list(self.coords2d_bones_frames.values())
dicts_to_update += list(self.coords2d_handles_r.values())
dicts_to_update += list(self.coords2d_handles_l.values())
# Perform the update for each dictionary
for d in dicts_to_update:
if old_names not in d:
continue
d[new_names] = d.pop(old_names)
# coords_2d_update(self, bpy.context)
########################################################################################################################
class ProportionalEditor:
# Class-level constants for interpolation types
LINEAR = "linear"
SMOOTH = "smooth"
SPHERE = "sphere"
ROOT = "root"
INVERSE_SQUARE = "inverse_square"
SHARP = "sharp"
CONSTANT = "constant"
RANDOM = "random"
def __init__(self):
# Dictionary mapping interpolation types to their functions
self.interpolation_functions = {
'SMOOTH': self._smooth,
'SPHERE': self._sphere,
'ROOT': self._root,
'INVERSE_SQUARE': self._inverse_square,
'SHARP': self._sharp,
'LINEAR': self._linear,
'CONSTANT': self._constant,
'RANDOM': self._random,
}
# Interpolation functions as methods
def _linear(self, t):
return 1 - t
def _smooth(self, t):
# Smooth step function: 3t² - 2t³
return 1-(3 * t * t - 2 * t * t * t)
def _sphere(self, t):
# Spherical falloff: sqrt(1 - (1-t)²)
return math.sqrt(1- t * t)
def _root(self, t):
# Square root falloff
return 1- math.sqrt(t)
def _inverse_square(self, t):
# Inverse square falloff: 1 - t²
return 1 - t * t
def _sharp(self, t):
# Sharp falloff: t⁴
return (1 - t) * (1 - t) * (1 - t) * (1 - t)
def _constant(self, t):
# Constant falloff (step function)
return 1.0 if t < 1.0 else 0.0
def _random(self, t):
# Random falloff
return np.random.random() * (1 - t)
def apply_interpolation(self, normalized_distance, interpolation_type):
"""
Apply interpolation curve to normalized distance (0-1 range)
Args:
normalized_distance: float between 0-1, where 0 is at center, 1 is at edge
interpolation_type: string matching class constants
Returns:
float: transformed distance value
"""
# Clamp to 0-1 range
t = max(0, min(1, normalized_distance))
# Get the interpolation function from dictionary
interpolation_func = self.interpolation_functions.get(interpolation_type) # # Default fallback
return interpolation_func(t)
class Markers_ManualRange(bpy.types.Operator):
"""Create Markers for an interactive Motion Path Manual frame range"""
bl_idname = "anim.emp_markers_range"
bl_label = "Emp_Markers_Framerange"
bl_options = {'REGISTER', 'UNDO'}
frame_start: bpy.props.IntProperty(name = 'Frame Start', description="Storing the frame start to check if the property or marker were changed")
frame_end: bpy.props.IntProperty(name = 'Frame End', description="Storing the frame end to check if the property or marker were changed")
def execute(self, context):
context.scene.emp.marker_frame_range = False
context.scene.timeline_markers.remove(self.frame_start_marker)
context.scene.timeline_markers.remove(self.frame_end_marker)
return {'FINISHED'}
def invoke(self, context, event):
#If the modal is already running, then don't run it the second time
scene = context.scene
emp = scene.emp
if emp.marker_frame_range:
emp.marker_frame_range = False
return {'CANCELLED'}
emp.marker_frame_range = True
self.frame_start = emp.frame_start
self.frame_end = emp.frame_end
#init_frame_range_markers(self, scene)
Tools.init_frame_start_marker(self, scene, name = 'MotionPath Start')
Tools.init_frame_end_marker(self, scene, name = 'MotionPath End')
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def modal(self, context, event):
scene = context.scene
if Tools.check_removed_markers(self, scene):
return {'PASS_THROUGH'}
#If the modal was running again or changed then exit
if not scene.emp.marker_frame_range:
self.execute(context)
return {'FINISHED'}
if Tools.update_frame_range_markers(self, scene.emp, 'frame_start', 'frame_end'):
return {'RUNNING_MODAL'}
if event.type in {'ESC'}: # Cancel
self.execute(context)
return {'FINISHED'}
return {'PASS_THROUGH'}
class GoToKeyframe(bpy.types.Operator):
"""Go to the last selected Frame"""
bl_idname = "anim.go_to_keyframe"
bl_label = "Go To Active Keyframe"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return context.scene.emp.motion_path
def execute(self, context):
selected_keyframes = context.scene.emp.selected_keyframes
if not len(selected_keyframes):
return {'CANCELLED'}
# Converting str to dict
bones_selected_keyframes = deserialize_dict(selected_keyframes)
if not len(bones_selected_keyframes):
return {'CANCELLED'}
# Flattening the list from multiple selections
values = [int(value) for selection in bones_selected_keyframes.values() for value in selection]
active_frame = values[-1]
context.scene.frame_set(active_frame)
return {'FINISHED'}
def update_prop_callback(self, context):
if self.vis_type == 'VELOCITY':
if self.clamp_min > self.clamp_max:
self.clamp_min = self.clamp_max
global velocity_update
velocity_update = True
self.update = True
self.area.tag_redraw()
def check_velocity_update(self, context):
global velocity_update
if 'velocity_update' not in globals():
return False
if velocity_update:
calculate_velocities(self, context)
velocity_update = False
self.update = True
return True
return False
def default_colors(self, context):
if self.vis_type != 'VELOCITY':
if (self.color_after.r, self.color_after.g, self.color_after.b) == (0.0, 0.0, 1.0):
self['color_after'] = (0.0, 1.0, 0.0)
self.update = True
else:
global velocity_update
velocity_update = True
if (self.color_after.r, self.color_after.g, self.color_after.b) == (0.0, 1.0, 0.0):
self['color_after'] = (0.0, 0.0, 1.0)
def disbale_non_select_circle(self, context):
if self.smooth:
self['smooth'] = False
if self.prop_edit:
self['prop_edit'] = False
def disbale_non_prop_edit(self, context):
if self.smooth:
self['smooth'] = False
if self.select_circle:
self['select_circle'] = False
def disbale_non_smooth(self, context):
if self.prop_edit:
self['prop_edit'] = False
if self.select_circle:
self['select_circle'] = False
class EditableMotionPathSettings(bpy.types.PropertyGroup):
'''All the settings for Editable motion path'''
motion_path: bpy.props.BoolProperty(name = "Motion Path", description = "Flag when Motion Path is on", default = False, override = {'LIBRARY_OVERRIDABLE'})
refresh: bpy.props.BoolProperty(name = "Refresh Motion Path", description = "Refresh the motion paths with hotkey ctrl", default = False, override = {'LIBRARY_OVERRIDABLE'})
settings: bpy.props.BoolProperty(name = "Motion Path Settings", description = "Open the settings Menu", default = False, override = {'LIBRARY_OVERRIDABLE'})
# mp_keyframe_scale: bpy.props.FloatProperty(name = "Scale Selecgted Keyframes Bounding Box", description = "Change the scale of the bounding box around the selected keyframes ", default = 0.1, step = 0.1, precision = 3)
color_before: bpy.props.FloatVectorProperty(name="Motion Path Before Color", subtype='COLOR', default=(1.0, 0.0, 0.0), min=0.0, max=1.0, description="Motion path color before the current frame", update=update_prop_callback)
color_after: bpy.props.FloatVectorProperty(name="Motion Path After Color", subtype='COLOR', default=(0.0, 1.0, 0.0), min=0.0, max=1.0, description="Motion path color before the current frame", update=update_prop_callback)
infront: bpy.props.BoolProperty(name = "Motion Path In Front", description = "Display motion path in front of all the objects", default = True, override = {'LIBRARY_OVERRIDABLE'})
points: bpy.props.BoolProperty(name = "Motion Path Points", description = "Display motion path points", default = True, override = {'LIBRARY_OVERRIDABLE'})
lines: bpy.props.BoolProperty(name = "Motion Path Lines", description = "Display motion path lines", default = True, override = {'LIBRARY_OVERRIDABLE'})
handles: bpy.props.BoolProperty(name = "Motion Path Handles", description = "Display motion path handles on keyframe selection", default = True, override = {'LIBRARY_OVERRIDABLE'})
display_frames: bpy.props.BoolProperty(name = "Frame Numbers", description = "Display frame numbers on all the keyframes", default = False, override = {'LIBRARY_OVERRIDABLE'})
radius: bpy.props.IntProperty(name = "Radius", description = "Radius for proportional editing", min = 1, max = 800, default = 30)
smooth: bpy.props.BoolProperty(name = "Smooth Keyframes", description = "Smoothing keyframes on the motion path", default = False, update=disbale_non_smooth)
smooth_strength: bpy.props.FloatProperty(name = "Smooth Strength", description = "How strong smoothing will apply", min = 0, max = 1, default = 0.05)
prop_edit: bpy.props.BoolProperty(name = "Proportional Editing", description = "Move multiple keyframes with proportional editing", default = False, update=disbale_non_prop_edit )
prop_edit_falloff: bpy.props.EnumProperty(name = 'Propotional Editing Falloff', description="Set the falloff interpolation for the motion path proportional editing", default = 'SMOOTH',
items = [('SMOOTH','smooth', 'smooth', 'SMOOTHCURVE', 0),
('SPHERE', 'sphere', 'sphere', 'SPHERECURVE', 1),
('ROOT','root', 'root', 'ROOTCURVE', 2),
('INVERSE_SQUARE','inverse_square', 'inverse_square', 'INVERSESQUARECURVE', 3),
('SHARP','sharp', 'sharp', 'SHARPCURVE', 4),
('LINEAR', 'linear', 'linear', 'IPO_LINEAR', 5),
('CONSTANT','constant', 'constant', 'IPO_CONSTANT', 6),
('RANDOM','random', 'random', 'IPO_ELASTIC', 7)])
select_circle: bpy.props.BoolProperty(name = "Selection Brush", description = "Select multiple Keyframes using the selection brush", default = False, update = disbale_non_select_circle)
camera_space: bpy.props.BoolProperty(name = "Camera Space", description = "Baking the motion path into the active camera space", default = False)
cursor_offset: bpy.props.BoolProperty(name = "Cursor Offset", description = "Offseting the Motion Path to the cursor position", default = False)
handle_types: bpy.props.EnumProperty(name = 'Set Keyframe Handle Type', description="Set handle type for selected keyframes", default = 'AUTO', update = update_handle_type_prop,
items = [('FREE', 'Free', 'Free', 'HANDLE_FREE', 0),
('ALIGNED','Aligned', 'Aligned', 'HANDLE_ALIGNED', 1),
('VECTOR', 'Vector', 'Vector', 'HANDLE_VECTOR', 2),
('AUTO','Automatic', 'Automatic', 'HANDLE_AUTO', 3),
('AUTO_CLAMPED','Auto Clamped', 'Auto Clamped', 'HANDLE_AUTOCLAMPED', 4)])
interpolation: bpy.props.EnumProperty(name = 'Set Interpolation', description="Set Keyframe Interpolation", default = 'BEZIER', update = update_interpolation_prop,
items = [('BEZIER', 'Bezier', 'Bezier', 'IPO_BEZIER', 0),
('LINEAR','Linear', 'Linear', 'IPO_LINEAR', 1),
('CONSTANT', 'Constant', 'Constant', 'IPO_CONSTANT', 2)])
channels: bpy.props.EnumProperty(name = 'Channel', description="Type of Frame Range", default = 'HEADS',
items = [('HEADS', 'Location','Create Motion Path on location channels','OBJECT_ORIGIN', 0),
('TAILS', 'Rotation','Create Motion Path on rotation channels','TRACKING', 1)])
frame_range: bpy.props.EnumProperty(name = 'Frame Range', description="Type of Frame Range", default = 'SCENE', update = update_frame_range_type,
items = [('KEYS_ALL', 'All_Keys','Use the Scene Frame Length for the Range', 0),
('SCENE', 'Scene','Use the Scene Frame Length for the Range', 1),
('MANUAL', 'Manual','Custom Frame range using numerical input or the markers frame ranger', 2),
('AROUND', 'Around Frames','Show only around the current frame', 3)])
frame_start: bpy.props.IntProperty(name = "Frame Start", description = "Define the motion path frame start", min = 0, update = update_manual_frame_range)
frame_end: bpy.props.IntProperty(name = "Frame End", description = "Define the motion path frame start", min = 0, update = update_manual_frame_range)
marker_frame_range: bpy.props.BoolProperty(name = "Marker Frame Range", description = "Flag when marker frame range turned on", default = False)
before: bpy.props.IntProperty(name = "Before", description = "Show the frames Before the current frame", min = 0, default = 10, update=update_prop_callback)
after: bpy.props.IntProperty(name = "After", description = "Show the frames After the current frame", min = 0, default = 10, update=update_prop_callback)
# Velocity properties
clamp_min: bpy.props.IntProperty(name = "Clamp Min", description = "Sets the clampping for the minimum values of velocity", min = 0, max = 100, default = 5, update=update_prop_callback)
clamp_max: bpy.props.IntProperty(name = "Clamp Max", description = "Sets the clampping for the minimum values of velocity", min = 0, max = 100, default = 95, update=update_prop_callback)
velocity_factor: bpy.props.FloatProperty(name = "Blending Factor", description = "Use a factor value to set the blending and contrast between the colors", min = 0.1, max = 5, default = 1, update=update_prop_callback)
display_size: bpy.props.BoolProperty(name = "Size and Thickness", description = "Set the Line Thickness and point size", default = False, override = {'LIBRARY_OVERRIDABLE'})
thickness: bpy.props.FloatProperty(name = "Motion Path Thickness", description = "The Width of the motion path", min = 0.1, default = 1)
keyframe_size: bpy.props.FloatProperty(name = "Keyframe Size", description = "The Size of the keyframe size", min = 1, default = 8)
frame_size: bpy.props.FloatProperty(name = "Frame Size", description = "The Size of the frame points", min = 1, default = 6)
selection_size: bpy.props.FloatProperty(name = "Selection Size", description = "The Size of the points around the selection", min = 0, default = 10)
vis_type: bpy.props.EnumProperty(name = 'Visualisation type', description="Set handle type for selected keyframes", default = 'BEFORE_AFTER', update = default_colors,
items = [('BEFORE_AFTER', 'Before-After', 'Before After', 0),
('ALTERNATE', 'Alternate', 'Alternating between the colors on each frame', 1),
('VELOCITY','Velocity', 'Velocity colors based on spacing', 2)])
update: bpy.props.BoolProperty(name = "Update", default = False)
# Used for undo purpose using json
selected_keyframes: bpy.props.StringProperty(name="Selected Keyframes", description="Serialized representation of selected keyframes", default = "{}")
########################################################################################################################
classes = (MotionPathOperator, EditableMotionPathSettings, GoToKeyframe, Markers_ManualRange)
def register():
from bpy.utils import register_class
for cls in classes:
register_class(cls)
bpy.types.Scene.emp = bpy.props.PointerProperty(type = EditableMotionPathSettings, override = {'LIBRARY_OVERRIDABLE'})
def unregister():
from bpy.utils import unregister_class
for cls in classes:
unregister_class(cls)
del bpy.types.Scene.emp