Files
blender-portable-repo/scripts/addons/poliigon-addon-blender/material_import_utils.py
T
2026-03-17 14:30:01 -06:00

587 lines
21 KiB
Python

# #### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import os
import re
from typing import List, Optional, Tuple, Union
import bpy
from .modules.poliigon_core.assets import (AssetData,
AssetType,
SIZES)
from .material_import_cycles_port_names import get_socket_name
from . import reporting
from .utils import compare_simple_property_group
ASSET_TYPE_TO_IMPORTED_TYPE = {
AssetType.TEXTURE: "Textures",
AssetType.MODEL: "Models",
AssetType.HDRI: "HRDIs",
AssetType.BRUSH: "Brushes",
AssetType.ALL: "All Assets"
}
def find_identical_material(asset_data: AssetData,
size: str,
mapping: str,
scale: float,
displacement: float,
use_16bit: bool,
mode_disp: str
) -> bpy.types.Material:
"""Tries to find an parameter-wise identical material in current scene."""
asset_name = asset_data.asset_name
asset_type = asset_data.asset_type
is_backplate = asset_data.is_backplate()
asset_type_imported = ASSET_TYPE_TO_IMPORTED_TYPE[asset_type]
identical_mat = None
for mat in bpy.data.materials:
if not mat.poliigon_props.asset_name.startswith(asset_name):
continue
if mat.poliigon_props.asset_type != asset_type_imported:
continue
if mat.poliigon_props.size != size:
continue
if mat.poliigon_props.mapping != mapping:
continue
if mat.poliigon_props.scale != scale:
continue
if mat.poliigon_props.displacement != displacement:
continue
if mat.poliigon_props.use_16bit != use_16bit:
continue
if mat.poliigon_props.mode_disp != mode_disp:
continue
if mat.poliigon_props.is_backplate != is_backplate:
continue
if not compare_simple_property_group(
bpy.context.window_manager.polligon_map_prefs,
mat.poliigon_props.map_prefs):
continue
identical_mat = mat
break
return identical_mat
def get_all_nodes(node_tree: bpy.types.NodeTree):
nodes = list(node_tree.nodes)
for node in node_tree.nodes:
if node.bl_idname != "ShaderNodeGroup":
continue
elif not node.node_tree:
continue
nodes.extend(get_all_nodes(node.node_tree))
return nodes
def get_node_by_type(
group: bpy.types.Node, bl_idname: str) -> bpy.types.Node:
"""Returns first node of given type (bl_idname) found in group."""
node_found = None
for _node in group.node_tree.nodes:
if _node.bl_idname != bl_idname:
continue
node_found = _node
break
return node_found
def get_node_by_name(group: bpy.types.Node, name: str) -> bpy.types.Node:
"""Returns first node with given name found in group."""
node_found = None
for _node in group.node_tree.nodes:
if _node.name != name:
continue
node_found = _node
break
return node_found
def get_all_node_trees(node_tree: bpy.types.NodeTree,
include_root: bool = True):
node_trees = [node_tree] if include_root else []
for node in node_tree.nodes:
if node.bl_idname != "ShaderNodeGroup":
continue
elif not node.node_tree:
continue
node_trees.extend(get_all_node_trees(node.node_tree))
return node_trees
def mat_get_nodes(mat: bpy.types.Material,
node_idname: str = "ShaderNodeTexImage"):
if mat is None:
return []
nodes = get_all_nodes(mat.node_tree)
tex_nodes = [
node for node in nodes
if node.bl_idname == node_idname
]
return tex_nodes
def regex_size_rename(name_old: str, size_new: str) -> str:
"""Returns a name with new_size, if a size is found in name_old"""
# Match in order an underscore, digit number (also multiple digits),
# immediately followed by K
# group(1) contains the digit number (size) we are interested in.
# Capturing group example: "whatever_4K" => 4
name_new = name_old
match_object = re.search(r"_(\d+K)", name_old)
if match_object is not None:
size_old = match_object.group(1)
name_new = name_old.replace(size_old, size_new)
return name_new
def rename_material_and_nodes(mat: bpy.types.Material,
size: str) -> None:
# Rename material, first
mat.name = regex_size_rename(mat.name, size)
# Then rename all group nodes containing size in name
nodes = get_all_nodes(mat.node_tree)
for _node in nodes:
if _node.bl_idname != "ShaderNodeGroup":
continue
_node.name = regex_size_rename(_node.name, size)
# Finally rename all node trees containing size in name
node_trees = get_all_node_trees(
mat.node_tree, include_root=False)
for _node_tree in node_trees:
_node_tree.name = regex_size_rename(
_node_tree.name, size)
def replace_tex_size(materials: List,
asset_files: List[str],
size: str,
link_blend: bool
) -> None:
"""Changes the texture resolution of all materials in list."""
if link_blend:
return
for mat in materials:
tex_nodes = mat_get_nodes(
mat, node_idname="ShaderNodeTexImage")
replaced_tex = False
for node in tex_nodes:
if node is None or node.image is None:
continue
# Match in order an underscore, digit number (also multiple
# digits), immediately followed by K,
# followed by an underscore or a period.
# group(1) contains the digit number we are interested in.
# Capturing group examples: "_4K." or "_16K_METALLIC"
path_tex = node.image.filepath
match_object = re.search(r"_(\d+K)[_\.]", path_tex)
dir_parent = os.path.basename(os.path.dirname(path_tex))
if match_object is not None:
imported_size = match_object.group(1)
elif "HIRES" in node.image.filepath:
imported_size = "HIRES"
elif dir_parent in SIZES:
imported_size = dir_parent
else:
# TODO(Andreas): Need logger, here
print("Invalid filepath for parsing", node.image.filepath)
continue
if imported_size == size:
continue
directory, filename = os.path.split(node.image.filepath)
filename_desired_size = filename.replace(imported_size, size)
directory_desired_size = directory.replace(imported_size, size)
path_desired_size = os.path.join(
directory_desired_size, filename_desired_size)
path_found = None
for path_asset_file in asset_files:
if path_asset_file == path_desired_size:
path_found = path_asset_file
break
if path_found is not None:
node.image.filepath = path_found
node.image.name = os.path.basename(path_found)
replaced_tex = True
# Finally also change the material name to the new size
if replaced_tex:
rename_material_and_nodes(mat, size)
def print_node_inputs_outputs(node: bpy.types.Node) -> None:
"""Prints input and output ports of a node with their names and data
type.
"""
print(f"Node: {node.name}")
print("Inputs:")
for idx, _in in enumerate(node.inputs):
print(" ", idx, _in.name, _in.type)
print("Outputs:")
for idx, _out in enumerate(node.outputs):
print(" ", idx, _out.name, _out.type)
def print_node_socket(
node: bpy.types.Node,
sock: bpy.types.NodeSocket,
addressed: Union[str, int]
) -> None:
"""Prints some information about a node's socket."""
print("NODE: ", node.name, node.type, node.bl_static_type)
if sock.is_output:
print(" OUT addressed: ", addressed)
idx = list(node.outputs).index(sock)
else:
print(" IN addressed: ", addressed)
idx = list(node.inputs).index(sock)
print(" idx: ", idx)
print(" bl_idname: ", sock.bl_idname)
# print(" bl_label: ", sock.bl_label)
print(" identifier:", sock.identifier)
print(" label: ", sock.label)
print(" name: ", sock.name)
def load_poliigon_node_group(node_type: str) -> bpy.types.Node:
"""Loads the needed node group from template, if not already local."""
if node_type in bpy.data.node_groups.keys():
return bpy.data.node_groups[node_type]
dir_script = os.path.join(os.path.dirname(__file__), "files")
path_template = os.path.join(dir_script,
"poliigon_material_template.blend")
if not os.path.exists(path_template):
msg = f"Material template file missing!\n{path_template}"
reporting.capture_message(
"add_converter_node_no_template", msg, "error")
return None
nodes_before = list(bpy.data.node_groups)
with bpy.data.libraries.load(path_template, link=False) as (from_file,
into):
into.node_groups = [
node_group for node_group in from_file.node_groups
if node_group in [node_type]
]
nodes_after = list(bpy.data.node_groups)
# Safely get the newly imported datablock, without referencing by name.
nodes_imported = list(set(nodes_after) - set(nodes_before))
if len(nodes_imported) == 0:
raise RuntimeError("No new node groups imported")
elif len(nodes_imported) > 1:
# Not supposed to occur
# TODO(Andreas): Need logger, here
print("Warning, more than one??")
node_mosaic = nodes_imported[0] # but just return first if more than one
node_mosaic.name = node_type # pass in UI friendly name
return node_mosaic
def filter_textures_by_workflow(textures: List[str],
size: str,
name_mat: str
) -> Tuple[List[str], bool]:
def parent_dir_name(path: str) -> str:
return os.path.basename(os.path.dirname(path))
def filename_no_ext(path: str) -> str:
return os.path.splitext(os.path.basename(path))[0]
textures_metallic = [
tex
for tex in textures
if filename_no_ext(tex).endswith("METALNESS") or parent_dir_name(tex) == "METALNESS"
]
textures_specular = [
tex
for tex in textures
if filename_no_ext(tex).endswith("SPECULAR") or parent_dir_name(tex) == "SPECULAR"
]
textures_dielectric = [
tex
for tex in textures
if tex not in textures_metallic and tex not in textures_specular
]
textures_overlay = [
tex
for tex in textures
if "OVERLAY" in os.path.splitext(os.path.basename(tex))[0]
]
has_col_or_alpha = False
for tex in textures:
filename = os.path.splitext(os.path.basename(tex))[0]
has_col = "COL" in filename
has_alpha = "ALPHA" in filename
if has_col or has_alpha:
has_col_or_alpha = True
break
only_overlay = False
# TODO(Andreas): Dear reviewer, before refactoring, below if statement
# had this additional condition:
# and len(textures_overlay) <= len(textures)
# Seeing how textures_overlay is generated above,
# it is always true, isn't it?
if not has_col_or_alpha and len(textures_overlay) > 0:
# This is an overlay, not a full texture.
only_overlay = True
textures_workflow = textures
elif len(textures_metallic) >= 4:
textures_workflow = textures_metallic + textures_dielectric
elif len(textures_specular) >= 4:
textures_workflow = textures_specular + textures_dielectric
elif len(textures_dielectric) >= 4:
textures_workflow = textures_dielectric
elif size == "PREVIEW":
textures_workflow = textures
elif has_col_or_alpha and len(textures_dielectric) > 0:
# Likely decals or seafoam, which only have color information
# but don't have OVERLAY as a map pass (only COL or ALPHAMASKED).
textures_workflow = textures_dielectric
elif has_col_or_alpha and len(textures_metallic) > 0:
# Likely remastered asset with too few metalness textures
textures_workflow = textures_metallic
else:
msg = (
f"Wrong tex counts for {name_mat} to determine workflow - "
f"metal:{len(textures_metallic)}, "
f"specular:{len(textures_specular)}, "
f"dielectric:{len(textures_dielectric)}"
)
reporting.capture_message(
"build_mat_error_workflow", msg, "error")
return None, only_overlay
return textures_workflow, only_overlay
def get_socket(
*,
node: bpy.types.Node,
sock_name: str,
sock_bl_idname_expected: str,
is_output: bool = True
) -> Optional[bpy.types.NodeSocket]:
"""Returns a socket of a given node.
Compared to standard access, this function enforces socket reference by
name, instead of int and additionally checks the socket being of expected
type.
"""
if type(sock_name) is not str:
msg = ("get_socket: For increased cross version compatibility index "
"port addressing is no longer allowed. Use bl_idname instead!"
f"{node.name}/{node.bl_idname} Name: {sock_name}")
print(msg)
reporting.capture_message("import_node_socket", msg, "error")
return None
if is_output:
socket_list = node.outputs
else:
socket_list = node.inputs
sock_name = get_socket_name(node, sock_name)
if sock_name not in socket_list:
msg = ("get_socket: Socket Name not found "
f"{node.name}/{node.bl_idname} Name: {sock_name}")
print(msg)
reporting.capture_message("import_node_socket", msg, "error")
return None
return socket_list[sock_name]
def create_link(
*,
node_tree,
node_out: bpy.types.Node,
sock_out_name: str,
sock_out_bl_idname_expected: str,
node_in: bpy.types.Node,
sock_in_name: str,
sock_in_bl_idname_expected: str,
allow_index: bool = False
) -> None:
"""Creates a link between an output and an input socket.
Compared to link creation, this function uses our port name table to find
the correct name for the running Blender version and enforces socket
reference by name, instead of int. Also additionally checks the socket
being of expected type for some increased safety against node changes.
Optionally index reference can still be allowed and is needed for nodes
like e.g. MIX or MATH (see port name tables SOCKET_NAMES), where named
reference is not possible, since all input sockets have identical names...
"""
if not allow_index and type(sock_out_name) is not str:
msg = ("create_link: For increased cross version compatibility index "
"port addressing is no longer allowed. Use bl_idname instead!"
f"{node_out.name}/{node_out.bl_idname}: Name: {sock_out_name}")
print(msg)
reporting.capture_message("import_node_link", msg, "error")
return
if not allow_index and type(sock_in_name) is not str:
msg = ("create_link: For increased cross version compatibility index "
"port addressing is no longer allowed. Use bl_idname instead!"
f"{node_in.name}/{node_in.bl_idname}: Name: {sock_in_name}")
print(msg)
reporting.capture_message("import_node_link", msg, "error")
return
sock_out_name = get_socket_name(node_out, sock_out_name)
sock_in_name = get_socket_name(node_in, sock_in_name)
if type(sock_out_name) is str and sock_out_name not in node_out.outputs:
msg = ("create_link_nodes: Output Name not found "
f"{node_out.name}/{node_out.bl_idname}: Name: {sock_out_name}\n"
f" Available: {node_out.outputs.keys()}")
print(msg)
reporting.capture_message("import_node_link", msg, "error")
return
if type(sock_in_name) is str and sock_in_name not in node_in.inputs:
msg = ("create_link_nodes: Input Name not found "
f"{node_in.name}/{node_in.bl_idname}: Name: {sock_in_name}\n"
f" Available: {node_in.inputs.keys()}")
print(msg)
reporting.capture_message("import_node_link", msg, "error")
return
sock_out = node_out.outputs[sock_out_name]
if sock_out.bl_idname != sock_out_bl_idname_expected:
msg = ("create_link_nodes: Wrong output port type "
f"{node_out.name}/{node_out.bl_idname}/{sock_out_name}: "
f"{sock_out.bl_idname} != {sock_out_bl_idname_expected}\n"
f" Available ports: {node_out.outputs.keys()}")
print(msg)
reporting.capture_message("import_node_link", msg, "error")
return
sock_in = node_in.inputs[sock_in_name]
if sock_in.bl_idname != sock_in_bl_idname_expected:
msg = ("create_link_nodes: Wrong input port type "
f"{node_in.name}/{node_in.bl_idname}/{sock_in_name}: "
f"{sock_in.bl_idname} != {sock_in_bl_idname_expected}\n"
f" Available ports: {node_in.inputs.keys()}")
print(msg)
reporting.capture_message("import_node_link", msg, "error")
return
node_tree.links.new(sock_out, sock_in)
def create_link_sock_out(
*,
node_tree,
sock_out: bpy.types.NodeSocket,
node_in: bpy.types.Node,
sock_in_name: str,
sock_in_bl_idname_expected: str,
allow_index: bool = False
) -> None:
"""Creates a link between an output and an input socket.
Special case version of above create_link(), which allows to
pass in the output socket directly, while still keeping the advantages
of create_link().
"""
node_out = sock_out.node
sock_out_name = sock_out.name
sock_out_bl_idname_expected = sock_out.bl_idname
create_link(
node_tree=node_tree,
node_in=node_in,
sock_in_name=sock_in_name,
sock_in_bl_idname_expected=sock_in_bl_idname_expected,
node_out=node_out,
sock_out_name=sock_out_name,
sock_out_bl_idname_expected=sock_out_bl_idname_expected,
allow_index=allow_index
)
def set_value(
*,
node: bpy.types.Node,
sock_name: str,
sock_bl_idname_expected: str,
value: any,
allow_index: bool = False
) -> None:
"""Sets the value of a node's input socket.
Compared to setting the value directly, this function uses our port name
table to find the correct name for the running Blender version and enforces
socket reference by name, instead of int. Also additionally checks the
socket being of expected type for some increased safety against node
changes.
Optionally index reference can still be allowed and is needed for nodes
like e.g. MIX or MATH (see port name tables SOCKET_NAMES), where named
reference is not possible, since all input sockets have identical names...
"""
if not allow_index and type(sock_name) is not str:
msg = ("set_value: For increased cross version compatibility index "
"port addressing is no longer allowed. Use bl_idname instead!"
f"{node.name}/{node.bl_idname}: Name: {sock_name}")
print(msg)
reporting.capture_message("import_node_value", msg, "error")
return
sock_name = get_socket_name(node, sock_name)
if type(sock_name) is str and sock_name not in node.inputs:
msg = ("set_value: Input Name not found "
f"{node.name}/{node.bl_idname}: Name: {sock_name}")
print(msg)
reporting.capture_message("import_node_value", msg, "error")
return
sock = node.inputs[sock_name]
if sock.bl_idname != sock_bl_idname_expected:
msg = ("set_value: Wrong input port type "
f"{node.name}/{node.bl_idname}/{sock_name}: "
f"{sock.bl_idname} != {sock_bl_idname_expected}")
print(msg)
reporting.capture_message("import_node_value", msg, "error")
return
sock.default_value = value