2026-03-16

This commit is contained in:
2026-03-17 15:39:39 -06:00
parent 67782275d5
commit 330fed4231
29 changed files with 532 additions and 199 deletions
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "brushstroke_tools"
version = "1.1.2"
version = "1.2.1"
name = "Brushstroke Tools"
tagline = "Brushstroke painting tools by the Blender Studio"
maintainer = "Simon Thommes <simon@blender.org>"
@@ -20,4 +20,4 @@ copyright = [
]
[permissions]
files = "Read/write brushstroke asset resources from/to disk"
files = "Read/write brushstroke asset resources from/to disk"
@@ -94,6 +94,12 @@ class BSBST_OT_pre_process_brushstroke(bpy.types.Operator):
for prop in props_list:
setattr(context.tool_settings.curve_paint_settings, prop, getattr(tool_settings, prop))
if bpy.app.version >= (5,1):
utils.ensure_resources()
ng_process = bpy.data.node_groups['.brushstroke_tools.draw_processing']
ng_process.asset_mark() # workaround to let Blender register the node tool as an operator with the automatically generated id
ng_process.asset_clear()
return {'FINISHED'}
class BSBST_OT_post_process_brushstroke(bpy.types.Operator):
@@ -119,7 +125,11 @@ class BSBST_OT_post_process_brushstroke(bpy.types.Operator):
if 'BSBST_surface_object' in context.object.keys():
if context.object['BSBST_surface_object']:
self.ng_process.nodes['surface_object'].inputs[0].default_value = context.object['BSBST_surface_object']
bpy.ops.geometry.execute_node_group(name="set_brush_stroke_color", session_uid=self.ng_process.session_uid)
if bpy.app.version < (5,1):
bpy.ops.geometry.execute_node_group(name="set_brush_stroke_color", session_uid=self.ng_process.session_uid)
else:
bpy.ops.geometry._brushstroke_tools_draw_processing()
preserve_draw_settings(context, restore=True)
return {'FINISHED'}
+52 -45
View File
@@ -30,21 +30,18 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
settings.brushstroke_method = self.method
if settings.curve_mode == 'GP':
bpy.ops.object.grease_pencil_add(type='EMPTY')
context.object.name = name
context.object.data.name = name
brushstrokes_object = context.object
context.collection.objects.unlink(brushstrokes_object)
else:
if settings.curve_mode == 'CURVE':
brushstrokes_data = bpy.data.curves.new(name, type='CURVE')
brushstrokes_data.dimensions = '3D'
elif settings.curve_mode == 'CURVES':
brushstrokes_data = bpy.data.hair_curves.new(name)
brushstrokes_object = bpy.data.objects.new(name, brushstrokes_data)
brushstrokes_data = bpy.data.grease_pencils.new(name)
bs_layer = brushstrokes_data.layers.new('Brushstrokes')
bs_layer.frames.new(1)
elif settings.curve_mode == 'CURVE':
brushstrokes_data = bpy.data.curves.new(name, type='CURVE')
brushstrokes_data.dimensions = '3D'
elif settings.curve_mode == 'CURVES':
brushstrokes_data = bpy.data.hair_curves.new(name)
brushstrokes_object = bpy.data.objects.new(name, brushstrokes_data)
# link to surface object's collections (fall back to active collection if all are linked data)
utils.link_to_collections_by_ref(brushstrokes_object, surface_object)
utils.link_to_collections_by_ref(brushstrokes_object, surface_object, unlink=False)
brushstrokes_object.visible_shadow = False
brushstrokes_object['BSBST_version'] = utils.addon_version
@@ -55,21 +52,18 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
def new_flow_object(self, context, name, surface_object):
settings = context.scene.BSBST_settings
if settings.curve_mode == 'GP':
bpy.ops.object.grease_pencil_add(type='EMPTY')
context.object.name = name
context.object.data.name = name
flow_object = context.object
context.collection.objects.unlink(flow_object)
else:
if settings.curve_mode == 'CURVE':
flow_data = bpy.data.curves.new(name, type='CURVE')
flow_data.dimensions = '3D'
elif settings.curve_mode == 'CURVES':
flow_data = bpy.data.hair_curves.new(name)
flow_object = bpy.data.objects.new(name, flow_data)
flow_data = bpy.data.grease_pencils.new(name)
fl_layer = flow_data.layers.new('Flow')
fl_layer.frames.new(1)
elif settings.curve_mode == 'CURVE':
flow_data = bpy.data.curves.new(name, type='CURVE')
flow_data.dimensions = '3D'
elif settings.curve_mode == 'CURVES':
flow_data = bpy.data.hair_curves.new(name)
flow_object = bpy.data.objects.new(name, flow_data)
# link to surface object's collections (fall back to active collection if all are linked data)
utils.link_to_collections_by_ref(flow_object, surface_object)
utils.link_to_collections_by_ref(flow_object, surface_object, unlink=False)
visibility_options = [
'visible_camera',
@@ -112,7 +106,7 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
self.report({"ERROR"}, "Surface Object needs an available UV Map")
return {"CANCELLED"}
name = f'{surface_object.name} - Brushstrokes'
name = utils.bs_name(surface_object.name)
brushstrokes_object = self.new_brushstrokes_object(context, name, surface_object)
flow_is_required = settings.brushstroke_method == 'SURFACE_FILL'
if flow_is_required:
@@ -151,7 +145,9 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
with context.temp_override(**override):
bpy.ops.object.material_slot_add()
brushstrokes_object.material_slots[0].material = preset_material
settings.silent_switch = True
settings.context_material = preset_material
settings.silent_switch = False
brushstrokes_object['BSBST_material'] = settings.context_material
# transfer preset modifiers to new brushstrokes TODO: refactor to deduplicate
@@ -238,7 +234,7 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
mod.show_group_selector = False
# update brushstroke context
utils.find_context_brushstrokes(None)
utils.find_context_brushstrokes(context.scene, context.view_layer.depsgraph)
for i, name in enumerate([bs.name for bs in settings.context_brushstrokes]):
if name == brushstrokes_object.name:
settings.active_context_brushstrokes_index = i
@@ -297,7 +293,7 @@ class BSBST_OT_delete_brushstrokes(bpy.types.Operator):
edit_toggle = settings.edit_toggle
settings.edit_toggle = False
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob:
settings.edit_toggle = edit_toggle
return {"CANCELLED"}
@@ -313,7 +309,7 @@ class BSBST_OT_delete_brushstrokes(bpy.types.Operator):
if surface_object:
context.view_layer.objects.active = surface_object
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if bs_ob:
context.view_layer.objects.active = bs_ob
bs_ob.select_set(True)
@@ -346,7 +342,7 @@ class BSBST_OT_duplicate_brushstrokes(bpy.types.Operator):
def execute(self, context):
settings = context.scene.BSBST_settings
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob:
return {"CANCELLED"}
@@ -410,7 +406,7 @@ class BSBST_OT_copy_brushstrokes(bpy.types.Operator):
def execute(self, context):
settings = context.scene.BSBST_settings
active_surface_object = utils.get_surface_object(utils.get_active_context_brushstrokes_object(context))
active_surface_object = utils.get_surface_object(utils.get_active_context_brushstrokes_object(context.scene))
surface_objects = [ob for ob in context.selected_objects
if ob.type=='MESH'
@@ -423,7 +419,7 @@ class BSBST_OT_copy_brushstrokes(bpy.types.Operator):
bs_objects = [bpy.data.objects.get(bs.name) for bs in settings.context_brushstrokes]
bs_objects = [bs for bs in bs_objects if bs]
else:
bs_objects = [utils.get_active_context_brushstrokes_object(context)]
bs_objects = [utils.get_active_context_brushstrokes_object(context.scene)]
if not bs_objects:
return {"CANCELLED"}
@@ -450,6 +446,17 @@ class BSBST_OT_copy_brushstrokes(bpy.types.Operator):
# remap surface pointers and context linked data TODO: refactor to deduplicate
for ob in new_bs:
utils.link_to_collections_by_ref(ob, surface_object)
# if it's still using the default name initialize names again
if utils.split_id_name(ob.name)[0] == utils.bs_name(active_surface_object.name):
if utils.is_flow_object(ob):
ob.name = utils.flow_name(utils.bs_name(surface_object.name))
else:
ob.name = utils.bs_name(surface_object.name)
elif ob.name.startswith(active_surface_object.name):
ob.name = f"{surface_object.name}{ob.name[len(active_surface_object.name):]}"
ob.parent = surface_object
utils.set_surface_object(ob, surface_object)
@@ -504,16 +511,16 @@ class BSBST_OT_select_surface(bpy.types.Operator):
return bool(settings.context_brushstrokes)
def execute(self, context):
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob:
return {"CANCELLED"}
surface_object = getattr(bs_ob, '["BSBST_surface_object"]', None)
if not surface_object:
return {"CANCELLED"}
bpy.context.view_layer.objects.active = surface_object
if not context.mode == 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
bpy.context.view_layer.objects.active = surface_object
for ob in bpy.data.objects:
ob.select_set(False)
surface_object.select_set(True)
@@ -537,7 +544,7 @@ class BSBST_OT_assign_surface(bpy.types.Operator):
return bool(settings.context_brushstrokes)
def execute(self, context):
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob:
return {"CANCELLED"}
@@ -555,7 +562,7 @@ class BSBST_OT_assign_surface(bpy.types.Operator):
layout.prop_search(self, 'surface_object', bpy.data, 'objects')
def invoke(self, context, event):
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
surf_ob = utils.get_surface_object(bs_ob)
if surf_ob:
self.surface_object = surf_ob.name
@@ -632,7 +639,7 @@ class BSBST_OT_copy_flow(bpy.types.Operator):
return bool(settings.context_brushstrokes)
def execute(self, context):
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
flow_ob_old = utils.get_flow_object(bs_ob)
if not bs_ob:
return {"CANCELLED"}
@@ -670,7 +677,7 @@ class BSBST_OT_copy_flow(bpy.types.Operator):
def invoke(self, context, event):
settings = context.scene.BSBST_settings
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
for i in range(len(self.bs_list)):
self.bs_list.remove(0)
@@ -721,7 +728,7 @@ class BSBST_OT_switch_deformable(bpy.types.Operator):
bs_objects = [bpy.data.objects.get(bs.name) for bs in settings.context_brushstrokes]
bs_objects = [bs for bs in bs_objects if bs]
else:
bs_objects = [utils.get_active_context_brushstrokes_object(context)]
bs_objects = [utils.get_active_context_brushstrokes_object(context.scene)]
if not bs_objects:
return {"CANCELLED"}
@@ -762,7 +769,7 @@ class BSBST_OT_switch_animated(bpy.types.Operator):
bs_objects = [bpy.data.objects.get(bs.name) for bs in settings.context_brushstrokes]
bs_objects = [bs for bs in bs_objects if bs]
else:
bs_objects = [utils.get_active_context_brushstrokes_object(context)]
bs_objects = [utils.get_active_context_brushstrokes_object(context.scene)]
if not bs_objects:
return {"CANCELLED"}
@@ -963,7 +970,7 @@ class BSBST_OT_make_preset(bpy.types.Operator):
@classmethod
def poll(cls, context):
return bool(utils.get_active_context_brushstrokes_object(context))
return bool(utils.get_active_context_brushstrokes_object(context.scene))
def execute(self, context):
@@ -977,7 +984,7 @@ class BSBST_OT_make_preset(bpy.types.Operator):
settings.preset_object.modifiers.remove(mod)
# transfer brushstrokes modifiers to preset
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob:
return {"CANCELLED"}
for mod in bs_ob.modifiers:
@@ -1098,7 +1105,7 @@ class BSBST_OT_brushstrokes_toggle_attribute(bpy.types.Operator):
edit_toggle = settings.edit_toggle
settings.edit_toggle = False
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob:
settings.edit_toggle = edit_toggle
return {"CANCELLED"}
@@ -25,7 +25,7 @@ def update_brushstroke_method(self, context):
preset_object = bpy.data.objects.get(preset_name)
settings.preset_object = preset_object
style_object = utils.get_active_context_brushstrokes_object(context)
style_object = utils.get_active_context_brushstrokes_object(context.scene)
if not style_object:
style_object = preset_object
@@ -45,7 +45,7 @@ def update_context_material(self, context):
if settings.silent_switch:
return
style_object = utils.get_active_context_brushstrokes_object(context)
style_object = utils.get_active_context_brushstrokes_object(context.scene)
if not style_object:
style_object = settings.preset_object
if not style_object:
@@ -119,7 +119,14 @@ def get_active_context_brushstrokes_index(self):
return self["active_context_brushstrokes_index"]
def set_active_context_brushstrokes_index(self, value):
settings = bpy.context.scene.BSBST_settings
scene = self.id_data
settings = scene.BSBST_settings
for window in bpy.context.window_manager.windows:
if window.scene == scene:
view_layer = window.view_layer
active_object = view_layer.objects.active
if not settings.context_brushstrokes:
if not settings.preset_object:
return
@@ -136,12 +143,11 @@ def set_active_context_brushstrokes_index(self, value):
return
if not bs_ob:
return
if not bpy.context.object:
if not active_object:
return
view_layer = bpy.context.view_layer
if bpy.context.object.visible_get(view_layer = view_layer):
if active_object.visible_get(view_layer = view_layer):
bpy.ops.object.mode_set(mode='OBJECT')
bpy.context.view_layer.objects.active = bs_ob
view_layer.objects.active = bs_ob
if bs_ob.visible_get(view_layer = view_layer):
bpy.ops.object.mode_set(mode='OBJECT')
for ob in bpy.data.objects:
@@ -257,7 +263,7 @@ class BSBST_Settings(bpy.types.PropertyGroup):
gpv3 = bpy.context.preferences.experimental.use_grease_pencil_version3
except:
v0, v1, v3 = bpy.app.version
gpv3 = v0 >= 4 and v1 >= 3
gpv3 = v0 >= 4 or (v0 == 4 and v1 >= 3)
curve_mode: bpy.props.EnumProperty(default='CURVES',
items= [('CURVE', 'Legacy', 'Use legacy curve type (Limited Support)', 'CURVE_DATA', 0),\
('CURVES', 'Curves', 'Use hair curves (Fully supported)', 'CURVES_DATA', 1),
+16 -13
View File
@@ -536,7 +536,7 @@ class BSBST_PT_brushstroke_tools_panel(bpy.types.Panel):
row = style_panel.row()
row_edit = row.row(align=True)
row_edit.operator('brushstroke_tools.select_surface', icon='OUTLINER_OB_SURFACE', text='')
bs_ob = utils.get_active_context_brushstrokes_object(context)
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
text = 'Edit Flow' if getattr(bs_ob, '["BSBST_method"]', None)=='SURFACE_FILL' else 'Edit Brushstrokes'
row_edit.operator('brushstroke_tools.edit_brushstrokes', icon='GREASEPENCIL', text = text)
row_edit.prop(settings, 'edit_toggle', icon='RESTRICT_SELECT_OFF' if settings.edit_toggle else 'RESTRICT_SELECT_ON', icon_only=True)
@@ -549,15 +549,15 @@ class BSBST_PT_brushstroke_tools_panel(bpy.types.Panel):
class BSBST_MT_PIE_brushstroke_data_marking(bpy.types.Menu):
bl_idname= "BSBST_MT_PIE_brushstroke_data_marking"
bl_label = "Mark Brushstroke Flow"
bl_label = "Mark Brushstroke Data"
items = {
"Brush Flow - Mark": ['FORCE_WIND'],
"Brush Flow - Clear": ['NONE'],
"Brush Break - Mark": ['MOD_PHYSICS'],
"Brush Break - Clear": ['NONE'],
"Brush Ignore - Mark": ['X'],
"Brush Ignore - Clear": ['NONE'],
"Brush Flow - Mark": ['geometry.brush_flow_mark','FORCE_WIND'],
"Brush Flow - Clear": ['geometry.brush_flow_clear','NONE'],
"Brush Break - Mark": ['geometry.brush_break_mark','MOD_PHYSICS'],
"Brush Break - Clear": ['geometry.brush_break_clear','NONE'],
"Brush Ignore - Mark": ['geometry.brush_ignore_mark','X'],
"Brush Ignore - Clear": ['geometry.brush_ignore_clear','NONE'],
}
def draw(self, context):
@@ -567,11 +567,14 @@ class BSBST_MT_PIE_brushstroke_data_marking(bpy.types.Menu):
for name, info in self.items.items():
pie.alert=True
op = pie.operator("geometry.execute_node_group", text=name, icon=info[0])
op.asset_library_type='CUSTOM'
op.asset_library_identifier=utils.asset_lib_name
op.relative_asset_identifier=f"core/brushstroke_tools-resources.blend/NodeTree/{name}"
if bpy.app.version < (5,1):
op = pie.operator("geometry.execute_node_group", text=name, icon=info[1])
op.asset_library_type='CUSTOM'
op.asset_library_identifier=utils.asset_lib_name
op.relative_asset_identifier=f"core/brushstroke_tools-resources.blend/NodeTree/{name}"
else:
op = pie.operator(info[0], text=name, icon=info[1])
class BSBST_OT_brushstroke_data_marking(bpy.types.Operator):
"""
Call pie menu for operators to mark brushstroke data on the surface mesh
@@ -37,9 +37,8 @@ linkable_sockets = [
asset_lib_name = 'Brushstroke Tools Library'
@persistent
def find_context_brushstrokes(dummy):
context = bpy.context
settings = context.scene.BSBST_settings
def find_context_brushstrokes(scene, depsgraph):
settings = scene.BSBST_settings
edit_toggle = settings.edit_toggle
settings.edit_toggle = False
@@ -49,7 +48,7 @@ def find_context_brushstrokes(dummy):
# identify context brushstrokes
for el in range(len(settings.context_brushstrokes)):
settings.context_brushstrokes.remove(0)
context_object = context.object
context_object = depsgraph.view_layer.objects.active
if not is_brushstrokes_object(context_object):
bs_ob = is_flow_object(context_object)
if bs_ob:
@@ -97,12 +96,11 @@ def find_context_brushstrokes(dummy):
settings.edit_toggle = edit_toggle
@persistent
def refresh_preset(dummy):
context = bpy.context
settings = context.scene.BSBST_settings
def refresh_preset(scene, depsgraph):
settings = scene.BSBST_settings
if not settings:
return
for ob in [settings.preset_object, get_active_context_brushstrokes_object(context)]:
for ob in [settings.preset_object, get_active_context_brushstrokes_object(scene)]:
if not ob:
continue
for mod in ob.modifiers:
@@ -313,6 +311,25 @@ def write_lib_version(dir: Path = None):
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:
@@ -545,8 +562,21 @@ def find_brush_style_by_name(name: str):
return brush_style
return None
def link_to_collections_by_ref(obj, ref_obj):
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
@@ -754,8 +784,8 @@ def context_brushstrokes(context):
settings = context.scene.BSBST_settings
return settings.context_brushstrokes
def get_active_context_brushstrokes_object(context):
settings = context.scene.BSBST_settings
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]
@@ -765,19 +795,22 @@ def get_active_context_brushstrokes_object(context):
def get_active_context_surface_object(context):
if not context.object:
return None
bs_ob = get_active_context_brushstrokes_object(context)
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 flow_name(name):
return f'{name}-FLOW'
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)
bs_ob = get_active_context_brushstrokes_object(context.scene)
if not bs_ob:
return {"CANCELLED"}