Files
2026-03-17 15:39:39 -06:00

1323 lines
41 KiB
Python

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