# 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 get_parent_collection(obj): '''return first parent collection found''' for col in bpy.context.scene.collection.children_recursive: if obj.name in col.objects: 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