Files
2026-03-17 14:30:01 -06:00

711 lines
32 KiB
Python

"""
Copyright (C) 2023 Adobe.
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 3 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, see <http://www.gnu.org/licenses/>.
"""
# file: material/manager.py
# brief: Material operations manager
# author Adobe - 3D & Immersive
# copyright 2023 Adobe Inc. All rights reserved.
import os
import bpy
import time
import traceback
from copy import deepcopy
from ..thread_ops import SUBSTANCE_Threads
from ..utils import SUBSTANCE_Utils
from ..common import RENDER_IMG_UPDATE_DELAY_S, ADDON_PACKAGE
class SUBSTANCE_MaterialManager():
@staticmethod
def get_image(name, filepath):
if name in bpy.data.images:
_image = bpy.data.images[name]
_image_filepath = bpy.path.abspath(_image.filepath)
_image_filepath = os.path.abspath(_image_filepath)
_image_filepath = _image_filepath.replace("\\", "/")
_new_filepath = filepath.replace("\\", "/")
if _image_filepath != _new_filepath:
_image.filepath = _new_filepath
_image.reload()
return _image
_image = bpy.data.images.load(filepath=filepath.replace("\\", "/"))
_image.name = name
return _image
@staticmethod
def get_existing_material(material_name):
if material_name in bpy.data.materials:
return bpy.data.materials[material_name]
return None
@staticmethod
def reset_image(node, image):
def _set_image():
node.image = image
SUBSTANCE_Threads.main_thread_run(_set_image)
@staticmethod
def auto_apply_material(context, data, material):
_selected_geo = SUBSTANCE_Utils.get_selected_geo(context.selected_objects)
if len(_selected_geo) > 0 and data["graph_idx"] == 0:
_selected_objects = [bpy.context.active_object]
SUBSTANCE_MaterialManager.apply_material(context, _selected_objects, material)
@staticmethod
def apply_material(context, objects, material):
_addon_prefs = context.preferences.addons[ADDON_PACKAGE].preferences
for _obj in objects:
if not hasattr(_obj.data, "materials"):
continue
if _addon_prefs.default_apply_type == "INSERT" and _obj.data.materials:
# _obj.data.materials.append(material)
_obj.active_material = material
else:
_obj.data.materials.append(material)
@staticmethod
def empty_material(material, graph):
material.use_nodes = True
try:
material.cycles.displacement_method = 'BOTH'
except Exception:
pass
# Cleanup graph
_mat_nodes = material.node_tree.nodes
for _node in _mat_nodes:
_mat_nodes.remove(_node)
# Cleanup Node groups
if graph.material.name in bpy.data.node_groups:
_subgroup = bpy.data.node_groups[graph.material.name]
for _node in _subgroup.nodes:
_subgroup.nodes.remove(_node)
if bpy.app.version >= (4, 0, 0):
for _item in _subgroup.interface.items_tree:
if _item.item_type == 'SOCKET':
if _item.in_out == 'INPUT' or _item.in_out == 'OUTPUT':
_subgroup.interface.remove(_item)
else:
for _input in _subgroup.inputs:
_subgroup.inputs.remove(_input)
for _output in _subgroup.outputs:
_subgroup.outputs.remove(_output)
@staticmethod
def init_nodes(material, graph, shader_graph):
_items = {}
for _node in shader_graph["nodes"]:
_name = _node["name"].replace("$matname", graph.material.name)
if _node["path"] == "root":
_nodes = material.node_tree.nodes
else:
_nodes = _items[_node["path"].replace("root/", "")].node_tree.nodes
if _name in _nodes:
_items[_node["id"]] = _nodes[_name]
else:
_items[_node["id"]] = _nodes.new(_node["type"])
_items[_node["id"]].name = _name
if _node["type"] == "NodeFrame":
_items[_node["id"]].label = _node["label"]
continue
_items[_node["id"]].location = (_node["location"][0], _node["location"][1])
if _node["type"] == "ShaderNodeGroup":
if graph.material.name in bpy.data.node_groups:
_node_tree = bpy.data.node_groups[graph.material.name]
else:
_node_tree = bpy.data.node_groups.new(
type='ShaderNodeTree',
name=graph.material.name)
_items[_node["id"]].node_tree = _node_tree
return _items
@staticmethod
def init_outputs(items, sbs_group, shader_prefs, shader_graph, addons_prefs, data, graph, new_nodes=None):
_location = [-600, 0]
for _output in graph.outputs:
if _output.name not in data["outputs"]:
continue
if not data["outputs"][_output.name]["enabled"]:
continue
if _output.type == "image":
if data["outputs"][_output.name]["path"] != "":
_filepath = data["outputs"][_output.name]["path"]
_filename = bpy.path.basename(data["outputs"][_output.name]["path"])
_output.filepath = _filepath
_output.filename = _filename
else:
_output.filepath = ""
_output.filename = ""
continue
_name = "Tx {}".format(_output.name)
_key = "tx_{}".format(_output.name)
if _name in sbs_group.node_tree.nodes:
items[_key] = sbs_group.node_tree.nodes[_name]
else:
if new_nodes is not None:
new_nodes.append(_key)
items[_key] = sbs_group.node_tree.nodes.new("ShaderNodeTexImage")
items[_key].name = "Tx {}".format(_output.name)
_img_name = "{}_{}".format(graph.material.name, _output.name)
_img = SUBSTANCE_MaterialManager.get_image(_img_name, _filepath)
items[_key].image = _img
if _output.name in shader_prefs["outputs"]:
_shader_output = shader_prefs["outputs"][_output.name]
_img.colorspace_settings.name = _shader_output["colorspace"]
else:
_img.colorspace_settings.name = addons_prefs.output_default_colorspace
if (_output.name in shader_graph["outputs"] and
"normal" in shader_graph["outputs"][_output.name]):
_nrm_key = "normal_{}".format(_output.name)
if new_nodes is not None:
new_nodes.append(_nrm_key)
items[_nrm_key] = sbs_group.node_tree.nodes.new("ShaderNodeNormalMap")
items[_nrm_key].name = "Normal {}".format(_output.name)
items[_nrm_key].location = (_location[0] + 300, _location[1])
# Add common properties to outputs that are not part of the shader
if _output.name not in shader_graph["outputs"]:
for _property in shader_graph["properties"]:
if _property["id"] == "tx_":
_new_propery = deepcopy(_property)
_new_propery["id"] = "tx_{}".format(_output.name)
shader_graph["properties"].append(_new_propery)
items[_key].location = (_location[0], _location[1])
_location[1] -= 300
else:
_name = "Val {}".format(_output.name)
_key = "val_{}".format(_output.name)
_value = data["outputs"][_output.name]["value"]
if _output.type == "integer2" or _output.type == "float2":
_value.append(0)
_value.append(0)
elif _output.type == "integer3" or _output.type == "float3":
_value.append(0)
if _name in sbs_group.node_tree.nodes:
items[_key] = sbs_group.node_tree.nodes[_name]
else:
if new_nodes is not None:
new_nodes.append(_key)
if _output.type == "integer" or _output.type == "float":
items[_key] = sbs_group.node_tree.nodes.new("ShaderNodeValue")
else:
items[_key] = sbs_group.node_tree.nodes.new("ShaderNodeCombineXYZ")
if _output.type == "integer" or _output.type == "float":
items[_key].outputs[0].default_value = _value
else:
items[_key].inputs[0].default_value = _value[0]
items[_key].inputs[1].default_value = _value[1]
items[_key].inputs[2].default_value = _value[2]
items[_key].location = (_location[0], _location[1])
_location[1] -= 300
@staticmethod
def init_sockets(items, shader_graph):
# Initialize shader sockets
for _socket in shader_graph["sockets"]:
if "dependency" in _socket:
if _socket["dependency"] not in items:
continue
if bpy.app.version >= (4, 0, 0):
if _socket["source"] == "input" and _socket["name"] not in items[_socket["id"]].inputs:
items[_socket["id"]].node_tree.interface.new_socket(
_socket["name"],
in_out="INPUT",
socket_type=_socket["type"])
elif _socket["source"] == "output" and _socket["name"] not in items[_socket["id"]].outputs:
items[_socket["id"]].node_tree.interface.new_socket(
_socket["name"],
in_out="OUTPUT",
socket_type=_socket["type"])
else:
if _socket["source"] == "input" and _socket["name"] not in items[_socket["id"]].inputs:
items[_socket["id"]].node_tree.inputs.new(_socket["type"], _socket["name"])
elif _socket["source"] == "output" and _socket["name"] not in items[_socket["id"]].outputs:
items[_socket["id"]].node_tree.outputs.new(_socket["type"], _socket["name"])
@staticmethod
def create_dyna_links(dyna_links, sbs_group, graph, data):
for _output in graph.outputs:
if _output.name not in data["outputs"]:
continue
if _output.name not in sbs_group.outputs and data["outputs"][_output.name]["enabled"]:
# Create links
if _output.type == "image":
dyna_links.append({
"from": "NODE_GROUP_IN",
"to": "tx_{}".format(_output.name),
"output": "UV",
"input": "Vector",
"path": "root/SBSAR"
})
dyna_links.append({
"from": "tx_{}".format(_output.name),
"to": "NODE_GROUP_OUT",
"output": "Color",
"input": _output.name,
"path": "root/SBSAR"
})
elif _output.type == "integer" or _output.type == "float":
dyna_links.append({
"from": "val_{}".format(_output.name),
"to": "NODE_GROUP_OUT",
"output": "Value",
"input": _output.name,
"path": "root/SBSAR"
})
else:
dyna_links.append({
"from": "val_{}".format(_output.name),
"to": "NODE_GROUP_OUT",
"output": "Vector",
"input": _output.name,
"path": "root/SBSAR"
})
# Create sockets
if bpy.app.version >= (4, 0, 0):
if _output.type == "image":
sbs_group.node_tree.interface.new_socket(
name=_output.name,
in_out="OUTPUT",
socket_type="NodeSocketColor")
elif _output.type == "integer":
sbs_group.node_tree.interface.new_socket(
name=_output.name,
in_out="OUTPUT",
socket_type="NodeSocketInt")
elif _output.type == "float":
sbs_group.node_tree.interface.new_socket(
name=_output.name,
in_out="OUTPUT",
socket_type="NodeSocketFloat")
else:
sbs_group.node_tree.interface.new_socket(
name=_output.name,
in_out="OUTPUT",
socket_type="NodeSocketVectorXYZ")
else:
if _output.type == "image":
sbs_group.node_tree.outputs.new("NodeSocketColor", _output.name)
elif _output.type == "integer":
sbs_group.node_tree.outputs.new("NodeSocketInt", _output.name)
elif _output.type == "float":
sbs_group.node_tree.outputs.new("NodeSocketFloat", _output.name)
else:
sbs_group.node_tree.outputs.new("NodeSocketVectorXYZ", _output.name)
@staticmethod
def init_links(material, items, shader_graph):
for _link in shader_graph["links"]:
if bpy.app.version >= (4, 0, 0):
pass
else:
if _link["input"] == "Emission Color":
_link["input"] = "Emission"
if (_link["from"] in items and
_link["to"] in items and
_link["output"] in items[_link["from"]].outputs and
_link["input"] in items[_link["to"]].inputs):
if _link["path"] == "root":
material.node_tree.links.new(
items[_link["from"]].outputs[_link["output"]],
items[_link["to"]].inputs[_link["input"]])
else:
items[_link["path"].replace("root/", "")].node_tree.links.new(
items[_link["from"]].outputs[_link["output"]],
items[_link["to"]].inputs[_link["input"]])
@staticmethod
def init_dyna_links(material, items, dyna_links):
for _link in dyna_links:
if (_link["from"] in items and
_link["to"] in items and
_link["output"] in items[_link["from"]].outputs and
_link["input"] in items[_link["to"]].inputs):
if _link["path"] == "root":
material.node_tree.links.new(
items[_link["from"]].outputs[_link["output"]],
items[_link["to"]].inputs[_link["input"]])
else:
items[_link["path"].replace("root/", "")].node_tree.links.new(
items[_link["from"]].outputs[_link["output"]],
items[_link["to"]].inputs[_link["input"]])
@staticmethod
def remove_unused_nodes(items, shader_graph, mat_nodes):
for _node in shader_graph["nodes"]:
if "dependency" in _node:
for _value in _node["dependency"]:
if _value not in items:
if _node["path"] == "root":
mat_nodes.remove(items[_node["id"]])
else:
_node_path = _node["path"].replace("root/", "")
items[_node_path].node_tree.nodes.remove(items[_node["id"]])
del items[_node["id"]]
break
if _node["type"] == "NodeFrame":
for _key in _node["children"]:
if _key in items:
items[_key].parent = items[_node["id"]]
items[_node["id"]].update()
@staticmethod
def init_properties(items, shader_graph, shader_prefs, graph, data, context, new_nodes=None):
if new_nodes is None:
new_nodes = []
for _item in items.keys():
new_nodes.append(_item)
for _property in shader_graph["properties"]:
if _property["type"] == "emissive_intensity":
if "input" in _property and "emissive" in data["outputs"]:
_value = getattr(shader_prefs["inputs"], _property["type"])
if bpy.app.version >= (4, 0, 0):
setattr(items[_property["id"]].inputs[_property["input"]], _property["name"], _value)
else:
setattr(items[_property["id"]].inputs[20], _property["name"], _value)
else:
if bpy.app.version >= (4, 0, 0):
setattr(items[_property["id"]].inputs[_property["input"]], _property["name"], 0.0)
else:
setattr(items[_property["id"]].inputs[20], _property["name"], 0.0)
else:
if _property["id"] not in items:
continue
if _property["id"] not in new_nodes:
continue
if "dependency" in _property:
for _value in _property["dependency"]:
if _value not in items:
continue
if _property["type"] == "string":
setattr(items[_property["id"]], _property["name"], _property["value"])
elif _property["type"] == "float":
setattr(items[_property["id"]], _property["name"], _property["value"])
elif _property["type"] == "tiling":
if "input" in _property:
_value = graph.tiling.get()
setattr(items[_property["id"]].inputs[_property["input"]], _property["name"], _value)
elif _property["type"] == "physical_size":
if "input" in _property:
_value = graph.physical_size.get()
_value = SUBSTANCE_Utils.get_physical_size(_value, context)
_value = [1/_value[0], 1/_value[1], 1/_value[0]]
setattr(items[_property["id"]].inputs[_property["input"]], _property["name"], _value)
elif _property["type"] == "ao_mix":
if "input" in _property:
_value = getattr(shader_prefs["inputs"], _property["type"])
setattr(items[_property["id"]].inputs[_property["input"]], _property["name"], _value)
elif _property["type"] == "disp_midlevel":
if "input" in _property:
_value = getattr(shader_prefs["inputs"], _property["type"])
setattr(items[_property["id"]].inputs[_property["input"]], _property["name"], _value)
elif _property["type"] == "disp_scale":
if "input" in _property:
_value = getattr(shader_prefs["inputs"], _property["type"])
setattr(items[_property["id"]].inputs[_property["input"]], _property["name"], _value)
elif _property["type"] == "disp_physical_scale":
if "input" in _property:
_value = graph.physical_size.get()[2]
setattr(items[_property["id"]].inputs[_property["input"]], _property["name"], _value)
elif _property["type"] == "projection_blend":
_value = getattr(shader_prefs["inputs"], _property["type"])
setattr(items[_property["id"]], _property["name"], _value)
else:
setattr(items[_property["id"]], _property["name"], _property["value"])
@staticmethod
def clean_sbsar_group(sbs_group, sbs_group_nodes, graph, data):
# Remove unused sockets
if bpy.app.version >= (4, 0, 0):
for _output in sbs_group.node_tree.interface.items_tree:
if _output.name not in data["outputs"]:
if _output.item_type == 'SOCKET':
if _output.in_out == 'OUTPUT':
sbs_group.node_tree.interface.remove(_output)
else:
for _output in sbs_group.node_tree.outputs:
if _output.name not in data["outputs"]:
sbs_group.node_tree.outputs.remove(_output)
# Remove disabled SBSAR nodes
for _node in sbs_group_nodes:
if (type(_node) is not bpy.types.ShaderNodeTexImage and
type(_node) is not bpy.types.ShaderNodeNormalMap and
type(_node) is not bpy.types.ShaderNodeValue and
type(_node) is not bpy.types.ShaderNodeCombineXYZ):
continue
_output = _node.name.replace("Tx ", "").replace("Normal ", "").replace("Val ", "")
if not graph.outputs[_output].shader_enabled:
sbs_group_nodes.remove(_node)
@staticmethod
def update_images(graph, data):
for _output in graph.outputs:
if _output.type == "image":
if _output.name in data["outputs"]:
_filepath = data["outputs"][_output.name]["path"]
_img_name = "{}_{}".format(graph.material.name, _output.name)
SUBSTANCE_MaterialManager.get_image(_img_name, _filepath)
@staticmethod
def get_current_nodes(items, new_nodes, mat_nodes, shader_graph, graph):
for _node in shader_graph["nodes"]:
_name = _node["name"].replace("$matname", graph.material.name)
if _node["path"] == "root":
_nodes = mat_nodes
else:
_nodes = items[_node["path"].replace("root/", "")].node_tree.nodes
if _name in _nodes:
items[_node["id"]] = _nodes[_name]
else:
new_nodes.append(_node["id"])
items[_node["id"]] = _nodes.new(_node["type"])
items[_node["id"]].name = _name
if _node["type"] == "NodeFrame":
items[_node["id"]].label = _node["label"]
continue
items[_node["id"]].location = (_node["location"][0], _node["location"][1])
if _node["type"] == "ShaderNodeGroup":
_node_tree = bpy.data.node_groups.new(
type='ShaderNodeTree',
name=graph.material.name)
items[_node["id"]].node_tree = _node_tree
@staticmethod
def cycles_update(items):
for _key in items:
if not hasattr(items[_key], "image"):
continue
_img = items[_key].image
items[_key].image = None
SUBSTANCE_Threads.timer_thread_run(
RENDER_IMG_UPDATE_DELAY_S,
SUBSTANCE_MaterialManager.reset_image,
(items[_key], _img))
@staticmethod
def create_shader_network(context, sbsar, graph, data):
try:
# Time
SUBSTANCE_Threads.cursor_push('WAIT')
_time_start = time.time()
_addons_prefs, _shader_idx, _shader = SUBSTANCE_Utils.get_selected_shader(context, int(graph.shaders_list))
_shader_file = SUBSTANCE_Utils.get_shader_file(_shader.filename)
_shader_graph = SUBSTANCE_Utils.get_json(_shader_file)
_shader_prefs = SUBSTANCE_Utils.get_shader_prefs(context, _shader)
_material = SUBSTANCE_MaterialManager.get_existing_material(graph.material.name)
if _material is None or _shader.name != graph.material.shader:
if _material is None:
_material = bpy.data.materials.new(name=graph.material.name)
if _addons_prefs.auto_apply_material:
SUBSTANCE_MaterialManager.auto_apply_material(context, data, _material)
SUBSTANCE_MaterialManager.empty_material(_material, graph)
_items = SUBSTANCE_MaterialManager.init_nodes(_material, graph, _shader_graph)
# Get the substance Node Group
_mat_nodes = _material.node_tree.nodes
_sbs_group = _material.node_tree.nodes["{}_sbsar".format(graph.material.name)]
_sbs_group_nodes = _sbs_group.node_tree.nodes
# Initialize Enabled outputs
SUBSTANCE_MaterialManager.init_outputs(
_items,
_sbs_group,
_shader_prefs,
_shader_graph,
_addons_prefs,
data,
graph)
# Initialize Sbsar NodeGroup socket
SUBSTANCE_MaterialManager.init_sockets(_items, _shader_graph)
# Create sockets & links for enabled outputs that are not part of the shader
_dyna_links = []
SUBSTANCE_MaterialManager.create_dyna_links(_dyna_links, _sbs_group, graph, data)
# Remove unused Nodes
SUBSTANCE_MaterialManager.remove_unused_nodes(_items, _shader_graph, _mat_nodes)
# Initialize shader properties
SUBSTANCE_MaterialManager.init_properties(
_items,
_shader_graph,
_shader_prefs,
graph,
data,
context)
# Initialize links
SUBSTANCE_MaterialManager.init_links(_material, _items, _shader_graph)
# Initialize dynamic links
SUBSTANCE_MaterialManager.init_dyna_links(_material, _items, _dyna_links)
graph.material.shader = _shader.name
_material.use_fake_user = _addons_prefs.auto_fake_usr
_out_message_type = "INFO"
_out_message = "Material [{}] create in [{}]seconds"
else:
# Reload image files and update file format
SUBSTANCE_MaterialManager.update_images(graph, data)
# Get the substance Node Group
_mat_nodes = _material.node_tree.nodes
_sbs_group = _material.node_tree.nodes["{}_sbsar".format(graph.material.name)]
_sbs_group_nodes = _sbs_group.node_tree.nodes
if graph.update_tx_only:
_items = {}
# Add all SBSAR texture nodes
for _node in _sbs_group_nodes:
if (type(_node) is not bpy.types.ShaderNodeTexImage):
continue
_output = _node.name.replace("Tx ", "")
_items[_output] = _node
_out_message_type = "INFO"
_out_message = "Textures [{}] updated in [{}]seconds"
else:
# Cleanup SBSAR group
SUBSTANCE_MaterialManager.clean_sbsar_group(_sbs_group, _sbs_group_nodes, graph, data)
# Get all graph items
_new_nodes = []
_items = {}
SUBSTANCE_MaterialManager.get_current_nodes(
_items,
_new_nodes,
_mat_nodes,
_shader_graph,
graph)
SUBSTANCE_MaterialManager.init_outputs(
_items,
_sbs_group,
_shader_prefs,
_shader_graph,
_addons_prefs,
data,
graph,
_new_nodes)
# Remove unused Nodes
SUBSTANCE_MaterialManager.remove_unused_nodes(_items, _shader_graph, _mat_nodes)
# Initialize shader properties
SUBSTANCE_MaterialManager.init_properties(
_items,
_shader_graph,
_shader_prefs,
graph,
data,
context,
_new_nodes)
# Initialize Sbsar NodeGroup socket
SUBSTANCE_MaterialManager.init_sockets(_items, _shader_graph)
# Create sockets & links for enabled outputs that are not part of the shader
_dyna_links = []
SUBSTANCE_MaterialManager.create_dyna_links(_dyna_links, _sbs_group, graph, data)
# Sort sockets
if bpy.app.version >= (4, 0, 0):
pass
else:
_n = len(_sbs_group.node_tree.outputs)
for _idx in range(_n):
_sorted = True
for _jdx in range(_n - _idx - 1):
_a_key = _sbs_group.node_tree.outputs[_jdx].name
_b_key = _sbs_group.node_tree.outputs[_jdx + 1].name
_a_weight = graph.outputs[_a_key].index
_b_weight = graph.outputs[_b_key].index
if _a_weight > _b_weight:
_sbs_group.node_tree.outputs.move(_jdx, _jdx + 1)
_sorted = False
if _sorted:
break
# Initialize links
SUBSTANCE_MaterialManager.init_links(_material, _items, _shader_graph)
# Initialize dynamic links
SUBSTANCE_MaterialManager.init_dyna_links(_material, _items, _dyna_links)
_out_message_type = "INFO"
_out_message = "Material [{}] updated in [{}]seconds"
# Hack to autoupdate images in cycles
if _addons_prefs.cycles_autoupdate_enabled:
SUBSTANCE_MaterialManager.cycles_update(_items)
except Exception:
_out_message_type = "ERROR"
_out_message = "An error ocurred while setting the material [{}]. [{}]seconds"
SUBSTANCE_Utils.log_data("ERROR", "Exception - Susbtance material creation error")
SUBSTANCE_Utils.log_traceback(traceback.format_exc())
# Time
_time_end = time.time()
_load_time = round(_time_end - _time_start, 3)
SUBSTANCE_Utils.log_data(
_out_message_type,
_out_message.format(graph.material.name, _load_time),
display=True)
# REDRAW
# _idx = context.scene.sbsar_index
# context.scene.sbsar_index = _idx
SUBSTANCE_Utils.toggle_redraw(context)
SUBSTANCE_Threads.cursor_pop()