hiding/unhinding while interating on objects can void an object, giving back None), because Blender's cached object list is invalidated
897 lines
33 KiB
Python
Executable File
897 lines
33 KiB
Python
Executable File
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}'") |