import bpy import math import numpy as np from math import tan from mathutils import Vector, Matrix from bpy_extras.object_utils import world_to_camera_view from mathutils.geometry import (barycentric_transform, intersect_point_tri, intersect_point_line, intersect_line_plane, tessellate_polygon) ## context manager to store restore class attr_set(): '''Receive a list of tuple [(data_path, "attribute" [, wanted value)] ] entering with-statement : Store existing values, assign wanted value (if any) exiting with-statement: Restore values to their old values ''' def __init__(self, attrib_list): self.store = [] # item = (prop, attr, [new_val]) for item in attrib_list: prop, attr = item[:2] self.store.append( (prop, attr, getattr(prop, attr)) ) if len(item) >= 3: setattr(prop, attr, item[2]) def __enter__(self): return self def __exit__(self, exc_type, exc_value, exc_traceback): for prop, attr, old_val in self.store: setattr(prop, attr, old_val) # --- Vector def triangle_normal(p1, p2, p3): """ Calculate the normal of a triangle given its three vertices. Parameters: p1, p2, p3: the 3 vertices of the triangle Returns: mathutils.Vector: The normalized normal vector of the triangle. """ ## Get edge vectors edge1 = Vector(p2) - Vector(p1) edge2 = Vector(p3) - Vector(p1) ## Get normal (Cross product of the edge vectors) normal = edge1.cross(edge2) normal.normalize() return normal def plane_coords(size=1): v = size * 0.5 return [Vector((-v, v, 0)), Vector((v, v, 0)), Vector((v, -v, 0)), Vector((-v, -v, 0))] def matrix_transform(coords, matrix): coords_4d = np.column_stack((coords, np.ones(len(coords), dtype='float64'))) return np.einsum('ij,aj->ai', matrix, coords_4d)[:, :-1] def vector_normalized(vec): return vec / np.sqrt(np.sum(vec**2)) def vector_magnitude(vec): return np.sqrt(vec.dot(vec)) def search_square(point, factor=0.05, cam=None): if cam is None: cam = bpy.context.scene.camera plane = plane_coords() mat = cam.matrix_world.copy() mat.translation = point depth = vector_magnitude(point - cam.matrix_world.to_translation()) mat_scale = Matrix.Scale(tan(cam.data.angle * 0.5) * depth * factor, 4) final_matrix = mat @ mat_scale return [final_matrix @ co for co in plane] def get_tri_from_face(hit_location, face_index, object_hit, depsgraph): eval_ob = object_hit.evaluated_get(depsgraph) face = eval_ob.data.polygons[face_index] vertices = [eval_ob.data.vertices[i] for i in face.vertices] face_co = [eval_ob.matrix_world @ v.co for v in vertices] tri = None for tri_idx in tessellate_polygon([face_co]): tri = [face_co[i] for i in tri_idx] tri_indices = [vertices[i].index for i in tri_idx] if intersect_point_tri(hit_location, *tri): break return tri, tri_indices def ray_cast_point(point, origin, depsgraph): '''Return object hit by ray cast, hit location and triangle vertices coordinates and indices point: point coordinate in world space origin: origin of the ray in world space depsgraph: current depsgraph (use bpy.context.evaluated_depsgraph_get()) return: object_hit (object): Object that was hit hit_location (Vector3, as np.array): Location Vector of the hit tri (list(Vector)): List of Vector3 world space coordinate of hitten triangle (tesselated from face if needed) tri_indices (list(int)): List of vertices index corresponding to tri coordinates if nothing hit. return None, None, None, None ''' ray = (point - origin) hit, hit_location, normal, face_index, object_hit, matrix = bpy.context.scene.ray_cast(depsgraph, origin, ray) if not hit: return None, None, None, None tri, tri_indices = get_tri_from_face(hit_location, face_index, object_hit, depsgraph) return object_hit, hit_location, tri, tri_indices def obj_ray_cast(obj, point, origin, depsgraph): """Wrapper for ray casting that moves the ray into object space""" # get the ray relative to the object matrix_inv = obj.matrix_world.inverted() ray_origin_obj = matrix_inv @ origin # matrix_transform(origin, matrix_inv) ray_target_obj = matrix_inv @ point # matrix_transform(point, matrix_inv) ray_direction_obj = ray_target_obj - ray_origin_obj # cast the ray success, location, normal, face_index = obj.ray_cast(ray_origin_obj, ray_direction_obj, depsgraph=depsgraph) if not success: return None, None, None, None # Get hit location world_space hit_location = obj.matrix_world @ location tri, tri_indices = get_tri_from_face(hit_location, face_index, obj, depsgraph) return obj, hit_location, tri, tri_indices def empty_at(name='Empty', pos=(0,0,0), collection=None, type='PLAIN_AXES', size=1, show_name=False): ''' Create an empty at given Vector3 position. Optional type (default 'PLAIN_AXES') in ,'ARROWS','SINGLE_ARROW','CIRCLE','CUBE','SPHERE','CONE','IMAGE' default size is 1.0 ''' mire = bpy.data.objects.get(name) if not mire: mire = bpy.data.objects.new(name, None) if collection is None: collection = bpy.context.collection if mire.name not in collection.all_objects: collection.objects.link(mire) mire.location = pos mire.empty_display_type = type mire.empty_display_size = size mire.show_name = show_name return mire def plane_on_bone(bone, arm=None, cam=None, set_rotation=True, mesh=True): ''' bone (posebone): reference pose bone arm (optional: Armature): Armature of the pose bone (if not passed found using bone.id_data) cam (optional: Camera) : Camera to align plane to (if not passed use scene camera) set_rotation (bool): rotate the plane on cam view axis according to bone direction in 2d cam space mesh (bool): create a real mesh ans return it, else return list of plane coordinate ''' if cam is None: cam = bpy.context.scene.camera if arm is None: arm = bone.id_data mat = cam.matrix_world.copy() if set_rotation: head_world_coord = arm.matrix_world @ bone.head mat.translation = head_world_coord ## Apply 2d bone rotation facing camera # Get 2d camera space coords (NDC: normalized device coordinate, 0,0 is bottom-left) head_2d, tail_2d = get_bone_head_tail_2d(bone, cam=cam) vec_from_corner_2d = (tail_2d - head_2d).normalized() up_vec_2d = Vector((0,1)) # angle = acos(up_vec_2d.dot(vec_from_corner_2d)) ## equivalent but not signed! angle = up_vec_2d.angle_signed(vec_from_corner_2d) ## Axis camera aim (seem slightly off) # rot_axis = Vector((0, 0, -1)) # rot_axis.rotate(cam.matrix_world) ## Axis camera origin -> pivot rot_axis = head_world_coord - cam.matrix_world.translation mat = rotate_matrix_around_pivot(mat, angle, head_world_coord, rot_axis) else: ## Use mid bone to better follow movement mat.translation = arm.matrix_world @ ((bone.tail + bone.head) / 2) # Mid bone if mesh: # get/create collection col = bpy.data.collections.get('interpolation_tool') if not col: col = bpy.data.collections.new('interpolation_tool') if col.name not in bpy.context.scene.collection.children: bpy.context.scene.collection.children.link(col) # get/create meshplane plane = bpy.data.objects.get('interpolation_plane') if not plane: plane = create_plane(name='interpolation_plane') # Display type as Wire for a discrete XP plane.display_type = 'WIRE' if plane.name not in col.objects: col.objects.link(plane) plane.matrix_world = mat return plane mat_scale = Matrix.Scale(10, 4) plane = plane_coords() return matrix_transform(plane, mat @ mat_scale) def place_object_to_ref_facing_cam(obj, ref_ob, bone=None, cam=None, set_rotation=True): ''' obj (Object): the object to place ref_ob (Object): the reference object or armature bone (posebone): reference pose bone arm (optional: Armature): Armature of the pose bone (if not passed found using bone.id_data) cam (optional: Camera) : Camera to align plane to (if not passed use scene camera) set_rotation (bool): rotate the plane on cam view axis according to bone direction in 2d cam space ''' if cam is None: cam = bpy.context.scene.camera # if ref_ob is None: # ref_ob = bone.id_data mat = cam.matrix_world.copy() if set_rotation: if bone: head_world_coord = ref_ob.matrix_world @ bone.head mat.translation = head_world_coord ## Apply 2d bone rotation facing camera # Get 2d camera space coords (NDC: normalized device coordinate, 0,0 is bottom-left) head_2d, tail_2d = get_bone_head_tail_2d(bone, cam=cam) else: mat.translation = ref_ob.matrix_world # Get 2d camera space coords (NDC: normalized device coordinate, 0,0 is bottom-left) scene = bpy.context.scene up_vec = Vector((0,0,1)) up_vec.rotate(ref_ob.matrix_world) tail_3d = ref_ob.matrix_world.to_translation() + up_vec head_2d = world_to_camera_view(scene, cam, ref_ob.matrix_world.to_translation()) tail_2d = world_to_camera_view(scene, cam, tail_3d) ratio = scene.render.resolution_y / scene.render.resolution_x head_2d.y *= ratio tail_2d.y *= ratio vec_from_corner_2d = (tail_2d - head_2d).normalized() up_vec_2d = Vector((0,1)) # angle = acos(up_vec_2d.dot(vec_from_corner_2d)) ## equivalent but not signed! angle = up_vec_2d.angle_signed(vec_from_corner_2d) ## Axis camera aim (seem slightly off) # rot_axis = Vector((0, 0, -1)) # rot_axis.rotate(cam.matrix_world) ## Axis camera origin -> pivot rot_axis = head_world_coord - cam.matrix_world.translation mat = rotate_matrix_around_pivot(mat, angle, head_world_coord, rot_axis) else: if bone: ## Use mid bone to better follow movement mat.translation = ref_ob.matrix_world @ ((bone.tail + bone.head) / 2) # Mid bone else: mat.translation = ref_ob.matrix_world ## change/adapt scale # mat_scale = Matrix.Scale(10, 4) # maybe move above mesh condition # mat = mat @ mat_scale obj.matrix_world = mat def create_plane(name='Plane', collection=None): '''Create a plane using pydata collection: link in passed collection, else do not link in scene ''' x = 100.0 y = 100.0 vert = [(-x, -y, 0.0), (x, -y, 0.0), (-x, y, 0.0), (x, y, 0.0)] fac = [(0, 1, 3, 2)] pl_data = bpy.data.meshes.new(name) pl_data.from_pydata(vert, [], fac) pl_obj = bpy.data.objects.new(name, pl_data) # collection = bpy.context.collection if collection: collection.objects.link(pl_obj) return pl_obj def intersect_with_tesselated_plane(point, origin, face_co): ''' face_co: World face coordinates ''' tri = None for tri_idx in tessellate_polygon([face_co]): tri = [face_co[i] for i in tri_idx] tri_indices = [i for i in tri_idx] hit_location = intersect_line_plane(origin, point, sum((Vector(v) for v in tri), Vector()) / 3, triangle_normal(*tri)) if intersect_point_tri(hit_location, *tri): break return np.array(hit_location), tri, tri_indices def get_bone_head_tail_2d(posebone, scene=None, cam=None) -> tuple[Vector, Vector]: '''Get 2D vectors in camera view of bone head and tails return tuple of 2d vectors (head_2d and tail_2d) ''' scene = scene or bpy.context.scene cam = cam or scene.camera arm = posebone.id_data # Get 3D locations of head and tail head_3d = arm.matrix_world @ posebone.head tail_3d = arm.matrix_world @ posebone.tail # Convert 3D locations to 2D head_2d = world_to_camera_view(scene, cam, head_3d) tail_2d = world_to_camera_view(scene, cam, tail_3d) ratio = scene.render.resolution_y / scene.render.resolution_x head_2d.y *= ratio tail_2d.y *= ratio return Vector((head_2d.x, head_2d.y)), Vector((tail_2d.x, tail_2d.y)) def rotate_matrix_around_pivot(matrix, angle, pivot, axis): '''Rotate a given matrix by a CW angle around pivot on a given axis matrix (Matrix): the matrix to rotate angle (Float, Radians): the angle in radians pivot (Vector3): the pivot 3D coordinate axis (Vector3): the vector axis of rotation ''' # Convert angle to radians ? # angle = math.radians(angle) # Create a rotation matrix rot_matrix = Matrix.Rotation(angle, 4, axis) # Create translation matrices translate_to_origin = Matrix.Translation(-pivot) translate_back = Matrix.Translation(pivot) # Combine the transformations : The order of multiplication is important new_matrix = translate_back @ rot_matrix @ translate_to_origin @ matrix return new_matrix # --- GREASE PENCIL def get_gp_draw_plane(obj=None): ''' return tuple with plane coordinate and normal of the curent drawing according to geometry''' if obj is None: obj = bpy.context.object settings = bpy.context.scene.tool_settings orient = settings.gpencil_sculpt.lock_axis #'VIEW', 'AXIS_Y', 'AXIS_X', 'AXIS_Z', 'CURSOR' loc = settings.gpencil_stroke_placement_view3d #'ORIGIN', 'CURSOR', 'SURFACE', 'STROKE' mat = obj.matrix_world plane_no = Vector((0.0, 0.0, 1.0)) plane_co = mat.to_translation() # -> orientation if orient == 'VIEW': mat = bpy.context.scene.camera.matrix_world # -> placement if loc == "CURSOR": plane_co = bpy.context.scene.cursor.location mat = bpy.context.scene.cursor.matrix elif orient == 'AXIS_Y':#front (X-Z) plane_no = Vector((0,1,0)) elif orient == 'AXIS_X':#side (Y-Z) plane_no = Vector((1,0,0)) elif orient == 'AXIS_Z':#top (X-Y) plane_no = Vector((0,0,1)) plane_no.rotate(mat) return plane_co, plane_no ## --- Animation def following_keys(forward=True, animation=False) -> list:# -> list[int] | list | None: '''Return a list of int or an empty list''' direction = 1 if forward else -1 cur_frame = bpy.context.scene.frame_current settings = bpy.context.scene.gp_interpo_settings scn = bpy.context.scene if forward: limit = scn.frame_preview_end if scn.use_preview_range else scn.frame_end else: limit = scn.frame_preview_start if scn.use_preview_range else scn.frame_start frames = [] if settings.mode == 'FRAME': jump = settings.step * direction if animation: limit += direction # offset by one for limit to be in range return list(range(cur_frame + jump , limit, jump)) else: return [cur_frame + jump] if settings.mode == 'GPKEY': layers = bpy.context.object.data.layers frames = [f.frame_number for l in layers for f in l.frames] elif settings.mode == 'RIGKEY': col = settings.target_collection if not col: col = bpy.context.scene.collection objs = [o for o in col.all_objects if o.type == 'ARMATURE'] # Add camera moves detection objs += [bpy.context.scene.camera] for obj in objs: print(obj.name) if not obj.animation_data or not obj.animation_data.action: continue frames += [round(k.co.x) for fc in obj.animation_data.action.fcurves for k in fc.keyframe_points] if not frames: return [] # Sort frames (invert if looking backward) frames = list(set(frames)) frames.sort(reverse=not forward) if animation: if forward: frame_list = [f for f in frames if cur_frame < f <= limit] else: frame_list = [f for f in frames if limit <= f < cur_frame] return frame_list ## Single frame if forward: frame_list = next(([f] for f in frames if f > cur_frame), []) else: frame_list = next(([f] for f in frames if f < cur_frame), []) return frame_list def index_list_from_bools(bool_list) -> list: '''Receive a list of boolean Return a list of sublists of indices where there is a continuity of True. e.g., [True, True, False, True] will return [[0,1][3]] ''' result = [] current_sublist = [] for i, value in enumerate(bool_list): if value: current_sublist.append(i) elif current_sublist: result.append(current_sublist) current_sublist = [] if current_sublist: result.append(current_sublist) return result ## -- animation def is_animated(obj): return True ## -- regions operations def location_to_region(worldcoords) -> Vector: '''Get a world 3d coordinate and return 2d region coordinate return: 2d vector in region space ''' from bpy_extras import view3d_utils return view3d_utils.location_3d_to_region_2d(bpy.context.region, bpy.context.space_data.region_3d, worldcoords) def region_to_location(viewcoords, depthcoords) -> Vector: '''Get 3d world coordinate from viewport region 2d coordianate viewcoords (Vector2): 2d region vector coordinate depthcoords (Vector3): 3d coordinate to define the depth return: Vector3 of the placed location ''' from bpy_extras import view3d_utils return view3d_utils.region_2d_to_location_3d(bpy.context.region, bpy.context.space_data.region_3d, viewcoords, depthcoords)