import bpy from bpy.props import (FloatProperty, BoolProperty, EnumProperty, StringProperty, IntProperty) from . import fn import math import re import json from pathlib import Path ## TODO : export json info to re-setup layers in AE automagically def check_outname(ob, l): vl_name = l.viewlayer_render if vl_name in {'exclude', 'View Layer'}: return ng_name = f'NG_{ob.name}' ## check in wich node tree this exists for scn in bpy.data.scenes: # if scn.name == 'Scene': # continue ng = scn.node_tree.nodes.get(ng_name) if ng: break print(scn.name) if not ng: print(f'Skip {vl_name}: Not found nodegroup {ng_name}' ) return ng_socket = ng.outputs.get(vl_name) if not ng_socket: print(f'Skip {vl_name}: Not found in nodegroup {ng_name} sockets') return if not len(ng_socket.links): print(f' socket is disconnected in {ng_name} nodegroup') return fo_node = ng_socket.links[0].to_node fo_socket = ng_socket.links[0].to_socket if fo_node.type != 'OUTPUT_FILE': print(f'Skip {vl_name}: node is not an output_file {fo_node.name}') return # fo_socket.name isn't right, have to iterate in paths idx = [i for i in fo_node.inputs].index(fo_socket) subpath = fo_node.file_slots[idx].path # fp = Path(fo_node.base_path.rstrip('/')) / subpath # fp = Path(bpy.path.abspath(str(fp)).rstrip("/")) # abspath on disk outname = subpath.split('/')[0] # folder name on disk return outname class GPEXP_OT_export_infos_for_compo(bpy.types.Operator): bl_idname = "gp.export_infos_for_compo" bl_label = "Export Infos For Compo" bl_description = "Export informations for compositing, including layers with masks, fusion mode, opacity" bl_options = {"REGISTER"} # @classmethod # def poll(cls, context): # return context.object and context.object.type == 'GPENCIL' skip_check : bpy.props.BoolProperty(default=False, options={'SKIP_SAVE'}) def invoke(self, context, event): self.l_infos = Path(bpy.data.filepath).parent / 'render' / 'infos.json' if self.skip_check: if self.l_infos.exists(): # Preferably skip if already user defined ? return {'FINISHED'} return self.execute(context) if self.l_infos.exists(): return context.window_manager.invoke_props_dialog(self) return self.execute(context) def draw(self, context): layout = self.layout layout.label(text='An infos Json already exists', icon = 'ERROR') layout.label(text='Do you want to overwrite ?') layout.label(text='Note: Must export before "Check Layers" step', icon='INFO') def execute(self, context): ## Repeat because might not be registered if called with invoke_default self.l_infos = Path(bpy.data.filepath).parent / 'render' / 'infos.json' dic = {} pool = [o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)] for o in pool: # if not o.visible_get(): # continue for l in o.data.layers: # skip non rendered layers if l.hide: continue if l.info.startswith('MA_'): # No point in storing information of masking layers... continue ## Can't check viewlayers and final fileout name if Render scene not even created... """ if not l.viewlayer_render or l.viewlayer_render == 'exclude': continue fo_name = check_outname(o, l) # get name used for output file folder (same in AE) if not fo_name: print(f'! Could not found fileout name for {o.name} > {l.info}') continue """ ldic = {} ## Check opacity, blend mode if l.opacity < 1.0: ldic['opacity'] = l.opacity if l.blend_mode != 'REGULAR': ldic['blend_mode'] = l.blend_mode if l.use_mask_layer: multi_mask = {} ## dict key as number for masks # for i, ml in enumerate(l.mask_layers): # mask = {} # if ml.hide: # continue # mask['name'] = ml.name # if ml.invert: # create key get only if inverted # mask['invert'] = ml.invert # # multi_mask[ml.name] = mask # multi_mask[i] = mask ## dict key as mask name for ml in l.mask_layers: mask = {} if ml.hide: continue # mask['name'] = ml.name if ml.invert: # create key get only if inverted mask['invert'] = ml.invert # ! no key if no invert multi_mask[ml.name] = mask if multi_mask: ldic['masks'] = multi_mask ## add to full dic if ldic: # add source object ? might be usefull to pin point layer ldic['object'] = o.name dic[fn.normalize_layer_name(l, get_only=True)] = ldic if dic: self.l_infos.parent.mkdir(exist_ok=True) # create render folder if needed with self.l_infos.open('w') as fd: json.dump(dic, fd, indent='\t') self.report({'INFO'}, f'Exported json at: {self.l_infos.as_posix()}') else: self.report({'WARNING'}, f'No custom data to write with those objects/layers') return {"CANCELLED"} return {"FINISHED"} class GPEXP_OT_restore_layers_state(bpy.types.Operator): bl_idname = "gp.restore_layers_state" bl_label = "Restore Layers State " bl_description = "Restore original state of layers" bl_options = {"REGISTER"} # , "UNDO" def execute(self, context): layers_info_path = Path(bpy.data.filepath).parent / 'render' / 'infos.json' if not layers_info_path.exists(): self.report({"ERROR"}, 'No Info Found') return {"CANCELLED"} layers_info = json.loads(layers_info_path.read_text(encoding='utf-8')) pool = [o for o in context.selected_objects if o.type == 'GPENCIL'] for gp in pool: for layer_name, info in layers_info.items(): if gp.name == info['object']: if not (layer := gp.data.layers.get(layer_name)): return if "opacity" in info: layer.opacity = info['opacity'] if "blend_mode" in info: layer.blend_mode = info['blend_mode'] return {"FINISHED"} class GPEXP_OT_layers_state(bpy.types.Operator): bl_idname = "gp.layers_state" bl_label = "Set Layers State" bl_description = "Display state of layer that migh need adjustement" bl_options = {"REGISTER"} # , "UNDO" # clear_unused_view_layers :BoolProperty(name="Clear unused view layers", # description="Delete view layer that aren't used in the nodetree anymore", # default=True) no_popup : BoolProperty(name='No Popup', default=False, description='To use for call in CLI or from other operators', options={'SKIP_SAVE'}) all_objects : BoolProperty(name='On All Object', default=True, description='On All object, else use selected objects') # , options={'SKIP_SAVE'} set_full_opacity : BoolProperty(name='Set Full Opacity', default=True, description='Check/Set full opacity') # , options={'SKIP_SAVE'} set_use_lights : BoolProperty(name='Disable Use Light', default=True, description='Check/Set use lights disabling') # , options={'SKIP_SAVE'} set_blend_mode : BoolProperty(name='Set Regular Blend Mode', default=True, description='Check/Set blend mode to regular') # , options={'SKIP_SAVE'} clear_frame_out_of_range : BoolProperty(name='Clear Frames Out Of Scene Range', default=False, description='Delete frames that before scene start and after scene end range\ \nWith a tolerance of one frame to avoid problem\ \nAffect all layers)') # , options={'SKIP_SAVE'} opacity_exclude_list : StringProperty(name='Skip', default='MA, MASK, mask, MSK, msk', description='Skip prefixes from this list when changing opacity\ \nSeparate multiple value with a comma (ex: MAIN)') # , options={'SKIP_SAVE'} hide_invisible_materials : BoolProperty(name='Hide Materials named invisible', default=True, description='Hide material with name starting with "invisible"') # , options={'SKIP_SAVE'} # @classmethod # def poll(cls, context): # return context.object def invoke(self, context, event): ## if no existing infos.json generated, call ops l_infos = Path(bpy.data.filepath).parent / 'render' / 'infos.json' if not l_infos.exists(): # only if infos not created bpy.ops.gp.export_infos_for_compo('INVOKE_DEFAULT') if self.no_popup: return self.execute(context) return context.window_manager.invoke_props_dialog(self) def draw(self, context): layout = self.layout layout.prop(self, 'all_objects') total = len([o for o in context.scene.objects if o.type == 'GPENCIL']) target_num = total if self.all_objects else len([o for o in context.selected_objects if o.type == 'GPENCIL']) layout.label(text=f'{target_num}/{total} targeted GP') layout.separator() layout.prop(self, 'clear_frame_out_of_range') layout.separator() layout.label(text='Set (or only perform a check):') row = layout.row() row.prop(self, 'set_full_opacity') if self.set_full_opacity: row.prop(self, 'opacity_exclude_list') layout.prop(self, 'set_use_lights') layout.prop(self, 'set_blend_mode') layout.prop(self, 'hide_invisible_materials') # layout.prop(self, 'clear_unused_view_layers') def execute(self, context): if self.all_objects: pool = [o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)] else: pool = [o for o in context.selected_objects if o.type == 'GPENCIL'] # pool = [context.object] changes = [] for ob in pool: changes.append(f'>> {ob.name}') layers = ob.data.layers if self.clear_frame_out_of_range: ct = fn.clear_frame_out_of_range(ob, verbose=False) if ct: changes.append(f'{ct} out of range frame deleted') for l in layers: used = False ## mask check # if l.mask_layers: # print(f'-> masks') # state = '' if l.use_mask_layer else ' (disabled)' # print(f'{ob.name} > {l.info}{state}:') # used = True # for ml in l.mask_layers: # mlstate = ' (disabled)' if ml.hide else '' # mlinvert = ' <>' if ml.invert else '' # print(f'{ml.info}{mlstate}{mlinvert}') if l.opacity != 1: if l.opacity == 0: ## Skip layer with zero opacity print(f'Skipped layer opacity reset (0 opacity) : {l.info}') elif any(x.strip() + '_' in l.info for x in self.opacity_exclude_list.strip(',').split(',') if x): # Skip layer if name has exclusion prefix print(f'Skipped layer opacity reset (prefix in exclusion list) : {l.info}') else: ## Set full opacity full_opacity_state = '' if self.set_full_opacity else ' (check only)' mess = f'{l.info} : opacity {l.opacity:.2f} >> 1.0{full_opacity_state}' print(mess) changes.append(mess) if self.set_full_opacity: l.opacity = 1.0 used = True if l.use_lights: use_lights_state = '' if self.set_use_lights else ' (check only)' mess = f'{l.info} : disable use lights{use_lights_state}' print(mess) # changes.append(mess) # don't report disable use_light... too many messages if self.set_use_lights: l.use_lights = False used = True if l.blend_mode != 'REGULAR': blend_mode_state = '' if self.set_blend_mode else ' (check only)' mess = f'{l.info} : blend mode "{l.blend_mode}" >> regular{blend_mode_state}' print(mess) changes.append(mess) if self.set_blend_mode: l.blend_mode = 'REGULAR' used = True if len(l.frames) == 1 and len(l.frames[0].strokes) == 0 and not l.hide: # probably used as separator l.hide = True mess = f'{l.info} : No frames. Hiding layer' print(mess) changes.append(mess) used = True if used: print() if changes: changes.append('') ## Disable multiframe editing on all GP (can cause artifacts on render) gp_mu_edit_ct = 0 for gp in bpy.data.grease_pencils: if gp.use_multiedit: print(f'Disabling multi-edit on GP {gp.name}') gp.use_multiedit = False gp_mu_edit_ct += 1 if gp_mu_edit_ct: changes.append(f'{gp_mu_edit_ct} multiframe-edit mode disabled') ## Hide invisible named materials if self.hide_invisible_materials: for m in bpy.data.materials: if m.is_grease_pencil and m.name.lower().startswith('invisible'): if not m.grease_pencil.hide: print(f'Hiding gp material {m.name}') m.grease_pencil.hide = True changes.append(f'{m.name} material hidden') if not self.no_popup: fn.show_message_box(_message=changes, _title="Layers Check Report", _icon='INFO') return {"FINISHED"} class GPEXP_OT_lower_layers_name(bpy.types.Operator): bl_idname = "gp.lower_layers_name" bl_label = "Normalize Layers Name" bl_description = "Make the object and layers name lowercase with dashed converted to underscore (without touching layer prefix and suffix)" bl_options = {"REGISTER", "UNDO"} # @classmethod # def poll(cls, context): # return context.object and context.object.type == 'GPENCIL' all_objects : BoolProperty(name='On All Object', default=True, description='On All object, else use selected objects') # , options={'SKIP_SAVE'} object_name : BoolProperty(name='Normalize Object Name', default=True, description='Make the object name lowercase') # , options={'SKIP_SAVE'} layer_name : BoolProperty(name='Normalize Layers Names', default=True, description='Make the layers name lowercase') # , options={'SKIP_SAVE'} # dash_to_undescore : BoolProperty(name='Dash To Underscore', # default=True, description='Make the layers name lowercase') # , options={'SKIP_SAVE'} def invoke(self, context, event): # self.ctrl=event.ctrl # self.alt=event.alt if event.alt: self.all_objects=True # return self.execute(context) return context.window_manager.invoke_props_dialog(self) def draw(self, context): layout = self.layout layout.prop(self, 'all_objects') if self.all_objects: gp_ct = len([o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)]) else: gp_ct = len([o for o in context.selected_objects if o.type == 'GPENCIL']) layout.label(text=f'{gp_ct} to lower-case') layout.separator() layout.label(text=f'Choose what to rename:') layout.prop(self, 'object_name') layout.prop(self, 'layer_name') # if self.layer_name: # box = layout.box() # box.prop(self, 'dash_to_undescore') if not self.object_name and not self.layer_name: layout.label(text=f'At least one choice!', icon='ERROR') def execute(self, context): if self.all_objects: pool = [o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)] else: pool = [o for o in context.selected_objects if o.type == 'GPENCIL'] for ob in pool: if self.object_name: rename_data = ob.name == ob.data.name ob.name = ob.name.lower().replace('-', '_') if rename_data: ob.data.name = ob.name if self.layer_name: for l in ob.data.layers: # if self.dash_to_undescore: l.info = l.info.replace('-', '_') fn.normalize_layer_name(l) # default : lower=True, dash_to_underscore=self.dash_to_undescore return {"FINISHED"} class GPEXP_OT_auto_number_object(bpy.types.Operator): bl_idname = "gp.auto_number_object" bl_label = "Auto Number Object" bl_description = "Automatic prefix number based on origin distance to camera and in_front values\nCtrl + Clic to delete name to delete numbering" bl_options = {"REGISTER", "UNDO"} # @classmethod # def poll(cls, context): # return context.object and context.object.type == 'GPENCIL' all_objects : BoolProperty(name='On All GP Object', default=True, description='On All object, else use selected Grease Pencil objects') # , options={'SKIP_SAVE'} rename_data : BoolProperty(name='Rename Gpencil Data', default=True, description='Rename Also the Grease Pencil data using same name as object') # , options={'SKIP_SAVE'} delete : BoolProperty(default=False, options={'SKIP_SAVE'}) def invoke(self, context, event): # if event.alt: # self.all_objects=True if event.ctrl or self.delete: regex_num = re.compile(r'^(\d{3})_') ct = 0 gps = [o for o in context.selected_objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)] for o in gps: if regex_num.match(o.name): o.name = o.name[4:] ct += 1 self.report({'INFO'}, f'{ct}/{len(gps)} number prefix removed from object names') return {"FINISHED"} return context.window_manager.invoke_props_dialog(self) def draw(self, context): layout = self.layout layout.prop(self, 'all_objects') if self.all_objects: gp_ct = len([o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)]) else: gp_ct = len([o for o in context.selected_objects if o.type == 'GPENCIL']) layout.prop(self, 'rename_data') layout.label(text=f'{gp_ct} objects to renumber') if not gp_ct: layout.label(text='No Gpencil object to renumber', icon = 'ERROR') def execute(self, context): if self.all_objects: pool = [o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)] else: pool = [o for o in context.selected_objects if o.type == 'GPENCIL'] def reversed_enumerate(collection: list): for i in range(len(collection)-1, -1, -1): yield i, collection[i] fronts = [] ## separate In Front objects: for i, o in reversed_enumerate(pool): if o.show_in_front: fronts.append(pool.pop(i)) cam_loc = context.scene.camera.matrix_world.to_translation() # filter by distance to camera object (considering origins) pool.sort(key=lambda x: math.dist(x.matrix_world.to_translation(), cam_loc)) fronts.sort(key=lambda x: math.dist(x.matrix_world.to_translation(), cam_loc)) # re-insert fitlered infront object before others pool = fronts + pool ct = 10 regex_num = re.compile(r'^(\d{3})_') for o in pool: renum = regex_num.search(o.name) if not renum: o.name = f'{str(ct).zfill(3)}_{o.name}' else: ## either replace or leave untouched # continue o.name = f'{str(ct).zfill(3)}_{o.name[4:]}' ct += 10 if self.rename_data and o.name != o.data.name: o.data.name = o.name return {"FINISHED"} class GPEXP_OT_check_masks(bpy.types.Operator): bl_idname = "gp.check_masks" bl_label = "Check Masks" bl_description = "Check and report all masked GP layers" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return context.object and context.object.type == 'GPENCIL' def execute(self, context): # if self.all_objects: # pool = [o for o in context.scene.objects if o.type == 'GPENCIL'] # else: # pool = [o for o in context.selected_objects if o.type == 'GPENCIL'] changes = [] pool = [o for o in context.scene.objects if o.type == 'GPENCIL' and fn.is_valid_name(o.name)] for o in pool: for l in o.data.layers: if l.use_mask_layer: obj_stat = f'{o.name} >>' if not obj_stat in changes: changes.append(obj_stat) print(obj_stat) hide_state = ' (hided)' if l.hide else '' text = f' {l.info}{hide_state}:' # :masks: changes.append(text) print(text) has_masks = False for ml in l.mask_layers: # 'hide', 'invert', 'name' h = ' hided' if ml.hide else '' i = ' (inverted)' if ml.invert else '' text = f' - {ml.name}{h}{i}' changes.append(text) print(text) has_masks = True if not has_masks: text = 'No masks!' changes.append(text) print(text) changes.append('') if changes: fn.show_message_box(_message=changes, _title="Masks Check Report", _icon='INFO') else: fn.show_message_box(_message='No Masks!', _title="Masks Check Report", _icon='INFO') return {"FINISHED"} class GPEXP_OT_select_layer_in_comp(bpy.types.Operator): bl_idname = "gp.select_layer_in_comp" bl_label = "Select Layer In Compositor" bl_description = "Select associated render_layer node in compositing" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return context.object and context.object.type == 'GPENCIL' def invoke(self, context, event): self.additive = event.shift return self.execute(context) def execute(self, context): gp = context.object.data act = gp.layers.active pool = fn.build_layers_targets_from_dopesheet(context) if not pool: self.report({'ERROR'}, 'No layers found in current GP dopesheet') return {"CANCELLED"} if not context.scene.node_tree: self.report({'ERROR'}, 'No compo node-tree in active scene') return {"CANCELLED"} scn = context.scene node_scene = fn.get_compo_scene(create=False) or scn nodes = node_scene.node_tree.nodes rl_nodes = [n for n in nodes if n.type == 'R_LAYERS'] if not rl_nodes: self.report({'ERROR'}, 'No render layers nodes in active scene') return {"CANCELLED"} # Deselect all nodes if shift is not pressed if not self.additive: for n in nodes: n.select = False used_vl = [n.layer for n in rl_nodes] selected = [] infos = [] for l in pool: if not l.select: continue vl_name = l.viewlayer_render if not vl_name: mess = f'{l.info} has no viewlayers' print(mess) infos.append(mess) continue if not vl_name in used_vl: mess = f'{l.info}: view layer "{vl_name}" not used in scene renderlayer nodes' print(mess) infos.append(mess) continue for n in rl_nodes: if n.layer == vl_name: print(f'{l.info} -> Select node {n.name}') selected.append(n.name) n.select = True if not infos and not selected: self.report({'ERROR'}, 'Nothing selected') return {"CANCELLED"} infos = infos + [f'-- Selected {len(selected)} nodes --'] + selected fn.show_message_box(_message=infos, _title="Selected viewlayer in compo", _icon='INFO') # Change viewed scene if not in current scene if selected and scn != node_scene: context.window.scene = node_scene return {"FINISHED"} classes=( GPEXP_OT_auto_number_object, GPEXP_OT_lower_layers_name, GPEXP_OT_export_infos_for_compo, GPEXP_OT_restore_layers_state, GPEXP_OT_layers_state, GPEXP_OT_check_masks, GPEXP_OT_select_layer_in_comp, ) def register(): for cls in classes: bpy.utils.register_class(cls) def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls)