From 01ce06201e0db1f4d216a123413a71b9011fd716 Mon Sep 17 00:00:00 2001 From: pullusb Date: Thu, 30 May 2024 18:33:05 +0200 Subject: [PATCH] move material to layer feature 3.1.0 - added: Feature to move all strokes using active material to an existing or new layer (material dropdown menu > `Move Material To Layer`) --- CHANGELOG.md | 4 + OP_material_move_to_layer.py | 171 +++++++++++++++++++++++++++++++++++ UI_tools.py | 2 + __init__.py | 4 +- utils.py | 127 ++++++++++++++------------ 5 files changed, 251 insertions(+), 57 deletions(-) create mode 100644 OP_material_move_to_layer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a1d4292..046677a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +3.1.0 + +- added: Feature to move all strokes using active material to an existing or new layer (material dropdown menu > `Move Material To Layer`) + 3.0.2 - changed: Exposed `Copy/Move Keys To Layer` in Dopesheet(Gpencil), in right clic context menu and `Keys` menu. diff --git a/OP_material_move_to_layer.py b/OP_material_move_to_layer.py new file mode 100644 index 0000000..701c622 --- /dev/null +++ b/OP_material_move_to_layer.py @@ -0,0 +1,171 @@ +import bpy +from bpy.types import Operator +import mathutils +from mathutils import Vector, Matrix, geometry +from bpy_extras import view3d_utils +from . import utils + +# def get_layer_list(self, context): +# '''return (identifier, name, description) of enum content''' +# if not context: +# return [('None', 'None','None')] +# if not context.object: +# return [('None', 'None','None')] +# return [(l.info, l.info, '') for l in context.object.data.layers] # if l != context.object.data.layers.active + +## in Class +# bl_property = "layers_enum" + +# layers_enum : bpy.props.EnumProperty( +# name="Send Material To Layer", +# description="Send active material to layer", +# items=get_layer_list, +# options={'HIDDEN'}, +# ) + +class GPTB_OT_move_material_to_layer(Operator) : + bl_idname = "gp.move_material_to_layer" + bl_label = 'Move Material To Layer' + bl_description = 'Move active material to an existing or new layer' + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + + layer_name : bpy.props.StringProperty( + name='Layer Name', default='', options={'SKIP_SAVE'}) + + @classmethod + def poll(cls, context): + return context.object and context.object.type == 'GPENCIL' + + def invoke(self, context, event): + if self.layer_name: + return self.execute(context) + if not len(context.object.data.layers): + self.report({'WARNING'}, 'No layers on current GP object') + return {'CANCELLED'} + + mat = context.object.data.materials[context.object.active_material_index] + self.mat_name = mat.name + + # wm.invoke_search_popup(self) + return context.window_manager.invoke_props_dialog(self, width=250) + + def draw(self, context): + layout = self.layout + # layout.operator_context = "INVOKE_DEFAULT" + layout.label(text=f'Move material "{self.mat_name}" to layer:', icon='MATERIAL') + + col = layout.column() + col.prop(self, 'layer_name', text='', icon='ADD') + # if self.layer_name: + # col.label(text='Ok/Enter to create new layer', icon='INFO') + + col.separator() + for l in reversed(context.object.data.layers): + + icon = 'GREASEPENCIL' if l == context.object.data.layers.active else 'BLANK1' + row = col.row() + row.alignment = 'LEFT' + col.operator('gp.move_material_to_layer', text=l.info, icon=icon, emboss=False).layer_name = l.info + + def execute(self, context): + if not self.layer_name: + print('Out') + return {'CANCELLED'} + + ## Active + selection + pool = [o for o in bpy.context.selected_objects if o.type == 'GPENCIL'] + if not context.object in pool: + pool.append(context.object) + + mat = context.object.data.materials[context.object.active_material_index] + + print(f'Moving strokes using material "{mat.name}" on {len(pool)} object(s)') + # import time + # t = time.time() # Dbg + total = 0 + oct = 0 + for ob in pool: + mat_index = next((i for i, ms in enumerate(ob.material_slots) if ms.material and ms.material == mat), None) + if mat_index is None: + print(f'/!\ {ob.name} has no Material {mat.name} in stack') + continue + + gpl = ob.data.layers + + if not (target_layer := gpl.get(self.layer_name)): + target_layer = gpl.new(self.layer_name) + + ## List existing frames + key_dict = {f.frame_number : f for f in target_layer.frames} + + ### Move Strokes to a new key (or existing key if comming for yet another layer) + fct = 0 + sct = 0 + for l in gpl: + if l == target_layer: + ## ! infinite loop if target layer is included + continue + for f in l.frames: + ## skip if no stroke has active material + if not next((s for s in f.strokes if s.material_index == mat_index), None): + continue + ## Get/Create a destination frame and keep a reference to it + if not (dest_key := key_dict.get(f.frame_number)): + dest_key = target_layer.frames.new(f.frame_number) + key_dict[dest_key.frame_number] = dest_key + + print(f'{ob.name} : frame {f.frame_number}') + ## Replicate strokes in dest_keys + stroke_to_delete = [] + for s in f.strokes: + if s.material_index == mat_index: + utils.copy_stroke_to_frame(s, dest_key) + stroke_to_delete.append(s) + + ## Debug + # if time.time() - t > 10: + # print('TIMEOUT') + # return {'CANCELLED'} + + sct += len(stroke_to_delete) + + # print('Removing frames') # Dbg + ## Remove from source frame (f) + for s in reversed(stroke_to_delete): + f.strokes.remove(s) + + ## ? Remove frame if layer is empty ? -> probably not, will show previous frame + + fct += 1 + l.frames.update() + + + if fct: + oct += 1 + print(f'{ob.name}: Moved {fct} frames -> {sct} Strokes') # Dbg + + total += fct + + report_type = 'INFO' if total else 'WARNING' + self.report({report_type}, f'Moved {total} frames accross {oct} object(s)') + return {'FINISHED'} + + +# def menu_duplicate_and_send_to_layer(self, context): +# if context.space_data.ui_mode == 'GPENCIL': +# self.layout.operator_context = 'INVOKE_REGION_WIN' +# self.layout.operator('gp.duplicate_send_to_layer', text='Move Keys To Layer').delete_source = True +# self.layout.operator('gp.duplicate_send_to_layer', text='Copy Keys To Layer') + +classes = ( +GPTB_OT_move_material_to_layer, +) + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + +def unregister(): + for cls in reversed(classes): + bpy.utils.unregister_class(cls) \ No newline at end of file diff --git a/UI_tools.py b/UI_tools.py index 3d94929..ebb48cd 100644 --- a/UI_tools.py +++ b/UI_tools.py @@ -432,6 +432,8 @@ def palette_manager_menu(self, context): layout.separator() layout.operator("gp.load_palette", text='Load json Palette', icon='IMPORT').filepath = prefs.palette_path layout.operator("gp.save_palette", text='Save json Palette', icon='EXPORT').filepath = prefs.palette_path + layout.separator() + layout.operator("gp.move_material_to_layer", text='Move Material To Layer', icon='MATERIAL') def expose_use_channel_color_pref(self, context): diff --git a/__init__.py b/__init__.py index 17600c4..73ae722 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": (3, 0, 2), +"version": (3, 1, 0), "blender": (4, 0, 0), "location": "Sidebar (N menu) > Gpencil > Toolbox / Gpencil properties", "warning": "", @@ -46,6 +46,7 @@ from . import OP_git_update from . import OP_layer_namespace from . import OP_pseudo_tint from . import OP_follow_curve +from . import OP_material_move_to_layer # from . import OP_eraser_brush # from . import TOOL_eraser_brush from . import handler_draw_cam @@ -803,6 +804,7 @@ addon_modules = ( OP_layer_picker, OP_layer_nav, OP_follow_curve, + OP_material_move_to_layer, # OP_eraser_brush, # TOOL_eraser_brush, # experimental eraser brush handler_draw_cam, diff --git a/utils.py b/utils.py index 8db8c71..ca10261 100644 --- a/utils.py +++ b/utils.py @@ -2,12 +2,13 @@ import bpy, os import numpy as np import bmesh import mathutils -from mathutils import Vector import math -from math import sqrt -from sys import platform import subprocess +from math import sqrt +from mathutils import Vector +from sys import platform + """ def get_gp_parent(layer) : @@ -263,55 +264,6 @@ def remapping(value, leftMin, leftMax, rightMin, rightMax): ### GP funcs # ----------------- -""" V1 -def get_gp_draw_plane(obj=None): - ''' return tuple with plane coordinate and normal - of the curent drawing accordign to geometry''' - - context = bpy.context - settings = context.scene.tool_settings - orient = settings.gpencil_sculpt.lock_axis #'VIEW', 'AXIS_Y', 'AXIS_X', 'AXIS_Z', 'CURSOR' - loc = settings.gpencil_stroke_placement_view3d #'ORIGIN', 'CURSOR', 'SURFACE', 'STROKE' - if obj: - mat = obj.matrix_world - else: - mat = context.object.matrix_world if context.object else None - - # -> placement - if loc == "CURSOR": - plane_co = context.scene.cursor.location - - else: # ORIGIN (also on origin if set to 'SURFACE', 'STROKE') - if not context.object: - plane_co = None - else: - plane_co = context.object.matrix_world.to_translation()# context.object.location - - # -> orientation - if orient == 'VIEW': - #only depth is important, no need to get view vector - plane_no = None - - elif orient == 'AXIS_Y':#front (X-Z) - plane_no = Vector((0,1,0)) - plane_no.rotate(mat) - - elif orient == 'AXIS_X':#side (Y-Z) - plane_no = Vector((1,0,0)) - plane_no.rotate(mat) - - elif orient == 'AXIS_Z':#top (X-Y) - plane_no = Vector((0,0,1)) - plane_no.rotate(mat) - - elif orient == 'CURSOR': - plane_no = Vector((0,0,1)) - plane_no.rotate(context.scene.cursor.matrix) - - return plane_co, plane_no -""" - -## V2 def get_gp_draw_plane(obj=None, orient=None): ''' return tuple with plane coordinate and normal of the curent drawing according to geometry''' @@ -459,14 +411,14 @@ def get_gp_datas(selection=True): print('EOL. No active GP object') return [] -def get_gp_layer(gp_data_block,name) : +def get_gp_layer(gp_data_block, name) : gp_layer = gp_data_block.layers.get(name) if not gp_layer : gp_layer = gp_data_block.layers.new(name) return gp_layer -def get_gp_frame(layer,frame_nb = None) : +def get_gp_frame(layer, frame_nb=None) : scene = bpy.context.scene if not frame_nb : frame_nb = scene.frame_current @@ -532,9 +484,72 @@ def selected_strokes(frame): stlist.append(s) return stlist -from math import sqrt -from mathutils import Vector +## Copy stroke to a frame +def copy_stroke_to_frame(s, frame, select=True): + '''Copy stroke to given frame + return created stroke + ''' + + ns = frame.strokes.new() + + ## Set strokes attr + stroke_attr = [ + 'line_width', + 'material_index', + 'draw_cyclic', + 'use_cyclic', + 'uv_scale', + 'uv_rotation', + 'hardness', + 'uv_translation', + 'vertex_color_fill', + ] + + for attr in stroke_attr: + if not hasattr(s, attr): + continue + # print(f'transfer stroke {attr}') # Dbg + setattr(ns, attr, getattr(s, attr)) + + ## create points + point_count = len(s.points) + ns.points.add(len(s.points)) + + ## Set points attr + # for p, np in zip(s.points, ns.points): + flat_list = [0.0] * point_count + flat_uv_fill_list = [0.0, 0.0] * point_count + flat_vector_list = [0.0, 0.0, 0.0] * point_count + flat_color_list = [0.0, 0.0, 0.0, 0.0] * point_count + + single_attr = [ + 'pressure', + 'strength', + 'uv_factor', + 'uv_rotation', + ] + + for attr in single_attr: + # print(f'transfer point {attr}') # Dbg + s.points.foreach_get(attr, flat_list) + ns.points.foreach_set(attr, flat_list) + + # print(f'transfer point co') # Dbg + s.points.foreach_get('co', flat_vector_list) + ns.points.foreach_set('co', flat_vector_list) + + # print(f'transfer point uv_fill') # Dbg + s.points.foreach_get('uv_fill', flat_uv_fill_list) + ns.points.foreach_set('uv_fill', flat_uv_fill_list) + + # print(f'transfer point vertex_color') # Dbg + s.points.foreach_get('vertex_color', flat_color_list) + ns.points.foreach_set('vertex_color', flat_color_list) + + ns.select = select + ns.points.update() + return ns # ----------------- ### Vector utils 3d