# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors # # SPDX-License-Identifier: GPL-3.0-or-later import os, ast, fnmatch, platform, subprocess from pathlib import Path from zipfile import ZipFile import bpy from bpy.app.handlers import persistent import math, shutil, errno, numpy from bpy.app.handlers import persistent from mathutils import Vector addon_version = (0,0,0) preview_name = '.BSBST-preview' ng_list = [ ".brushstroke_tools.draw_processing", ".brushstroke_tools.pre_processing", ".brushstroke_tools.animation", ".brushstroke_tools.surface_fill", ".brushstroke_tools.surface_draw", ".brushstroke_tools.geometry_input", ".brushstroke_tools.mask_surface", ] linkable_sockets = [ bpy.types.NodeTreeInterfaceSocketObject, bpy.types.NodeTreeInterfaceSocketMaterial, bpy.types.NodeTreeInterfaceSocketVector, bpy.types.NodeTreeInterfaceSocketFloat, bpy.types.NodeTreeInterfaceSocketInt, bpy.types.NodeTreeInterfaceSocketString, ] asset_lib_name = 'Brushstroke Tools Library' @persistent def find_context_brushstrokes(scene, depsgraph): settings = scene.BSBST_settings edit_toggle = settings.edit_toggle settings.edit_toggle = False len_prev = len(settings.context_brushstrokes) name_prev = settings.context_brushstrokes[settings.active_context_brushstrokes_index].name if len_prev else '' idx = settings.active_context_brushstrokes_index # identify context brushstrokes for el in range(len(settings.context_brushstrokes)): settings.context_brushstrokes.remove(0) context_object = depsgraph.view_layer.objects.active if not is_brushstrokes_object(context_object): bs_ob = is_flow_object(context_object) if bs_ob: context_object = bs_ob else: bs_ob = context_object surf_ob = get_surface_object(context_object) if surf_ob: context_object = surf_ob for ob in bpy.data.objects: if not is_brushstrokes_object(ob): continue surf_ob = get_surface_object(ob) flow_ob = get_flow_object(ob) if not surf_ob: if not flow_ob: continue if not surf_ob == context_object: if not flow_ob == context_object: continue bs = settings.context_brushstrokes.add() bs.name = ob.name bs.method = ob['BSBST_method'] bs.hide_viewport_base = ob.hide_get() if name_prev == ob.name: idx = len(settings.context_brushstrokes)-1 if not settings.context_brushstrokes: settings.edit_toggle = edit_toggle return if bs_ob: for i, bs in enumerate(settings.context_brushstrokes): if bs.name == bs_ob.name: if name_prev != bs.name: settings.ui_options = False settings.silent_switch = True settings.active_context_brushstrokes_index = i settings.silent_switch = False elif len_prev == len(settings.context_brushstrokes): settings.silent_switch = True settings.active_context_brushstrokes_index = idx settings.silent_switch = False settings.active_context_brushstrokes_index = max(min(settings.active_context_brushstrokes_index, len(settings.context_brushstrokes)-1), 0) settings.edit_toggle = edit_toggle @persistent def refresh_preset(scene, depsgraph): settings = scene.BSBST_settings if not settings: return for ob in [settings.preset_object, get_active_context_brushstrokes_object(scene)]: if not ob: continue for mod in ob.modifiers: mod_info = ob.modifier_info.get(mod.name) if not mod_info: mod_info = ob.modifier_info.add() mod_info.name = mod.name if not mod.type == 'NODES': continue if not mod.node_group: continue for v in mod.node_group.interface.items_tree.values(): if type(v) is bpy.types.NodeTreeInterfacePanel: v_id = f'Panel_{v.index}' # TODO: replace with panel identifier once that is exposed in Blender 4.3 else: v_id = v.identifier if v_id in [s.name for s in mod_info.socket_info]: continue n = mod_info.socket_info.add() n.name = v_id # TODO: clean up old settings def mark_socket_context_type(mod_info, socket_name, link_type): socket_info = mod_info.socket_info.get(socket_name) if not socket_info: socket_info = mod_info.socket_info.add() socket_info.name = socket_name socket_info.link_context_type = link_type def mark_socket_hidden(mod_info, socket_name, hide=True): socket_info = mod_info.socket_info.get(socket_name) if not socket_info: socket_info = mod_info.socket_info.add() socket_info.name = socket_name socket_info.hide_ui = hide def mark_panel_hidden(mod_info, panel_name, hide=True): mod = mod_info.id_data.modifiers.get(mod_info.name) if not mod: return if not mod.type == 'NODES': return ng = mod.node_group if not ng: return v_id = '' for k, v in ng.interface.items_tree.items(): if type(v) != bpy.types.NodeTreeInterfacePanel: continue if v.name == panel_name: v_id = f'Panel_{v.index}' break if not v_id: return socket_info = mod_info.socket_info.get(v_id) if not socket_info: socket_info = mod_info.socket_info.add() socket_info.name = v_id socket_info.hide_ui = hide def set_brushstroke_material(ob, material): prev_mat = None if 'BSBST_material' in ob.keys(): if ob['BSBST_material'] == material: return else: prev_mat = ob['BSBST_material'] ob['BSBST_material'] = material for mod in ob.modifiers: mod_info = ob.modifier_info.get(mod.name) if not mod_info: continue for s in mod_info.socket_info: if not s.link_context: continue if not s.link_context_type == 'MATERIAL': continue mod[s.name] = material ob.update_tag() if ob.type == 'EMPTY': return if not ob.material_slots: override = bpy.context.copy() override['object'] = ob with bpy.context.temp_override(**override): bpy.ops.object.material_slot_add() ob.material_slots[0].material = material else: for m_slot in ob.material_slots: if m_slot.material == prev_mat: m_slot.material = material def deep_copy_mod_info(source_object, target_object): for mod_info in source_object.modifier_info: mod_info_tgt = target_object.modifier_info.add() for attr in mod_info.keys(): if attr == 'socket_info': continue setattr(mod_info_tgt, attr, getattr(mod_info, attr)) for socket_info in mod_info.socket_info: socket_info_tgt = mod_info_tgt.socket_info.add() for attr in socket_info.keys(): setattr(socket_info_tgt, attr, getattr(socket_info, attr)) def get_addon_directory() -> Path: """ Returns the path of the addon directory. """ path = os.path.dirname(os.path.realpath(__file__)) abspath = bpy.path.abspath(path) return Path(abspath) def get_default_resource_directory() -> Path: path = Path.home().joinpath('Blender Studio Tools/Brushstroke Tools/') if platform.system() == "Windows": path = Path.home().joinpath('AppData/Roaming/Blender Studio Tools/Brushstroke Tools/') elif platform.system() == "Darwin": path = Path.home().joinpath('Library/Application Support/Blender Studio Tools/Brushstroke Tools/') else: path = Path.home().joinpath('.config/blender_studio_tools/brushstroke_tools/') return path def get_resource_directory() -> Path: """ Returns the path to be used to append resource data-blocks. """ addon_prefs = bpy.context.preferences.addons[__package__].preferences resource_dir = addon_prefs.resource_path if resource_dir: return Path(resource_dir) else: return get_default_resource_directory() def check_resources_valid(): path = get_resource_directory() if not path.exists: return False check_paths = [ "core/brushstroke_tools-resources.blend", "blender_assets.cats.txt", ".version" ] for s in check_paths: if not path.joinpath(s).exists(): return False return True def unpack_resources(): if check_resources_valid(): lib_version = read_lib_version() if compare_versions(addon_version, lib_version)<=0: return addon_prefs = bpy.context.preferences.addons[__package__].preferences if addon_prefs.resource_path != '': #TODO: more options for auto-update or popup return copy_resources_to_dir() update_asset_lib_path() def import_resources(ng_names = ng_list, filepath = ''): """ Imports the necessary blend data resources required by the addon. """ addon_prefs = bpy.context.preferences.addons[__package__].preferences if not filepath: filepath = get_resource_directory() data_pre = set() for attr in dir(bpy.data): if not type(getattr(bpy.data, attr)) == type(bpy.data.scenes): continue data_pre |= set(getattr(bpy.data, attr)) resource_path = str(filepath.joinpath('core/brushstroke_tools-resources.blend')) with bpy.data.libraries.load(resource_path, link=addon_prefs.import_method=='LINK', relative=addon_prefs.import_relative_path) as (data_src, data_dst): data_dst.node_groups = ng_names[:] if addon_prefs.import_method=='APPEND': # pack imported resources for img in bpy.data.images: if not img.library_weak_reference: continue if img.packed_file: continue if 'brushstroke_tools-resources.blend' in img.library_weak_reference.filepath: img.pack() data_post = set() for attr in dir(bpy.data): if not type(getattr(bpy.data, attr)) == type(bpy.data.scenes): continue data_post |= set(getattr(bpy.data, attr)) return data_post - data_pre def read_lib_version(dir: Path = None): if not dir: dir = get_resource_directory() with open(dir.joinpath(".version"), "r") as file: version = ast.literal_eval(file.read()) return version def write_lib_version(dir: Path = None): if not dir: dir = get_resource_directory() with open(dir.joinpath(".version"), "w") as file: file.write(str(addon_version)) def get_file_blend_version(path: Path): with open(path, "rb") as f: prefix = f.read(4) f.seek(0) if prefix.startswith(b"BLENDER"): header = f.read(12) elif prefix == b"\x28\xb5\x2f\xfd": # zstd dctx = zstd.ZstdDecompressor() with dctx.stream_reader(f) as reader: header = reader.read(12) else: raise ValueError("Unknown blend format") version = header[9:12].decode() return int(version[0]), int(version[1:]) def copy_resources_to_dir(tgt_dir = ''): source_dir = get_addon_directory().joinpath('assets') if not tgt_dir: tgt_dir = get_resource_directory() try: shutil.copytree(source_dir, tgt_dir, dirs_exist_ok=True) write_lib_version() except OSError as err: # error caused if the source was not a directory if err.errno == errno.ENOTDIR: shutil.copy2(source_dir, tgt_dir) write_lib_version() else: print("Error: % s" % err) refresh_brushstroke_styles() def install_brush_style_pack(filepath, tgt_dir='', ot=None): if type(filepath) != Path: filepath = Path(filepath) filename, extension = os.path.splitext(filepath) if not tgt_dir: tgt_dir = get_resource_directory().joinpath('styles') elif type(tgt_dir) != Path: tgt_dir = Path(tgt_dir) if extension=='.zip': with ZipFile(filepath, 'r') as zip_object: zip_object.extractall( path=tgt_dir) elif extension.startswith('.blend'): shutil.copy2(filepath, tgt_dir) else: if ot: ot.report({"ERROR"}, "Selected file has to be either .zip or .blend") else: print("ERROR: Selected file has to be either .zip or .blend") return False return True def compare_versions(v1: tuple, v2: tuple): """ Returns n when v1 > v2, 0 when v1 == v2, -n when v1 < v2, while n = 'Index of first significant version tuple element' + 1. e.g. (0,2,0), (0,2,1) -> -3 """ c = 1 for e1, e2 in zip(v1, v2): if e1 > e2: return c elif e1 < e2: return -c c += 1 return 0 def split_id_name(name): if not '.'in name: return (name, None) name_el = name.split('.') extension = name_el[-1] if not extension.isdigit(): return (name, None) name_string = '.'.join(name_el[:-1]) return (name_string, extension) def import_brushstroke_material(): name = 'Brush Material' path = str(get_resource_directory().joinpath('core/brushstroke_tools-resources.blend')) addon_prefs = bpy.context.preferences.addons[__package__].preferences mats_pre = set(bpy.data.materials) ng_pre = set(bpy.data.node_groups) with bpy.data.libraries.load(path, link=addon_prefs.import_method=='LINK', relative=addon_prefs.import_relative_path) as (data_src, data_dst): data_dst.materials = [name] mats_new = list(set(bpy.data.materials) - mats_pre) # de-duplicate imported node-groups ng_new = list(set(bpy.data.node_groups) - ng_pre) ng_remove = [] for ng in ng_new: ng_name_elements = ng.name.split('.') if len(ng_name_elements) == 1: continue root_ng = bpy.data.node_groups.get('.'.join(ng_name_elements[:-1])) if not root_ng: continue ng.user_remap(root_ng) ng_remove += [ng] for ng in reversed(ng_remove): bpy.data.node_groups.remove(ng) # return imported material if mats_new: return mats_new[0] else: return bpy.data.materials.get(name) def import_node_group(name, path): ng_pre = set(bpy.data.node_groups) addon_prefs = bpy.context.preferences.addons[__package__].preferences with bpy.data.libraries.load(path, link=addon_prefs.import_method=='LINK', relative=addon_prefs.import_relative_path) as (data_src, data_dst): data_dst.node_groups = [name] if addon_prefs.import_method=='APPEND': # pack imported resources for img in bpy.data.images: if not img.library_weak_reference: continue if path in img.library_weak_reference.filepath: if len(img.packed_files) > 0: continue img.pack() new_ids = set(bpy.data.node_groups) - ng_pre for ng in new_ids: if ng.name == name: return ng for ng in new_ids: if split_id_name(name)[0] == split_id_name(ng.name)[0]: return ng return None def ensure_node_group(name, path=''): ng = bpy.data.node_groups.get(name) if ng: return ng if not path: path=str(get_resource_directory().joinpath('core/brushstroke_tools-resources.blend')) ng = import_node_group(name, path) return ng def ensure_resources(): ng_missing = set() for n in ng_list: if not bpy.data.node_groups.get(n): ng_missing.add(n) if ng_missing: import_resources(list(ng_missing)) def register_asset_lib(): asset_libs = bpy.context.preferences.filepaths.asset_libraries if asset_lib_name in [a.name for a in asset_libs]: return asset_libs[asset_lib_name] lib = asset_libs.new() lib.name = asset_lib_name lib.path = str(get_resource_directory()) lib.use_relative_path = False def unregister_asset_lib(): asset_libs = bpy.context.preferences.filepaths.asset_libraries lib = asset_libs.get(asset_lib_name) if not lib: return asset_libs.remove(lib) def update_asset_lib_path(): asset_libs = bpy.context.preferences.filepaths.asset_libraries if asset_lib_name not in [a.name for a in asset_libs]: register_asset_lib() return lib = asset_libs[asset_lib_name] lib.path = str(get_resource_directory()) refresh_brushstroke_styles() def refresh_brushstroke_styles(): addon_prefs = bpy.context.preferences.addons[__package__].preferences bs_list = addon_prefs.brush_styles for a in range(len(bs_list)): bs_list.remove(0) lib_path = get_resource_directory() add_brush_styles_from_directory(bs_list, lib_path) # find additional local brush styles if not 'node_groups' in dir(bpy.data): return add_brush_styles_from_names(bs_list, [ng.name for ng in bpy.data.node_groups], '', name_filter = [bs.name for bs in bs_list]) def add_brush_styles_from_names(bs_list, ng_names, filepath, name_filter = []): names = [name for name in ng_names if name.startswith('BSBST-BS')] for ng_name in names: name, extension = split_id_name(ng_name) name_elements = name.split('.') if extension: name_elements = name_elements[:-1]+[f'{name_elements[-1]}.{extension}'] if name_elements[-1] in name_filter: continue b_style = bs_list.add() b_style.name = name_elements[-1] b_style.id_name = ng_name b_style.filepath = filepath if len(name_elements) >= 4: b_style.category = name_elements[1] b_style.type = name_elements[-2] def add_brush_styles_from_directory(bs_list, path): if not 'libraries' in dir(bpy.data): return subdirs = [f.path for f in os.scandir(path) if f.is_dir()] files = [f.path for f in os.scandir(path) if not f.is_dir()] for filepath in files: if not filepath.endswith('.blend'): continue names = [] with bpy.data.libraries.load(filepath) as (data_from, data_to): add_brush_styles_from_names(bs_list, data_from.node_groups, filepath) for d in subdirs: add_brush_styles_from_directory(bs_list, d) def find_brush_style_by_name(name: str): addon_prefs = bpy.context.preferences.addons[__package__].preferences for brush_style in addon_prefs.brush_styles: if name == brush_style.name: return brush_style return None def link_to_collections_by_ref(obj, ref_obj, unlink=True): col_list = [] if unlink: for col in obj.users_collection: if col.library: continue col_list += [col] if col_list: for col in col_list: col.objects.unlink(obj) col_list = [] for col in ref_obj.users_collection: if col.library: continue col_list += [col] if col_list: for col in col_list: col.objects.link(obj) else: if bpy.context.collection.library: bpy.context.scene.collection.objects.link(obj) else: bpy.context.collection.objects.link(obj) def copy_collection_property(col_target, col_source): for i in range(len(col_target)): col_target.remove(0) for i in range(len(col_source)): element_source = col_source[i] element_target = col_target.add() for k, v in element_source.items(): element_target[k] = v def update_filtered_brush_styles(self, context): addon_prefs = context.preferences.addons[__package__].preferences active_bs_name = '' if self.brush_styles_filtered: active_bs_name = self.brush_styles_filtered[self.brush_styles_filtered_active_index].name copy_collection_property(self.brush_styles_filtered, addon_prefs.brush_styles) # filter by type if self.brush_type != 'ALL': bs_count = len(self.brush_styles_filtered) for i, bs in enumerate(reversed(self.brush_styles_filtered[:])): if bs.type.upper() != self.brush_type: self.brush_styles_filtered.remove(bs_count-i-1) # filter by category if self.brush_category != 'ALL': bs_count = len(self.brush_styles_filtered) for i, bs in enumerate(reversed(self.brush_styles_filtered[:])): if bs.category.upper() != self.brush_category: self.brush_styles_filtered.remove(bs_count-i-1) # filter by name filtered_list = fnmatch.filter([bs.name.lower() for bs in self.brush_styles_filtered], f'*{self.name_filter}*'.lower()) bs_count = len(self.brush_styles_filtered) for i, bs in enumerate(reversed(self.brush_styles_filtered[:])): if bs.name.lower() not in filtered_list: self.brush_styles_filtered.remove(bs_count-i-1) self.brush_styles_filtered_active_index = 0 for i, bs in enumerate(self.brush_styles_filtered): if bs.name == active_bs_name: self.brush_styles_filtered_active_index = i break return def transfer_modifier(modifier_name, target_obj, source_obj): """ Core taken from https://projects.blender.org/studio/blender-studio-tools """ # create target mod source_mod = source_obj.modifiers.get(modifier_name) target_mod = target_obj.modifiers.new(source_mod.name, source_mod.type) props = [p.identifier for p in source_mod.bl_rna.properties if not p.is_readonly] for prop in props: value = getattr(source_mod, prop) setattr(target_mod, prop, value) if source_mod.type == 'NODES': # Transfer geo node attributes for key, value in source_mod.items(): try: target_mod[key] = value except (TypeError, ValueError) as e: target_mod[key] = type(target_mod[key])(value) # Transfer geo node bake settings target_mod.bake_directory = source_mod.bake_directory for index, target_bake in enumerate(target_mod.bakes): source_bake = source_mod.bakes[index] props = [p.identifier for p in source_bake.bl_rna.properties if not p.is_readonly] for prop in props: value = getattr(source_bake, prop) setattr(target_bake, prop, value) def is_brushstrokes_object(object): if not object: return False return 'BSBST_active' in object.keys() def is_surface_object(object): if not object: return False for ob in bpy.data.objects: if 'BSBST_surface_object' not in ob.keys(): continue if ob['BSBST_surface_object'] == object: return True return False def is_flow_object(object): if not object: return False for ob in bpy.data.objects: if 'BSBST_flow_object' not in ob.keys(): continue if ob['BSBST_flow_object'] == object: return ob return False def get_deformable(object): if not object: return False if 'BSBST_deformable' in object.keys(): return object['BSBST_deformable'] return False def get_animated(object): if not object: return False if 'BSBST_animated' in object.keys(): return object['BSBST_animated'] return False def set_deformable(object, deformable=True): if not object: return object['BSBST_deformable'] = bool(deformable) def set_animated(object, animated=True): if not object: return object['BSBST_animated'] = bool(animated) def get_surface_object(bs): if not bs: return None if 'BSBST_surface_object' not in bs.keys(): return None return bs['BSBST_surface_object'] def set_surface_object(bs, surf_ob): if not bs: return objects = [bs] flow_ob = get_flow_object(bs) if flow_ob: objects += [flow_ob] # assign surface pointer for ob in objects: for mod in bs.modifiers: mod_info = bs.modifier_info.get(mod.name) if not mod_info: continue for s in mod_info.socket_info: if not s.link_context: continue if not s.link_context_type == 'SURFACE_OBJECT': continue mod[s.name] = surf_ob ob.parent = surf_ob ob.parent_type = 'OBJECT' surf_ob.update_tag() if bs.type == 'CURVES': bs.data.surface = surf_ob bs.data.surface_uv_map = surf_ob.data.uv_layers.active.name bs['BSBST_surface_object'] = surf_ob def get_flow_object(bs): if not bs: return None if 'BSBST_flow_object' not in bs.keys(): return None return bs['BSBST_flow_object'] def set_flow_object(bs, ob): if not bs: return # assign flow pointer for mod in bs.modifiers: mod_info = bs.modifier_info.get(mod.name) if not mod_info: continue for s in mod_info.socket_info: if not s.link_context: continue if not s.link_context_type == 'FLOW_OBJECT': continue mod[s.name] = ob ob.update_tag() bs['BSBST_flow_object'] = ob def context_brushstrokes(context): settings = context.scene.BSBST_settings return settings.context_brushstrokes def get_active_context_brushstrokes_object(scene): settings = scene.BSBST_settings if not settings.context_brushstrokes: return None bs = settings.context_brushstrokes[settings.active_context_brushstrokes_index] bs_ob = bpy.data.objects.get(bs.name) return bs_ob def get_active_context_surface_object(context): if not context.object: return None bs_ob = get_active_context_brushstrokes_object(context.scene) if bs_ob: return get_surface_object(bs_ob) if context.object.type == 'MESH': return context.object def bs_name(surf_name: str) -> str: return f'{surf_name} - Brushstrokes' def flow_name(bs_name: str) -> str: return f'{bs_name}-FLOW' def edit_active_brushstrokes(context): context.view_layer.depsgraph.update() bs_ob = get_active_context_brushstrokes_object(context.scene) if not bs_ob: return {"CANCELLED"} flow_object = get_flow_object(bs_ob) surface_object = get_surface_object(bs_ob) active_object = bs_ob if flow_object: active_object = flow_object if not active_object: return {"CANCELLED"} context.view_layer.objects.active = active_object for ob in bpy.data.objects: ob.select_set(False) surface_object.select_set(True) active_object.select_set(True) # enter mode and tool context if active_object.type=='GREASEPENCIL': bpy.ops.object.mode_set(mode='PAINT_GREASE_PENCIL') bpy.ops.wm.tool_set_by_id(name="builtin.draw") context.scene.tool_settings.gpencil_stroke_placement_view3d = 'SURFACE' else: bpy.ops.object.mode_set(mode='EDIT') bpy.ops.wm.tool_set_by_id(name="brushstroke_tools.draw") return {'FINISHED'} def round_n(val, n): """ Round value to n number of significant digits. """ return round(val, n-int(math.floor(math.log10(abs(val))))-1) def clear_preview(): preview_img = bpy.data.images.get(preview_name) preview_texture = bpy.data.textures.get(preview_name) if preview_img: bpy.data.images.remove(preview_img) if preview_texture: bpy.data.textures.remove(preview_texture) def set_preview(pixels, size = (256, 256), id=''): preview_img = bpy.data.images.get(preview_name) preview_texture = bpy.data.textures.get(preview_name) if not pixels or compare_versions(bpy.app.version, (4,2,4)) < 0: clear_preview() return if preview_img: if id and id == preview_img['BSBST-id']: return if not len(preview_img.pixels) == len(pixels): bpy.data.images.remove(preview_img) preview_img = None if not preview_img: preview_img = bpy.data.images.new(preview_name, width=size[0], height=size[1]) if not preview_texture: preview_texture = bpy.data.textures.new(name=preview_name, type="IMAGE") settings = bpy.context.scene.BSBST_settings settings.preview_texture = preview_texture preview_texture.extension = 'EXTEND' preview_texture.crop_max_x = size[1]/size[0] preview_texture.image = preview_img preview_img.pixels.foreach_set(numpy.array(pixels, dtype=numpy.float32)) preview_img['BSBST-id'] = id preview_img.pack() # TODO delete pre-save # TODO set height of preview region def find_local_geonodes_resources(): ids = dict() for ob in bpy.data.objects: if ob.library: continue if not is_brushstrokes_object(ob): continue for mod in ob.modifiers: if mod.type != 'NODES': continue if fnmatch.filter(ng_list, split_id_name(mod.node_group.name)[0]): if mod.node_group not in ids.keys(): ids[mod.node_group] = [ob] else: ids[mod.node_group] += [ob] return ids def find_local_material_resources(): ids = dict() for mat in bpy.data.materials: if mat.library: continue if 'BSBST' in mat.keys(): ids[mat] = [] for ob in bpy.data.objects: if ob.library: continue if not is_brushstrokes_object(ob): continue for m_slot in ob.material_slots: if not m_slot.material: continue mat = m_slot.material if mat in ids.keys(): ids[mat] += [ob] return ids def find_local_brush_style_resources(): ids = dict() for ng in bpy.data.node_groups: if ng.library: continue if ng.name.startswith('BSBST-BS'): ids[ng] = [] for mat in bpy.data.materials: if mat.library: continue if not mat.node_tree: continue node = mat.node_tree.nodes.get('Brush Style') if node is None: continue ng = node.node_tree if not ng: continue if ng in ids.keys(): ids[ng] += [mat] return ids def blend_data_from_id(id): for attr in dir(bpy.data): data = getattr(bpy.data, attr) if not data: continue if not type(data) == type(bpy.data.scenes): continue if id.id_type == data[0].id_type: return data return None def force_cleanup_ids_recursive(ids): flag = False for id in list(ids)[:]: if id.users == id.use_fake_user: ids.remove(id) blend_data_from_id(id).remove(id) flag = True if flag: force_cleanup_ids_recursive(ids) def force_remove_id_and_dependencies(id_remove): data = set() for attr in dir(bpy.data): if not type(getattr(bpy.data, attr)) == type(bpy.data.scenes): continue data |= set(getattr(bpy.data, attr)) for id in list(data)[:]: if id == id_remove: continue if id.users == id.use_fake_user: data.remove(id) if id_remove not in data: blend_data_from_id(id_remove).remove(id_remove) return else: data.remove(id_remove) blend_data_from_id(id_remove).remove(id_remove) force_cleanup_ids_recursive(data) def version_modifiers(object): version_prev = object['BSBST_version'] for mod in object.modifiers: if not mod.type == 'NODES': continue ng = mod.node_group if not ng: continue object['BSBST_version'] = addon_version return def upgrade_geonodes_from_library(): del_id = set() data_pre = set() for attr in dir(bpy.data): if not type(getattr(bpy.data, attr)) == type(bpy.data.scenes): continue data_pre |= set(getattr(bpy.data, attr)) id_new = import_resources() for id in id_new: id_name, id_extension = split_id_name(id.name) if id_name not in ng_list: continue for id_local in blend_data_from_id(id): if id_local == id: continue ng_local_name, ng_local_extension = split_id_name(id_local.name) if not ng_local_name == id_name: continue id_local.user_remap(id) # check and remove old id if id_local.users == id_local.use_fake_user: del_id.add(id_local) for id in del_id: force_remove_id_and_dependencies(id) # rename new ids for id in id_new: if id.library: continue id_name, id_extension = split_id_name(id.name) if id_extension: id.name = id_name data_post = set() for attr in dir(bpy.data): if not type(getattr(bpy.data, attr)) == type(bpy.data.scenes): continue data_post |= set(getattr(bpy.data, attr)) new_ids = data_post - data_pre force_cleanup_ids_recursive(new_ids) for ob in bpy.data.objects: if not is_brushstrokes_object(ob): continue version_modifiers(ob) return def copy_curve_mapping(tgt_mapping, src_mapping): for tgt_curve, src_curve in zip(tgt_mapping.curves, src_mapping.curves): for i in range(len(tgt_curve.points)-2): tgt_curve.points.remove(tgt_curve.points[0]) for i in range(len(src_curve.points)-2): tgt_p = tgt_curve.points.new(0,0) for i in range(len(src_curve.points)): src_p = src_curve.points[i] tgt_p = tgt_curve.points[i] for el in dir(src_p): try: setattr(tgt_p, el, getattr(src_p, el)) except: pass tgt_mapping.update() return def match_mat(tgt_mat, src_mat): """ Retain settings of brushstroke material to upgrade node-tree version. """ path_list = [ "brush_style", "diffuse_color", "node_tree.nodes['Color Attribute'].mute", "node_tree.nodes['Color Texture'].mute", "node_tree.nodes['Color'].outputs[0].default_value", "node_tree.nodes['Image Texture'].image", "node_tree.nodes['UV Map'].uv_map", "node_tree.nodes['Color Variation'].inputs[0].default_value", "node_tree.nodes['Variation Scale'].outputs[0].default_value", "node_tree.nodes['Variation Hue'].inputs[0].default_value", "node_tree.nodes['Variation Saturation'].inputs[0].default_value", "node_tree.nodes['Variation Luminance'].inputs[0].default_value", "node_tree.nodes['Use Strength'].mute", "node_tree.nodes['Opacity'].inputs[0].default_value", "node_tree.nodes['Backface Culling'].mute", "node_tree.nodes['Principled BSDF'].inputs[1].default_value", "node_tree.nodes['Principled BSDF'].inputs[2].default_value", "node_tree.nodes['Bump'].mute", "node_tree.nodes['Bump'].inputs[0].default_value", "node_tree.nodes['Translucency Add'].mute", "node_tree.nodes['Translucency Strength'].inputs[0].default_value", "node_tree.nodes['Translucency Tint'].inputs[7].default_value", "node_tree.nodes['Brush Style'].node_group", ] for attr_path in path_list: try: exec(f'tgt_mat.{attr_path} = src_mat.{attr_path}') except: pass tgt_curve_node = tgt_mat.node_tree.nodes.get('Brush Curve') src_curve_node = src_mat.node_tree.nodes.get('Brush Curve') copy_curve_mapping(tgt_curve_node.mapping, src_curve_node.mapping) tgt_bs_node = tgt_mat.node_tree.nodes.get('Brush Style') src_bs_node = src_mat.node_tree.nodes.get('Brush Style') if not tgt_bs_node or not src_bs_node: print("ERROR: Could not find Brush Style node in material!") return for i, input in enumerate(src_bs_node.inputs): tgt_bs_node.inputs[i].default_value = input.default_value return def clear_FX_nodes(nt): # clear target FX nodes del_nodes = [] node = nt.nodes.get('Effects In') while True: if not node.outputs: break s = node.outputs.get('Value') if not s: s = node.outputs[0] if not s.links: break node = s.links[0].to_node if not node: break if node.name == 'Effects Out': break del_nodes += [node] for n in del_nodes: nt.nodes.remove(n) def transfer_FX_nodes(new_mat, old_mat): tgt_nt = new_mat.node_tree src_nt = old_mat.node_tree clear_FX_nodes(tgt_nt) # insert new nodes src_FX_nodes = [] node = src_nt.nodes.get('Effects In') while True: if not node.outputs: break s = node.outputs.get('Value') if not s: s = node.outputs[0] if not s.links: break node = s.links[0].to_node if not node: break if node.name == 'Effects Out': break src_FX_nodes += [node] attr_list = [ 'name', 'label', 'node_tree', 'location', 'mute', ] tgt_FX_nodes = [] for n in src_FX_nodes: n_new = tgt_nt.nodes.new(n.bl_idname) tgt_FX_nodes += [n_new] for attr in attr_list: if not attr in dir(n): continue setattr(n_new, attr, getattr(n, attr)) if n.parent: new_parent = tgt_nt.nodes.get(n.parent.name) if new_parent: n_new.parent = new_parent n_new.location = Vector(n_new.location) + Vector(new_parent.location) # link inputs for tgt_in, src_in in zip(n_new.inputs, n.inputs): for l in src_in.links: n_out = tgt_nt.nodes.get(l.from_node.name) if not n_out: continue s_out = n_out.outputs.get(l.from_socket.name) if not s_out: continue l_new = tgt_nt.links.new(s_out, tgt_in) # link outputs for tgt_out, src_out in zip(n_new.outputs, n.outputs): for l in src_out.links: n_in = tgt_nt.nodes.get(l.to_node.name) if not n_in: continue s_in = n_in.inputs.get(l.to_socket.name) if not s_in: continue l_new = tgt_nt.links.new(tgt_out, s_in) # copy values for tgt_in, src_in in zip(n_new.inputs, n.inputs): tgt_in.default_value = src_in.default_value # offset location by effects in offset = Vector(src_nt.nodes.get('Effects In').location - tgt_nt.nodes.get('Effects In').location) for tgt_n in tgt_FX_nodes: tgt_n.location = Vector(tgt_n.location) + offset return def upgrade_materials_from_library(): mats = find_local_material_resources().keys() data_pre = set() for attr in dir(bpy.data): if not type(getattr(bpy.data, attr)) == type(bpy.data.scenes): continue data_pre |= set(getattr(bpy.data, attr)) base_mat = import_brushstroke_material() for old_mat in mats: new_mat = base_mat.copy() match_mat(new_mat, old_mat) old_mat.user_remap(new_mat) transfer_FX_nodes(new_mat, old_mat) name = old_mat.name bpy.data.materials.remove(old_mat) new_mat.name = name bpy.data.materials.remove(base_mat) data_post = set() for attr in dir(bpy.data): if not type(getattr(bpy.data, attr)) == type(bpy.data.scenes): continue data_post |= set(getattr(bpy.data, attr)) new_ids = data_post - data_pre force_cleanup_ids_recursive(new_ids) return def upgrade_brush_styles_from_library(): refresh_brushstroke_styles() for mat in bpy.data.materials: if 'BSBST' not in mat.keys(): continue if not mat.brush_style: continue brush_style = find_brush_style_by_name(mat.brush_style) if not brush_style.filepath: continue ng_old = bpy.data.node_groups.get(brush_style.id_name) if not ng_old: continue ng_new = import_node_group(brush_style.id_name, brush_style.filepath) if not ng_new: continue ng_old.user_remap(ng_new) bpy.data.node_groups.remove(ng_old) ng_new.name = brush_style.id_name return def open_in_file_manager(path): if platform.system() == "Windows": os.startfile(path) elif platform.system() == "Darwin": subprocess.Popen(["open", path]) else: subprocess.Popen(["xdg-open", path]) class BSBST_brush_style(bpy.types.PropertyGroup): name: bpy.props.StringProperty(default='') id_name: bpy.props.StringProperty(default='') filepath: bpy.props.StringProperty(default='') category: bpy.props.StringProperty(default='') type: bpy.props.StringProperty(default='') classes = [ BSBST_brush_style, ] def register(): for c in classes: bpy.utils.register_class(c) bpy.app.handlers.depsgraph_update_post.append(refresh_preset) def unregister(): for c in classes: bpy.utils.unregister_class(c) bpy.app.handlers.depsgraph_update_post.remove(refresh_preset)