## GP clipboard : Copy/Cut/Paste Grease Pencil strokes to/from OS clipboard across layers and blends ## View3D > Toolbar > Gpencil > GP clipboard ## in 4.2- existed in standalone scripts: https://github.com/Pullusb/GP_clipboard import bpy import mathutils from mathutils import Vector import json from time import time from operator import itemgetter from itertools import groupby from .utils import is_locked, is_hidden def convertAttr(Attr): '''Convert given value to a Json serializable format''' if isinstance(Attr, (mathutils.Vector,mathutils.Color)): return Attr[:] elif isinstance(Attr, mathutils.Matrix): return [v[:] for v in Attr] elif isinstance(Attr,bpy.types.bpy_prop_array): return [Attr[i] for i in range(0,len(Attr))] else: return(Attr) def getMatrix(layer) : matrix = mathutils.Matrix.Identity(4) if parent := layer.parent: if parent.type == 'ARMATURE' and layer.parent_bone: bone = parent.pose.bones[layer.parent_bone] matrix = bone.matrix @ parent.matrix_world matrix = matrix.copy() @ layer.matrix_parent_inverse else: matrix = parent.matrix_world @ layer.matrix_parent_inverse return matrix.copy() # default_pt_uv_fill = Vector((0.5, 0.5)) def dump_gp_point(p, l, obj, radius=True, opacity=True, vertex_color=True, fill_color=True, uv_factor=True, rotation=True): '''add properties of a given points to a dic and return it''' point_dict = {} #point_attr_list = ('co', 'radius', 'select', 'opacity') #select#'rna_type' #for att in point_attr_list: # point_dict[att] = convertAttr(getattr(p, att)) if l.parent: mat = getMatrix(l) point_dict['position'] = convertAttr(obj.matrix_world @ mat @ getattr(p,'position')) else: point_dict['position'] = convertAttr(obj.matrix_world @ getattr(p,'position')) # point_dict['select'] = convertAttr(getattr(p,'select')) # need selection ? if radius and p.radius != 1.0: point_dict['radius'] = convertAttr(getattr(p,'radius')) if opacity and p.opacity != 1.0: point_dict['opacity'] = convertAttr(getattr(p,'opacity')) ## get vertex color (long...) if vertex_color and p.vertex_color[:] != (0.0, 0.0, 0.0, 0.0): point_dict['vertex_color'] = convertAttr(p.vertex_color) if rotation and p.rotation != 0.0: point_dict['rotation'] = convertAttr(p.rotation) ## No time infos # if delta_time and p.delta_time != 0.0: # point_dict['delta_time'] = convertAttr(getattr(p,'delta_time')) return point_dict def dump_gp_stroke_range(s, sid, l, obj, radius=True, opacity=True, vertex_color=True, fill_color=True, fill_opacity=True, rotation=True): '''Get a grease pencil stroke and return a dic with attribute (points attribute being a dic of dics to store points and their attributes) ''' stroke_dict = {} # stroke_attr_list = ('line_width',) # for att in stroke_attr_list: # stroke_dict[att] = getattr(s, att) ## Dump following these value only if they are non default if s.material_index != 0: stroke_dict['material_index'] = s.material_index if s.cyclic: stroke_dict['cyclic'] = s.cyclic if s.softness != 0.0: stroke_dict['softness'] = s.softness if s.aspect_ratio != 1.0: stroke_dict['aspect_ratio'] = s.aspect_ratio if s.start_cap != 0: stroke_dict['start_cap'] = s.start_cap if s.end_cap != 0: stroke_dict['end_cap'] = s.end_cap if fill_color and s.fill_color[:] != (0,0,0,0): stroke_dict['fill_color'] = convertAttr(s.fill_color) if fill_opacity and s.fill_opacity != 0.0: stroke_dict['fill_opacity'] = s.fill_opacity ## No time infos # if s.time_start != 0.0: # stroke_dict['time_start'] = s.time_start points = [] if sid is None: # no ids, just full points... for p in s.points: points.append(dump_gp_point(p, l, obj, radius=radius, opacity=opacity, vertex_color=vertex_color, rotation=rotation)) else: for pid in sid: points.append(dump_gp_point(s.points[pid], l, obj, radius=radius, opacity=opacity, vertex_color=vertex_color, rotation=rotation)) stroke_dict['points'] = points return stroke_dict def copycut_strokes(layers=None, copy=True, keep_empty=True): ''' copy all visibles selected strokes on active frame layers can be None, a single layer object or list of layer object as filter if keep_empty is False the frame is deleted when all strokes are cutted ''' t0 = time() ### must iterate in all layers ! (since all layers are selectable / visible !) obj = bpy.context.object gp = obj.data gpl = gp.layers # if not color:#get active color name # color = gp.palettes.active.colors.active.name if not layers: # by default all visible layers layers = [l for l in gpl if not is_hidden(l) and not is_locked(l)] # [] if not isinstance(layers, list): # if a single layer object is send put in a list layers = [layers] stroke_list = [] # one stroke list for all layers. for l in layers: f = l.current_frame() if f: # active frame can be None if not copy: staylist = [] # init part of strokes that must survive on this layer rm_list = [] # init strokes that must be removed from this layer for s_index, stroke in enumerate(f.drawing.strokes): if stroke.select: # separate in multiple stroke if parts of the strokes a selected. sel = [i for i, p in enumerate(stroke.points) if p.select] substrokes = [] # list of list containing isolated selection # continuity stroke have same substract result between point index and enumerator for k, g in groupby(enumerate(sel), lambda x:x[0]-x[1]): group = list(map(itemgetter(1), g)) substrokes.append(group) for ss in substrokes: if len(ss) > 1: # avoid copy isolated points stroke_list.append(dump_gp_stroke_range(stroke, ss, l, obj)) # Cutting operation if not copy: maxindex = len(stroke.points)-1 if len(substrokes) == maxindex+1: # if only one substroke, then it's the full stroke # f.drawing.strokes.remove(stroke) # gpv2 rm_list.append(s_index) else: neg = [i for i, p in enumerate(stroke.points) if not p.select] staying = [] for k, g in groupby(enumerate(neg), lambda x:x[0]-x[1]): group = list(map(itemgetter(1), g)) # extend group to avoid gap when cut, a bit dirty if group[0] > 0: group.insert(0,group[0]-1) if group[-1] < maxindex: group.append(group[-1]+1) staying.append(group) for ns in staying: if len(ns) > 1: staylist.append(dump_gp_stroke_range(stroke, ns, l, obj)) # make a negative list containing all last index if rm_list: f.drawing.remove_strokes(indices=rm_list) if not copy: selected_ids = [i for i, s in enumerate(f.drawing.strokes) if s.select] # delete all selected strokes... if selected_ids: f.drawing.remove_strokes(indices=selected_ids) # ...recreate these uncutted ones #pprint(staylist) if staylist: add_multiple_strokes(staylist, l) # If nothing left on the frame choose to leave an empty frame or delete it (let previous frame appear) if not copy and not keep_empty: if not len(f.drawing.strokes): l.frames.remove(f) print(len(stroke_list), 'strokes copied in', time()-t0, 'seconds') #print(stroke_list) return stroke_list """# Unused def copy_all_strokes(layers=None): ''' copy all stroke, not affected by selection on active frame layers can be None, a single layer object or list of layer object as filter if keep_empty is False the frame is deleted when all strokes are cutted ''' t0 = time() scene = bpy.context.scene obj = bpy.context.object gp = obj.data gpl = gp.layers if not layers: # by default all visible layers layers = [l for l in gpl if not is_hidden(l) and not is_locked(l)]# include locked ? if not isinstance(layers, list): # if a single layer object is send put in a list layers = [layers] stroke_list = []# one stroke list for all layers. for l in layers: f = l.current_frame() if not f: continue# active frame can be None for s in f.drawing.strokes: ## full stroke version # if s.select: stroke_list.append(dump_gp_stroke_range(s, None, l, obj)) print(len(stroke_list), 'strokes copied in', time()-t0, 'seconds') #print(stroke_list) return stroke_list """ def copy_all_strokes_in_frame(frame=None, layers=None, obj=None, radius=True, opacity=True, vertex_color=True, fill_color=True, fill_opacity=True, rotation=True): ''' copy all stroke, not affected by selection on active frame layers can be None, a single layer object or list of layer object as filter ''' t0 = time() scene = bpy.context.scene obj = bpy.context.object gp = obj.data gpl = gp.layers if not frame or not obj: return if not layers: # by default all visible layers layers = [l for l in gpl if not is_hidden(l) and not is_locked(l)] # include locked ? if not isinstance(layers, list): # if a single layer object is send put in a list layers = [layers] stroke_list = [] for l in layers: f = l.current_frame() if not f: continue# active frame can be None for s in f.drawing.strokes: ## full stroke version # if s.select: # send index of all points to get the whole stroke with "range" stroke_list.append( dump_gp_stroke_range(s, [i for i in range(len(s.points))], l, obj, radius=radius, opacity=opacity, vertex_color=vertex_color, fill_color=fill_color, fill_opacity=fill_opacity, rotation=rotation)) # print(len(stroke_list), 'strokes copied in', time()-t0, 'seconds') return stroke_list def add_stroke(s, frame, layer, obj, select=False): '''add stroke on a given frame, (layer is for parentage setting)''' # print(3*'-',s) pts_to_add = len(s['points']) frame.drawing.add_strokes([pts_to_add]) ns = frame.drawing.strokes[-1] ## set strokes atrributes for att, val in s.items(): if att not in ('points'): setattr(ns, att, val) ob_mat_inv = obj.matrix_world.inverted() if layer.parent: layer_matrix = getMatrix(layer).inverted() transform_matrix = ob_mat_inv @ layer_matrix else: transform_matrix = ob_mat_inv ## Set points attributes for i, pt in enumerate(s['points']): for k, v in pt.items(): if k == 'position': setattr(ns.points[i], k, v) ns.points[i].position = transform_matrix @ ns.points[i].position # invert of object * invert of layer * coordinate else: setattr(ns.points[i], k, v) if select: ns.points[i].select = True ## Opacity initialized at 0.0 (should be 1.0) if not 'opacity' in pt: ns.points[i].opacity = 1.0 ## Radius initialized at 0.0 (should probably be 0.01) if not 'radius' in pt: ns.points[i].radius = 0.01 def add_multiple_strokes(stroke_list, layer=None, use_current_frame=True, select=False): ''' add a list of strokes to active frame of given layer if no layer specified, active layer is used if use_current_frame is True, a new frame will be created only if needed if select is True, newly added strokes are set selected if stroke list is empty create an empty frame at current frame ''' scene = bpy.context.scene obj = bpy.context.object gp = obj.data gpl = gp.layers #default: active if not layer: layer = gpl.active fnum = scene.frame_current target_frame = False act = layer.current_frame() ## set frame if needed if act: if use_current_frame or act.frame_number == fnum: #work on current frame if exists # use current frame anyway if one key exist at this scene.frame target_frame = act if not target_frame: #no active frame #or active exists but not aligned scene.current with use_current_frame disabled target_frame = layer.frames.new(fnum) for s in stroke_list: add_stroke(s, target_frame, layer, obj, select=select) # print(len(stroke_list), 'strokes pasted') ### OPERATORS class GPCLIP_OT_copy_strokes(bpy.types.Operator): bl_idname = "gp.copy_strokes" bl_label = "GP Copy strokes" bl_description = "Copy strokes to text in paperclip" bl_options = {"REGISTER"} #copy = bpy.props.BoolProperty(default=True) @classmethod def poll(cls, context): return context.object and context.object.type == 'GREASEPENCIL' def execute(self, context): # if not context.object or not context.object.type == 'GREASEPENCIL': # self.report({'ERROR'},'No GP object selected') # return {"CANCELLED"} t0 = time() #ct = check_radius() strokelist = copycut_strokes(copy=True, keep_empty=True) if not strokelist: self.report({'ERROR'}, 'Nothing to copy') return {"CANCELLED"} bpy.context.window_manager.clipboard = json.dumps(strokelist)#copy=self.copy #if ct: # self.report({'ERROR'}, "Copie OK\n{} points ont une épaisseur supérieure a 1.0 (max = {:.2f})\nCes épaisseurs seront plafonnées à 1 au 'coller'".format(ct[0], ct[1])) self.report({'INFO'}, f'Copied (time : {time() - t0:.4f})') # print('copy total time:', time() - t0) return {"FINISHED"} class GPCLIP_OT_cut_strokes(bpy.types.Operator): bl_idname = "gp.cut_strokes" bl_label = "GP Cut strokes" bl_description = "Cut strokes to text in paperclip" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return context.object and context.object.type == 'GREASEPENCIL' def execute(self, context): # if not context.object or not context.object.type == 'GREASEPENCIL': # self.report({'ERROR'},'No GP object selected') # return {"CANCELLED"} t0 = time() strokelist = copycut_strokes(copy=False, keep_empty=True) # ct = check_radius() if not strokelist: self.report({'ERROR'},'Nothing to cut') return {"CANCELLED"} bpy.context.window_manager.clipboard = json.dumps(strokelist) self.report({'INFO'}, f'Cutted (time : {time() - t0:.4f})') return {"FINISHED"} class GPCLIP_OT_paste_strokes(bpy.types.Operator): bl_idname = "gp.paste_strokes" bl_label = "GP Paste strokes" bl_description = "paste stroke from paperclip" bl_options = {"REGISTER"} @classmethod def poll(cls, context): return context.object and context.object.type == 'GREASEPENCIL' def execute(self, context): # if not context.object or not context.object.type == 'GREASEPENCIL': # self.report({'ERROR'},'No GP object selected to paste on') # return {"CANCELLED"} t0 = time() #add a validity check por the content of the paperclip (check if not data.startswith('[{') ? ) try: data = json.loads(bpy.context.window_manager.clipboard) except: mess = 'Clipboard does not contain drawing data (load error)' self.report({'ERROR'}, mess) return {"CANCELLED"} print('data loaded', time() - t0) add_multiple_strokes(data, use_current_frame=True, select=True) print('total_time', time() - t0) return {"FINISHED"} ### --- multi copy class GPCLIP_OT_copy_multi_strokes(bpy.types.Operator): bl_idname = "gp.copy_multi_strokes" bl_label = "GP Copy Multi Strokes" bl_description = "Copy multiple layers>frames>strokes from selected layers to str in paperclip" bl_options = {"REGISTER"} #copy = bpy.props.BoolProperty(default=True) @classmethod def poll(cls, context): return context.object and context.object.type == 'GREASEPENCIL' radius : bpy.props.BoolProperty(name='radius', default=True, description='Dump point radius attribute (already skipped if at default value)') opacity : bpy.props.BoolProperty(name='opacity', default=True, description='Dump point opacity attribute (already skipped if at default value)') vertex_color : bpy.props.BoolProperty(name='vertex color', default=True, description='Dump point vertex_color attribute (already skipped if at default value)') fill_color : bpy.props.BoolProperty(name='fill color', default=True, description='Dump point fill_color attribute (already skipped if at default value)') fill_opacity : bpy.props.BoolProperty(name='fill opacity', default=True, description='Dump point fill_opacity attribute (already skipped if at default value)') uv_factor : bpy.props.BoolProperty(name='uv factor', default=True, description='Dump point uv_factor attribute (already skipped if at default value)') rotation : bpy.props.BoolProperty(name='rotation', default=True, description='Dump point rotation attribute (already skipped if at default value)') def invoke(self, context, event): # self.file_dump = event.ctrl return context.window_manager.invoke_props_dialog(self) # , width=400 # return self.execute(context) def draw(self, context): layout=self.layout layout.use_property_split = True col = layout.column() col.label(text='Keep following point attributes:') col.prop(self, 'radius') col.prop(self, 'opacity') col.prop(self, 'vertex_color') col.prop(self, 'fill_color') col.prop(self, 'fill_opacity') col.prop(self, 'rotation') return def execute(self, context): bake_moves = True skip_empty_frame = False org_frame = context.scene.frame_current obj = context.object gpl = obj.data.layers t0 = time() #ct = check_radius() layerdic = {} layerpool = [l for l in gpl if not is_hidden(l) and l.select] # and not is_locked(l) if not layerpool: self.report({'ERROR'}, 'No layers selected in GP dopesheet (needs to be visible and selected to be copied)\nHint: Changing active layer reset selection to active only') return {"CANCELLED"} if not bake_moves: # copy only drawed frames as is. for l in layerpool: if not l.frames: continue# skip empty layers frame_dic = {} for f in l.frames: if skip_empty_frame and not len(f.drawing.strokes): continue context.scene.frame_set(f.frame_number) # use matrix of this frame strokelist = copy_all_strokes_in_frame(frame=f, layers=l, obj=obj, radius=self.radius, opacity=self.opacity, vertex_color=self.vertex_color, fill_color=self.fill_color, fill_opacity=self.fill_opacity, rotation=self.rotation) frame_dic[f.frame_number] = strokelist layerdic[l.name] = frame_dic else: # bake position: copy frame where object as moved even if frame is unchanged for l in layerpool: print('dump layer:', l.name) if not l.frames: continue# skip empty layers frame_dic = {} fnums_dic = {f.frame_number: f for f in l.frames} context.scene.frame_set(context.scene.frame_start) curmat = prevmat = obj.matrix_world.copy() for i in range(context.scene.frame_start, context.scene.frame_end): context.scene.frame_set(i) # use matrix of this frame curmat = obj.matrix_world.copy() # if object has moved or current time is on a draw key if prevmat != curmat or i in fnums_dic.keys(): # get the current used frame for j in fnums_dic.keys(): if j >= i: f = fnums_dic[j] break ## skip empty frame if specified if skip_empty_frame and not len(f.drawing.strokes): continue strokelist = copy_all_strokes_in_frame(frame=f, layers=l, obj=obj, radius=self.radius, opacity=self.opacity, vertex_color=self.vertex_color, fill_color=self.fill_color, fill_opacity=self.fill_opacity, rotation=self.rotation) frame_dic[i] = strokelist prevmat = curmat layerdic[l.name] = frame_dic ## All to clipboard manager bpy.context.window_manager.clipboard = json.dumps(layerdic) # reset original frame. context.scene.frame_set(org_frame) self.report({'INFO'}, f'Copied layers (time : {time() - t0:.4f})') # print('copy total time:', time() - t0) return {"FINISHED"} class GPCLIP_OT_paste_multi_strokes(bpy.types.Operator): bl_idname = "gp.paste_multi_strokes" bl_label = "GP Paste Multi Strokes" bl_description = "Paste multiple layers>frames>strokes from paperclip on active layer" bl_options = {"REGISTER"} #copy = bpy.props.BoolProperty(default=True) @classmethod def poll(cls, context): return context.object and context.object.type == 'GREASEPENCIL' def execute(self, context): org_frame = context.scene.frame_current obj = context.object gpl = obj.data.layers t0 = time() # add a validity check por the content of the paperclip (check if not data.startswith('[{') ? ) try: data = json.loads(bpy.context.window_manager.clipboard) except: mess = 'Clipboard does not contain drawing data (load error)' self.report({'ERROR'}, mess) return {"CANCELLED"} print('data loaded', time() - t0) # add layers (or merge with existing names ?) ### structure # {layername : # {1: [strokelist of frame 1], 3: [strokelist of frame 3]} # } for layname, allframes in data.items(): layer = gpl.get(layname) if not layer: layer = gpl.new(layname) for fnum, fstrokes in allframes.items(): context.scene.frame_set(int(fnum)) # use matrix of this frame for copying (maybe just evaluate depsgraph for object add_multiple_strokes(fstrokes, use_current_frame=False) # create a new frame at each encoutered occurence print('total_time', time() - t0) # reset original frame. context.scene.frame_set(org_frame) self.report({'INFO'}, f'Copied layers (time : {time() - t0:.4f})') # print('copy total time:', time() - t0) return {"FINISHED"} ##--PANEL class GPCLIP_PT_clipboard_ui(bpy.types.Panel): # bl_idname = "gp_clipboard_panel" bl_label = "GP Clipboard" bl_space_type = "VIEW_3D" bl_region_type = "UI" bl_category = "Gpencil" bl_options = {'DEFAULT_CLOSED'} def draw(self, context): layout = self.layout col = layout.column(align=True) row = col.row(align=True) row.operator('gp.copy_strokes', text='Copy Strokes', icon='COPYDOWN') row.operator('gp.cut_strokes', text='Cut Strokes', icon='PASTEFLIPUP') col.operator('gp.paste_strokes', text='Paste Strokes', icon='PASTEDOWN') # layout.separator() col = layout.column(align=True) col.operator('gp.copy_multi_strokes', text='Copy Layers', icon='COPYDOWN') col.operator('gp.paste_multi_strokes', text='Paste Layers', icon='PASTEDOWN') ###---TEST zone """ ##main defs def copy_strokes_to_paperclip(): bpy.context.window_manager.clipboard = json.dumps(copycut_strokes(copy=True, keep_empty=True))#default layers are visible one def cut_strokes_to_paperclip(): bpy.context.window_manager.clipboard = json.dumps(copycut_strokes(copy=False, keep_empty=True)) def paste_strokes_from_paperclip(): #add condition to detect if clipboard contains loadable values add_multiple_strokes(json.loads(bpy.context.window_manager.clipboard), use_current_frame=True)#layer= layers.active #copy_strokes_to_paperclip() #paste_strokes_from_paperclip() #test direct #li = copycut_strokes(copy=True) #add_multiple_strokes(li, bpy.context.scene.grease_pencil.layers['correct']) """ #use directly operator idname in shortcut settings : # gp.copy_strokes # gp.cut_strokes # gp.paste_strokes # gp.copy_multi_strokes # gp.paste_multi_strokes ###---REGISTER + copy cut paste keymapping addon_keymaps = [] def register_keymaps(): addon = bpy.context.window_manager.keyconfigs.addon km = addon.keymaps.new(name = "Grease Pencil", space_type = "EMPTY", region_type='WINDOW')# in Grease context # km = addon.keymaps.new(name = "3D View", space_type = "VIEW_3D")# in 3D context # km = addon.keymaps.new(name = "Window", space_type = "EMPTY")# from everywhere kmi = km.keymap_items.new("gp.copy_strokes", type = "C", value = "PRESS", ctrl=True, shift=True) kmi.repeat = False addon_keymaps.append((km, kmi)) kmi = km.keymap_items.new("gp.cut_strokes", type = "X", value = "PRESS", ctrl=True, shift=True) kmi.repeat = False addon_keymaps.append((km, kmi)) kmi = km.keymap_items.new("gp.paste_strokes", type = "V", value = "PRESS", ctrl=True, shift=True) kmi.repeat = False addon_keymaps.append((km, kmi)) def unregister_keymaps(): for km, kmi in addon_keymaps: km.keymap_items.remove(kmi) addon_keymaps.clear() classes = ( GPCLIP_OT_copy_strokes, GPCLIP_OT_cut_strokes, GPCLIP_OT_paste_strokes, GPCLIP_OT_copy_multi_strokes, GPCLIP_OT_paste_multi_strokes, GPCLIP_PT_clipboard_ui, ) def register(): if bpy.app.background: return for cl in classes: bpy.utils.register_class(cl) ## make scene property for empty key preservation and bake movement for layers... register_keymaps() def unregister(): if bpy.app.background: return unregister_keymaps() for cl in reversed(classes): bpy.utils.unregister_class(cl) if __name__ == "__main__": register()