import bpy, os import numpy as np import bmesh import mathutils import math import subprocess from time import time from math import sqrt from mathutils import Vector from sys import platform ## constants values ## Default stroke and points attributes stroke_attr = [ 'start_cap', 'end_cap', 'softness', 'material_index', 'fill_opacity', 'fill_color', 'cyclic', 'aspect_ratio', 'time_start', # 'curve_type', # read-only ] point_attr = [ 'position', 'radius', 'rotation', 'opacity', 'vertex_color', 'delta_time', # 'select', ] ### Attribute value, types and shape attribute_value_string = { 'FLOAT': "value", 'INT': "value", 'FLOAT_VECTOR': "vector", 'FLOAT_COLOR': "color", 'BYTE_COLOR': "color", 'STRING': "value", 'BOOLEAN': "value", 'FLOAT2': "value", 'INT8': "value", 'INT32_2D': "value", 'QUATERNION': "value", 'FLOAT4X4': "value", } attribute_value_dtype = { 'FLOAT': np.float32, 'INT': np.dtype('int'), 'FLOAT_VECTOR': np.float32, 'FLOAT_COLOR': np.float32, 'BYTE_COLOR': np.int8, 'STRING': np.dtype('str'), 'BOOLEAN': np.dtype('bool'), 'FLOAT2': np.float32, 'INT8': np.int8, 'INT32_2D': np.dtype('int'), 'QUATERNION': np.float32, 'FLOAT4X4': np.float32, } attribute_value_shape = { 'FLOAT': (), 'INT': (), 'FLOAT_VECTOR': (3,), 'FLOAT_COLOR': (4,), 'BYTE_COLOR': (4,), 'STRING': (), 'BOOLEAN': (), 'FLOAT2':(2,), 'INT8': (), 'INT32_2D': (2,), 'QUATERNION': (4,), 'FLOAT4X4': (4,4), } def translate_range(OldValue, OldMin, OldMax, NewMax, NewMin): return (((OldValue - OldMin) * (NewMax - NewMin)) / (OldMax - OldMin)) + NewMin def get_matrix(ob) : '''return a copy of the world_matrix, applied object matrix if its a bone''' if isinstance(ob, bpy.types.PoseBone) : return ob.id_data.matrix_world @ ob.matrix.copy()# * ? else : return ob.matrix_world.copy() def set_matrix(gp_frame,mat): for stroke in gp_frame.drawing.strokes : for point in stroke.points : point.position = mat @ point.position # get view vector location (the 2 methods work fine) def get_view_origin_position(): ## method 1 # from bpy_extras import view3d_utils # region = bpy.context.region # rv3d = bpy.context.region_data # view_loc = view3d_utils.region_2d_to_origin_3d(region, rv3d, (region.width/2.0, region.height/2.0)) ## method 2 r3d = bpy.context.space_data.region_3d view_loc2 = r3d.view_matrix.inverted().translation return view_loc2 def location_to_region(worldcoords): 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): 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) def vector_len_from_coord(a, b): '''get either two points or world coordinates and return length''' from mathutils import Vector if type(a) is Vector: return (a - b).length else: return (a.co - b.co).length def transfer_value(Value, OldMin, OldMax, NewMin, NewMax): '''map a value from a range to another (transfer/translate value)''' return (((Value - OldMin) * (NewMax - NewMin)) / (OldMax - OldMin)) + NewMin def object_derived_get(ob, scene): if ob.dupli_type != 'NONE' : ob.dupli_list_create(scene) ob_matrix_pairs = [(dob.object, dob.matrix.copy()) for dob in ob.dupli_list] ob.dupli_list_clear() else: ob_matrix_pairs = [(ob, ob.matrix_world.copy())] return ob_matrix_pairs # ----------------- ### Bmesh # ----------------- def link_vert(v,ordered_vert) : for e in v.link_edges : other_vert = e.other_vert(v) if other_vert not in ordered_vert : ordered_vert.append(other_vert) link_vert(other_vert,ordered_vert) return ordered_vert def find_loops(bm) : verts = [] loops = [] print([v for v in bm.verts if len(v.link_edges)==1]) for v in [v for v in bm.verts if len(v.link_edges)==1] : if v not in verts : loop = link_vert(v,[v]) loops.append(loop) for vert in loop : verts.append(vert) return loops def get_perimeter(points) : perimeter = 0 print('pointlen',len(points)) for i,point in enumerate(points) : if i != 0 : perimeter += (Vector(point) -Vector(points[i-1])).length return perimeter def points_to_bm_face(points,depth=0) : bm = bmesh.new() for point in points : bm.verts.new((point[0],point[1],depth)) bm.faces.new(bm.verts) bm.faces.ensure_lookup_table() return bm def gp_stroke_to_bmesh(strokes): strokes_info = [] for stroke in strokes : info = {'stroke' : stroke ,'color':stroke.colorname,'line_width':stroke.line_width} bm = bmesh.new() strength = bm.verts.layers.float.new('strength') pressure = bm.verts.layers.float.new('pressure') select = bm.verts.layers.int.new('select') verts = [] for i,point in enumerate(stroke.points) : v = bm.verts.new(point.co) v[strength] = point.strength v[pressure] = point.pressure v[select] = point.select verts.append(v) if i > 0 : e = bm.edges.new([verts[-1],verts[-2]]) info['bmesh']= bm strokes_info.append(info) return strokes_info # ----------------- ### GP Drawing # ----------------- def layer_active_index(gpl): '''Get layer list and return index of active layer Can return None if no active layer found (active item can be a group) ''' return next((i for i, l in enumerate(gpl) if l == gpl.active), None) def get_top_layer_from_group(gp, group): upper_layer = None for layer in gp.layers: if layer.parent_group == group: upper_layer = layer return upper_layer def get_closest_active_layer(gp): '''Get active layer from GP object, getting upper layer if in group if a group is active, return the top layer of this group if group is active but no layer in it, return None ''' if gp.layers.active: return gp.layers.active ## No active layer, return active from group (can be None !) return get_top_layer_from_group(gp, gp.layer_groups.active) def closest_layer_active_index(gp, fallback_index=0): '''Get active layer index from GP object, getting upper layer if in group if a group is active, return index at the top layer of this group if group is active but no layer in it, return fallback_index (0 by default, stack bottom)''' closest_active_layer = get_closest_active_layer(gp) if closest_active_layer: return next((i for i, l in enumerate(gp.layers) if l == closest_active_layer), fallback_index) return fallback_index ## Check for nested lock def is_locked(stack_item): '''Check if passed stack item (layer or group) is locked either itself or by parent groups''' if stack_item.lock: return True if stack_item.parent_group: return is_locked(stack_item.parent_group) return False def is_parent_locked(stack_item): '''Check if passed stack item (layer or group) is locked by parent groups''' if stack_item.parent_group: return is_locked(stack_item.parent_group) return False ## Check for nested hide def is_hidden(stack_item): '''Check if passed stack item (layer or group) is hidden either itself or by parent groups''' if stack_item.hide: return True if stack_item.parent_group: return is_hidden(stack_item.parent_group) return False def is_parent_hidden(stack_item): '''Check if passed stack item (layer or group) is hidden by parent groups''' if stack_item.parent_group: return is_hidden(stack_item.parent_group) return False def simple_draw_gp_stroke(pts, frame, width = 2, mat_id = 0): ''' draw basic stroke by passing list of point 3D coordinate the frame to draw on and optional width parameter (default = 2) ''' stroke = frame.drawing.strokes.new() stroke.line_width = width stroke.display_mode = '3DSPACE' stroke.material_index = mat_id # readonly -> stroke.is_nofill_stroke# boundary_stroke stroke.points.add(len(pts)) seq = [i for vec in pts for i in vec]## foreach_set flatlist for speed stroke.points.foreach_set('co', seq) ## one by one # for i, pt in enumerate(pts): # stroke.points.add() # dest_point = stroke.points[i] # dest_point.position = pt return stroke ## OLD - need update def draw_gp_stroke(loop_info, frame, palette, width = 2) : stroke = frame.drawing.strokes.new(palette) stroke.line_width = width stroke.display_mode = '3DSPACE'# old -> draw_mode for i,info in enumerate(loop_info) : stroke.points.add() dest_point = stroke.points[i] for attr,value in info.items() : setattr(dest_point,attr,value) return stroke def get_camera_frame_info(cam, distance = 1): ''' return a list with 4 screen corner top-right first rotating CC 4-------1 | | 3-------2 ''' cam_coord = cam.matrix_world.to_translation() scene = bpy.context.scene #shift_x = cam.data.shift_x #shift_y = cam.data.shift_y #cam.data.shift_x = 0 #cam.data.shift_y = 0 frame = cam.data.view_frame(scene) frame = [cam.matrix_world * corner for corner in frame] #frame = [corner+(corner-cam_coord).normalized()*distance for corner in frame] #cam.data.shift_x = shift_x #cam.data.shift_y = shift_y # bpy.context.scene.cursor_location = frame[0]# test by placing cursor return frame def id_convert(fimg,id,operation = 'EQUAL',border = True): new_img = fimg.copy() width = len(new_img[0]) if operation == 'EQUAL' : thresh_mask = new_img[...] == id elif operation == 'GREATER' : thresh_mask = new_img[...] > id elif operation == 'LOWER' : thresh_mask = new_img[...] < id else : return new_img[:] = 1.0 new_img[thresh_mask] = 0.0 if border : # Adding black around the image new_img = np.concatenate((new_img,[[1]*width])) new_img = np.concatenate(([[1]*width],new_img)) new_img = np.insert(new_img,width,1,axis = 1) new_img = np.insert(new_img,0,1,axis = 1) return new_img def remapping(value, leftMin, leftMax, rightMin, rightMax): # Figure out how 'wide' each range is leftSpan = leftMax - leftMin rightSpan = rightMax - rightMin # Convert the left range into a 0-1 range (float) valueScaled = float(value - leftMin) / float(leftSpan) # Convert the 0-1 range into a value in the right range. return rightMin + (valueScaled * rightSpan) # ----------------- ### GP funcs # ----------------- def get_gp_draw_plane(obj=None, orient=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 if orient is None: 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 in ('AXIS_Y', 'FRONT'): # front (X-Z) plane_no = Vector((0,1,0)) elif orient in ('AXIS_X', 'SIDE'): # side (Y-Z) plane_no = Vector((1,0,0)) elif orient in ('AXIS_Z', 'TOP'): # top (X-Y) plane_no = Vector((0,0,1)) plane_no.rotate(mat) return plane_co, plane_no def check_angle_from_view(obj=None, plane_no=None, context=None): '''Return angle to obj according to chosen drawing axis''' import math from bpy_extras import view3d_utils if not context: context = bpy.context if not plane_no: _plane_co, plane_no = get_gp_draw_plane(obj=obj) view_direction = view3d_utils.region_2d_to_vector_3d(context.region, context.region_data, (context.region.width/2.0, context.region.height/2.0)) angle = math.degrees(view_direction.angle(plane_no)) # correct angle value when painting from other side (seems a bit off...) if angle > 90: angle = abs(90 - (angle - 90)) # over angle warning if angle > 75: return angle, 'ERROR', f"Canvas not aligned at all! (angle: {angle:.2f}°), use Realign GP or change draw axis" if angle > 6: return angle, 'ERROR', f'Canvas not aligned! angle: {angle:.2f}°, use Realign GP' if angle > 3: # below 3 is reasonable (camera seem to view induce error !) return angle, 'INFO', f'Canvas almost aligned (angle: {angle:.2f}°)' if angle == 0: return angle, 'INFO', f'Canvas perfectly aligned (angle: {angle:.2f}°)' # below 1 consider aligned return angle, 'INFO', f'Canvas alignement seem ok (angle: {angle:.2f}°)' ## need big update def create_gp_palette(gp_data_block,info) : palette = gp_data_block.palettes.active name = info["name"] if palette.colors.get(name) : return palette.colors.get(name) else : p = palette.colors.new() for attr,value in info.items() : setattr(p,attr,value) return p """ def get_gp_data_block() : scene = bpy.context.scene if scene.tool_settings.grease_pencil_source == 'OBJECT' and ob and ob.grease_pencil: gp_data_block = ob.grease_pencil elif scene.grease_pencil : gp_data_block = scene.grease_pencil else : gp_data_block =bpy.data.grease_pencil.new('GPencil') scene.grease_pencil = gp_data_block palette = gp_data_block.palettes.active if not palette : palette = gp_data_block.palettes.new("palette") return gp_data_block,palette """ def get_gp_objects(selection=True): '''return selected objects or only the active one''' if not bpy.context.active_object or bpy.context.active_object.type != 'GREASEPENCIL': print('No active GP object') return [] active = bpy.context.active_object if selection: selection = [o for o in bpy.context.selected_objects if o.type == 'GREASEPENCIL'] if not active in selection: selection += [active] return selection if bpy.context.active_object and bpy.context.active_object.type == 'GREASEPENCIL': return [active] return [] def get_gp_datas(selection=True): '''return selected objects or only the active one''' if not bpy.context.active_object or bpy.context.active_object.type != 'GREASEPENCIL': print('No active GP object') return [] active_data = bpy.context.active_object.data if selection: selected = [] for o in bpy.context.selected_objects: if o.type == 'GREASEPENCIL': if o.data not in selected: selected.append(o.data) # selected = [o.data for o in bpy.context.selected_objects if o.type == 'GREASEPENCIL'] if not active_data in selected: selected += [active_data] return selected if bpy.context.active_object and bpy.context.active_object.type == 'GREASEPENCIL': return [active_data] print('EOL. No active GP object') return [] def get_gp_layer(gp_data_block, name) : gp_layer = gp_data_block.layers.get(name) if not gp_layer : gp_layer = gp_data_block.layers.new(name) return gp_layer def get_gp_frame(layer, frame_nb=None) : scene = bpy.context.scene if not frame_nb : frame_nb = scene.frame_current frames={} for i,f in enumerate(layer.frames) : frames[f.frame_number]=i if not scene.frame_current in frames.keys(): dest_frame = layer.frames.new(frame_nb) else : dest_frame = layer.frames[frames[frame_nb]] dest_frame.clear() return dest_frame def get_active_frame(layer_name=None): '''Return active frame of active layer or from layer name passed''' if layer_name: lay = bpy.context.scene.grease_pencil.layers.get(layer_name) if lay: frame = lay.current_frame() if frame: return frame else: print ('no active frame for layer', layer_name) else: print('no layers named', layer_name, 'in scene layers') else:#active layer frame = bpy.context.scene.grease_pencil.layers.active.current_frame() if frame: return frame else: print ('no active frame on active layer') def get_stroke_2D_coords(stroke): '''return a list containing points 2D coordinates of passed gp stroke object''' return [location_to_region(p.position) for p in stroke.points] '''#foreach method for retreiving multiple other attribute quickly and stack them point_nb = len(stroke.points) seq = [0]*(point_nb*3) stroke.points.foreach_get("co",seq) print("raw_list", seq)#Dbg import numpy as np #can use np.stack to add points infos on same index (on different line/dimension) #https://docs.scipy.org/doc/numpy-1.14.0/reference/generated/numpy.stack.html ''' def get_all_stroke_2D_coords(frame): '''return a list of lists with all strokes's points 2D location''' ## using modification from get_stroke_2D_coords func' return [get_stroke_2D_coords(s) for s in frame.drawing.strokes] ## direct #return[[location_to_region(p.position) for p in s.points] for s in frame.drawing.strokes] def selected_strokes(frame): '''return all stroke having a point selected as a list of strokes objects''' stlist = [] for i, s in enumerate(frame.drawing.strokes): if any(pt.select for pt in s.points): stlist.append(s) return stlist ## Copy stroke to a frame def copy_stroke_to_frame(s, frame, select=True): '''Copy stroke to given frame return created stroke ''' frame.drawing.add_strokes([len(s.points)]) ns = frame.drawing.strokes[-1] # print(len(s.points), 'new:', len(ns.points)) #ns.material_index ## replicate attributes (simple loop) ## TODO : might need to create atribute domain if does not exists in destination for attr in stroke_attr: setattr(ns, attr, getattr(s, attr)) for src_p, dest_p in zip(s.points, ns.points): for attr in point_attr: setattr(dest_p, attr, getattr(src_p, attr)) ## Define selection dest_p.select=select ## Direcly iterate over attribute ? # src_start = src_dr.curve_offsets[0].value # src_end = src_start + data_size # dst_start = dst_dr.curve_offsets[0].value # dst_end = dst_start + data_size # for src_idx, dest_idx in zip(range(src_start, src_end),range(dst_start, dst_end)): # setattr(dest_attr.data[dest_idx], val_type, getattr(source_attr.data[src_idx], val_type)) return ns """## Works, but do not copy all attributes type (probably ok for GP though) def bulk_frame_copy_attributes(source_attr, target_attr): '''Get and apply data as flat numpy array based on attribute type''' if source_attr.data_type == 'INT': data = np.empty(len(source_attr.data), dtype=np.int32) source_attr.data.foreach_get('value', data) target_attr.data.foreach_set('value', data) elif source_attr.data_type == 'INT8': data = np.empty(len(source_attr.data), dtype=np.int8) source_attr.data.foreach_get('value', data) target_attr.data.foreach_set('value', data) elif source_attr.data_type == 'FLOAT': data = np.empty(len(source_attr.data), dtype=np.float32) source_attr.data.foreach_get('value', data) target_attr.data.foreach_set('value', data) elif source_attr.data_type == 'FLOAT_VECTOR': data = np.empty(len(source_attr.data) * 3, dtype=np.float32) source_attr.data.foreach_get('vector', data) target_attr.data.foreach_set('vector', data) elif source_attr.data_type == 'FLOAT_COLOR': data = np.empty(len(source_attr.data) * 4, dtype=np.float32) source_attr.data.foreach_get('color', data) target_attr.data.foreach_set('color', data) elif source_attr.data_type == 'BOOLEAN': data = np.empty(len(source_attr.data), dtype=bool) source_attr.data.foreach_get('value', data) target_attr.data.foreach_set('value', data) ## works in slowmotion (keep as reference for testing) # def copy_attribute_values(src_dr, dst_dr, source_attr, dest_attr, data_size): # ## Zip method to copy one by one # val_type = {'FLOAT_COLOR': 'color','FLOAT_VECTOR': 'vector'}.get(source_attr.data_type, 'value') # src_start = src_dr.curve_offsets[0].value # src_end = src_start + data_size # dst_start = dst_dr.curve_offsets[0].value # dst_end = dst_start + data_size # for src_idx, dest_idx in zip(range(src_start, src_end),range(dst_start, dst_end)): # setattr(dest_attr.data[dest_idx], val_type, getattr(source_attr.data[src_idx], val_type)) """ def bulk_copy_attributes(source_attr, target_attr): '''Get and apply data as flat numpy array based on attribute type''' value_string = attribute_value_string[source_attr.data_type] dtype = attribute_value_dtype[source_attr.data_type] shape = attribute_value_shape[source_attr.data_type] domain_size = len(source_attr.data) ## Need to pass attributes to get domain size # domain_size = attributes.domain_size(source_attr.domain) # start = time() data = np.empty((domain_size, *shape), dtype=dtype).ravel() source_attr.data.foreach_get(value_string, data) target_attr.data.foreach_set(value_string, data) # end = time() # np_empty = end - start ## np.prod (works, supposedly faster but tested slower) # data = np.empty(int(domain_size * np.prod(shape)), dtype=dtype) # source_attr.data.foreach_get(value_string, data) # target_attr.data.foreach_set(value_string, data) ## np.zeros (works, sometimes faster on big set of attributes) # start = time() # data = np.zeros((domain_size, *shape), dtype=dtype) # source_attr.data.foreach_get(value_string, np.ravel(data)) # target_attr.data.foreach_set(value_string, np.ravel(data)) # end = time() # np_zero = end - start # print('np EMPTY faster' if np_empty < np_zero else 'np ZERO faster', source_attr.domain, source_attr.data_type, domain_size) # print('np_zero', np_zero) # print('np_empty', np_empty) # print() def copy_frame_at(source_frame, layer, frame_number): '''Copy a frame (source_frame) to a layer at given frame_number''' source_drawing = source_frame.drawing # frame_copy_start = time() # time_dbg frame = layer.frames.new(frame_number) dr = frame.drawing dr.add_strokes([len(s.points) for s in source_drawing.strokes]) for attr_name in source_drawing.attributes.keys(): source_attr = source_drawing.attributes[attr_name] if attr_name not in dr.attributes: dr.attributes.new( name=attr_name, type=source_attr.data_type, domain=source_attr.domain) target_attr = dr.attributes[attr_name] # start_time = time() # time_dbg-per-attrib # bulk_frame_copy_attributes(source_attr, target_attr) # only some attributes bulk_copy_attributes(source_attr, target_attr) # copy_attribute_values(source_drawing, dr, source_attr, target_attr, source_drawing.attributes.domain_size(source_attr.domain)) # super slow # end_time = time() # time_dbg-per-attrib # print(f"copy_attribute '{attr_name}' execution time: {end_time - start_time} seconds") # time_dbg-per-attrib # frame_copy_end = time() # time_dbg # print(f"frame copy execution time: {frame_copy_end - frame_copy_start} seconds") # time_dbg # ----------------- ### Vector utils 3d # ----------------- 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 single_vector_length(v): return sqrt((v[0] * v[0]) + (v[1] * v[1]) + (v[2] * v[2])) def vector_length(A,B): ''''take two Vector3 and return length''' return sqrt((A[0] - B[0])**2 + (A[1] - B[1])**2 + (A[2] - B[2])**2) def vector_length_coeff(size, A, B): ''' Calculate the vector lenght return the coefficient to multiply this vector to obtain a vector of the size given in paramerter ''' Vlength = sqrt((A[0] - B[0])**2 + (A[1] - B[1])**2 + (A[2] - B[2])**2) if Vlength == 0: print('problem Vector lenght == 0 !') return (1) return (size / Vlength) def cross_vector_coord(foo, bar, size): '''Return the coord in space of a cross vector between the two point with specified size''' between = foo - bar #create a generic Up vector (on Y or Z) up = Vector([1.0,0,0]) new = Vector.cross(up, between)#the cross product return a 90 degree Vector if new == Vector([0.0000, 0.0000, 0.0000]): #new == 0 if up vector and between are aligned ! (so change up vector) up = Vector([0,-1.0,0]) new = Vector.cross(up, between)#the cross product return a 90 degree Vector perpendicular = foo + new coeff = vector_length_coeff(size, foo, perpendicular) #position the point in space by adding the new vector multiplied by coeff value to get wanted lenght return (foo + (new * coeff)) def midpoint(p1, p2): '''middle location between 2 vector is calculated by adding the two vector and divide by two''' ##mid = (foo + bar) / 2 return (Vector([(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2])) def extrapolate_points_by_length(a,b, length): ''' Return a third point C from by continuing in AB direction Length define BC distance. both vector2 and vector3 ''' # return b + ((b - a).normalized() * length)# one shot ab = b - a if not ab: return None return b + (ab.normalized() * length) # ----------------- ### Vector utils 2d # ----------------- def is_vector_close(a, b, rel_tol=1e-03): '''compare Vector or sequence of value by default tolerance is set on 1e-03 (0.001) ''' return all([math.isclose(i, j, rel_tol=rel_tol) for i, j in zip(a,b)]) def single_vector_length_2d(v): return sqrt((v[0] * v[0]) + (v[1] * v[1])) def vector_length_2d(A,B): ''''take two Vector and return length''' return sqrt((A[0] - B[0])**2 + (A[1] - B[1])**2) def vector_length_coeff_2d(size, A, B): ''' Calculate the vector lenght return the coefficient to multiply this vector to obtain a vector of the size given in paramerter ''' Vlength = sqrt((A[0] - B[0])**2 + (A[1] - B[1])**2) if Vlength == 0: print('problem Vector lenght == 0 !') return (1) return (size / Vlength) def cross_vector_coord_2d(foo, bar, size): '''Return the coord in space of a cross vector between the two point with specified size''' ##middle location between 2 vector is calculated by adding the two vector and divide by two ##mid = (foo + bar) / 2 between = foo - bar #create a generic Up vector (on Y or Z) up = Vector([0,1.0]) new = Vector.cross(up, between)#the cross product return a 90 degree Vector if new == Vector([0.0000, 0.0000]): #new == 0 if up vector and between are aligned ! (so change up vector) up = Vector([0,-1.0,0]) new = Vector.cross(up, between)#the cross product return a 90 degree Vector perpendicular = foo + new coeff = vector_length_coeff(size, foo, perpendicular) #position the point in space by adding the new vector multiplied by coeff value to get wanted lenght return (foo + (new * coeff)) def midpoint_2d(p1, p2): return (Vector([(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2])) # ----------------- ### Collection management # ----------------- def set_collection(ob, collection, unlink=True) : ''' link an object in a collection and create it if necessary, if unlink object is removed from other collections''' scn = bpy.context.scene col = None visible = False linked = False # check if collection exist or create it for c in bpy.data.collections : if c.name == collection : col = c if not col : col = bpy.data.collections.new(name=collection) # link the collection to the scene's collection if necessary for c in scn.collection.children : if c.name == col.name : visible = True if not visible : scn.collection.children.link(col) # check if the object is already in the collection and link it if necessary for o in col.objects : if o == ob : linked = True if not linked : col.objects.link(ob) # remove object from scene's collection for o in scn.collection.objects : if o == ob : scn.collection.objects.unlink(ob) # if unlink flag we remove the object from other collections if unlink : for c in ob.users_collection : if c.name != collection : c.objects.unlink(ob) # ----------------- ### Path utils # ----------------- def get_addon_prefs(): return bpy.context.preferences.addons[__package__].preferences def open_addon_prefs(): '''Open addon prefs windows with focus on current addon''' from .__init__ import bl_info wm = bpy.context.window_manager wm.addon_filter = 'All' if not 'COMMUNITY' in wm.addon_support: # reactivate community wm.addon_support = set([i for i in wm.addon_support] + ['COMMUNITY']) wm.addon_search = bl_info['name'] bpy.context.preferences.active_section = 'ADDONS' bpy.ops.preferences.addon_expand(module=__package__) bpy.ops.screen.userpref_show('INVOKE_DEFAULT') def open_file(file_path) : '''Open filepath with default browser''' if platform.lower() == 'darwin': subprocess.call(('open', file_path)) elif platform.lower().startswith('win'): os.startfile(file_path) # subprocess.call(('start', file_path)) else:#linux subprocess.call(('xdg-open', file_path)) def open_folder(folderpath): '''Open the folder at given path with default browser''' myOS = platform if myOS.startswith('linux') or myOS.startswith('freebsd'): cmd = 'xdg-open' elif myOS.startswith('win'): cmd = 'explorer' if not folderpath: return('/') else:#elif myOS == "darwin": cmd = 'open' if not folderpath: return('//') folderpath = os.path.normpath(folderpath)# to prevent bad path string fullcmd = [cmd, folderpath] print(fullcmd) # subprocess.call(fullcmd) subprocess.Popen(fullcmd) return ' '.join(fullcmd)#back to string to return and print def detect_OS(): """return str of os name : Linux, Windows, Mac (None if undetected)""" myOS = platform if myOS.startswith('linux') or myOS.startswith('freebsd'):# linux # print("operating system : Linux") return ("Linux") elif myOS.startswith('win'):# Windows # print("operating system : Windows") return ("Windows") elif myOS == "darwin":# OS X # print("operating system : Mac") return ('Mac') else:# undetected print("Cannot detect OS, python 'sys.platform' give :", myOS) return None def fuzzy_match(s1, s2, tol=0.8, case_sensitive=False): '''Tell if two strings are similar using a similarity ratio (0 to 1) value passed as third arg''' from difflib import SequenceMatcher # can also use difflib.get_close_matches(word, possibilities, n=3, cutoff=0.6) if case_sensitive: similarity = SequenceMatcher(None, s1, s2) else: similarity = SequenceMatcher(None, s1.lower(), s2.lower()) return similarity.ratio() > tol def fuzzy_match_ratio(s1, s2, case_sensitive=False): '''Tell how much two passed strings are similar 1.0 being exactly similar''' from difflib import SequenceMatcher if case_sensitive: similarity = SequenceMatcher(None, s1, s2) else: similarity = SequenceMatcher(None, s1.lower(), s2.lower()) return similarity.ratio() def convert_attr(Attr): '''Convert given value to a Json serializable format''' if isinstance(Attr, (mathutils.Vector,mathutils.Color)): return Attr[:] elif isinstance(Attr, mathutils.Matrix): return [v[:] for v in Attr] elif isinstance(Attr,bpy.types.bpy_prop_array): return [Attr[i] for i in range(0,len(Attr))] else: return(Attr) ## confirm pop-up message: def show_message_box(_message = "", _title = "Message Box", _icon = 'INFO'): '''Show message box with element passed as string or list if _message if a list of lists: if first element is "OPERATOR": List format: ["OPERATOR", operator_id, text, icon, {prop_name: value, ...}] if sublist have 2 element: considered a label [text, icon] if sublist have 3 element: considered as an operator [ops_id_name, text, icon] if sublist have 4 element: considered as a property [object, propname, text, icon] ''' def draw(self, context): layout = self.layout for l in _message: if isinstance(l, str): layout.label(text=l) elif l[0] == "OPERATOR": # Special operator case with properties layout.operator_context = "INVOKE_DEFAULT" op = layout.operator(l[1], text=l[2], icon=l[3], emboss=False) if len(l) > 4 and isinstance(l[4], dict): for prop_name, value in l[4].items(): setattr(op, prop_name, value) elif len(l) == 2: # label with icon layout.label(text=l[0], icon=l[1]) elif len(l) == 3: # ops layout.operator_context = "INVOKE_DEFAULT" layout.operator(l[0], text=l[1], icon=l[2], emboss=False) # <- highligh the entry elif len(l) == 4: # prop row = layout.row(align=True) row.label(text=l[2], icon=l[3]) row.prop(l[0], l[1], text='') if isinstance(_message, str): _message = [_message] bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon) # ----------------- ### UI utils # ----------------- def refresh_areas(): for window in bpy.context.window_manager.windows: for area in window.screen.areas: area.tag_redraw() # for area in bpy.context.screen.areas: # area.tag_redraw() ## kmi draw for addon without delete button def draw_kmi(km, kmi, layout): map_type = kmi.map_type ## col = _indented_layout(layout) # layout.label(text=f'{km.name} - {km.space_type} - {km.region_type}') # debug # layout.label(text=f'{km.name}') col = layout.column() if kmi.show_expanded: col = col.column(align=True) box = col.box() else: box = col.column() split = box.split() # header bar row = split.row(align=True) row.prop(kmi, "show_expanded", text="", emboss=False) row.prop(kmi, "active", text="", emboss=False) if km.is_modal: row.separator() row.prop(kmi, "propvalue", text="") else: row.label(text=kmi.name) row = split.row() row.prop(kmi, "map_type", text="") if map_type == 'KEYBOARD': row.prop(kmi, "type", text="", full_event=True) elif map_type == 'MOUSE': row.prop(kmi, "type", text="", full_event=True) elif map_type == 'NDOF': row.prop(kmi, "type", text="", full_event=True) elif map_type == 'TWEAK': subrow = row.row() subrow.prop(kmi, "type", text="") subrow.prop(kmi, "value", text="") elif map_type == 'TIMER': row.prop(kmi, "type", text="") else: row.label() ### / Hided delete button if (not kmi.is_user_defined) and kmi.is_user_modified: row.operator("preferences.keyitem_restore", text="", icon='BACK').item_id = kmi.id else: pass ### NO REMOVE # row.operator( # "preferences.keyitem_remove", # text="", # # Abusing the tracking icon, but it works pretty well here. # icon=('TRACKING_CLEAR_BACKWARDS' if kmi.is_user_defined else 'X') # ).item_id = kmi.id ### Hided delete button / # Expanded, additional event settings if kmi.show_expanded: box = col.box() split = box.split(factor=0.4) sub = split.row() if km.is_modal: sub.prop(kmi, "propvalue", text="") else: # One day... # sub.prop_search(kmi, "idname", bpy.context.window_manager, "operators_all", text="") sub.prop(kmi, "idname", text="") if map_type not in {'TEXTINPUT', 'TIMER'}: sub = split.column() subrow = sub.row(align=True) if map_type == 'KEYBOARD': subrow.prop(kmi, "type", text="", event=True) subrow.prop(kmi, "value", text="") subrow_repeat = subrow.row(align=True) subrow_repeat.active = kmi.value in {'ANY', 'PRESS'} subrow_repeat.prop(kmi, "repeat", text="Repeat") elif map_type in {'MOUSE', 'NDOF'}: subrow.prop(kmi, "type", text="") subrow.prop(kmi, "value", text="") subrow = sub.row() subrow.scale_x = 0.75 subrow.prop(kmi, "any", toggle=True) subrow.prop(kmi, "shift", toggle=True) subrow.prop(kmi, "ctrl", toggle=True) subrow.prop(kmi, "alt", toggle=True) subrow.prop(kmi, "oskey", text="Cmd", toggle=True) subrow.prop(kmi, "key_modifier", text="", event=True) # Operator properties box.template_keymap_item_properties(kmi) ## Modal key maps attached to this operator # if not km.is_modal: # kmm = kc.keymaps.find_modal(kmi.idname) # if kmm: # draw_km(display_keymaps, kc, kmm, None, layout + 1) # layout.context_pointer_set("keymap", km) # ----------------- ### linking utility # ----------------- def link_objects_in_blend(filepath, obj_name_list, link=True): '''Link an object by name from a file, if link is False, append instead of linking''' if isinstance(obj_name_list, str): obj_name_list = [obj_name_list] with bpy.data.libraries.load(filepath, link=link) as (data_from, data_to): data_to.objects = [o for o in data_from.objects if o in obj_name_list] return data_to.objects def check_materials_in_blend(filepath): '''Return a list of all material in remote blend file''' with bpy.data.libraries.load(filepath, link=False) as (data_from, data_to): l = [m for m in data_from.materials] return l def check_objects_in_blend(filepath, avoid_camera=True): '''Return a list of object name in remote blend file''' with bpy.data.libraries.load(filepath, link=False) as (data_from, data_to): if avoid_camera: l = [o for o in data_from.objects if not any(x in o.lower() for x in ('camera', 'draw_cam', 'obj_cam'))] else: l = [o for o in data_from.objects] return l # ----------------- ### props handling # ----------------- def iterate_selector(zone, attr, state, info_attr = None, active_access='active'): '''Iterate with given attribute''' item_number = len(zone) if item_number <= 1: return if getattr(zone, attr) == None: print('no', attr, 'in', zone) return if state: # swap info = None bottom = None new_index = getattr(zone, attr) + state setattr(zone, attr, new_index % item_number) if new_index == item_number: bottom = 1 # bottom reached, cycle to first elif new_index < 0: bottom = -1 # up reached, cycle to last if info_attr: active_item = getattr(zone, active_access) # active by default if active_item: info = getattr(active_item, info_attr) return info, bottom def iterate_active_layer(gpd, state): '''Iterate active GP layer in stack gpd: Grease Pencil Data ''' layers = gpd.layers l_count = len(layers) if state: # swap # info = None # bottom = None ## Get active layer index active_index = closest_layer_active_index(gpd, fallback_index=None) if active_index == None: ## fallback to first layer if nothing found gpd.layers.active = layers[0] return target_index = active_index + state new_index = target_index % l_count ## set active layer gpd.layers.active = layers[new_index] if target_index == l_count: bottom = 1 # bottom reached, cycle to first elif target_index < 0: bottom = -1 # up reached, cycle to last # info = gpd.layers.active.name # return info, bottom # ----------------- ### Curve handle # ----------------- def create_curve(location=(0,0,0), direction=(1,0,0), name='curve_path', enter_edit=True, context=None): '''Create curve at provided location and direction vector''' ## option to create nurbs instaed of bezier ? context = context or bpy.context ## using ops (dirty) # bpy.ops.curve.primitive_bezier_curve_add(radius=1, enter_editmode=enter_edit, align='WORLD', location=location, scale=(1, 1, 1)) # curve = context.object # curve.name = 'curve_path' # # fast straighten # bpy.ops.curve.handle_type_set(type='VECTOR') # bpy.ops.curve.handle_type_set(type='ALIGNED') # bpy.ops.transform.translate(value=(1, 0, 0), orient_type='LOCAL', # orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), orient_matrix_type='LOCAL', # constraint_axis=(True, False, False), mirror=True, use_proportional_edit=False) ## using data curve_data = bpy.data.curves.new(name, 'CURVE') # ('CURVE', 'SURFACE', 'FONT') curve_data.dimensions = '3D' curve_data.use_path = True curve = bpy.data.objects.new(name, curve_data) spl = curve_data.splines.new('BEZIER') # ('POLY', 'BEZIER', 'NURBS') spl.bezier_points.add(1) # One point already exists for i in range(2): spl.bezier_points[i].handle_left_type = 'VECTOR' # ('FREE', 'VECTOR', 'ALIGNED', 'AUTO') spl.bezier_points[i].handle_right_type = 'VECTOR' spl.bezier_points[1].co = direction # Back to aligned mode for i in range(2): spl.bezier_points[i].handle_right_type = spl.bezier_points[i].handle_left_type = 'ALIGNED' # Select second point spl.bezier_points[1].select_control_point = True spl.bezier_points[1].select_left_handle = True spl.bezier_points[1].select_right_handle = True # link context.scene.collection.objects.link(curve) # curve object settings curve.location = location curve.show_in_front = True # enter edit if enter_edit and context.mode == 'OBJECT': curve.select_set(True) context.view_layer.objects.active = curve bpy.ops.object.mode_set(mode='EDIT', toggle=False) # EDIT_CURVE ## set viewport overlay visibility for better view if context.space_data.type == 'VIEW_3D': context.space_data.overlay.show_curve_normals = True context.space_data.overlay.normals_length = 0.2 return curve def get_direction_vector_from_enum(string) -> Vector: orient_vectors = { 'FORWARD_X' : Vector((1,0,0)), 'FORWARD_Y' : Vector((0,1,0)), 'FORWARD_Z' : Vector((0,0,1)), 'TRACK_NEGATIVE_X' : Vector((-1,0,0)), 'TRACK_NEGATIVE_Y' : Vector((0,-1,0)), 'TRACK_NEGATIVE_Z' : Vector((0,0,-1)) } return orient_vectors[string] def orentation_track_from_vector(input_vector) -> str: '''return closest world track orientation name from passed vector direction''' orient_vectors = { 'FORWARD_X' : Vector((1,0,0)), 'FORWARD_Y' : Vector((0,1,0)), 'FORWARD_Z' : Vector((0,0,1)), 'TRACK_NEGATIVE_X' : Vector((-1,0,0)), 'TRACK_NEGATIVE_Y' : Vector((0,-1,0)), 'TRACK_NEGATIVE_Z' : Vector((0,0,-1)) } orient = None min_angle = 10000 for track, v in orient_vectors.items(): angle = input_vector.angle(v) if angle < min_angle: min_angle = angle orient = track return orient def create_follow_path_constraint(ob, curve, follow_curve=False, use_fixed_location=True): '''return create constraint''' # # Clear bone follow path constraint exiting_fp_constraints = [c for c in ob.constraints if c.type == 'FOLLOW_PATH'] for c in exiting_fp_constraints: ob.constraints.remove(c) # loc = ob.matrix_world @ ob.matrix.to_translation() if ob.location != (0,0,0): old_loc = ob.location ob.location = (0,0,0) print(f'ob moved from {old_loc} to (0,0,0) to counter follow curve offset') const = ob.constraints.new('FOLLOW_PATH') const.target = curve if follow_curve: const.use_curve_follow = True if use_fixed_location: const.use_fixed_location = True return const ## on_bones: # prefs = get_addon_prefs() # root_name = prefs.tgt_bone # root = ob.pose.bones.get(root_name) # if not root: # return ('ERROR', f'posebone {root_name} not found in armature {ob.name} check addon preferences to change name') # # Clear bone follow path constraint # exiting_fp_constraints = [c for c in root.constraints if c.type == 'FOLLOW_PATH'] # for c in exiting_fp_constraints: # root.constraints.remove(c) # # loc = ob.matrix_world @ root.matrix.to_translation() # if root.name == ('world', 'root') and root.location != (0,0,0): # old_loc = root.location # root.location = (0,0,0) # print(f'root moved from {old_loc} to (0,0,0) to counter follow curve offset') # const = root.constraints.new('FOLLOW_PATH') # const.target = curve # # axis only in this case, should be in addon to prefs # ## determine which axis to use... maybe found orientation in world space from matrix_basis ? # root_world_base_direction = root.bone.matrix_local @ get_direction_vector_from_enum(bpy.context.scene.anim_cycle_settings.forward_axis) # const.forward_axis = orentation_track_from_vector(root_world_base_direction) # 'TRACK_NEGATIVE_Y' # bpy.context.scene.anim_cycle_settings.forward_axis # 'FORWARD_X' # print('const.forward_axis: ', const.forward_axis) # const.use_curve_follow = True # return curve, const # ----------------- ### Object # ----------------- def go_edit_mode(ob, context=None): '''set mode to object, set passed obhject as active and go Edit''' context = context or bpy.context bpy.ops.object.mode_set(mode='OBJECT', toggle=False) ob.select_set(True) context.view_layer.objects.active = ob bpy.ops.object.mode_set(mode='EDIT', toggle=False) ## Not used anymore def all_anim_enabled(objects) -> bool: '''return False if at least one fcurve/group has disabled animation in passed object anim''' for o in objects: if o.animation_data and o.animation_data.action: ## Skip hided object ? # if o.hide_get(): # continue ## Check if groups are muted for grp in o.animation_data.action.groups: if grp.mute: return False ## Check if fcurves are muted for fcu in o.animation_data.action.fcurves: if fcu.mute: return False if o.type in ('GREASEPENCIL', 'CAMERA'): if o.data.animation_data and o.data.animation_data.action: ## Check if object data attributes fcurves are muted for fcu in o.animation_data.action.fcurves: if fcu.mute: return False return True ## Not used anymore def all_object_modifier_enabled(objects) -> bool: '''Return False if one modifier of one object has GP modifier disabled in viewport but enabled in render''' for o in objects: if o.type != 'GREASEPENCIL': continue for m in o.modifiers: if m.show_render and not m.show_viewport: return False ## return False when viewport but no render ? # if not m.show_render and m.show_viewport: # return False return True ## Only used in anim_status in 'per GP object' mode def has_fully_enabled_anim(o): if o.animation_data and o.animation_data.action: ## Skip hided object ? (missmatch) # if o.hide_get(): # continue ## Check if groups are muted for grp in o.animation_data.action.groups: if grp.mute: return False ## Check if fcurves are muted for fcu in o.animation_data.action.fcurves: if fcu.mute: return False if o.type in ('GREASEPENCIL', 'CAMERA'): if o.data.animation_data and o.data.animation_data.action: ## Check if object data attributes fcurves are muted for fcu in o.animation_data.action.fcurves: if fcu.mute: return False return True def anim_status(objects) -> tuple((str, str)): '''Return a tutple of icon string status in ('ALL_ON', 'MIXED', 'ALL_OFF', 'NONE')''' on_count = off_count = count = 0 for o in objects: ## Skip hidden objects if o.hide_get() and o.hide_render: continue ### Per Object # count += 1 # if has_fully_enabled_anim(o): # on_count += 1 # else: # off_count += 1 ### Consider All channels individually if o.animation_data and o.animation_data.action: for grp in o.animation_data.action.groups: ## Check if groups are muted if grp.mute: off_count += 1 else: on_count += 1 count += 1 for fcu in o.animation_data.action.fcurves: ## Check if fcurves are muted if fcu.mute: off_count += 1 else: on_count += 1 count += 1 if o.type in ('GREASEPENCIL', 'CAMERA'): datablock = o.data if datablock.animation_data is None: continue if not datablock.animation_data.action: continue ## Check if object data attributes fcurves are muted for fcu in datablock.animation_data.action.fcurves: if fcu.mute: off_count += 1 else: on_count += 1 count += 1 if not on_count and not off_count: return ('BLANK1', 'BLANK1') # 'NONE' elif on_count == count: return ('LAYER_ACTIVE', 'BLANK1') # 'ALL_ON' elif off_count == count: return ('BLANK1', 'LAYER_ACTIVE') # 'ALL_OFF' else: return ('LAYER_USED', 'LAYER_USED') # 'MIXED' def gp_modifier_status(objects) -> tuple((str, str)): '''return icons on/off tuple''' on_count = off_count = count = 0 for o in objects: if o.type != 'GREASEPENCIL': continue ## Skip hided object if o.hide_get() and o.hide_render: continue for m in o.modifiers: if m.show_render and not m.show_viewport: off_count += 1 else: on_count += 1 count += 1 ## return False when viewport but no render ? # if not m.show_render and m.show_viewport: # return False if not on_count and not off_count: return ('BLANK1', 'BLANK1') # 'NONE' elif on_count == count: return ('LAYER_ACTIVE', 'BLANK1') # 'ALL_ON' elif off_count == count: return ('BLANK1', 'LAYER_ACTIVE') # 'ALL_OFF' else: return ('LAYER_USED', 'LAYER_USED')