background_plane_manager/core.py

809 lines
25 KiB
Python
Raw 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.

# SPDX-License-Identifier: GPL-3.0-or-later
## Standalone based on SPA code
## Code from create_gp_texture_ref.py (Copyright (C) 2023, The SPA Studios. All rights reserved.)
## adapted to create as a single 'GP as image' and fit in camera with driver
import bpy
import os
import re
import time
from pathlib import Path
from mathutils import Vector, geometry
from pprint import pprint as pp
from .constants import PREFIX, BGCOL
def get_addon_prefs():
return bpy.context.preferences.addons[__package__].preferences
def norm_str(string, separator='_', format=str.lower, padding=0):
import unicodedata
string = str(string)
string = string.replace('_', ' ')
string = string.replace('-', ' ')
string = re.sub('[ ]+', ' ', string)
string = re.sub('[ ]+\/[ ]+', '/', string)
string = string.strip()
if format:
string = format(string)
# Padd rightest number
string = re.sub(r'(\d+)(?!.*\d)', lambda x : x.group(1).zfill(padding), string)
string = string.replace(' ', separator)
string = unicodedata.normalize('NFKD', string).encode('ASCII', 'ignore').decode("utf-8")
return string
def clean_image_name(name):
'''Strip blender numbering then strip extension'''
name = re.sub(r'\.\d{3}$', '', name.strip())
return os.path.splitext(name)[0].strip()
def get_image_infos_from_object(ob):
'''Get image filepath of a background whatever the type
return image_data, transparency'''
transparency = None
if ob.type == 'EMPTY':
image_data = ob.data
transparency = ob.color[3]
elif ob.type == 'MESH':
texture_plane_ng = bpy.data.node_groups.get('texture_plane')
if not texture_plane_ng:
print(f'{ob.name} : No texture plane nodegroup found !')
return
mat = ob.active_material
node = next((n for n in mat.node_tree.nodes
if n.type=='GROUP' and n.node_tree is texture_plane_ng), None)
if not node:
print(f'{ob.name} : No texture plane nodegroup in material {mat.name}')
return
## Get input texture
tex_node = next(n for n in mat.node_tree.nodes
if n.type=='TEX_IMAGE')
if not tex_node:
print(f'{ob.name} : No image texture node in material {mat.name}')
return
image_data = tex_node.image
transparency = node.inputs['Transparency'].default_value
elif ob.type == 'GPENCIL':
image_data = ob.data.materials[0].grease_pencil.fill_image
transparency = ob.data.layers[0].opacity
if not image_data:
return
return (image_data, transparency)
def get_image(ob):
'''Get image data from image object'''
infos = get_image_infos_from_object(ob)
if infos is None:
return
img, _opacity = infos
return img
def get_opacity(ob):
'''Get opacity from image object'''
infos = get_image_infos_from_object(ob)
if infos is None:
return
_img, opacity = infos
return opacity
def set_opacity(ob, opacity):
'''Set opacity of BG image object whatever the type'''
if ob.type == 'EMPTY':
ob.color[3] = opacity
elif ob.type == 'MESH':
texture_plane_ng = bpy.data.node_groups.get('texture_plane')
if texture_plane_ng:
node = next(n for n in ob.active_material.node_tree.nodes
if n.type == 'GROUP' and n.node_tree is texture_plane_ng)
node.inputs['Transparency'].default_value = opacity
elif ob.type == 'GPENCIL':
ob.data.layers[0].opacity = opacity
def create_plane_holder(img, name=None, parent=None, link_in_col=None):
'''
img :: blender image or path to an image
prefix :: name holder with image name with given prefix
parent :: object to parent to (only if passed)
link_in_col :: collection to link after creation
Return created object mesh without face
None if creation failed
'''
## get image if a path is passed in
if not isinstance(img, bpy.types.Image):
try:
# load from path (str)
img = bpy.data.images.load(str(img), check_existing=True)
except:
return
# name = img.name.split('.')[0]
name = name or clean_image_name(img.name)
obj_name = name # PREFIX + name # <- always set prefix ?
x,y = img.size[:]
# create and place object plane
# find height from width (as 1)
if y < x:
h = y/x
w = 1
else:
h = 1
w = x/y
# size/2 to get center image (half width | height)
h = h/2
w = w/2
verts = [
(-w, h, 0),#2
(w, h, 0),#3
(w, -h, 0),#4
(-w, -h, 0),#1
]
mesh = bpy.data.meshes.new(obj_name)
mesh.from_pydata(verts, [(3,2), (2,1), (1,0), (0,3)], [])
holder = bpy.data.objects.new(obj_name, mesh)
holder.lock_location = (True, True, False)
holder.hide_render = True
if link_in_col is not None:
link_in_col.objects.link(holder)
# bpy.context.scene.collection.objects.link(holder)
if parent:
holder.parent = parent
return holder
## Remove func is no intermediate collection created
def get_col(name, parent=None, create=True):
parent = parent or bpy.context.scene.collection
col = bpy.data.collections.get(name)
if not col and create:
col = bpy.data.collections.new(name)
if col not in parent.children[:]:
parent.children.link(col)
return col
def create_cam(name, type='ORTHO', size=6, focale=50):
'''Create an orthographic camera with given scale
size :: othographic scale of the camera
Return camera object
'''
cam_data = bpy.data.cameras.new(name)
cam_data.type = type
cam_data.clip_start = 0.02
cam_data.clip_end = 2000
cam_data.ortho_scale = size
cam_data.lens = focale
cam = bpy.data.objects.new(name, cam_data)
cam.rotation_euler.x = 1.5707963705062866
cam.location = (0, -8, 0)
bpy.context.scene.collection.objects.link(cam)
return cam
def link_nodegroup(filepath, group_name, link=True, keep_existing=False):
ng = bpy.data.node_groups.get(group_name)
if ng:
if keep_existing:
return ng
## risk deleting nodegroup from other object !
# else:
# bpy.data.node_groups.remove(ng)
lib_path = bpy.path.relpath(str(filepath))
with bpy.data.libraries.load(lib_path, link=link) as (data_from, data_to):
data_to.node_groups = [n for n in data_from.node_groups if n.startswith(group_name)]
group = bpy.data.node_groups.get(group_name)
if group:
return group
def create_plane_driver(plane, cam, distance=None):
# Multiple planes spacing
plane.lock_location = (True, True, False)
plane.lock_rotation = (True,)*3
plane.lock_scale = (True,)*3
plane['scale'] = 1.0
plane['distance'] = distance if distance is not None else 9.0
# DRIVERS
## LOC X AND Y (shift) ##
for axis in range(2):
driver = plane.driver_add('scale', axis)
# Driver type
driver.driver.type = 'SCRIPTED'
# Variable DISTANCE
var = driver.driver.variables.new()
var.name = "distance"
#-# use a propertie to drive distance to camera
var.type = 'SINGLE_PROP'
var.targets[0].id = plane
var.targets[0].data_path = '["distance"]'
#-# Can use directly object location (but give refresh problem)
# var.type = 'TRANSFORMS'
# var.targets[0].id = plane
# var.targets[0].transform_type = 'LOC_Z'
# var.targets[0].transform_space = 'LOCAL_SPACE'
# Variable FOV
var = driver.driver.variables.new()
var.name = "FOV"
var.type = 'SINGLE_PROP'
var.targets[0].id_type = "OBJECT"
var.targets[0].id = cam
var.targets[0].data_path = 'data.angle'
# Variable scale
var = driver.driver.variables.new()
var.name = "scale"
var.type = 'SINGLE_PROP'
var.targets[0].id = plane
var.targets[0].data_path = '["scale"]'
# Expression
# -distance*tan(FOV/2)
driver.driver.expression = \
"tan(FOV/2) * distance * scale * 2"
# "tan(FOV/2) * -distance * scale * 2" # inverse dist if based on locZ
# Driver for location
dist_drv = plane.driver_add('location', 2)
dist_drv.driver.type = 'SCRIPTED' #'AVERAGE' #need to be inverted
var = dist_drv.driver.variables.new()
var.name = "distance"
var.type = 'SINGLE_PROP'
var.targets[0].id = plane
var.targets[0].data_path = '["distance"]'
dist_drv.driver.expression = '-distance'
return driver, dist_drv
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
return collection (get/created)
'''
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)
return col
def placeholder_name(name='', context=None) -> str:
# def increment(match):
# return str(int(match.group(1))+1).zfill(len(match.group(1)))
context = context or bpy.context
name = name.strip()
if not name:
# Create a default name (fing last 3 number, before blender increment if exists, default increment)
numbers = [int(match.group(1)) for o in context.scene.objects\
if (match := re.search(r'^drawing.*_(\d+)(?:\.\d{3})?$', o.name, flags=re.I))\
# and o.type == 'GPENCIL'
]
if numbers:
numbers.sort()
name = f'drawing_{numbers[-1] + 1:03d}'
else:
name = 'drawing_001'
# elif context.scene.objects.get(name):
# name = re.sub(r'(\d+)(?!.*\d)', increment, name) # find rightmost number
return name
def reset_gp_uv(ob):
uvs = [(0.5, 0.5), (-0.5, 0.5), (-0.5, -0.5), (0.5, -0.5)]
try:
for p, uv in zip(ob.data.layers[0].frames[0].strokes[0].points, uvs):
p.uv_fill = uv
except:
print('Could not set Gp points UV')
## Get empty coords
def get_ref_object_space_coord(o):
size = o.empty_display_size
x,y = o.empty_image_offset
img = o.data
res_x, res_y = img.size
scaling = 1 / max(res_x, res_y)
# 3----2
# | |
# 0----1
corners = [
Vector((0,0)),
Vector((res_x, 0)),
Vector((0, res_y)),
Vector((res_x, res_y)),
]
obj_space_corners = []
for co in corners:
nco_x = ((co.x + (x * res_x)) * size) * scaling
nco_y = ((co.y + (y * res_y)) * size) * scaling
obj_space_corners.append(Vector((nco_x, nco_y, 0)))
return obj_space_corners
## Generate GP object
def create_gpencil_reference(
gpd: bpy.types.GreasePencil,
gpf: bpy.types.GPencilFrame,
image: bpy.types.Image,
) -> bpy.types.GPencilStroke:
"""
Add a rectangular stroke textured with `image` to the given grease pencil fame.
:param gpd: The grease pencil data.
:param gpf: The grease pencil frame.
:param image: The image to use as texture.
:return: The created grease pencil stroke.
"""
name = clean_image_name(image.name) + '_texgp'
# Create new material
mat = bpy.data.materials.new(f".ref/{name}")
bpy.data.materials.create_gpencil_data(mat)
gpd.materials.append(mat)
idx = gpd.materials.find(mat.name)
# Setup material settings
mat.grease_pencil.show_stroke = False
mat.grease_pencil.show_fill = True
mat.grease_pencil.fill_image = image
mat.grease_pencil.fill_style = "TEXTURE"
mat.grease_pencil.mix_factor = 0.0
mat.grease_pencil.texture_offset = (0.0, 0.0)
mat.grease_pencil.texture_angle = 0.0
mat.grease_pencil.texture_scale = (1.0, 1.0)
mat.grease_pencil.texture_clamp = True
# Create the stroke
gps_new = gpf.strokes.new()
gps_new.points.add(4, pressure=0, strength=0)
## This will make sure that the uv's always remain the same
# gps_new.use_automatic_uvs = False # <<- /!\ exists only in SPA core changes
gps_new.use_cyclic = True
gps_new.material_index = idx
x,y = image.size[:]
# create and place object plane
# find height from width (as 1)
if y < x:
h = y/x
w = 1
else:
h = 1
w = x/y
# size/2 to get center image (half width | height)
h = h/2
w = w/2
coords = [
(w, h, 0),
(-w, h, 0),
(-w, -h, 0),
(w, -h, 0),
]
uvs = [(0.5, 0.5), (-0.5, 0.5), (-0.5, -0.5), (0.5, -0.5)]
for i, (co, uv) in enumerate(zip(coords, uvs)):
gps_new.points[i].co = co
gps_new.points[i].uv_fill = uv
return gps_new
def import_image_as_gp_reference(
image,
pack_image: bool = False,
):
"""
Import image from `image` as a textured rectangle in the given
grease pencil object.
:param image: The image filepath or image datablock
:param pack_image: Whether to pack the image into the Blender file.
"""
if not image:
return
scene = bpy.context.scene
if not isinstance(image, bpy.types.Image):
image = bpy.data.images.load(str(image), check_existing=True)
## Create grease pencil object
gpd = bpy.data.grease_pencils.new(image.name)
ob = bpy.data.objects.new(image.name, gpd)
gpl = gpd.layers.new(image.name)
gpf = gpl.frames.new(0) # TDOO: test negative
if pack_image:
image.pack()
gps: bpy.types.GPencilStroke = create_gpencil_reference(
gpd,
gpf,
image,
)
gps.select = True # Not needed
cam = scene.camera # should be bg_cam
# Create the driver and parent
ob.use_grease_pencil_lights = False
ob['is_background'] = True
print(f'Created {ob.name} GP background object')
return ob
def gp_transfer_mode(ob, context=None):
context= context or bpy.context
if ob.type != 'GPENCIL' or context.object is ob:
return
prev_mode = context.mode
possible_gp_mods = ('OBJECT',
'EDIT_GPENCIL', 'SCULPT_GPENCIL', 'PAINT_GPENCIL',
'WEIGHT_GPENCIL', 'VERTEX_GPENCIL')
if prev_mode not in possible_gp_mods:
prev_mode = None
mode_swap = False
## TODO optional: Option to stop mode sync ?
## Set in same mode as previous object
if context.scene.tool_settings.lock_object_mode:
if context.mode != 'OBJECT':
mode_swap = True
bpy.ops.object.mode_set(mode='OBJECT')
# set active
context.view_layer.objects.active = ob
## keep same mode accross objects
if mode_swap and prev_mode is not None:
bpy.ops.object.mode_set(mode=prev_mode)
else:
## keep same mode accross objects
context.view_layer.objects.active = ob
if context.mode != prev_mode is not None:
bpy.ops.object.mode_set(mode=prev_mode)
for o in [o for o in context.scene.objects if o.type == 'GPENCIL']:
o.select_set(o == ob) # select only active (when not in object mode)
## generate tex plane
# def create_image_plane(coords, name):
# '''Create an a mesh plane with a defaut UVmap from passed coordinate
# object and mesh get passed name
# return plane object
# '''
# fac = [(0, 1, 3, 2)]
# me = bpy.data.meshes.new(name)
# me.from_pydata(coords, [], fac)
# plane = bpy.data.objects.new(name, me)
# # col = bpy.context.collection
# # col.objects.link(plane)
# me.uv_layers.new(name='UVMap')
# return plane
def create_material(name, img, node_group, keep_existing=False):
if keep_existing:
## Reuse if exist and seem clean
m = bpy.data.materials.get(name)
if m:
if name in m.name and m.use_nodes:
valid_image = next((n for n in m.node_tree.nodes if n.type == 'TEX_IMAGE' and n.image == img), None)
valid_nodegroup = next((n for n in m.node_tree.nodes if n.type == 'GROUP' and n.node_tree), None)
if valid_image and valid_nodegroup:
# Replace node_tree if different
print('node_group: ', node_group)
print('valid_nodegroup.node_tree: ', valid_nodegroup.node_tree)
if node_group != valid_nodegroup.node_tree:
valid_nodegroup = node_group
return m
# create mat
mat = bpy.data.materials.new(name)
mat.blend_method = 'BLEND' # 'CLIP' (clip is able to respect space)
mat.use_nodes = True
node_tree = mat.node_tree
nodes = node_tree.nodes
links = node_tree.links
# clear default BSDG
principled = nodes.get('Principled BSDF')
if principled: nodes.remove(principled)
mat_output = nodes.get('Material Output')
group = nodes.new("ShaderNodeGroup")
group.node_tree = node_group
group.location = (0, 276)
img_tex = nodes.new('ShaderNodeTexImage')
img_tex.image = img
img_tex.interpolation = 'Linear' # 'Closest','Cubic','Smart'
img_tex.extension = 'CLIP' # or EXTEND
img_tex.location = (-400, 218)
# links.new(img_tex.outputs['Color'], group.inputs['Color'])
# links.new(img_tex.outputs['Alpha'], group.inputs['Alpha'])
links.new(img_tex.outputs[0], group.inputs[0])
links.new(img_tex.outputs[1], group.inputs[1])
links.new(group.outputs[0], mat_output.inputs[0])
return mat
def create_image_plane(img, node_group, parent=None):
'''
img :: blender image or path to an image
parent :: object to parent to (only if passed)
Return created object or None if creation failed
'''
## get image if a path is passed in
if not isinstance(img, bpy.types.Image):
try:
img = bpy.data.images.load(str(img), check_existing=True)
except:
return
name = img.name.split('.')[0]
obj_name = f'{name}_texplane'
x,y = img.size[:]
# create and place object plane
# find height from width (as 1)
if y < x:
h = y/x
w = 1
else:
h = 1
w = x/y
# size/2 to get center image (half width | height)
h = h/2
w = w/2
verts = [
(-w, h, 0),#2
(w, h, 0),#3
(w, -h, 0),#4
(-w, -h, 0),#1
]
faces = [(3, 2, 1, 0)]
mesh = bpy.data.meshes.new(obj_name)
mesh.from_pydata(verts, [], faces)
plane = bpy.data.objects.new(obj_name, mesh)
# setup material (link nodegroup)
plane.data.uv_layers.new()
# Create and assign material
mat = create_material(name, img, node_group)
plane.data.materials.append(mat)
bpy.context.scene.collection.objects.link(plane)
# full lock (or just kill selectability...)
plane.lock_location = [True]*3
plane.hide_select = True
if parent:
plane.parent = parent
return plane
def create_empty_image(image):
if not isinstance(image, bpy.types.Image):
try:
image = bpy.data.images.load(str(image), check_existing=True)
except:
return
# ob = bpy.data.objects.new(f'{image_name}_texempty', None)
ob = bpy.data.objects.new(image.name, None)
# ob.hide_viewport = (cam_type == 'ORTHO') # hide if in persp mode
# load image on the empty
ob.empty_display_type = 'IMAGE'
ob.data = image
# ob.empty_display_size = bg_cam.data.ortho_scale # set display size
ob.use_empty_image_alpha = True
ob.color[3] = 0.5 # transparency
return ob
## Scan background objects and reload
def scan_backgrounds(context=None, scene=None, all_bg=False):
'''return a list (sorted by negative locZ)
of all holder object (parent of texempty and texplane)
'''
context = context or bpy.context
scene = scene or context.scene
oblist = []
if all_bg:
# oblist = [o for o in scene.objects if o.get('is_background_holder')]
oblist = [o for o in scene.objects if o.name.startswith(PREFIX) and o.type == 'MESH']
else:
col = bpy.data.collections.get(BGCOL)
if not col:
return oblist
# oblist = [o for o in col.all_objects if o.get('is_background_holder')]
oblist = [o for o in col.all_objects if o.name.startswith(PREFIX) and o.type == 'MESH']
oblist.sort(key=lambda x : x.location.z, reverse=True)
return oblist
def add_loc_z_driver(plane):
# Create properties
# plane['distance'] = abs(plane.location.z)
plane['distance'] = -plane.location.z
# Driver for location
dist_drv = plane.driver_add('location', 2)
dist_drv.driver.type = 'SCRIPTED' #'AVERAGE' #need to be inverted
var = dist_drv.driver.variables.new()
var.name = "distance"
var.type = 'SINGLE_PROP'
var.targets[0].id = plane
var.targets[0].data_path = '["distance"]'
dist_drv.driver.expression = '-distance'
def reload_bg_list(scene=None):
'''List holder plane object and add them to bg collection'''
start = time.time()
scn = scene or bpy.context.scene
uilist = scn.bg_props.planes
# current_index = scn.bg_props.index
oblist = scan_backgrounds()
# print('reload_bg_list > oblist:')
# pp(oblist)
warning = []
if not oblist:
return f'No BG found, need to be in collection "{BGCOL}" !'
## Clean
for ob in oblist:
bg_viewlayer = bpy.context.view_layer.layer_collection.children.get(BGCOL)
if not bg_viewlayer:
return "No 'Background' collection in scene master collection !"
ob_viewcol = bg_viewlayer.children.get(ob.name)
if not ob_viewcol:
warning.append(f'{ob.name} not found in "Background" collection')
continue
# if vl_col is hided or object is hided
# visible = any(o for o in ob.children if o.visible_get()) # ob.visible_get()
# for o in ob.children: o.hide_set(False)
ob_viewcol.hide_viewport = False
ob_viewcol.collection.hide_viewport = False
# ob_viewcol.exclude = not visible
## Driver check (don't erase if exists, else will delete persp mode drivers...)
if not ob.get('distance'):
if ob.animation_data:
for dr in ob.animation_data.drivers:
if dr.driver.variables.get('distance'):
# dist = ob.get('distance')
ob.animation_data.drivers.remove(dr)
add_loc_z_driver(ob)
uilist.clear()
## Populate the list
for ob in oblist: # populate list
item = uilist.add()
item.plane = ob
item.type = "bg"
scn.bg_props.index = len(uilist) - 1 # id (len of list in the ad loop)
if ob.children and ob.children[0].type == 'GPENCIL':
reset_gp_uv(ob.children[0]) # Force reset UV
## Add Grease pencil objects
gp_list = [o for o in bpy.context.scene.objects if o.type == 'GPENCIL' \
and not o.get('is_background') and o not in oblist]
for ob in gp_list:
item = uilist.add()
item.plane = ob
item.type = "obj"
scn.bg_props.index = len(uilist) - 1 # id (len of list in the ad loop)
print(f"{len(uilist)} loaded in {time.time()-start:.2f}")
return warning
def coord_distance_from_cam_straight(coord=None, context=None, camera=None):
context = context or bpy.context
coord = coord or context.scene.cursor.location
camera = camera or context.scene.camera
if not camera:
return
view_mat = camera.matrix_world
view_point = view_mat @ Vector((0, 0, -1000))
co = geometry.intersect_line_plane(view_mat.translation, view_point, coord, view_point)
if co is None:
return None
return (co - view_mat.translation).length
def coord_distance_from_cam(coord, context=None, camera=None):
context = context or bpy.context
coord = coord or context.scene.cursor.location
camera = camera or context.scene.camera
if not camera:
return
return (coord - camera.matrix_world.to_translation()).length