From 9d2644eb9ff59e9a4400bbd2b6bd55a45a0961ac Mon Sep 17 00:00:00 2001 From: Pullusb Date: Mon, 15 Dec 2025 23:57:57 +0100 Subject: [PATCH] Update for blender 5+ with retro-compatibility - Added version check to keep addon compatible with previous version - Some feature are still broken (e.g: realign, broken since 4.3's gpv3) - note: DIR_PATH string property do not accept relative path since Blender 5.0. preferences brush_path,output_path and playblast_path had "//" by default. those props are turned to stnadard string for now --- CHANGELOG.md | 4 +++ OP_breakdowner.py | 7 ++-- OP_cursor_snap_canvas.py | 8 +++-- OP_file_checker.py | 29 ++++++++-------- OP_helpers.py | 56 ++++++++++++------------------- OP_layer_manager.py | 8 +++-- OP_palettes_linker.py | 4 +-- OP_realign.py | 16 ++++----- UI_tools.py | 19 ++++++++--- __init__.py | 72 +++++++++++++++++++++++----------------- utils.py | 37 ++++++++++++++++----- 11 files changed, 153 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e5195..f9d49ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog +5.0.0 + +- added: Update for blender 5+. With retro-compatibility + 4.2.0 - added: Delete Grease pencil strokes or points view bound (selection outside of viewport region is untouched) diff --git a/OP_breakdowner.py b/OP_breakdowner.py index aef291f..50c36fb 100644 --- a/OP_breakdowner.py +++ b/OP_breakdowner.py @@ -3,6 +3,7 @@ import bpy import re from mathutils import Vector, Matrix from math import radians, degrees +from . import utils # exemple for future improve: https://justinsbarrett.com/tweenmachine/ @@ -21,7 +22,7 @@ def get_surrounding_points(fc, frame): return p_pt, n_pt -## unused direct breackdown func +## unused direct breakdown func def breakdown_keys(percentage=50, channels=('location', 'rotation_euler', 'scale'), axe=(0,1,2)): cf = bpy.context.scene.frame_current# use operator context (may be unsynced timeline) axes_name = ('x', 'y', 'z') @@ -42,7 +43,7 @@ def breakdown_keys(percentage=50, channels=('location', 'rotation_euler', 'scale skipping = [] - for fc in action.fcurves: + for fc in utils.get_fcurves(obj): # if fc.data_path.split('"')[1] in bone_names_filter:# bones # if fc.data_path.split('.')[-1] in channels and fc.array_index in axe:# bones if fc.data_path in channels and fc.array_index in axe:# .split('.')[-1] @@ -252,7 +253,7 @@ class OBJ_OT_breakdown_obj_anim(bpy.types.Operator): ## TODO for ob in context.selected objects, need to reduce list with upper filters... - for fc in action.fcurves: + for fc in utils.get_fcurves(obj): # if fc.data_path.split('"')[1] in bone_names_filter:# bones # if fc.data_path.split('.')[-1] in channels and fc.array_index in axe:# bones if fc.data_path in self.channels:# .split('.')[-1]# and fc.array_index in axe diff --git a/OP_cursor_snap_canvas.py b/OP_cursor_snap_canvas.py index a49d86d..ae73ab3 100644 --- a/OP_cursor_snap_canvas.py +++ b/OP_cursor_snap_canvas.py @@ -180,10 +180,14 @@ def selection_changed(): ## Note: Same owner as layer manager (will be removed as well) def subscribe_object_change(): subscribe_to = (bpy.types.LayerObjects, 'active') + if bpy.app.version >= (5, 0, 0): + owner = bpy.types.GreasePencil + else: + owner = bpy.types.GreasePencilv3 bpy.msgbus.subscribe_rna( key=subscribe_to, # owner of msgbus subcribe (for clearing later) - owner=bpy.types.GreasePencilv3, # <-- attach to ID during it's lifetime. + owner=owner, # <-- attach to ID during it's lifetime. args=(), notify=selection_changed, options={'PERSISTENT'}, @@ -222,4 +226,4 @@ def unregister(): if cursor_follow.__name__ in [hand.__name__ for hand in bpy.app.handlers.frame_change_post]: bpy.app.handlers.frame_change_post.remove(cursor_follow) - bpy.msgbus.clear_by_owner(bpy.types.GreasePencilv3) \ No newline at end of file + bpy.msgbus.clear_by_owner(bpy.types.GreasePencil) \ No newline at end of file diff --git a/OP_file_checker.py b/OP_file_checker.py index aec37fa..e53bdd5 100755 --- a/OP_file_checker.py +++ b/OP_file_checker.py @@ -20,7 +20,8 @@ def remove_stroke_exact_duplications(apply=True, verbose=True, select=False): ct = 0 if verbose: print('\nRemove redundant strokes in GP frames...') - gp_datas = [gp for gp in bpy.data.grease_pencils_v3] + gp_source = bpy.data.grease_pencils if bpy.app.version >= (5,0,0) else bpy.data.grease_pencils_v3 + gp_datas = [gp for gp in gp_source] for gp in gp_datas: for l in gp.layers: for f in l.frames: @@ -221,17 +222,19 @@ class GPTB_OT_file_checker(bpy.types.Operator): o.use_grease_pencil_lights = False ## Disabled animation - if fix.list_disabled_anim: - fcu_ct = 0 - for act in bpy.data.actions: - if not act.users: - continue - for fcu in act.fcurves: - if fcu.mute: - fcu_ct += 1 - print(f"muted: {act.name} > {fcu.data_path}") - if fcu_ct: - problems.append(f'{fcu_ct} anim channel disabled (details in console)') + # TODO : fix for Blender 5.0 + if bpy.app.version < (5,0,0): + if fix.list_disabled_anim: + fcu_ct = 0 + for act in bpy.data.actions: + if not act.users: + continue + for fcu in act.fcurves: + if fcu.mute: + fcu_ct += 1 + print(f"muted: {act.name} > {fcu.data_path}") + if fcu_ct: + problems.append(f'{fcu_ct} anim channel disabled (details in console)') ## Object visibility conflict if fix.list_obj_vis_conflict: @@ -330,7 +333,7 @@ class GPTB_OT_file_checker(bpy.types.Operator): # ## Set onion skin filter to 'All type' # fix_kf_type = 0 - # for gp in bpy.data.grease_pencils_v3:#from data + # for gp in bpy.data.grease_pencils: #from data # if not gp.is_annotation: # if gp.onion_keyframe_type != 'ALL': # gp.onion_keyframe_type = 'ALL' diff --git a/OP_helpers.py b/OP_helpers.py index 2b367b9..0081c96 100644 --- a/OP_helpers.py +++ b/OP_helpers.py @@ -421,12 +421,12 @@ class GPTB_OT_toggle_mute_animation(Operator): self.selection = event.shift return self.execute(context) - def set_action_mute(self, act): - for i, fcu in enumerate(act.fcurves): + def set_action_mute(self, bag): + for i, fcu in enumerate(bag.fcurves): print(i, fcu.data_path, fcu.array_index) # fcu.group don't have mute attribute in api. fcu.mute = self.mute - for g in act.groups: + for g in bag.groups: g.mute = self.mute def execute(self, context): @@ -443,22 +443,15 @@ class GPTB_OT_toggle_mute_animation(Operator): if self.mode == 'CAMERA' and o.type != 'CAMERA': continue - # mute attribute animation for GP and cameras + ## Mute attribute animation for GP and cameras if o.type in ('GREASEPENCIL', 'CAMERA') and o.data.animation_data: - gp_act = o.data.animation_data.action - if gp_act: + if data_channelbag := utils.get_active_channelbag(o.data): print(f'\n---{o.name} data:') - self.set_action_mute(gp_act) + self.set_action_mute(data_channelbag) - if not o.animation_data: - continue - act = o.animation_data.action - if not act: - continue - - print(f'\n---{o.name}:') - self.set_action_mute(act) - + if object_channelbag := utils.get_active_channelbag(o): + print(f'\n---{o.name}:') + self.set_action_mute(object_channelbag) return {'FINISHED'} @@ -495,7 +488,7 @@ class GPTB_OT_toggle_hide_gp_modifier(Operator): class GPTB_OT_list_disabled_anims(Operator): bl_idname = "gp.list_disabled_anims" bl_label = "List Disabled Anims" - bl_description = "List disabled animations channels in scene. (shit+clic to list only on seleciton)" + bl_description = "List disabled animations channels in scene. (shit+clic to list only on selection)" bl_options = {"REGISTER"} mute : bpy.props.BoolProperty(default=False) @@ -521,27 +514,22 @@ class GPTB_OT_list_disabled_anims(Operator): # continue if o.type == 'GREASEPENCIL': - if o.data.animation_data: - gp_act = o.data.animation_data.action - if gp_act: - for i, fcu in enumerate(gp_act.fcurves): - if fcu.mute: - if o not in oblist: - oblist.append(o) - li.append(f'{o.name}:') - li.append(f' - {fcu.data_path} {fcu.array_index}') - - if not o.animation_data: - continue - act = o.animation_data.action - if not act: - continue + if fcurves := utils.get_fcurves(o.data): + for i, fcu in enumerate(fcurves): + if fcu.mute: + if o not in oblist: + oblist.append(o) + li.append(f'{o.name}:') + li.append(f' - {fcu.data_path} {fcu.array_index}') - for g in act.groups: + bag = utils.get_active_channelbag(o) + if not bag: + continue + for g in bag.groups: if g.mute: li.append(f'{o.name} - group: {g.name}') - for i, fcu in enumerate(act.fcurves): + for i, fcu in enumerate(bag.fcurves): # print(i, fcu.data_path, fcu.array_index) if fcu.mute: if o not in oblist: diff --git a/OP_layer_manager.py b/OP_layer_manager.py index 6f8e8b1..5aa86e9 100644 --- a/OP_layer_manager.py +++ b/OP_layer_manager.py @@ -624,11 +624,15 @@ def obj_layer_name_callback(): def subscribe_layer_change(): subscribe_to = (bpy.types.GreasePencilv3Layers, "active") + if bpy.app.version >= (5, 0, 0): + owner = bpy.types.GreasePencil + else: + owner = bpy.types.GreasePencilv3 bpy.msgbus.subscribe_rna( key=subscribe_to, # owner of msgbus subcribe (for clearing later) # owner=handle, - owner=bpy.types.GreasePencilv3, # <-- can attach to an ID during all it's lifetime... + owner=owner, # <-- can attach to an ID during all it's lifetime... # Args passed to callback function (tuple) args=(), # Callback function for property update @@ -783,4 +787,4 @@ def unregister(): # Delete layer index trigger # /!\ can remove msgbus made for other functions or other addons using same owner - bpy.msgbus.clear_by_owner(bpy.types.GreasePencilv3) \ No newline at end of file + bpy.msgbus.clear_by_owner(bpy.types.GreasePencil) \ No newline at end of file diff --git a/OP_palettes_linker.py b/OP_palettes_linker.py index cb17b44..e9de62c 100644 --- a/OP_palettes_linker.py +++ b/OP_palettes_linker.py @@ -165,9 +165,9 @@ class GPTB_OT_import_obj_palette(Operator): # self.report({'WARNING'}, 'All materials are already in other selected object') # unlink objects and their gp data + data_source = bpy.data.grease_pencils if bpy.app.version >= (5, 0, 0) else bpy.data.grease_pencils_v3 for src_ob in linked_objs: - bpy.data.grease_pencils_v3.remove(src_ob.data) - + data_source.remove(src_ob.data) return {"FINISHED"} diff --git a/OP_realign.py b/OP_realign.py index b41b842..f590854 100644 --- a/OP_realign.py +++ b/OP_realign.py @@ -158,10 +158,10 @@ def align_all_frames(reproject=True, ref=None, all_strokes=True): chanel = 'rotation_quaternion' if o.rotation_mode == 'QUATERNION' else 'rotation_euler' ## double list keys - rot_keys = [int(k.co.x) for fcu in o.animation_data.action.fcurves for k in fcu.keyframe_points if fcu.data_path == chanel] + rot_keys = [int(k.co.x) for fcu in utils.get_fcurves(o) for k in fcu.keyframe_points if fcu.data_path == chanel] ## normal iter - # for fcu in o.animation_data.action.fcurves: + # for fcu in utils.get_fcurves(o): # if fcu.data_path != chanel : # continue # for k in fcu.keyframe_points(): @@ -268,12 +268,12 @@ class GPTB_OT_realign(bpy.types.Operator): self.alert = '' o = context.object - if o.animation_data and o.animation_data.action: - act = o.animation_data.action + fcurves = utils.get_fcurves(o) + if fcurves: for chan in ('rotation_euler', 'rotation_quaternion'): - if act.fcurves.find(chan): + if fcurves.find(chan): self.alert = 'Animated Rotation (CONSTANT interpolation)' - interpos = [p for fcu in act.fcurves if fcu.data_path == chan for p in fcu.keyframe_points if p.interpolation != 'CONSTANT'] + interpos = [p for fcu in fcurves if fcu.data_path == chan for p in fcu.keyframe_points if p.interpolation != 'CONSTANT'] if interpos: self.alert = f'Animated Rotation ! ({len(interpos)} key not constant)' break @@ -323,8 +323,8 @@ class GPTB_OT_realign(bpy.types.Operator): oframe = context.scene.frame_current o = bpy.context.object - if o.animation_data and o.animation_data.action: - if o.animation_data.action.fcurves.find('rotation_euler') or o.animation_data.action.fcurves.find('rotation_quaternion'): + if fcurves := utils.get_fcurves(o): + if fcurves.find('rotation_euler') or fcurves.find('rotation_quaternion'): align_all_frames(reproject=self.reproject) print(f'\nAnim realign ({time()-t0:.2f}s)') self.exit(context, oframe) diff --git a/UI_tools.py b/UI_tools.py index 67067d4..d866e69 100644 --- a/UI_tools.py +++ b/UI_tools.py @@ -771,9 +771,15 @@ GPTB_PT_palettes_list_ui, # subpanels def register(): for cls in classes: bpy.utils.register_class(cls) - bpy.types.GPENCIL_MT_material_context_menu.append(palette_manager_menu) + + if bpy.app.version >= (5,0,0): + bpy.types.GREASE_PENCIL_MT_material_context_menu.append(palette_manager_menu) + # bpy.types.GREASE_PENCIL_MT_material_context_menu.append(palette_manager_menu) + else: + bpy.types.GPENCIL_MT_material_context_menu.append(palette_manager_menu) + # bpy.types.GPENCIL_MT_material_context_menu.append(palette_manager_menu) + bpy.types.DOPESHEET_PT_grease_pencil_mode.append(expose_use_channel_color_pref) - # bpy.types.GPENCIL_MT_material_context_menu.append(palette_manager_menu) # bpy.types.DOPESHEET_PT_gpencil_layer_display.append(expose_use_channel_color_pref) # bpy.types.VIEW3D_HT_header.append(interpolate_header_ui) # WIP @@ -786,9 +792,14 @@ def unregister(): # bpy.types.VIEW3D_HT_header.remove(interpolate_header_ui) # WIP bpy.types.DOPESHEET_PT_grease_pencil_mode.remove(expose_use_channel_color_pref) - bpy.types.GPENCIL_MT_material_context_menu.remove(palette_manager_menu) + if bpy.app.version >= (5,0,0): + bpy.types.GREASE_PENCIL_MT_material_context_menu.remove(palette_manager_menu) + # bpy.types.GREASE_PENCIL_MT_material_context_menu.remove(palette_manager_menu) + else: + bpy.types.GPENCIL_MT_material_context_menu.remove(palette_manager_menu) + # bpy.types.GPENCIL_MT_material_context_menu.remove(palette_manager_menu) + # bpy.types.DOPESHEET_PT_gpencil_layer_display.remove(expose_use_channel_color_pref) - # bpy.types.GPENCIL_MT_material_context_menu.remove(palette_manager_menu) # if bpy.app.version >= (3,0,0): diff --git a/__init__.py b/__init__.py index 82558af..de58fb6 100755 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,7 @@ bl_info = { "name": "GP toolbox", "description": "Tool set for Grease Pencil in animation production", "author": "Samuel Bernou, Christophe Seux", -"version": (4, 2, 0), +"version": (5, 0, 0), "blender": (4, 3, 0), "location": "Sidebar (N menu) > Gpencil > Toolbox / Gpencil properties", "warning": "", @@ -160,23 +160,6 @@ class GPTB_prefs(bpy.types.AddonPreferences): ) ## output settings for automated renders - output_parent_level = IntProperty( - name='Parent level', - description="Go up in folder to define a render path relative to the file in upper directotys", - default=0, - min=0, - max=20 - ) - - output_path : StringProperty( - name="Output path", - description="Path relative to blend to place render", - default="//render", maxlen=0, subtype='DIR_PATH') - - playblast_path : StringProperty( - name="Playblast Path", - description="Path to folder for playblasts output", - default="//playblast", maxlen=0, subtype='DIR_PATH') use_env_palettes : BoolProperty( name="Use Project Palettes", @@ -206,10 +189,39 @@ class GPTB_prefs(bpy.types.AddonPreferences): default=True, ) - brush_path : StringProperty( - name="Brushes directory", - description="Path to brushes containing the blends holding the brushes", - default="//", maxlen=0, subtype='DIR_PATH')#, update = set_palette_path + if bpy.app.version >= (5, 0, 0): + # DIR_PATH type do not accept relative ("//") since blender 5 + ## using standard string + brush_path : StringProperty( + name="Brushes directory", + description="Path to brushes containing the blends holding the brushes", + default="//", maxlen=0) + + output_path : StringProperty( + name="Output path", + description="Path relative to blend to place render", + default="//render", maxlen=0) + + playblast_path : StringProperty( + name="Playblast Path", + description="Path to folder for playblasts output", + default="//playblast", maxlen=0) + + else: + brush_path : StringProperty( + name="Brushes directory", + description="Path to brushes containing the blends holding the brushes", + default="//", maxlen=0, subtype='DIR_PATH')#, update = set_palette_path + + output_path : StringProperty( + name="Output path", + description="Path relative to blend to place render", + default="//render", maxlen=0, subtype='DIR_PATH') + + playblast_path : StringProperty( + name="Playblast Path", + description="Path to folder for playblasts output", + default="//playblast", maxlen=0, subtype='DIR_PATH') ## namespace separator : StringProperty( @@ -279,7 +291,7 @@ class GPTB_prefs(bpy.types.AddonPreferences): ) ## KF jumper - kfj_use_shortcut: BoolProperty( + kfj_use_shortcut : BoolProperty( name = "Use Keyframe Jump Shortcut", description = "Auto bind shotcut for keyframe jump (else you can bind manually using 'screen.gp_keyframe_jump' id_name)", default = True) @@ -289,17 +301,17 @@ class GPTB_prefs(bpy.types.AddonPreferences): description="Shortcut to trigger previous keyframe jump", default="F5") - kfj_prev_shift: BoolProperty( + kfj_prev_shift : BoolProperty( name = "Shift", description = "add shift", default = False) - kfj_prev_alt: BoolProperty( + kfj_prev_alt : BoolProperty( name = "Alt", description = "add alt", default = False) - kfj_prev_ctrl: BoolProperty( + kfj_prev_ctrl : BoolProperty( name = "combine with ctrl", description = "add ctrl", default = False) @@ -309,17 +321,17 @@ class GPTB_prefs(bpy.types.AddonPreferences): description="Shortcut to trigger keyframe jump", default="F6") - kfj_next_shift: BoolProperty( + kfj_next_shift : BoolProperty( name = "Shift", description = "add shift", default = False) - kfj_next_alt: BoolProperty( + kfj_next_alt : BoolProperty( name = "Alt", description = "add alt", default = False) - kfj_next_ctrl: BoolProperty( + kfj_next_ctrl : BoolProperty( name = "combine with ctrl", description = "add ctrl", default = False) @@ -704,8 +716,6 @@ def set_namespace_env(name_env, prop_group): n.is_project = n.tag in project_pfix def set_env_properties(): - - prefs = get_addon_prefs() fps = os.getenv('FPS') diff --git a/utils.py b/utils.py index 1787573..661d77e 100644 --- a/utils.py +++ b/utils.py @@ -1485,6 +1485,30 @@ def has_fully_enabled_anim(o): return False return True +def get_fcurves(obj): + '''return f-curves of object's active action''' + if bpy.app.version >= (5, 0, 0): + # action slot + if obj.animation_data and obj.animation_data.action_slot: + active_slot = obj.animation_data.action_slot + return active_slot.id_data.layers[0].strips[0].channelbag(active_slot).fcurves + else: + if obj.animation_data and obj.animation_data.action: + return obj.animation_data.action.fcurves + return [] + +def get_active_channelbag(obj): + '''return active channelbag in blender 5, else action''' + if bpy.app.version >= (5, 0, 0): + # action slot + if obj.animation_data and obj.animation_data.action_slot: + active_slot = obj.animation_data.action_slot + return active_slot.id_data.layers[0].strips[0].channelbag(active_slot) + else: + if obj.animation_data and obj.animation_data.action: + return obj.animation_data.action + + def anim_status(objects) -> tuple((str, str)): '''Return a tutple of icon string status in ('ALL_ON', 'MIXED', 'ALL_OFF', 'NONE')''' @@ -1504,8 +1528,9 @@ def anim_status(objects) -> tuple((str, str)): # off_count += 1 ### Consider All channels individually - if o.animation_data and o.animation_data.action: - for grp in o.animation_data.action.groups: + + if channelbag := get_active_channelbag(o): + for grp in channelbag.groups: ## Check if groups are muted if grp.mute: off_count += 1 @@ -1514,7 +1539,7 @@ def anim_status(objects) -> tuple((str, str)): count += 1 - for fcu in o.animation_data.action.fcurves: + for fcu in channelbag.fcurves: ## Check if fcurves are muted if fcu.mute: off_count += 1 @@ -1524,12 +1549,8 @@ def anim_status(objects) -> tuple((str, str)): if o.type in ('GREASEPENCIL', 'CAMERA'): datablock = o.data - if datablock.animation_data is None: - continue - if not datablock.animation_data.action: - continue ## Check if object data attributes fcurves are muted - for fcu in datablock.animation_data.action.fcurves: + for fcu in get_fcurves(datablock): if fcu.mute: off_count += 1 else: