gp_toolbox/utils.py

1576 lines
52 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

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