import bpy import os from pathlib import Path import numpy as np from . import utils from bpy.props import (BoolProperty, PointerProperty, CollectionProperty, StringProperty) def remove_stroke_exact_duplications(apply=True): '''Remove accidental stroke duplication (points exactly in the same place) :apply: Remove the duplication instead of just listing dupes return number of duplication found/deleted ''' # TODO: add additional check of material (even if unlikely to happen) ct = 0 gp_datas = [gp for gp in bpy.data.grease_pencils] for gp in gp_datas: for l in gp.layers: for f in l.frames: stroke_list = [] for s in reversed(f.drawing.strokes): point_list = [p.position for p in s.points] if point_list in stroke_list: ct += 1 if apply: # Remove redundancy f.drawing.strokes.remove(s) else: stroke_list.append(point_list) return ct class GPTB_OT_file_checker(bpy.types.Operator): bl_idname = "gp.file_checker" bl_label = "Check File" bl_description = "Check / correct some aspect of the file, properties and such and report" bl_options = {"REGISTER"} ## list of actions : # Lock main cam # set scene res # set scene percentage at 100: # set show slider and sync range # set fps # set cursor type # GP use additive drawing (else creating a frame in dopesheet makes it blank...) # GP stroke placement/projection check # Disabled animation # Objects visibility conflict # Objects modifiers visibility conflict # GP modifiers broken target check # Set onion skin filter to 'All type' # Set filepath type # Set Lock object mode state # Disable use light on all object # Remove redundant strokes in frames apply_fixes : bpy.props.BoolProperty(name="Apply Fixes", default=False, description="Apply possible fixes instead of just listing (pop the list again in fix mode)", options={'SKIP_SAVE'}) def invoke(self, context, event): # need some self-control (I had to...) self.ctrl = event.ctrl return self.execute(context) def execute(self, context): prefs = utils.get_addon_prefs() fix = prefs.fixprops problems = [] ## Old method : Apply fixes based on pref (inverted by ctrl key) # # If Ctrl is pressed, invert behavior (invert boolean) # apply ^= self.ctrl apply = self.apply_fixes if self.ctrl: apply = True ## Lock main cam: if fix.lock_main_cam: if not 'layout' in Path(bpy.data.filepath).stem.lower(): # dont touch layout cameras if context.scene.camera: cam = context.scene.camera if cam.name == 'draw_cam' and cam.parent: if cam.parent.type == 'CAMERA': cam = cam.parent else: cam = None if cam: triple = (True,True,True) if cam.lock_location[:] != triple or cam.lock_rotation[:] != triple: problems.append('Lock main camera') if apply: cam.lock_location = cam.lock_rotation = triple ## set scene res at pref res according to addon pref if fix.set_scene_res: rx, ry = prefs.render_res_x, prefs.render_res_y # TODO set (rx, ry) to camera resolution if specified in camera name if context.scene.render.resolution_x != rx or context.scene.render.resolution_y != ry: problems.append(f'Resolution {context.scene.render.resolution_x}x{context.scene.render.resolution_y} >> {rx}x{ry}') if apply: context.scene.render.resolution_x, context.scene.render.resolution_y = rx, ry ## set scene percentage at 100: if fix.set_res_percentage: if context.scene.render.resolution_percentage != 100: problems.append('Resolution output to 100%') if apply: context.scene.render.resolution_percentage = 100 ## set fps according to preferences settings if fix.set_fps: if context.scene.render.fps != prefs.fps: problems.append( (f"framerate corrected {context.scene.render.fps} >> {prefs.fps}", 'ERROR') ) if apply: context.scene.render.fps = prefs.fps ## set show slider and sync range if fix.set_slider_n_sync: for window in bpy.context.window_manager.windows: screen = window.screen for area in screen.areas: if area.type == 'DOPESHEET_EDITOR': if hasattr(area.spaces[0], 'show_sliders'): setattr(area.spaces[0], 'show_sliders', True) if hasattr(area.spaces[0], 'show_locked_time'): setattr(area.spaces[0], 'show_locked_time', True) ## set cursor type if context.mode in ("EDIT_GREASE_PENCIL", "SCULPT_GREASE_PENCIL"): tool = fix.select_active_tool if tool != 'none': if bpy.context.workspace.tools.from_space_view3d_mode(bpy.context.mode, create=False).idname != tool: problems.append(f'tool changed to {tool.split(".")[1]}') if apply: bpy.ops.wm.tool_set_by_id(name=tool) # Tweaktoolcode # ## GP use additive drawing (else creating a frame in dopesheet makes it blank...) # if not context.scene.tool_settings.use_gpencil_draw_additive: # problems.append(f'Activated Gp additive drawing mode (snowflake)') # context.scene.tool_settings.use_gpencil_draw_additive = True ## GP stroke placement/projection check if fix.check_front_axis: if context.scene.tool_settings.gpencil_sculpt.lock_axis != 'AXIS_Y': problems.append('/!\\ Draw axis not "Front" (Need Manual change if not Ok)') if fix.check_placement: if bpy.context.scene.tool_settings.gpencil_stroke_placement_view3d != 'ORIGIN': problems.append('/!\\ Draw placement not "Origin" (Need Manual change if not Ok)') ## GP Use light disable if fix.set_gp_use_lights_off: gp_with_lights = [o for o in context.scene.objects if o.type == 'GREASEPENCIL' and o.use_grease_pencil_lights] if gp_with_lights: problems.append(f'Disable "Use Lights" on {len(gp_with_lights)} Gpencil objects') if apply: for o in gp_with_lights: 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)') ## Object visibility conflict if fix.list_obj_vis_conflict: viz_ct = 0 for o in context.scene.objects: if not (o.hide_get() == o.hide_viewport == o.hide_render): hv = 'No' if o.hide_get() else 'Yes' vp = 'No' if o.hide_viewport else 'Yes' rd = 'No' if o.hide_render else 'Yes' viz_ct += 1 print(f'{o.name} : viewlayer {hv} - viewport {vp} - render {rd}') if viz_ct: problems.append(['gp.list_object_visibility_conflicts', f'{viz_ct} objects visibility conflicts (details in console)', 'OBJECT_DATAMODE']) ## GP modifiers visibility conflict if fix.list_gp_mod_vis_conflict: mod_viz_ct = 0 for o in context.scene.objects: for m in o.modifiers: if m.show_viewport != m.show_render: vp = 'Yes' if m.show_viewport else 'No' rd = 'Yes' if m.show_render else 'No' mod_viz_ct += 1 print(f'{o.name} - modifier {m.name}: viewport {vp} != render {rd}') if mod_viz_ct: problems.append(['gp.list_modifier_visibility', f'{mod_viz_ct} modifiers visibility conflicts (details in console)', 'MODIFIER_DATA']) ## check if GP modifier have broken layer targets if fix.list_broken_mod_targets: for o in [o for o in bpy.context.scene.objects if o.type == 'GREASEPENCIL']: lay_name_list = [l.name for l in o.data.layers] for m in o.modifiers: if not hasattr(m, 'layer_filter'): continue if m.layer_filter != '' and not m.layer_filter in lay_name_list: mess = f'Broken modifier layer target: {o.name} > {m.name} > {m.layer_filter}' print(mess) problems.append(mess) ## Use median point if fix.set_pivot_median_point: if context.scene.tool_settings.transform_pivot_point != 'MEDIAN_POINT': problems.append(f"Pivot changed from '{context.scene.tool_settings.transform_pivot_point}' to 'MEDIAN_POINT'") if apply: context.scene.tool_settings.transform_pivot_point = 'MEDIAN_POINT' if fix.disable_guide: if context.scene.tool_settings.gpencil_sculpt.guide.use_guide == True: problems.append(f"Disabled Draw Guide") if apply: context.scene.tool_settings.gpencil_sculpt.guide.use_guide = False if fix.autokey_add_n_replace: if context.scene.tool_settings.auto_keying_mode != 'ADD_REPLACE_KEYS': problems.append(f"Autokey mode reset to 'Add & Replace'") if apply: context.scene.tool_settings.auto_keying_mode = 'ADD_REPLACE_KEYS' if fix.file_path_type != 'none': pathes = [] for p in bpy.utils.blend_paths(): if fix.file_path_type == 'RELATIVE': if not p.startswith('//'): pathes.append(p) elif fix.file_path_type == 'ABSOLUTE': if p.startswith('//'): pathes.append(p) if pathes: mess = f'{len(pathes)}/{len(bpy.utils.blend_paths())} paths not {fix.file_path_type.lower()} (see console)' problems.append(mess) print(mess) print('\n'.join(pathes)) print('-') if fix.lock_object_mode != 'none': lockmode = bpy.context.scene.tool_settings.lock_object_mode if fix.lock_object_mode == 'LOCK': if not lockmode: problems.append(f"Lock object mode toggled On") if apply: bpy.context.scene.tool_settings.lock_object_mode = True elif fix.lock_object_mode == 'UNLOCK': if lockmode: problems.append(f"Lock object mode toggled Off") if apply: bpy.context.scene.tool_settings.lock_object_mode = False if fix.remove_redundant_strokes: ct = remove_stroke_exact_duplications(apply=apply) if ct > 0: mess = f'Removed {ct} strokes duplications' if apply else f'Found {ct} strokes duplications' problems.append(mess) # ## Set onion skin filter to 'All type' # fix_kf_type = 0 # 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' # fix_kf_type += 1 # if fix_kf_type: # problems.append(f"{fix_kf_type} GP onion skin filter to 'All type'") # for ob in context.scene.objects:#from object # if ob.type == 'GREASEPENCIL': # ob.data.onion_keyframe_type = 'ALL' #### --- print fix/problems report if problems: print('===File check===') for p in problems: if isinstance(p, str): print(p) else: print(p[0]) if not self.apply_fixes: ## button to call the operator again with apply_fixes set to True problems.append(['OPERATOR', 'gp.file_checker', 'Apply Fixes', 'FORWARD', {'apply_fixes': True}]) # Show in viewport title = "Changed Settings" if apply else "Checked Settings (nothing changed)" utils.show_message_box(problems, _title = title, _icon = 'INFO') else: self.report({'INFO'}, 'All good') return {"FINISHED"} class GPTB_OT_copy_string_to_clipboard(bpy.types.Operator): bl_idname = "gp.copy_string_to_clipboard" bl_label = "Copy String" bl_description = "Copy passed string to clipboard" bl_options = {"REGISTER"} string : bpy.props.StringProperty(options={'SKIP_SAVE'}) def execute(self, context): if not self.string: # self.report({'ERROR'}, 'Nothing to copy') return {"CANCELLED"} bpy.context.window_manager.clipboard = self.string self.report({'INFO'}, f'Copied: {self.string}') return {"FINISHED"} class GPTB_OT_copy_multipath_clipboard(bpy.types.Operator): bl_idname = "gp.copy_multipath_clipboard" bl_label = "Choose Path to Copy" bl_description = "Copy Chosen Path" bl_options = {"REGISTER"} string : bpy.props.StringProperty(options={'SKIP_SAVE'}) def invoke(self, context, event): if not self.string: return {"CANCELLED"} self.pathes = [] try: absolute = os.path.abspath(bpy.path.abspath(self.string)) abs_parent = os.path.dirname(os.path.abspath(bpy.path.abspath(self.string))) path_abs = str(Path(bpy.path.abspath(self.string)).resolve()) except: # case of invalid / non-accessable path bpy.context.window_manager.clipboard = self.string return context.window_manager.invoke_props_dialog(self, width=800) self.pathes.append(('Raw Path', self.string)) self.pathes.append(('Parent', os.path.dirname(self.string))) if absolute != self.string: self.pathes.append(('Absolute', absolute)) if absolute != self.string: self.pathes.append(('Absolute Parent', abs_parent)) if absolute != path_abs: self.pathes.append(('Resolved',path_abs)) self.pathes.append(('File name', os.path.basename(self.string))) maxlen = max(len(l[1]) for l in self.pathes) popup_width = 800 if maxlen < 50: popup_width = 500 elif maxlen > 100: popup_width = 1000 return context.window_manager.invoke_props_dialog(self, width=popup_width) def draw(self, context): layout = self.layout layout.use_property_split = True layout.separator() col = layout.column() for l in self.pathes: split=col.split(factor=0.2, align=True) split.operator('gp.copy_string_to_clipboard', text=l[0], icon='COPYDOWN').string = l[1] split.label(text=l[1]) def execute(self, context): return {"FINISHED"} class GPTB_OT_links_checker(bpy.types.Operator): bl_idname = "gp.links_checker" bl_label = "Links check" bl_description = "Check states of file direct links" bl_options = {"REGISTER"} def execute(self, context): return {"FINISHED"} def draw(self, context): layout = self.layout layout.label(text=self.title) if self.broke_ct: layout.label(text="You can try to scan for missing files:") ## How to launch directly without filebrowser ? # in Shot folder layout.operator('file.find_missing_files', text='in parent hierarchy').directory = Path(bpy.data.filepath).parents[1].as_posix() if self.proj: # In Library layout.operator('file.find_missing_files', text='in library').directory = (Path(self.proj)/'library').as_posix() # In all project layout.operator('file.find_missing_files', text='in all project (last resort)').directory = self.proj layout.separator() # layout = layout.column() # thinner linespace for l in self.all_lnks: if l[1] == 'CANCEL': layout.label(text=l[0], icon=l[1]) continue if l[1] == 'LIBRARY_DATA_BROKEN': split=layout.split(factor=0.85) split.label(text=l[0], icon=l[1]) # layout.label(text=l[0], icon=l[1]) else: split=layout.split(factor=0.70, align=True) split.label(text=l[0], icon=l[1]) ## resolve() return somethin different than os.path.abspath. # split.operator('wm.path_open', text='Open folder', icon='FILE_FOLDER').filepath = Path(bpy.path.abspath(l[0])).resolve().parent.as_posix() # split.operator('wm.path_open', text='Open file', icon='FILE_TICK').filepath = Path(bpy.path.abspath(l[0])).resolve().as_posix() split.operator('wm.path_open', text='Open Folder', icon='FILE_FOLDER').filepath = Path(os.path.abspath(bpy.path.abspath(l[0]))).parent.as_posix() split.operator('wm.path_open', text='Open File', icon='FILE_TICK').filepath = Path(os.path.abspath(bpy.path.abspath(l[0]))).as_posix() split.operator('gp.copy_multipath_clipboard', text='Copy Path', icon='COPYDOWN').string = l[0] # split.operator('gp.copy_string_to_clipboard', text='Copy Path', icon='COPYDOWN').string = l[0] # copy blend path directly def invoke(self, context, event): self.all_lnks = [] self.title = '' self.broke_ct = 0 abs_ct = 0 rel_ct = 0 ## check for broken links viewed = [] for current, lib in zip(bpy.utils.blend_paths(local=True), bpy.utils.blend_paths(absolute=True, local=True)): # avoid relisting same path mutliple times if current in viewed: continue # TODO find a proper way to show the number of user of this path... viewed.append(current) realib = Path(current) # path as-is lfp = Path(lib) # absolute path try: # Try because some path may fail parsing if not lfp.exists(): self.broke_ct += 1 self.all_lnks.append( (f"{realib.as_posix()}", 'LIBRARY_DATA_BROKEN') ) else: if realib.as_posix().startswith('//'): rel_ct += 1 self.all_lnks.append( (f"{realib.as_posix()}", 'LINKED') ) else: abs_ct += 1 self.all_lnks.append( (f"{realib.as_posix()}", 'LIBRARY_DATA_INDIRECT') ) except: self.broke_ct += 1 self.all_lnks.append( (f"{current}" , 'CANCEL') ) # error accessing file if not self.all_lnks: self.report({'INFO'}, 'No external links in files') return {"FINISHED"} bct = f"{self.broke_ct} broken " if self.broke_ct else '' act = f"{abs_ct} absolute " if abs_ct else '' rct = f"{rel_ct} clean " if rel_ct else '' self.title = f"{bct}{act}{rct}" self.all_lnks.sort(key=lambda x: x[1], reverse=True) if self.all_lnks: print('===File check===') for p in self.all_lnks: if isinstance(p, str): print(p) else: print(p[0]) # Show in viewport maxlen = max(len(x) for x in viewed) # if broke_ct == 0: # show_message_box(self.all_lnks, _title = self.title, _icon = 'INFO')# Links # return {"FINISHED"} popup_width = 800 if maxlen < 50: popup_width = 500 elif maxlen > 100: popup_width = 1000 self.proj = os.environ.get('PROJECT_ROOT') return context.window_manager.invoke_props_dialog(self, width=popup_width) class GPTB_OT_list_viewport_render_visibility(bpy.types.Operator): bl_idname = "gp.list_viewport_render_visibility" bl_label = "List Viewport And Render Visibility Conflicts" bl_description = "List objects visibility conflicts, when viewport and render have different values" bl_options = {"REGISTER"} def invoke(self, context, event): self.ob_list = [o for o in context.scene.objects if o.hide_viewport != o.hide_render] return context.window_manager.invoke_props_dialog(self, width=250) def draw(self, context): # TODO: Add visibility check with viewlayer visibility as well layout = self.layout for o in self.ob_list: row = layout.row() row.label(text=o.name) row.prop(o, 'hide_viewport', text='', emboss=False) # invert_checkbox=True row.prop(o, 'hide_render', text='', emboss=False) # invert_checkbox=True def execute(self, context): return {'FINISHED'} ### -- Sync visibility ops (Could be fused in one ops, but having 3 different operators allow to call from search menu) class GPTB_OT_sync_visibility_from_viewlayer(bpy.types.Operator): bl_idname = "gp.sync_visibility_from_viewlayer" bl_label = "Sync Visibility From Viewlayer" bl_description = "Set viewport and render visibility to match viewlayer visibility" bl_options = {"REGISTER", "UNDO"} def execute(self, context): for obj in context.scene.objects: is_hidden = obj.hide_get() # Get viewlayer visibility obj.hide_viewport = is_hidden obj.hide_render = is_hidden return {'FINISHED'} class GPTB_OT_sync_visibility_from_viewport(bpy.types.Operator): bl_idname = "gp.sync_visibility_from_viewport" bl_label = "Sync Visibility From Viewport" bl_description = "Set viewlayer and render visibility to match viewport visibility" bl_options = {"REGISTER", "UNDO"} def execute(self, context): for obj in context.scene.objects: is_hidden = obj.hide_viewport obj.hide_set(is_hidden) obj.hide_render = is_hidden return {'FINISHED'} class GPTB_OT_sync_visibility_from_render(bpy.types.Operator): bl_idname = "gp.sync_visibility_from_render" bl_label = "Sync Visibility From Render" bl_description = "Set viewlayer and viewport visibility to match render visibility" bl_options = {"REGISTER", "UNDO"} def execute(self, context): for obj in context.scene.objects: is_hidden = obj.hide_render obj.hide_set(is_hidden) obj.hide_viewport = is_hidden return {'FINISHED'} class GPTB_OT_sync_visibible_to_render(bpy.types.Operator): bl_idname = "gp.sync_visibible_to_render" bl_label = "Sync Overall Viewport Visibility To Render" bl_description = "Set render visibility from" bl_options = {"REGISTER", "UNDO"} def execute(self, context): for obj in context.scene.objects: ## visible_get is the current visibility status combination of hide_viewport and viewlayer hide (eye) obj.hide_render = not obj.visible_get() return {'FINISHED'} class GPTB_PG_object_visibility(bpy.types.PropertyGroup): """Property group to handle object visibility""" is_hidden: BoolProperty( name="Hide in Viewport", description="Toggle object visibility in viewport", get=lambda self: self.get("is_hidden", False), set=lambda self, value: self.set_visibility(value) ) object_name: StringProperty(name="Object Name") def set_visibility(self, value): """Set the visibility using hide_set()""" obj = bpy.context.view_layer.objects.get(self.object_name) if obj: obj.hide_set(value) self["is_hidden"] = value class GPTB_OT_list_object_visibility_conflicts(bpy.types.Operator): bl_idname = "gp.list_object_visibility_conflicts" bl_label = "List Object Visibility Conflicts" bl_description = "List objects visibility conflicts, when viewport and render have different values" bl_options = {"REGISTER"} visibility_items: CollectionProperty(type=GPTB_PG_object_visibility) # type: ignore[valid-type] def invoke(self, context, event): # Clear and rebuild both collections self.visibility_items.clear() # Store objects with conflicts ## TODO: Maybe better (but less detailed) to just check o.visible_get (global visiblity) against render viz ? objects_with_conflicts = [o for o in context.scene.objects if not (o.hide_get() == o.hide_viewport == o.hide_render)] # Create visibility items in same order for obj in objects_with_conflicts: item = self.visibility_items.add() item.object_name = obj.name item["is_hidden"] = obj.hide_get() return context.window_manager.invoke_props_dialog(self, width=250) def draw(self, context): layout = self.layout # Add sync buttons at the top row = layout.row(align=False) row.label(text="Sync All Visibility From:") row.operator("gp.sync_visibility_from_viewlayer", text="", icon='HIDE_OFF') row.operator("gp.sync_visibility_from_viewport", text="", icon='RESTRICT_VIEW_OFF') row.operator("gp.sync_visibility_from_render", text="", icon='RESTRICT_RENDER_OFF') layout.separator() col = layout.column() # We can safely iterate over visibility_items since objects are stored in same order for vis_item in self.visibility_items: obj = context.view_layer.objects.get(vis_item.object_name) if not obj: continue row = col.row(align=False) row.label(text=obj.name) ## Viewlayer visibility "as prop" to allow slide toggle # hide_icon='HIDE_ON' if vis_item.is_hidden else 'HIDE_OFF' hide_icon='HIDE_ON' if obj.hide_get() else 'HIDE_OFF' # based on object state row.prop(vis_item, "is_hidden", text="", icon=hide_icon, emboss=False) # Direct object properties row.prop(obj, 'hide_viewport', text='', emboss=False) row.prop(obj, 'hide_render', text='', emboss=False) def execute(self, context): return {'FINISHED'} ## not exposed in UI, Check is performed in Check file (can be called in popped menu) class GPTB_OT_list_modifier_visibility(bpy.types.Operator): bl_idname = "gp.list_modifier_visibility" bl_label = "List Objects Modifiers Visibility Conflicts" bl_description = "List Modifier visibility conflicts, when viewport and render have different values" bl_options = {"REGISTER"} def invoke(self, context, event): self.ob_list = [] for o in context.scene.objects: if not len(o.modifiers): continue mods = [] for m in o.modifiers: if m.show_viewport != m.show_render: if not mods: self.ob_list.append([o, mods, "OUTLINER_OB_" + o.type]) mods.append(m) self.ob_list.sort(key=lambda x: x[2]) # regroup by objects type (this or x[0] for object name) return context.window_manager.invoke_props_dialog(self, width=250) def draw(self, context): layout = self.layout if not self.ob_list: layout.label(text='No modifier visibility conflict found', icon='CHECKMARK') return for o in self.ob_list: layout.label(text=o[0].name, icon=o[2]) for m in o[1]: row = layout.row() row.label(text='') row.label(text=m.name, icon='MODIFIER_ON') row.prop(m, 'show_viewport', text='', emboss=False) # invert_checkbox=True row.prop(m, 'show_render', text='', emboss=False) # invert_checkbox=True def execute(self, context): return {'FINISHED'} classes = ( GPTB_OT_list_viewport_render_visibility, # Only viewport and render GPTB_OT_sync_visibility_from_viewlayer, GPTB_OT_sync_visibility_from_viewport, GPTB_OT_sync_visibility_from_render, GPTB_OT_sync_visibible_to_render, GPTB_PG_object_visibility, GPTB_OT_list_object_visibility_conflicts, GPTB_OT_list_modifier_visibility, GPTB_OT_copy_string_to_clipboard, GPTB_OT_copy_multipath_clipboard, GPTB_OT_file_checker, GPTB_OT_links_checker, ) def register(): for cls in classes: bpy.utils.register_class(cls) def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls)