import bpy import os import re import json from pathlib import Path def get_addon_prefs(): return bpy.context.preferences.addons[__package__].preferences # region Manage nodes def get_compositor_tree(scene=None): '''Return the compositing node group of passed scene (active scene if not passed) None if the scene has no compositing node group ''' scene = scene or bpy.context.scene return scene.compositing_node_group def get_group_output_node(tree, create=False): '''Return the active group output node of the tree (compositor output since Blender 5.0) create: create the node, and an "Image" output socket in tree interface, if missing ''' out = next((n for n in tree.nodes if n.type == 'GROUP_OUTPUT' and n.is_active_output), None) if out is None: out = next((n for n in tree.nodes if n.type == 'GROUP_OUTPUT'), None) if not create: return out if not any(i.item_type == 'SOCKET' and i.in_out == 'OUTPUT' for i in tree.interface.items_tree): tree.interface.new_socket(name='Image', in_out='OUTPUT', socket_type='NodeSocketColor') if out is None: out = tree.nodes.new('NodeGroupOutput') return out def real_loc(n): return n.location_absolute def get_media_type(file_format): '''Return the media type corresponding to passed file format''' if file_format == 'OPEN_EXR_MULTILAYER': return 'MULTI_LAYER_IMAGE' if file_format == 'FFMPEG': return 'VIDEO' return 'IMAGE' def set_format_settings(fmt, settings): '''Apply a dict of attributes to an image format settings (e.g: file output node format) media_type and file_format are set first, available values of other attributes depend on them ''' settings = dict(settings) file_format = settings.pop('file_format', None) media_type = settings.pop('media_type', None) if file_format and not media_type: media_type = get_media_type(file_format) if media_type: fmt.media_type = media_type if file_format: fmt.file_format = file_format for k, v in settings.items(): setattr(fmt, k, v) def set_scene_output_from_active_fileout_item(): scn = bpy.context.scene rd = scn.render ntree = get_compositor_tree(scn) fo = ntree.nodes.active if fo.type != 'OUTPUT_FILE': return item = fo.file_output_items[fo.active_item_index] if fo.format.media_type == 'MULTI_LAYER_IMAGE': ## All items are layers of the same file full_path = os.path.join(fo.directory, fo.file_name) fmt = fo.format else: full_path = os.path.join(fo.directory, fo.file_name + item.name) fmt = item.format if item.override_node_format else fo.format rd.filepath = full_path ## set those attr first to avoid error settings other attributes in next loop rd.image_settings.media_type = fmt.media_type rd.image_settings.file_format = fmt.file_format rd.image_settings.color_mode = fmt.color_mode rd.image_settings.color_depth = fmt.color_depth if fmt.color_depth else '8' # Force set since Sometimes it's weirdly set to "" (not in enum choice) excluded = ['media_type', 'file_format', 'color_mode', 'color_depth', 'view_settings', 'views_format'] ''' ## all attrs # 'cineon_black', 'cineon_gamma', 'cineon_white', # 'color_depth', 'color_mode', 'compression', 'display_settings', # 'exr_codec', 'file_format', 'jpeg2k_codec', 'quality', # 'rna_type', 'stereo_3d_format', 'tiff_codec', 'use_cineon_log', # 'use_jpeg2k_cinema_48', 'use_jpeg2k_cinema_preset', 'use_jpeg2k_ycc', # 'use_preview', 'use_zbuffer'] ''' for attr in dir(fmt): if attr.startswith('__') or attr.startswith('bl_') or attr in excluded: continue if hasattr(scn.render.image_settings, attr) and not scn.render.image_settings.is_property_readonly(attr): setattr(scn.render.image_settings, attr, getattr(fmt, attr)) def set_file_output_format(fo): """Default fileout format for output file node Get from OUTPUT_RENDER_FILE_FORMAT environment variable else fallback to multilayer EXR """ env_file_format = json.loads(os.environ.get('OUTPUT_RENDER_FILE_FORMAT', '{}')) if not env_file_format: env_file_format = { 'file_format': 'OPEN_EXR_MULTILAYER', 'exr_codec': 'ZIP', 'color_depth': '16', 'color_mode': 'RGBA' } set_format_settings(fo.format, env_file_format) def set_file_output_path(fo, path, suffix=''): '''Set directory and file name of a file output node from a single path Multilayer: last element is the file name, ex: "//render/name/name_" Other formats: whole path is the directory, ex: "//render/name/" suffix: optional subfolder added at the end of the directory, ex: "tech/" ''' if fo.format.file_format == 'OPEN_EXR_MULTILAYER': directory, file_name = re.match(r'(.*[\\/])?(.*)', path).groups() directory = directory or '' else: directory, file_name = path, '' if suffix: directory = directory.rstrip('\\/') + '/' + suffix if directory else suffix fo.directory = directory fo.file_name = file_name ## Source socket type to file output item socket type FILE_OUTPUT_ITEM_TYPES = { 'VALUE': 'FLOAT', 'INT': 'INT', 'BOOLEAN': 'BOOLEAN', 'VECTOR': 'VECTOR', 'RGBA': 'RGBA', } def add_file_output_item(fo, name, socket=None): '''Add an item to file output node socket: source socket, if passed item type (and vector dimensions) match the socket return created item and its input socket ''' socket_type = FILE_OUTPUT_ITEM_TYPES.get(socket.type, 'RGBA') if socket is not None else 'RGBA' item = fo.file_output_items.new(socket_type, name) if socket_type == 'VECTOR' and hasattr(socket, 'default_value'): ## ex: render layer "Vector" pass is 4D item.vector_socket_dimensions = len(socket.default_value) ## items inputs are in the same order (followed by a virtual socket) return item, fo.inputs[len(fo.file_output_items) - 1] def clear_disconnected(fo): '''Remove unlinked items from file output node''' items = fo.file_output_items for i in reversed(range(len(items))): if not fo.inputs[i].is_linked: print(f'Deleting unlinked fileout item: {items[i].name}') items.remove(items[i]) def create_node(type, tree=None, **kargs): '''Get a type, a tree to add in, and optionnaly multiple attribute to set return created node ''' tree = tree or get_compositor_tree() node = tree.nodes.new(type) for k,v in kargs.items(): setattr(node, k, v) return node def recursive_node_connect_check(l, target_node): '''Get a link and a node return True if link is connected to passed target_node further in the node tree ''' if l.to_node == target_node: return True for o in l.to_node.outputs: for sl in o.links: if recursive_node_connect_check(sl, target_node): return True return False # region create and connect file output def create_and_connect_file_output(node, outputs, file_out, out_name, base_path, out_base, scene, remap_names, file_format, suffix='', color_depth=None, location_offset=(500, 50), base_path_template=None, use_slot_template=False): """Helper function to create file output node and connect it to outputs Args: node: Source node outputs: List of outputs to connect file_out: Existing file output node or None out_name: Name for the file output node base_path: Base path for output files out_base: Base name for output files scene: Blender scene remap_names: Dictionary for remapping output names file_format: Format settings suffix: Optional suffix for paths color_depth: Optional override for color depth location_offset: Offset for the node position Returns: bpy.types.CompositorNode: Created or used file output node """ if not outputs: return None tree = get_compositor_tree(scene) nodes = tree.nodes links = tree.links fo = file_out if not fo: fo = nodes.get(out_name) if not fo: fo = create_node('CompositorNodeOutputFile', tree=tree, width=600) fo.file_output_items.clear() # Ensure there is no default item if file_format: set_format_settings(fo.format, file_format) else: set_file_output_format(fo) if color_depth: fo.format.color_depth = color_depth fo.name = out_name if node.parent: fo.parent = node.parent fo.location_absolute = (real_loc(node)[0] + location_offset[0], real_loc(node)[1] + location_offset[1]) if base_path_template is not None: path = build_path_from_template(base_path_template, node_name=node.name.strip(), node_label=node.label.strip(), scene_name=node.scene.name if node.type == 'R_LAYERS' else None, viewlayer_name=node.layer if node.type == 'R_LAYERS' else None, ) elif base_path: path = base_path elif fo.format.file_format == 'OPEN_EXR_MULTILAYER': # default behavior without template path = f'//render/{out_base}/{out_base}_' else: path = f'//render/{out_base}/' ## suffix is added as a subfolder of the directory set_file_output_path(fo, path, suffix=suffix) for o in outputs: if next((l for l in o.links if recursive_node_connect_check(l, fo)), None): continue if (socket_remaps := remap_names.get(node.name)) and (custom_name := socket_remaps.get(o.name)): slot_name = bpy.path.clean_name(custom_name) else: slot_name = bpy.path.clean_name(o.name) ## Multilayer: item name is the layer name in the file ## Other formats: item name is the subpath added to directory + file name if fo.format.file_format == 'OPEN_EXR_MULTILAYER' or use_slot_template: ## use_slot_template always False (currently never use slot template) item_name = slot_name else: item_name = f'{slot_name}/{slot_name}_' _item, out_input = add_file_output_item(fo, item_name, socket=o) links.new(o, out_input) clear_disconnected(fo) fo.update() return fo def connect_to_file_output(node_list, file_out=None, base_path='', excludes=None, remap_names=None, file_format=None, tech_pass_names=None, tech_file_format=None, template=None): """Connect selected nodes output to file output(s) if a file output is selected, add intputs on it Args: node_list (list[bpy.types.Nodes,]): Nodes to connect file_out (bpy.types.CompositorNode, optional): File output node to connect to instead of new Defaults to None base_path (str, optional): Directory of images to render. if not passed, will use source node layer name Defaults to ''. file_format (dict, optionnal): converts each dictionary key into a file output format attribute and assigns the corresponding value. Defaults to None. excludes (dict, optionnal): List of output names to exclude {node_name: [outputs,]}. Defaults toNone. remap_names (dict, optionnal): List of output names to remap {node_name: {output_name: new_name}}. tech_pass_names (list[str], optional): list of of socket names to set as separate file output for technical passes Defaults to None tech_file_format (dict, optionnal): converts each dictionary key into a file output format for tech passes if not passed, will use file_format. Defaults to None. Returns: list[bpy.types.CompositorNode]: All nodes created. """ scene = bpy.context.scene if not isinstance(node_list, list): node_list = [node_list] node_list = [n for n in node_list if n.type != 'OUTPUT_FILE'] if not node_list: return [] created_nodes = [] excludes = excludes or {} remap_names = remap_names or {} if tech_file_format is None: ## Fallback to file format (then customize) is not provided tech_file_format = file_format.copy() if file_format else None if tech_file_format: # Set to EXR if provided is not exr type e.g PNG if tech_file_format.get('file_format') not in ('OPEN_EXR', 'OPEN_EXR_MULTILAYER'): tech_file_format['file_format'] = 'OPEN_EXR' ## Force 32 bit tech_file_format['color_depth'] = '32' ## Enforce a lossless format if provided is not if (codec := tech_file_format.get('exr_codec')) and codec not in ['ZIP','PIZ','RLE','ZIPS']: tech_file_format['exr_codec'] = 'ZIP' for node in node_list: exclusions = excludes.get(node.name) or [] # Get all available outputs excluding those in exclusions all_outputs = [o for o in node.outputs if not o.is_unavailable and o.name not in exclusions] # Base name for output nodes if node.label: out_base = node.label else: out_base = node.name out_base = bpy.path.clean_name(out_base) # Categorize outputs crypto_outputs = [o for o in all_outputs if 'crypto' in o.name.lower()] if tech_pass_names: # Filter tech passes tech_outputs = [o for o in all_outputs if o.name.lower() in tech_pass_names] # Regular outputs (excluding crypto and tech passes) regular_outputs = [o for o in all_outputs if o not in crypto_outputs and o not in tech_outputs] else: # If not splitting tech passes, include them with regular outputs regular_outputs = [o for o in all_outputs if o not in crypto_outputs] tech_outputs = [] y_offset = 50 node_margin = 100 # Create and connect regular outputs if regular_outputs: out_name = f'OUT_{out_base}' fo_regular = create_and_connect_file_output( node, regular_outputs, file_out, out_name, base_path, out_base, scene, remap_names, file_format, location_offset=(500, y_offset), base_path_template=template ) if fo_regular and fo_regular not in created_nodes: created_nodes.append(fo_regular) y_offset += -22 * len(regular_outputs) - node_margin # Create and connect tech outputs with 32-bit depth if split_tech_passes is True if tech_outputs: out_name = f'OUT_{out_base}_tech' fo_tech = create_and_connect_file_output( node, tech_outputs, None, out_name, base_path, out_base, scene, remap_names, tech_file_format, suffix='tech/', color_depth='32', location_offset=(500, y_offset), base_path_template=template ) if fo_tech and fo_tech not in created_nodes: created_nodes.append(fo_tech) y_offset -= node_margin y_offset += -22 * len(tech_outputs) # Create and connect crypto outputs with 32-bit depth if crypto_outputs: out_name = f'OUT_{out_base}_cryptos' fo_crypto = create_and_connect_file_output( node, crypto_outputs, None, out_name, base_path, out_base, scene, remap_names, tech_file_format, suffix='cryptos/', color_depth='32', location_offset=(500, y_offset), base_path_template=template ) if fo_crypto and fo_crypto not in created_nodes: created_nodes.append(fo_crypto) return created_nodes # endregion # endregion # region Collections def get_collection_children_recursive(col, cols=None) -> list: '''return a list of all the child collections and their subcollections in the passed collection''' cols = cols if cols is not None else [] for sub in col.children: if sub not in cols: cols.append(sub) if len(sub.children): cols = get_collection_children_recursive(sub, cols) return cols def get_view_layer_collection(col, vl_col=None, view_layer=None): '''return viewlayer collection from collection col: the collection to get viewlayer collection from view_layer (viewlayer, optional) : viewlayer to search in, if not passed, use active viewlayer ''' if vl_col is None: if view_layer: vl_col = view_layer.layer_collection else: vl_col = bpy.context.view_layer.layer_collection for sub in vl_col.children: if sub.collection == col: return sub if len(sub.children): c = get_view_layer_collection(col, sub) if c is not None: return c ## Alternative implementation # def get_view_layer_collection(collection, vl_col=None): # """Find the view layer collection corresponding to a given collection""" # if vl_col is None: # vl_col = bpy.context.view_layer.layer_collection # if vl_col.collection == collection: # return vl_col # for child in vl_col.children: # found = get_view_layer_collection(collection, child) # if found: # return found # return None def get_parents_cols(col, root=None, scene=None, cols=None): '''Return a list of parents collections of passed col root : Pass a collection to search in (recursive) Else search in master collection scene: scene to search in (active scene if not passed) cols: used internally by the function to collect results ''' if cols is None: cols = [] if root == None: scn = scene or bpy.context.scene root=scn.collection for sub in root.children: if sub == col: cols.append(root) if len(sub.children): cols = get_parents_cols(col, root=sub, cols=cols) return cols # endregion # region utilities def set_properties_editor_tab(tab, skip_if_exists=True): '''Take a tab name and apply it to properties editor tab: identifier of the tab, possible name in: ['TOOL', 'SCENE', 'RENDER', 'OUTPUT', 'VIEW_LAYER', 'WORLD', 'COLLECTION', 'OBJECT', 'CONSTRAINT', 'MODIFIER', 'DATA', 'BONE', 'BONE_CONSTRAINT', 'MATERIAL', 'TEXTURE', 'PARTICLES', 'PHYSICS', 'SHADERFX'] skip_if_exists: do nothing if a properties editor is alrteady on this tab ''' if bpy.context.area.type == 'PROPERTIES': bpy.context.area.spaces.active.context = tab return prop_space = None for area in bpy.context.screen.areas: if area.type == 'PROPERTIES': for space in area.spaces: if space.type == 'PROPERTIES': if skip_if_exists and space.context == tab: return if prop_space is None: prop_space = space if prop_space is not None: prop_space.context = tab return 1 def get_object_icon(obj): '''Return outliner icon name of passed object''' if obj.type == 'LIGHT_PROBE': return 'OUTLINER_OB_LIGHTPROBE' return f'OUTLINER_OB_{obj.type}' def show_and_active_object(obj, make_active=True, select=True, unhide=True): ''' Show the object Disable exclude parent collection collection and select with options Activate and show all parents collections active : Make the object active select : Select the object (independent of active state) unhide : show object in viewport (activate both visibility status) ''' # Activate parents collections # activate_parent_collections(obj, ensure_visible=ensure_collection_visible) if unhide: # Show obj object if obj.hide_viewport: obj.hide_viewport = False if obj.hide_get(): obj.hide_set(False) # Make object Active if make_active: bpy.context.view_layer.objects.active = obj # Select object if select: obj.select_set(True) def show_message_box(message = "", title = "Message Box", icon = 'INFO'): '''Show message box with element passed as string or list if message is a list of lists: if sublist have 2 element: considered a label [text, icon] if sublist have 3 element: considered as an operator [ops_id_name, text, icon] if sublist have 4 element: considered as a property [object, propname, text, icon] ''' def draw(self, context): layout = self.layout for l in message: if isinstance(l, str): layout.label(text=l) elif len(l) == 2: # label with icon layout.label(text=l[0], icon=l[1]) elif len(l) == 3: # ops layout.operator_context = "INVOKE_DEFAULT" layout.operator(l[0], text=l[1], icon=l[2], emboss=False) # <- highligh the entry elif len(l) == 4: # prop row = layout.row(align=True) row.label(text=l[2], icon=l[3]) row.prop(l[0], l[1], text='') if isinstance(message, str): message = [message] bpy.context.window_manager.popup_menu(draw, title = title, icon = icon) def get_rightmost_number_in_string(string) -> str: """Get the rightmost number passed string return the number as string, empty string if no number found """ res = re.search(r'(\d+)(?!.*\d)', string) if not res: return '' return res.group(1) def build_path_from_template(template: str, node_name: str=None, node_label: str=None, scene_name: str=None, viewlayer_name: str=None, socket_name: str=None, socket_index: str=None, ) -> str: """Get a name from a template string Possible keyword: {node_name} : name of the node {node_label} : label of the node (fallback to node_name if not set) {scene_name} : name of the source render layer node scene {viewlayer_name} : name of the source render layer viewlayer {socket_name} : name of source the node socket {socket_index} : index of the node in the node tree (010, 020, etc.) ## Info from Blend {blend_name} : name of the blend file {blend_version} : version of the blend file {date} : current date (YYYYMMDD format) {time} : current time (HHMMSS format) """ ## note: socket index is a bad idea because the final order is not decided before execute if not template: return '' # Get blend file info blend_path = bpy.data.filepath blend_name = '' blend_version = '' if blend_path: blend_name = Path(blend_path).stem blend_version = get_rightmost_number_in_string(blend_name) # Get current date and time import datetime now = datetime.datetime.now() current_date = now.strftime("%Y%m%d") current_time = now.strftime("%H%M%S") # Handle node_label to name fallback if "{node_label}" in template: if node_label is None or not node_label: node_label = node_name if socket_index: # Ensure socket_index is formatted as a zero-padded string and increment on base 10 (allow to insert inbetween renders) socket_index = f"{int(socket_index)*10:03d}" def clean_name(name): # Clean names to be file-system safe if name is None: return '' return bpy.path.clean_name(name) # Create a dictionary of all available variables template_vars = { 'node_name': clean_name(node_name), 'node_label': clean_name(node_label), 'socket_name': clean_name(socket_name), 'scene_name': clean_name(scene_name), 'viewlayer_name': clean_name(viewlayer_name), 'socket_index': socket_index or '', 'blend_name': clean_name(blend_name), 'blend_version': blend_version, 'date': current_date, 'time': current_time, } # Apply template using format_map to handle missing keys gracefully try: applied_template = template.format_map(template_vars) except KeyError as e: # If a key is missing, return template with error message # print(f"Template error: Unknown variable {e}") return f"Template error: Unknown {e}" return applied_template # endregion # region visibility states ## targets for conformation def get_target_collection(target_name, context): """Get the target collection based on active or the target name, None if nothing""" if target_name: return next((c for c in context.scene.collection.children_recursive if c.name == target_name), None) else: return context.collection def get_target_object(target_name, context): """Get the target object based on active or the target name, None if nothing""" if target_name: return bpy.context.scene.objects.get(target_name) else: return context.object def store_visibility_states(collection=None): """Store visibility states of objects and collections Args: collection: Target collection to scan. If None, scans entire scene starting from root. Returns: dict: Visibility states for objects, collections, and viewlayer collections """ visibility_states = { 'objects': {}, 'collections': {}, 'view_layer_collections': {} } # Determine target viewlayer collection if collection is None: target_vl_collection = bpy.context.view_layer.layer_collection else: target_vl_collection = get_view_layer_collection(collection) if target_vl_collection is None: # Collection not found in current viewlayer return visibility_states # Store visibility states by iterating through viewlayer collections def store_visibility_recursive(vl_col): # Get the actual collection from viewlayer collection col = vl_col.collection # Store collection visibility states visibility_states['collections'][col.name] = { 'hide_select': getattr(col, 'hide_select', False), 'hide_viewport': getattr(col, 'hide_viewport', False), 'hide_render': getattr(col, 'hide_render', False), } # Store viewlayer collection visibility states visibility_states['view_layer_collections'][vl_col.name] = { 'exclude': getattr(vl_col, 'exclude', False), 'hide_viewport': getattr(vl_col, 'hide_viewport', False), 'indirect_only': getattr(vl_col, 'indirect_only', False), 'holdout': getattr(vl_col, 'indirect_only', False), } # Store objects in this collection for obj in col.objects: if obj.name not in visibility_states['objects']: # Avoid duplicates visibility_states['objects'][obj.name] = { 'hide_viewport': obj.hide_viewport, 'hide_render': obj.hide_render, 'hide_select': obj.hide_select, 'hide_viewlayer': obj.hide_get(), } # Recursively process children for child_vl_col in vl_col.children: store_visibility_recursive(child_vl_col) store_visibility_recursive(target_vl_collection) return visibility_states def store_collection_states(collection, context=None): '''Pass a collection and store visibility state''' context = context or bpy.context scene = context.scene outliner_state = scene.get('outliner_state', {}) ## By name # if collection is None: # collection = next((c for c in context.scene.collection.children_recursive if c.name == target_name), None) state = store_visibility_states(collection) key = collection.name if collection else "ALL" outliner_state[key] = state scene['outliner_state'] = outliner_state # return ({'INFO'}, f"Stored visibility states for '{key}'") def apply_collection_states(collection, context=None): context = context or bpy.context scene = context.scene outliner_state = scene.get('outliner_state', {}) if collection is None: # If no collection is passed, apply to all collections collection = context.scene.collection key = "ALL" else: key = collection.name state = outliner_state.get(key) if not state: return ({'WARNING'}, f"No stored state for '{key}'") # return {'CANCELLED'} ## Collect objects before changing visibility (can invalidate all_objects cache) all_objects = {o.name: o for o in collection.all_objects} for obj_name, obj_state in state['objects'].items(): # obj = context.scene.objects.get(obj_name) obj = all_objects.get(obj_name) if obj: obj.hide_viewport = obj_state.get('hide_viewport', False) obj.hide_render = obj_state.get('hide_render', False) obj.hide_select = obj_state.get('hide_select', False) try: obj.hide_set(obj_state.get('hide_viewlayer', False)) except Exception: pass for col_name, col_state in state['collections'].items(): col = next((c for c in context.scene.collection.children_recursive if c.name == col_name), None) if col: col.hide_select = col_state.get('hide_select', False) col.hide_viewport = col_state.get('hide_viewport', False) col.hide_render = col_state.get('hide_render', False) for vlcol_name, vlcol_state in state['view_layer_collections'].items(): col = next((c for c in context.scene.collection.children_recursive if c.name == vlcol_name), None) vlcol = get_view_layer_collection(col, context.view_layer.layer_collection) if vlcol: vlcol.exclude = vlcol_state.get('exclude', False) vlcol.hide_viewport = vlcol_state.get('hide_viewport', False) vlcol.indirect_only = vlcol_state.get('indirect_only', False) vlcol.holdout = vlcol_state.get('holdout', False) ## --- # # Restore object states # for obj, obj_state in state.get('objects', {}).items(): # # obj = bpy.data.objects.get(obj_name) # obj.hide_viewport = obj_state.get('hide_viewport', False) # obj.hide_render = obj_state.get('hide_render', False) # obj.hide_select = obj_state.get('hide_select', False) # try: # obj.hide_set(obj_state.get('hide_viewlayer', False)) # except Exception: # pass # # Restore collection states # for col, col_state in state.get('collections', {}).items(): # # col = bpy.data.collections.get(col_name) # col.hide_select = col_state.get('hide_select', False) # col.hide_viewport = col_state.get('hide_viewport', False) # col.hide_render = col_state.get('hide_render', False) # for vlcol, vlcol_state in state.get('view_layer_collections', {}).items(): # vlcol.hide_select = vlcol_state.get('hide_select', False) # vlcol.hide_viewport = vlcol_state.get('hide_viewport', False) # vlcol.hide_render = vlcol_state.get('hide_render', False) ### ---- # Restore view layer collection states # def apply_vl_states(vl_col): # vl_state = state.get('view_layer_collections', {}).get(vl_col.name) # if vl_state: # vl_col.exclude = vl_state.get('exclude', False) # vl_col.hide_viewport = vl_state.get('hide_viewport', False) # vl_col.indirect_only = vl_state.get('indirect_only', False) # vl_col.holdout = vl_state.get('holdout', False) # for child in vl_col.children: # apply_vl_states(child) # if collection: # vl_col = get_view_layer_collection(collection) # if vl_col: # apply_vl_states(vl_col) # else: # apply_vl_states(context.view_layer.layer_collection) def delete_collection_states(collection_name, context=None): """Delete stored visibility states for a collection or all if None""" context = context or bpy.context scene = context.scene outliner_state = scene.get('outliner_state', {}) key = collection_name if collection_name else "ALL" if key in outliner_state: del outliner_state[key] scene['outliner_state'] = outliner_state return ({'INFO'}, f"Deleted visibility states for '{key}'") else: return ({'WARNING'}, f"No stored state for '{key}'")