diff --git a/materialx/__init__.py b/materialx/__init__.py new file mode 100644 index 000000000..b62a68378 --- /dev/null +++ b/materialx/__init__.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +""" +MaterialX nodes addon +""" + +bl_info = { + "name": "MaterialX nodes", + "description": "MaterialX nodes addon", + "author": "AMD", + "version": (1, 0, 0), + "blender": (3, 4, 0), + "location": "Editor Type -> Shader Editor", + "doc_url": "{BLENDER_MANUAL_URL}/addons/materials/materialx.html", + "warning": "Alpha", + "support": "TESTING", + "category": "Material", +} + +ADDON_ALIAS = "materialx" + + +from . import ( + preferences, + nodes, + ui, + utils, +) + +from . import logging +log = logging.Log("__init__") + + +def register(): + log("register") + + preferences.register() + nodes.register() + ui.register() + + +def unregister(): + log("unregister") + + utils.clear_temp_dir() + + ui.unregister() + nodes.unregister() + preferences.unregister() diff --git a/materialx/bl_nodes/__init__.py b/materialx/bl_nodes/__init__.py new file mode 100644 index 000000000..e00417f99 --- /dev/null +++ b/materialx/bl_nodes/__init__.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +from .. import logging +log = logging.Log("bl_nodes") + + +from . import ( + color, + converter, + input, + output, + shader, + texture, + vector, +) +node_parser_classes = ( + output.ShaderNodeOutputMaterial, + + color.ShaderNodeInvert, + color.ShaderNodeMixRGB, + + converter.ShaderNodeMath, + + input.ShaderNodeValue, + input.ShaderNodeRGB, + + shader.ShaderNodeAddShader, + shader.ShaderNodeMixShader, + shader.ShaderNodeEmission, + shader.ShaderNodeBsdfGlass, + shader.ShaderNodeBsdfDiffuse, + shader.ShaderNodeBsdfPrincipled, + + texture.ShaderNodeTexImage, + + vector.ShaderNodeNormalMap, +) diff --git a/materialx/bl_nodes/color.py b/materialx/bl_nodes/color.py new file mode 100644 index 000000000..63bcbec03 --- /dev/null +++ b/materialx/bl_nodes/color.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +from .node_parser import NodeParser +from . import log + + +class ShaderNodeInvert(NodeParser): + def export(self): + fac = self.get_input_value('Fac') + color = self.get_input_value('Color') + + return fac.blend(color, 1.0 - color) + + +class ShaderNodeMixRGB(NodeParser): + + def export(self): + fac = self.get_input_value('Fac') + color1 = self.get_input_value('Color1') + color2 = self.get_input_value('Color2') + + # these mix types are copied from cycles OSL + blend_type = self.node.blend_type + + if blend_type in ('MIX', 'COLOR'): + res = fac.blend(color1, color2) + + elif blend_type == 'ADD': + res = fac.blend(color1, color1 + color2) + + elif blend_type == 'MULTIPLY': + res = fac.blend(color1, color1 * color2) + + elif blend_type == 'SUBTRACT': + res = fac.blend(color1, color1 - color2) + + elif blend_type == 'DIVIDE': + res = fac.blend(color1, color1 / color2) + + elif blend_type == 'DIFFERENCE': + res = fac.blend(color1, abs(color1 - color2)) + + elif blend_type == 'DARKEN': + res = fac.blend(color1, color1.min(color2)) + + elif blend_type == 'LIGHTEN': + res = fac.blend(color1, color1.max(color2)) + + elif blend_type == 'VALUE': + res = color1 + + elif blend_type == 'SCREEN': + tm = 1.0 - fac + res = 1.0 - (tm + fac * (1.0 - color2)) * (1.0 - color1) + + elif blend_type == 'SOFT_LIGHT': + tm = 1.0 - fac + scr = 1.0 - (1.0 - color2) * (1.0 - color1) + res = tm * color1 + fac * ((1.0 - color1) * color2 * color1 + color1 * scr) + + elif blend_type == 'LINEAR_LIGHT': + test_val = color2 > 0.5 + res = test_val.if_else(color1 + fac * (2.0 * (color2 - 0.5)), + color1 + fac * (2.0 * color2 - 1.0)) + + else: + # TODO: support operations SATURATION, HUE, SCREEN, BURN, OVERLAY + log.warn("Ignoring unsupported Blend Type", blend_type, self.node, self.material, + "mix will be used") + res = fac.blend(color1, color2) + + if self.node.use_clamp: + res = res.clamp() + + return res diff --git a/materialx/bl_nodes/converter.py b/materialx/bl_nodes/converter.py new file mode 100644 index 000000000..0154cd263 --- /dev/null +++ b/materialx/bl_nodes/converter.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +from .node_parser import NodeParser +from . import log + + +class ShaderNodeMath(NodeParser): + """ Map Blender operations to MaterialX definitions, see the stdlib_defs.mtlx in MaterialX """ + + def export(self): + op = self.node.operation + in1 = self.get_input_value(0) + # single operand operations + if op == 'SINE': + res = in1.sin() + elif op == 'COSINE': + res = in1.cos() + elif op == 'TANGENT': + res = in1.tan() + elif op == 'ARCSINE': + res = in1.asin() + elif op == 'ARCCOSINE': + res = in1.acos() + elif op == 'ARCTANGENT': + res = in1.atan() + elif op == 'LOGARITHM': + res = in1.log() + elif op == 'ABSOLUTE': + res = abs(in1) + elif op == 'FLOOR': + res = in1.floor() + elif op == 'FRACT': + res = in1 % 1.0 + elif op == 'CEIL': + res = in1.ceil() + elif op == 'ROUND': + f = in1.floor() + res = (in1 % 1.0).if_else('>=', 0.5, f + 1.0, f) + + else: # 2-operand operations + in2 = self.get_input_value(1) + + if op == 'ADD': + res = in1 + in2 + elif op == 'SUBTRACT': + res = in1 - in2 + elif op == 'MULTIPLY': + res = in1 * in2 + elif op == 'DIVIDE': + res = in1 / in2 + elif op == 'POWER': + res = in1 ** in2 + elif op == 'MINIMUM': + res = in1.min(in2) + elif op == 'MAXIMUM': + res = in1.max(in2) + + else: + in3 = self.get_input_value(2) + + if op == 'MULTIPLY_ADD': + res = in1 * in2 + in3 + else: + log.warn("Unsupported math operation", op) + return None + + if self.node.use_clamp: + res = res.clamp() + + return res diff --git a/materialx/bl_nodes/input.py b/materialx/bl_nodes/input.py new file mode 100644 index 000000000..870f5a2c0 --- /dev/null +++ b/materialx/bl_nodes/input.py @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +from .node_parser import NodeParser + + +class ShaderNodeValue(NodeParser): + """ Returns float value """ + + def export(self): + return self.get_output_default() + + +class ShaderNodeRGB(NodeParser): + """ Returns color value """ + + def export(self): + return self.get_output_default() diff --git a/materialx/bl_nodes/node_parser.py b/materialx/bl_nodes/node_parser.py new file mode 100644 index 000000000..c8ab2a0d2 --- /dev/null +++ b/materialx/bl_nodes/node_parser.py @@ -0,0 +1,390 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +import math + +import bpy +import MaterialX as mx + +from .. import utils +from ..utils import pass_node_reroute +from ..nodes import get_mx_node_cls +from ..nodes.node import MxNode + +from .. import logging +log = logging.Log("bl_nodes.node_parser") + + +OUTPUT_TYPE = {'RGBA': 'color3', + 'VALUE': 'float', + 'VECTOR': 'vector3'} + + +class Id: + def __init__(self): + self.id = 0 + + def __call__(self): + self.id += 1 + return self.id + + +class NodeItem: + """This class is a wrapper used for doing operations on MaterialX nodes, floats, and tuples""" + + def __init__(self, id: Id, ng: [mx.Document, mx.NodeGraph], data: [tuple, float, mx.Node], prefix=''): + self.id = id + self.nodegraph = ng + self.data = data + self.nodedef = None + if isinstance(data, mx.Node): + MxNode_cls, _ = get_mx_node_cls(data, prefix) + self.nodedef = MxNode_cls.get_nodedef(self.type) + + def node_item(self, value): + if isinstance(value, NodeItem): + return value + + return NodeItem(self.id, self.nodegraph, value) + + @property + def type(self): + if isinstance(self.data, float): + return 'float_' + elif isinstance(self.data, tuple): + return 'tuple_' + else: + return self.data.getType() + + def set_input(self, name, value): + if value is None: + return + + val_data = value.data if isinstance(value, NodeItem) else value + nd_input = self.nodedef.getActiveInput(name) + input = self.data.addInput(name, nd_input.getType()) + utils.set_param_value(input, val_data, input.getType()) + + def set_inputs(self, inputs): + for name, value in inputs.items(): + self.set_input(name, value) + + # MATH OPERATIONS + def _arithmetic_helper(self, other, op_node, func): + ''' helper function for overridden math functions. + This simply creates an arithmetic node of rpr_type + if one of the operands has node data, else maps the function to data ''' + + if other is None: + if isinstance(self.data, float): + result_data = func(self.data) + elif isinstance(self.data, tuple): + result_data = tuple(map(func, self.data)) + else: + result_data = self.nodegraph.addNode(op_node, f"{op_node}_{self.id()}", + self.data.getType()) + input = result_data.addInput('in', self.data.getType()) + utils.set_param_value(input, self.data, self.data.getType()) + + else: + other_data = other.data if isinstance(other, NodeItem) else other + if isinstance(self.data, (float, tuple)) and isinstance(other_data, (float, tuple)): + if isinstance(self.data, float) and isinstance(other_data, float): + result_data = func(self.data, other_data) + else: + data = self.data + + # converting data or other_data to have equal length + if isinstance(data, float): + data = (data,) * len(other_data) + elif isinstance(other_data, float): + other_data = (other_data,) * len(data) + elif len(data) < len(other_data): + data = (*data, 1.0) + elif len(other_data) < len(data): + other_data = (*other_data, 1.0) + + result_data = tuple(map(func, data, other_data)) + + else: + nd_type = self.data.getType() if isinstance(self.data, mx.Node) else \ + other_data.getType() + + result_data = self.nodegraph.addNode(op_node, f"{op_node}_{self.id()}", nd_type) + input1 = result_data.addInput('in1', nd_type) + utils.set_param_value(input1, self.data, nd_type) + input2 = result_data.addInput('in2', nd_type) + utils.set_param_value(input2, other_data, nd_type) + + return self.node_item(result_data) + + def __add__(self, other): + return self._arithmetic_helper(other, 'add', lambda a, b: a + b) + + def __sub__(self, other): + return self._arithmetic_helper(other, 'subtract', lambda a, b: a - b) + + def __mul__(self, other): + return self._arithmetic_helper(other, 'multiply', lambda a, b: a * b) + + def __truediv__(self, other): + return self._arithmetic_helper(other, 'divide', + lambda a, b: a / b if not math.isclose(b, 0.0) else 0.0) + + def __mod__(self, other): + return self._arithmetic_helper(other, 'modulo', lambda a, b: a % b) + + def __pow__(self, other): + return self._arithmetic_helper(other, 'power', lambda a, b: a ** b) + + def __neg__(self): + return 0.0 - self + + def __abs__(self): + return self._arithmetic_helper(None, 'absval', lambda a: abs(a)) + + def floor(self): + return self._arithmetic_helper(None, 'floor', lambda a: float(math.floor(a))) + + def ceil(self): + return self._arithmetic_helper(None, 'ceil', lambda a: float(math.ceil(a))) + + # right hand methods for doing something like 1.0 - Node + def __radd__(self, other): + return self + other + + def __rsub__(self, other): + return self.node_item(other) - self + + def __rmul__(self, other): + return self * other + + def __rtruediv__(self, other): + return self.node_item(other) / self + + def __rmod__(self, other): + return self.node_item(other) % self + + def __rpow__(self, other): + return self.node_item(other) ** self + + def dot(self, other): + dot = self._arithmetic_helper(other, 'dotproduct', lambda a, b: a * b) + if isinstance(dot.data, tuple): + dot.data = sum(dot.data) + + return dot + + def if_else(self, cond: str, other, if_value, else_value): + if cond == '>': + res = self._arithmetic_helper(other, 'ifgreater', lambda a, b: float(a > b)) + elif cond == '>=': + res = self._arithmetic_helper(other, 'ifgreatereq', lambda a, b: float(a >= b)) + elif cond == '==': + res = self._arithmetic_helper(other, 'ifequal', lambda a, b: float(a == b)) + elif cond == '<': + return self.node_item(other).if_else('>', self, else_value, if_value) + elif cond == '<=': + return self.node_item(other).if_else('>=', self, else_value, if_value) + elif cond == '!=': + return self.if_else('==', other, else_value, if_value) + else: + raise ValueError("Incorrect condition:", cond) + + if isinstance(res.data, float): + return if_value if res.data == 1.0 else else_value + elif isinstance(res.data, tuple): + return if_value if res.data[0] == 1.0 else else_value + else: + res.set_input('value1', if_value) + res.set_input('value2', else_value) + return res + + def min(self, other): + return self._arithmetic_helper(other, 'min', lambda a, b: min(a, b)) + + def max(self, other): + return self._arithmetic_helper(other, 'max', lambda a, b: max(a, b)) + + def clamp(self, min_val=0.0, max_val=1.0): + """ clamp data to min/max """ + return self.min(max_val).max(min_val) + + def sin(self): + return self._arithmetic_helper(None, 'sin', lambda a: math.sin(a)) + + def cos(self): + return self._arithmetic_helper(None, 'cos', lambda a: math.cos(a)) + + def tan(self): + return self._arithmetic_helper(None, 'tan', lambda a: math.tan(a)) + + def asin(self): + return self._arithmetic_helper(None, 'asin', lambda a: math.asin(a)) + + def acos(self): + return self._arithmetic_helper(None, 'acos', lambda a: math.acos(a)) + + def atan(self): + return self._arithmetic_helper(None, 'atan', lambda a: math.atan(a)) + + def log(self): + return self._arithmetic_helper(None, 'ln', lambda a: math.log(a)) + + def blend(self, value1, value2): + """ Line interpolate value between value1(0.0) and value2(1.0) by self.data as factor """ + return self * value2 + (1.0 - self) * value1 + + +class NodeParser: + """ + This is the base class that parses a blender node. + Subclasses should override only export() function. + """ + + nodegraph_path = "NG" + + def __init__(self, id: Id, doc: mx.Document, material: bpy.types.Material, + node: bpy.types.Node, obj: bpy.types.Object, out_key, output_type, cached_nodes, + group_nodes=(), **kwargs): + self.id = id + self.doc = doc + self.material = material + self.node = node + self.object = obj + self.out_key = out_key + self.out_type = output_type + self.cached_nodes = cached_nodes + self.group_nodes = group_nodes + self.kwargs = kwargs + + @staticmethod + def get_output_type(to_socket): + # Need to check ShaderNodeNormalMap separately because + # if has input color3 type but materialx normalmap got vector3 + return 'vector3' if to_socket.node.type == 'NORMAL_MAP' else OUTPUT_TYPE.get(to_socket.type, 'color3') + + @staticmethod + def get_node_parser_cls(bl_idname): + """ Returns NodeParser class for node_idname or None if not found """ + from . import node_parser_classes + return next((cls for cls in node_parser_classes if cls.__name__ == bl_idname), None) + + # INTERNAL FUNCTIONS + def _export_node(self, node, out_key, to_socket, group_node=None): + if group_node: + if self.group_nodes: + group_nodes = self.group_nodes + (group_node,) + else: + group_nodes = (group_node,) + else: + group_nodes = self.group_nodes + + # dynamically define output type of node + output_type = self.get_output_type(to_socket) + + # check if this node was already parsed and cached + node_item = self.cached_nodes.get((node.name, out_key, output_type)) + if node_item: + return node_item + + # getting corresponded NodeParser class + NodeParser_cls = self.get_node_parser_cls(node.bl_idname) + if not NodeParser_cls: + log.warn(f"Ignoring unsupported node {node.bl_idname}", node, self.material) + self.cached_nodes[(node.name, out_key, output_type)] = None + return None + + node_parser = NodeParser_cls(self.id, self.doc, self.material, node, self.object, + out_key, output_type, self.cached_nodes, group_nodes, **self.kwargs) + + node_item = node_parser.export() + + self.cached_nodes[(node.name, out_key, output_type)] = node_item + return node_item + + def _parse_val(self, val): + """Turn blender socket value into python's value""" + + if isinstance(val, (int, float)): + return float(val) + + if len(val) in (3, 4): + return tuple(val) + + if isinstance(val, str): + return val + + raise TypeError("Unknown value type to pass to rpr", val) + + def node_item(self, value): + if isinstance(value, NodeItem): + return value + + nodegraph = utils.get_nodegraph_by_path(self.doc, self.nodegraph_path, True) + return NodeItem(self.id, nodegraph, value) + + # HELPER FUNCTIONS + # Child classes should use them to do their export + def get_output_default(self): + """ Returns default value of output socket """ + socket_out = self.node.outputs[self.out_key] + + return self.node_item(self._parse_val(socket_out.default_value)) + + def get_input_default(self, in_key): + """ Returns default value of input socket """ + + socket_in = self.node.inputs[in_key] + return self.node_item(self._parse_val(socket_in.default_value)) + + def get_input_link(self, in_key: [str, int]): + """Returns linked parsed node or None if nothing is linked or not link is not valid""" + + socket_in = self.node.inputs[in_key] + if not socket_in.links: + return None + + link = socket_in.links[0] + + if not link.is_valid: + log.warn("Invalid link ignored", link, socket_in, self.node, self.material) + return None + + link = pass_node_reroute(link) + if not link: + return None + + if isinstance(link.from_node, MxNode): + mx_node = link.from_node.compute(link.from_socket.name, doc=self.doc) + return mx_node + + return self._export_node(link.from_node, link.from_socket.name, link.to_socket) + + def get_input_value(self, in_key): + """ Returns linked node or default socket value """ + + val = self.get_input_link(in_key) + if val is not None: + return val + + return self.get_input_default(in_key) + + def create_node(self, node_name, nd_type, *, prefix='', inputs=None): + nodegraph = utils.get_nodegraph_by_path(self.doc, self.nodegraph_path, True) + node = nodegraph.addNode(node_name, f"{node_name}_{self.id()}", nd_type) + node_item = NodeItem(self.id, nodegraph, node, prefix) + + mx_type = node_item.nodedef.getType() + if mx_type != nd_type: + node.setType(mx_type) + + if inputs: + node_item.set_inputs(inputs) + + return node_item + + # EXPORT FUNCTION + def export(self) -> [NodeItem, None]: + """Main export function which should be overridable in child classes""" + return None diff --git a/materialx/bl_nodes/output.py b/materialx/bl_nodes/output.py new file mode 100644 index 000000000..dead86320 --- /dev/null +++ b/materialx/bl_nodes/output.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD +import MaterialX as mx + +from .node_parser import NodeParser, Id, log + + +class ShaderNodeOutputMaterial(NodeParser): + nodegraph_path = "" + + def __init__(self, doc, material, node, obj, **kwargs): + super().__init__(Id(), doc, material, node, obj, None, None, {}, **kwargs) + + def export(self): + surface = self.get_input_link('Surface') + if surface is None: + return None + + linked_input_type = surface.getType() if isinstance(surface, mx.Node) else surface.type + + if linked_input_type != 'surfaceshader': + log.warn("Incorrect node tree to export: output node doesn't have correct input") + + return None + + result = self.create_node('surfacematerial', 'material', inputs={ + 'surfaceshader': surface, + }) + + return result diff --git a/materialx/bl_nodes/shader.py b/materialx/bl_nodes/shader.py new file mode 100644 index 000000000..da2e62603 --- /dev/null +++ b/materialx/bl_nodes/shader.py @@ -0,0 +1,349 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +import math +import MaterialX as mx + +from .node_parser import NodeParser +from ..utils import get_mx_node_input_types +from . import log + + +def enabled(val): + if val is None: + return False + + if isinstance(val, mx.Node): + return True + + if isinstance(val.data, float) and math.isclose(val.data, 0.0): + return False + + if isinstance(val.data, tuple) and \ + math.isclose(val.data[0], 0.0) and \ + math.isclose(val.data[1], 0.0) and \ + math.isclose(val.data[2], 0.0): + return False + + return True + + +def get_node_type(node): + return node.getType() if isinstance(node, mx.Node) else node.nodedef.getType() + + +class ShaderNodeBsdfPrincipled(NodeParser): + nodegraph_path = "" + + def export(self): + # GETTING REQUIRED INPUTS + # Note: if some inputs are not needed they won't be taken + + base_color = self.get_input_value('Base Color') + + subsurface = self.get_input_value('Subsurface') + subsurface_radius = None + subsurface_color = None + if enabled(subsurface): + subsurface_radius = self.get_input_value('Subsurface Radius') + subsurface_color = self.get_input_value('Subsurface Color') + + metallic = self.get_input_value('Metallic') + specular = self.get_input_value('Specular') + # specular_tint = self.get_input_value('Specular Tint') + roughness = self.get_input_value('Roughness') + + anisotropic = None + anisotropic_rotation = None + if enabled(metallic): + # TODO: use Specular Tint input + anisotropic = self.get_input_value('Anisotropic') + if enabled(anisotropic): + anisotropic_rotation = self.get_input_value('Anisotropic Rotation') + # anisotropic_rotation = 0.5 - (anisotropic_rotation % 1.0) + + sheen = self.get_input_value('Sheen') + # sheen_tint = None + # if enabled(sheen): + # sheen_tint = self.get_input_value('Sheen Tint') + + clearcoat = self.get_input_value('Clearcoat') + clearcoat_roughness = None + if enabled(clearcoat): + clearcoat_roughness = self.get_input_value('Clearcoat Roughness') + + ior = self.get_input_value('IOR') + + transmission = self.get_input_value('Transmission') + transmission_roughness = None + if enabled(transmission): + transmission_roughness = self.get_input_value('Transmission Roughness') + + emission = self.get_input_value('Emission') + emission_strength = self.get_input_value('Emission Strength') + + alpha = self.get_input_value('Alpha') + # transparency = 1.0 - alpha + + normal = self.get_input_link('Normal') + clearcoat_normal = self.get_input_link('Clearcoat Normal') + tangent = self.get_input_link('Tangent') + + # CREATING STANDARD SURFACE + result = self.create_node('standard_surface', 'surfaceshader', prefix='BXDF', inputs={ + 'base': 1.0, + 'base_color': base_color, + 'diffuse_roughness': roughness, + 'normal': normal, + 'tangent': tangent, + }) + + if enabled(metallic): + result.set_input('metalness', metallic) + + if enabled(specular): + result.set_inputs({ + 'specular': specular, + 'specular_color': base_color, + 'specular_roughness': roughness, + 'specular_IOR': ior, + 'specular_anisotropy': anisotropic, + 'specular_rotation': anisotropic_rotation, + }) + + if enabled(transmission): + result.set_inputs({ + 'transmission': transmission, + 'transmission_color': base_color, + 'transmission_extra_roughness': transmission_roughness, + }) + + if enabled(subsurface): + result.set_inputs({ + 'subsurface': subsurface, + 'subsurface_color': subsurface_color, + 'subsurface_radius': subsurface_radius, + 'subsurface_anisotropy': anisotropic, + }) + + if enabled(sheen): + result.set_inputs({ + 'sheen': sheen, + 'sheen_color': base_color, + 'sheen_roughness': roughness, + }) + + if enabled(clearcoat): + result.set_inputs({ + 'coat': clearcoat, + 'coat_color': base_color, + 'coat_roughness': clearcoat_roughness, + 'coat_IOR': ior, + 'coat_anisotropy': anisotropic, + 'coat_rotation': anisotropic_rotation, + 'coat_normal': clearcoat_normal, + }) + + if enabled(emission): + result.set_inputs({ + 'emission': emission_strength, + 'emission_color': emission, + }) + + return result + + +class ShaderNodeBsdfDiffuse(NodeParser): + nodegraph_path = "" + + def export(self): + color = self.get_input_value('Color') + roughness = self.get_input_value('Roughness') + normal = self.get_input_link('Normal') + + # Also tried burley_diffuse_bsdf and oren_nayar_diffuse_bsdf here, but Blender crashes with them + # CREATING STANDARD SURFACE + result = self.create_node('standard_surface', 'surfaceshader', prefix='BXDF', inputs={ + 'base_color': color, + 'diffuse_roughness': 1.0 - roughness, + 'normal': normal, + }) + + return result + + +class ShaderNodeBsdfGlass(NodeParser): + def export(self): + color = self.get_input_value('Color') + roughness = self.get_input_value('Roughness') + ior = self.get_input_value('IOR') + normal = self.get_input_link('Normal') + + # CREATING STANDARD SURFACE + result = self.create_node('standard_surface', 'surfaceshader', prefix='BXDF', inputs={ + 'base': 0.0, + 'normal': normal, + 'specular': 1.0, + 'specular_color': color, + 'specular_roughness': roughness, + 'specular_IOR': ior, + 'specular_anisotropy': 0.0, + 'specular_rotation': 0.0, + 'transmission': 1.0, + 'transmission_color': color, + 'transmission_extra_roughness': roughness, + }) + + return result + + +class ShaderNodeEmission(NodeParser): + nodegraph_path = "" + + def export(self): + result = self.create_node('standard_surface', 'surfaceshader', prefix='BXDF') + + color = self.get_input_value('Color') + strength = self.get_input_value('Strength') + + if enabled(color) and enabled(strength): + result.set_inputs({ + 'emission': 1.0, + 'emission_color': color * strength, + }) + + return result + + +class ShaderNodeMixShader(NodeParser): + nodegraph_path = "" + + def export(self): + factor = self.get_input_value(0) + shader1 = self.get_input_link(1) + shader2 = self.get_input_link(2) + + mix = None + input_types = get_mx_node_input_types('mix', 'PBR') + + if shader1 is None and shader2 is None: + return None + + if shader2 is None: + shader1_type = get_node_type(shader1) + if shader1_type not in input_types: + log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. ' + f'Input type must be of types {input_types}, actual: {shader1_type}') + return shader1 + + mix = self.create_node('mix', shader1_type.lower(), prefix='PBR', inputs={ + 'fg': shader1, + 'mix': factor + }) + + if shader1 is None: + shader2_type = get_node_type(shader2) + if shader2_type not in input_types: + log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. ' + f'Input type must be of types {input_types}, actual: {shader2_type}') + return shader2 + + mix = self.create_node('mix', shader2_type.lower(), prefix='PBR', inputs={ + 'bg': shader2, + 'mix': factor + }) + + if shader1 is not None and shader2 is not None: + shader1_type = get_node_type(shader1) + shader2_type = get_node_type(shader2) + if shader1_type != shader2_type: + log.warn(f'Types of input shaders must be the same. ' + f'First shader type: {shader1_type}, second shader type: {shader2_type}') + + return shader1 + + if shader1_type not in input_types: + log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. ' + f'Input type must be of types {input_types}, actual: {shader1_type}, {shader2_type}') + return shader1 + + mix = self.create_node('mix', shader1_type.lower(), prefix='PBR', inputs={ + 'fg': shader1, + 'bg': shader2, + 'mix': factor + }) + + if not mix: + return None + + result = self.create_node('surface', 'surfaceshader', prefix='PBR', inputs={ + mix.nodedef.getType().lower(): mix, + 'opacity': 1.0 + }) + + return result + + +class ShaderNodeAddShader(NodeParser): + nodegraph_path = "" + + def export(self): + shader1 = self.get_input_link(0) + shader2 = self.get_input_link(1) + + add = None + input_types = get_mx_node_input_types('add', 'PBR') + + if shader1 is None and shader2 is None: + return None + + if shader2 is None: + shader1_type = get_node_type(shader1) + if shader1_type not in input_types: + log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. ' + f'Input type must be of types {input_types}, actual: {shader1_type}') + return shader1 + + add = self.create_node('add', shader1_type.lower(), prefix='PBR', inputs={ + 'in1': shader1 + }) + + if shader1 is None: + shader2_type = get_node_type(shader2) + if shader2_type not in input_types: + log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. ' + f'Input type must be of types {input_types}, actual: {shader2_type}') + return shader2 + + add = self.create_node('add', shader2_type.lower(), prefix='PBR', inputs={ + 'in2': shader2 + }) + + if shader1 is not None and shader2 is not None: + shader1_type = get_node_type(shader1) + shader2_type = get_node_type(shader2) + if shader1_type != shader2_type: + log.warn(f'Types of input shaders must be the same. ' + f'First shader type: {shader1_type}, second shader type: {shader2_type}') + + return shader1 + + if shader1_type not in input_types: + log.warn(f'Node {self.node.bl_idname} ({self.material.name_full}) ignored. ' + f'Input type must be of types {input_types}, actual: {shader1_type}, {shader2_type}') + return shader1 + + add = self.create_node('add', shader1_type.lower(), prefix='PBR', inputs={ + 'in1': shader1, + 'in2': shader2 + }) + + if not add: + return None + + result = self.create_node('surface', 'surfaceshader', prefix='PBR', inputs={ + add.nodedef.getType().lower(): add, + 'opacity': 1.0 + }) + + return result diff --git a/materialx/bl_nodes/texture.py b/materialx/bl_nodes/texture.py new file mode 100644 index 000000000..46e607178 --- /dev/null +++ b/materialx/bl_nodes/texture.py @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +from .node_parser import NodeParser +from ..utils import cache_image_file + + +TEXTURE_ERROR_COLOR = (1.0, 0.0, 1.0) # following Cycles color for wrong Texture nodes + + +class ShaderNodeTexImage(NodeParser): + def export(self): + image_error_result = self.node_item(TEXTURE_ERROR_COLOR) + image = self.node.image + + # TODO support UDIM Tilesets and SEQUENCE + if not image or image.source in ('TILED', 'SEQUENCE'): + return image_error_result + + img_path = cache_image_file(image) + if not img_path: + return image_error_result + + # TODO use Vector input for UV + uv = self.create_node('texcoord', 'vector2') + + result = self.create_node('image', self.out_type, inputs={ + 'file': img_path, + 'texcoord': uv, + }) + + return result diff --git a/materialx/bl_nodes/vector.py b/materialx/bl_nodes/vector.py new file mode 100644 index 000000000..7cbf5ba81 --- /dev/null +++ b/materialx/bl_nodes/vector.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +import bpy + +from .node_parser import NodeParser +from . import log + + +DEFAULT_SPACE = 'OBJECT' + + +class ShaderNodeNormalMap(NodeParser): + def export(self): + color = self.get_input_value('Color') + strength = self.get_input_value('Strength') + space = self.node.space + + if space not in ('TANGENT', 'OBJECT'): + log.warn("Ignoring unsupported Space", space, self.node, self.material, + f"{DEFAULT_SPACE} will be used") + space = DEFAULT_SPACE + + if space == 'TANGENT' and bpy.context.scene.render.engine == 'HYDRA_STORM': + log.warn("Known issue: HdStorm doesn't work good with tangent space. Consider changing to object space", + space, self.node, self.material) + + result = self.create_node('normalmap', 'vector3', inputs={ + 'in': color , + 'scale': strength, + 'space': space.lower(), + }) + + return result diff --git a/materialx/logging.py b/materialx/logging.py new file mode 100644 index 000000000..3e688d7cd --- /dev/null +++ b/materialx/logging.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +import sys +import logging.handlers + +from . import ADDON_ALIAS + + +FORMAT_STR = "%(asctime)s %(levelname)s %(name)s [%(thread)d]: %(message)s" + +# root logger for the addon +logger = logging.getLogger(ADDON_ALIAS) +logger.setLevel('INFO') + +# file_handler = logging.handlers.RotatingFileHandler(PLUGIN_ROOT_DIR / 'usdhydra.log', +# mode='w', encoding='utf-8', delay=True, +# backupCount=config.logging_backups) +# file_handler.doRollover() +# file_handler.setFormatter(logging.Formatter(FORMAT_STR)) +# logger.addHandler(file_handler) + +console_handler = logging.StreamHandler(stream=sys.stdout) +console_handler.setFormatter(logging.Formatter(FORMAT_STR)) +logger.addHandler(console_handler) + + +def msg(args): + return ", ".join(str(arg) for arg in args) + + +class Log: + def __init__(self, tag): + self.logger = logger.getChild(tag) + + def __call__(self, *args): + self.debug(*args) + + def debug(self, *args): + self.logger.debug(msg(args)) + + def info(self, *args): + self.logger.info(msg(args)) + + def warn(self, *args): + self.logger.warning(msg(args)) + + def error(self, *args): + self.logger.error(msg(args)) + + def critical(self, *args): + self.logger.critical(msg(args)) + + def dump_args(self, func): + """This decorator dumps out the arguments passed to a function before calling it""" + arg_names = func.__code__.co_varnames[:func.__code__.co_argcount] + + def echo_func(*args, **kwargs): + self.debug("<{}>: {}{}".format( + func.__name__, + tuple("{}={}".format(name, arg) for name, arg in zip(arg_names, args)), + # args if args else "", + " {}".format(kwargs.items()) if kwargs else "", + )) + return func(*args, **kwargs) + + return echo_func diff --git a/materialx/nodes/__init__.py b/materialx/nodes/__init__.py new file mode 100644 index 000000000..1c7deb95c --- /dev/null +++ b/materialx/nodes/__init__.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +import importlib + +import bpy +import nodeitems_utils +import sys + +from . import node, categories, generate_node_classes +from .. import utils + + +sys.path.append(str(utils.ADDON_DATA_DIR)) +generate_node_classes.generate_basic_classes() + +gen_modules = [importlib.import_module(f"{utils.NODE_CLASSES_FOLDER}.{f.name[:-len(f.suffix)]}") + for f in utils.NODE_CLASSES_DIR.glob("gen_*.py")] + +mx_node_classes = [] +for mod in gen_modules: + mx_node_classes.extend(mod.mx_node_classes) + +# sorting by category and label +mx_node_classes = sorted(mx_node_classes, key=lambda cls: (cls.category.lower(), cls.bl_label.lower())) + + +register_sockets, unregister_sockets = bpy.utils.register_classes_factory([ + node.MxNodeInputSocket, + node.MxNodeOutputSocket, +]) + +register_nodes, unregister_nodes = bpy.utils.register_classes_factory(mx_node_classes) + + +def register(): + register_sockets() + register_nodes() + + nodeitems_utils.register_node_categories(utils.with_prefix("MX_NODES"), categories.get_node_categories()) + + +def unregister(): + nodeitems_utils.unregister_node_categories(utils.with_prefix("MX_NODES")) + + unregister_nodes() + unregister_sockets() + + +def get_mx_node_cls(mx_node, prefix=''): + node_name = mx_node.getCategory() + suffix = f'_{node_name}' + if prefix: + suffix = prefix + suffix + classes = tuple(cls for cls in mx_node_classes if cls.__name__.endswith(suffix)) + + if not classes: + raise KeyError(f"Unable to find MxNode class for {mx_node}") + + def params_set(node, out_type): + return {f"in_{p.getName()}:{p.getType()}" for p in node.getActiveInputs()} | \ + {out_type.lower()} + + node_params_set = params_set(mx_node, mx_node.getType()) + + for cls in classes: + for nodedef, data_type in cls.get_nodedefs(): + nd_outputs = nodedef.getActiveOutputs() + nd_params_set = params_set(nodedef, 'multioutput' if len(nd_outputs) > 1 else + nd_outputs[0].getType()) + if node_params_set.issubset(nd_params_set): + return cls, data_type + + raise TypeError(f"Unable to find suitable nodedef for {mx_node}") diff --git a/materialx/nodes/categories.py b/materialx/nodes/categories.py new file mode 100644 index 000000000..66a19b377 --- /dev/null +++ b/materialx/nodes/categories.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +import bpy + +from collections import defaultdict + +from nodeitems_utils import NodeCategory, NodeItem + +from ..utils import title_str, code_str, with_prefix + + +class MxNodeCategory(NodeCategory): + @classmethod + def poll(cls, context): + return context.space_data.tree_type == 'ShaderNodeTree' + + +def get_node_categories(): + from . import mx_node_classes + + d = defaultdict(list) + for MxNode_cls in mx_node_classes: + d[MxNode_cls.category].append(MxNode_cls) + + categories = [] + for category, category_classes in d.items(): + categories.append( + MxNodeCategory(with_prefix(code_str(category), '_MX_NG_'), title_str(category), + items=[NodeItem(MxNode_cls.bl_idname) + for MxNode_cls in category_classes])) + + categories.append( + MxNodeCategory(with_prefix('LAYOUT', '_MX_NG_'), 'Layout', + items=[NodeItem("NodeFrame"), + NodeItem("NodeReroute")])) + + return categories diff --git a/materialx/nodes/generate_node_classes.py b/materialx/nodes/generate_node_classes.py new file mode 100644 index 000000000..a2b899a4c --- /dev/null +++ b/materialx/nodes/generate_node_classes.py @@ -0,0 +1,354 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +import re +from collections import defaultdict + +import MaterialX as mx + +from .. import utils +from .. import ADDON_ALIAS + +from .. import logging +log = logging.Log("nodes.generate_node_classes") + + +def parse_value_str(val_str, mx_type, *, first_only=False, is_enum=False): + if mx_type == 'string': + if is_enum: + res = tuple(x.strip() for x in val_str.split(',')) + return res[0] if first_only else res + return val_str + + if mx_type == 'integer': + return int(val_str) + if mx_type in ('float', 'angle'): + return float(val_str) + if mx_type == 'boolean': + return val_str == "true" + if mx_type.endswith('array'): + return val_str + + if mx_type.startswith('color') or mx_type.startswith('vector') or mx_type.startswith('matrix'): + res = tuple(float(x) for x in val_str.split(',')) + return res[0] if first_only else res + + return val_str + + +def generate_property_code(mx_param, category): + mx_type = mx_param.getType() + prop_attrs = {} + + prop_attrs['name'] = mx_param.getAttribute('uiname') if mx_param.hasAttribute('uiname') \ + else utils.title_str(mx_param.getName()) + + prop_attrs['description'] = mx_param.getAttribute('doc') + + while True: # one way loop just for having break instead using nested 'if else' + if mx_type == 'string': + if mx_param.hasAttribute('enum'): + prop_type = "EnumProperty" + items = parse_value_str(mx_param.getAttribute('enum'), mx_type, is_enum=True) + prop_attrs['items'] = tuple((it, utils.title_str(it), utils.title_str(it)) for it in items) + break + prop_type = "StringProperty" + break + if mx_type == 'filename': + if category in ("texture2d", "texture3d"): + prop_type = "PointerProperty" + break + + prop_type = "StringProperty" + prop_attrs['subtype'] = 'FILE_PATH' + break + if mx_type == 'integer': + prop_type = "IntProperty" + break + if mx_type == 'float': + prop_type = "FloatProperty" + break + if mx_type == 'boolean': + prop_type = "BoolProperty" + break + if mx_type == 'angle': + prop_type = "FloatProperty" + prop_attrs['subtype'] = 'ANGLE' + break + + if mx_type in ('surfaceshader', 'displacementshader', 'volumeshader', 'lightshader', + 'material', 'BSDF', 'VDF', 'EDF'): + prop_type = "StringProperty" + break + + m = re.fullmatch(r'matrix(\d)(\d)', mx_type) + if m: + prop_type = "FloatVectorProperty" + prop_attrs['subtype'] = 'MATRIX' + prop_attrs['size'] = int(m[1]) * int(m[2]) + break + + m = re.fullmatch(r'color(\d)', mx_type) + if m: + prop_type = "FloatVectorProperty" + prop_attrs['subtype'] = 'COLOR' + prop_attrs['size'] = int(m[1]) + prop_attrs['soft_min'] = 0.0 + prop_attrs['soft_max'] = 1.0 + break + + m = re.fullmatch(r'vector(\d)', mx_type) + if m: + prop_type = "FloatVectorProperty" + dim = int(m[1]) + prop_attrs['subtype'] = 'XYZ' if dim == 3 else 'NONE' + prop_attrs['size'] = dim + break + + m = re.fullmatch(r'(.+)array', mx_type) + if m: + prop_type = "StringProperty" + # TODO: Change to CollectionProperty + break + + prop_type = "StringProperty" + log.warn("Unsupported mx_type", mx_type, mx_param, mx_param.getParent().getName()) + break + + for mx_attr, prop_attr in (('uimin', 'min'), ('uimax', 'max'), + ('uisoftmin', 'soft_min'), ('uisoftmax', 'soft_max'), + ('value', 'default')): + if mx_param.hasAttribute(mx_attr): + if prop_attr == 'default' and category in ("texture2d", "texture3d") and mx_type == 'filename': + continue + + prop_attrs[prop_attr] = parse_value_str( + mx_param.getAttribute(mx_attr), mx_type, first_only=mx_attr != 'value') + + prop_attr_strings = [] + for name, val in prop_attrs.items(): + val_str = f'"{val}"' if isinstance(val, str) else str(val) + prop_attr_strings.append(f"{name}={val_str}") + + if mx_type == 'filename' and category in ("texture2d", "texture3d"): + prop_attr_strings.insert(0, "type=bpy.types.Image") + + prop_attr_strings.append('update=MxNode.update_prop') + + return f"{prop_type}({', '.join(prop_attr_strings)})" + + +def get_attr(mx_param, name, else_val=""): + return mx_param.getAttribute(name) if mx_param.hasAttribute(name) else else_val + + +def nodedef_data_type(nodedef): + nd_name = nodedef.getName() + node_name = nodedef.getNodeString() + + if nd_name.startswith('rpr_'): + return nodedef.getActiveOutputs()[0].getType() + + m = re.fullmatch(rf'ND_{node_name}_(.+)', nd_name) + if m: + return m[1] + + return nodedef.getActiveOutputs()[0].getType() + + +def generate_data_type(nodedef): + outputs = nodedef.getActiveOutputs() + if len(outputs) != 1: + return f"{{'multitypes': {{'{nodedef.getName()}': None, 'nodedef_name': '{nodedef.getName()}'}}}}" + + return f"{{'{nodedef.getActiveOutputs()[0].getType()}': {{'{nodedef.getName()}': None, 'nodedef_name': '{nodedef.getName()}'}}}}" + + +def input_prop_name(nd_type, name): + return f'nd_{nd_type}_in_{name}' + + +def output_prop_name(nd_type, name): + return f'nd_{nd_type}_out_{name}' + + +def folder_prop_name(name): + return 'f_' + utils.code_str(name.lower()) + + +def get_mx_node_class_name(nodedef, prefix): + return f"MxNode_{prefix}_{nodedef.getNodeString()}" + + +def generate_mx_node_class_code(nodedefs, prefix, category): + nodedef = nodedefs[0] + if not category: + category = get_attr(nodedef, 'nodegroup', prefix) + + class_name = get_mx_node_class_name(nodedef, prefix) + code_strings = [] + + data_types = {} + for nd in nodedefs: + data_types[nodedef_data_type(nd)] = {'nd_name': nd.getName(), 'nd': None } + + code_strings.append( +f""" +class {class_name}(MxNode): + _file_path = FILE_PATH + _data_types = {data_types} + + bl_label = '{get_attr(nodedef, 'uiname', utils.title_str(nodedef.getNodeString()))}' + bl_idname = '{utils.with_prefix(class_name)}' + bl_description = "{get_attr(nodedef, 'doc')}" + + category = '{category}' +""") + + ui_folders = [] + for mx_param in [*nodedef.getParameters(), *nodedef.getActiveInputs()]: + f = mx_param.getAttribute("uifolder") + if f and f not in ui_folders: + ui_folders.append(f) + + if len(ui_folders) > 2 or category in ("texture2d", "texture3d"): + code_strings += [" bl_width_default = 250", ""] + + if ui_folders: + code_strings.append(f" _ui_folders = {tuple(ui_folders)}") + + data_type_items = [] + index_default = 0 + for i, nd in enumerate(nodedefs): + nd_type = nodedef_data_type(nd) + + data_type_items.append((nd_type, utils.title_str(nd_type), utils.title_str(nd_type))) + if nd_type == 'color3': + index_default = i + + code_strings += [ + f' data_type: EnumProperty(name="Type", description="Input Data Type", ' + f"items={data_type_items}, default='{data_type_items[index_default][0]}', " + f"update=MxNode.update_data_type)", + ] + + for i, f in enumerate(ui_folders): + if i == 0: + code_strings.append("") + + code_strings.append( + f' {folder_prop_name(f)}: BoolProperty(name="{f}", ' + f'description="Enable {f}", default={i == 0}, update=MxNode.update_ui_folders)') + + for nd in nodedefs: + nd_type = nodedef_data_type(nd) + code_strings.append("") + + for input in nd.getActiveInputs(): + prop_code = generate_property_code(input, category) + code_strings.append(f" {input_prop_name(nd_type, input.getName())}: {prop_code}") + + for output in nd.getActiveOutputs(): + prop_code = generate_property_code(output, category) + code_strings.append(f" {output_prop_name(nd_type, output.getName())}: {prop_code}") + + code_strings.append("") + return '\n'.join(code_strings) + + +def generate_classes_code(file_path, prefix, category): + IGNORE_NODEDEF_DATA_TYPE = ('matrix33', 'matrix44', 'matrix33FA', 'matrix44FA') + + code_strings = [] + code_strings.append( +f"""# Automatically generated classes for MaterialX nodes. +# Do not edit manually, changes will be overwritten. + +import bpy +from bpy.props import ( + EnumProperty, + FloatProperty, + IntProperty, + BoolProperty, + StringProperty, + PointerProperty, + FloatVectorProperty, +) +from {ADDON_ALIAS}.nodes.node import MxNode + +""") + + if file_path.is_relative_to(utils.MX_LIBS_DIR): + code_strings.append( +f"""from {ADDON_ALIAS}.utils import MX_LIBS_DIR +FILE_PATH = MX_LIBS_DIR / "{(file_path.relative_to(utils.MX_LIBS_DIR)).as_posix()}" +""") + elif file_path.is_relative_to(utils.MX_ADDON_LIBS_DIR): + code_strings.append( +f"""from {ADDON_ALIAS}.utils import MX_ADDON_LIBS_DIR +FILE_PATH = MX_ADDON_LIBS_DIR / "{(file_path.relative_to(utils.MX_ADDON_LIBS_DIR)).as_posix()}" +""") + else: + code_strings.append( +f""" +FILE_PATH = "{file_path.as_posix()}" +""") + + doc = mx.createDocument() + search_path = mx.FileSearchPath(str(utils.MX_LIBS_DIR)) + mx.readFromXmlFile(doc, str(file_path), searchPath=search_path) + nodedefs = doc.getNodeDefs() + + # grouping node_def_classes by node and nodegroup + node_def_classes_by_node = defaultdict(list) + for nodedef in nodedefs: + if nodedef.getSourceUri(): + continue + + if nodedef_data_type(nodedef) in IGNORE_NODEDEF_DATA_TYPE: + log.warn(f"Ignoring nodedef {nodedef.getName()}") + continue + + node_def_classes_by_node[(nodedef.getNodeString(), nodedef.getAttribute('nodegroup'))].\ + append(nodedef) + + # creating MxNode types + mx_node_class_names = [] + for nodedefs_by_node in node_def_classes_by_node.values(): + code_strings.append(generate_mx_node_class_code(nodedefs_by_node, prefix, category)) + mx_node_class_names.append(get_mx_node_class_name(nodedefs_by_node[0], prefix)) + + code_strings.append(f""" +mx_node_classes = [{', '.join(mx_node_class_names)}] +""") + + return '\n'.join(code_strings) + + +def generate_basic_classes(): + gen_code_dir = utils.NODE_CLASSES_DIR + gen_code_dir.mkdir(exist_ok=True) + + files = [ + ('BXDF', "PBR", utils.MX_LIBS_DIR / "bxdf/standard_surface.mtlx"), + ('USD', "USD", utils.MX_LIBS_DIR / "bxdf/usd_preview_surface.mtlx"), + ('STD', None, utils.MX_LIBS_DIR / "stdlib/stdlib_defs.mtlx"), + ('PBR', "PBR", utils.MX_LIBS_DIR / "pbrlib/pbrlib_defs.mtlx"), + ] + + for prefix, category, file_path in files: + module_name = f"gen_{file_path.name[:-len(file_path.suffix)]}" + module_file = gen_code_dir / f"{module_name}.py" + + if module_file.is_file(): + continue + + log(f"Generating {module_file} from {file_path}") + module_code = generate_classes_code(file_path, prefix, category) + module_file.write_text(module_code) + + module_file = gen_code_dir / "__init__.py" + module_file.write_text( +"""# Automatically generated classes for MaterialX nodes. +# Do not edit manually, changes will be overwritten. +""") diff --git a/materialx/nodes/node.py b/materialx/nodes/node.py new file mode 100644 index 000000000..38f8c068a --- /dev/null +++ b/materialx/nodes/node.py @@ -0,0 +1,419 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +import MaterialX as mx + +import bpy + +from .. import utils + +from .. import logging +log = logging.Log("nodes.node") + +mtlx_documents = {} + + +class MxNodeInputSocket(bpy.types.NodeSocket): + bl_idname = utils.with_prefix('MxNodeInputSocket') + bl_label = "MX Input Socket" + + def draw(self, context, layout, node, text): + if not is_mx_node_valid(node): + return + + nd = node.nodedef + nd_input = nd.getActiveInput(self.name) + nd_type = nd_input.getType() + + uiname = utils.get_attr(nd_input, 'uiname', utils.title_str(nd_input.getName())) + is_prop_area = context.area.type == 'PROPERTIES' + if self.is_linked or utils.is_shader_type(nd_type) or nd_input.getValue() is None: + uitype = utils.title_str(nd_type) + layout.label(text=uitype if uiname.lower() == uitype.lower() or is_prop_area else f"{uiname}: {uitype}") + else: + if nd_type == 'boolean': + layout.use_property_split = False + layout.alignment = 'LEFT' + layout.prop(node, node._input_prop_name(self.name), text='' if is_prop_area else uiname) + + + def draw_color(self, context, node): + return utils.get_socket_color(node.nodedef.getActiveInput(self.name).getType() + if is_mx_node_valid(node) else 'undefined') + + +class MxNodeOutputSocket(bpy.types.NodeSocket): + bl_idname = utils.with_prefix('MxNodeOutputSocket') + bl_label = "MX Output Socket" + + def draw(self, context, layout, node, text): + if not is_mx_node_valid(node): + return + + nd = node.nodedef + mx_output = nd.getActiveOutput(self.name) + uiname = utils.get_attr(mx_output, 'uiname', utils.title_str(mx_output.getName())) + uitype = utils.title_str(mx_output.getType()) + if uiname.lower() == uitype.lower() or len(nd.getActiveOutputs()) == 1: + layout.label(text=uitype) + else: + layout.label(text=f"{uiname}: {uitype}") + + def draw_color(self, context, node): + return utils.get_socket_color(node.nodedef.getActiveOutput(self.name).getType() + if is_mx_node_valid(node) else 'undefined') + + +class MxNode(bpy.types.ShaderNode): + """Base node from which all MaterialX nodes will be made""" + _file_path: str + # bl_compatibility = {'USDHydra'} + # bl_icon = 'MATERIAL' + + bl_label = "" + bl_description = "" + bl_width_default = 200 + + _data_types = {} # available types and nodedefs + _ui_folders = () # list of ui folders mentioned in nodedef + category = "" + + def update_prop(self, context): + self.socket_value_update(context) + + @classmethod + def get_nodedef(cls, data_type): + if not cls._data_types[data_type]['nd']: + # loading nodedefs + if cls._file_path not in mtlx_documents: + doc = mx.createDocument() + search_path = mx.FileSearchPath(str(utils.MX_LIBS_DIR)) + mx.readFromXmlFile(doc, str(utils.MX_LIBS_DIR / cls._file_path), searchPath=search_path) + mtlx_documents[cls._file_path] = doc + + doc = mtlx_documents[cls._file_path] + + for val in cls._data_types.values(): + val['nd'] = doc.getNodeDef(val['nd_name']) + + return cls._data_types[data_type]['nd'] + + @classmethod + def get_nodedefs(cls): + for data_type in cls._data_types.keys(): + yield cls.get_nodedef(data_type), data_type + + @property + def nodedef(self): + return self.get_nodedef(self.data_type) + + @property + def mx_node_path(self): + nd = self.nodedef + if '/' in self.name or utils.is_shader_type(nd.getActiveOutputs()[0].getType()): + return self.name + + return f"NG/{self.name}" + + def _folder_prop_name(self, name): + return f"f_{utils.code_str(name.lower())}" + + def _input_prop_name(self, name): + return f"nd_{self.data_type}_in_{name}" + + def update(self): + bpy.app.timers.register(self.mark_invalid_links) + self.socket_value_update(bpy.context) + + def mark_invalid_links(self): + if not is_mx_node_valid(self): + return + + nodetree = self.id_data + + if not (hasattr(nodetree, 'links')): + return + + for link in nodetree.links: + if hasattr(link.from_socket.node, 'nodedef') and hasattr(link.to_socket.node, 'nodedef'): + + socket_from_type = link.from_socket.node.nodedef.getActiveOutput(link.from_socket.name).getType() + socket_to_type = link.to_socket.node.nodedef.getActiveInput(link.to_socket.name).getType() + + if socket_to_type != socket_from_type: + link.is_valid = False + continue + + link.is_valid = True + + def update_data_type(self, context): + # updating names for inputs and outputs + nodedef = self.nodedef + for i, nd_input in enumerate(utils.get_nodedef_inputs(nodedef, False)): + self.inputs[i].name = nd_input.getName() + for i, nd_output in enumerate(nodedef.getActiveOutputs()): + self.outputs[i].name = nd_output.getName() + + self.update_prop(context) + + def init(self, context): + nodedef = self.nodedef + + for nd_input in utils.get_nodedef_inputs(nodedef, False): + self.create_input(nd_input) + + for nd_output in nodedef.getActiveOutputs(): + self.create_output(nd_output) + + if self._ui_folders: + self.update_ui_folders(context) + + def draw_buttons(self, context, layout): + is_prop_area = context.area.type == 'PROPERTIES' + + if len(self._data_types) > 1: + layout1 = layout + if is_prop_area: + layout1 = layout1.split(factor=0.012, align=True) + col = layout1.column() + layout1 = layout1.column() + layout1.prop(self, 'data_type') + + nodedef = self.nodedef + + if self._ui_folders: + col = layout.column(align=True) + r = None + for i, f in enumerate(self._ui_folders): + if i % 3 == 0: # putting 3 buttons per row + col.use_property_split = False + col.use_property_decorate = False + r = col.row(align=True) + r.prop(self, self._folder_prop_name(f), toggle=True) + + for nd_input in utils.get_nodedef_inputs(nodedef, True): + f = nd_input.getAttribute('uifolder') + + if f and not getattr(self, self._folder_prop_name(f)): + continue + + name = nd_input.getName() + if self.category in ("texture2d", "texture3d") and nd_input.getType() == 'filename': + split = layout.row(align=True).split(factor=0.4 if is_prop_area else 0.25, align=True) + col = split.column() + col.alignment='RIGHT' if is_prop_area else 'EXPAND' + col.label(text=nd_input.getAttribute('uiname') if nd_input.hasAttribute('uiname') + else utils.title_str(name)) + col = split.column() + col.template_ID(self, self._input_prop_name(name), + open="image.open", new="image.new") + + else: + layout1 = layout + if is_prop_area: + layout1 = layout1.split(factor=0.012, align=True) + col = layout1.column() + layout1 = layout1.column() + layout1.prop(self, self._input_prop_name(name)) + + # COMPUTE FUNCTION + def compute(self, out_key, **kwargs): + from ..bl_nodes.node_parser import NodeItem + + log("compute", self, out_key) + + doc = kwargs['doc'] + nodedef = self.nodedef + nd_output = self.get_nodedef_output(out_key) + node_path = self.mx_node_path + + values = [] + for in_key in range(len(self.inputs)): + nd_input = self.get_nodedef_input(in_key) + f = nd_input.getAttribute('uifolder') + if f and not getattr(self, self._folder_prop_name(f)): + continue + + values.append((in_key, self.get_input_value(in_key, **kwargs))) + + mx_nodegraph = utils.get_nodegraph_by_node_path(doc, node_path, True) + node_name = utils.get_node_name_by_node_path(node_path) + mx_node = mx_nodegraph.addNode(nodedef.getNodeString(), node_name, nd_output.getType()) + + for in_key, val in values: + nd_input = self.get_nodedef_input(in_key) + nd_type = nd_input.getType() + + if isinstance(val, (mx.Node, NodeItem)): + mx_input = mx_node.addInput(nd_input.getName(), nd_type) + utils.set_param_value(mx_input, val, nd_type) + continue + + if isinstance(val, tuple) and isinstance(val[0], mx.Node): + # node with multioutput type + in_node, in_nd_output = val + mx_input = mx_node.addInput(nd_input.getName(), nd_type) + utils.set_param_value(mx_input, in_node, nd_type, in_nd_output) + continue + + if utils.is_shader_type(nd_type): + continue + + nd_val = nd_input.getValue() + if nd_val is None: + continue + + mx_input = mx_node.addInput(nd_input.getName(), nd_type) + utils.set_param_value(mx_input, val, nd_type) + + for nd_input in utils.get_nodedef_inputs(nodedef, True): + f = nd_input.getAttribute('uifolder') + if f and not getattr(self, self._folder_prop_name(f)): + continue + + val = self.get_param_value(nd_input.getName()) + nd_type = nd_input.getType() + + mx_param = mx_node.addInput(nd_input.getName(), nd_type) + utils.set_param_value(mx_param, val, nd_type) + + if len(nodedef.getActiveOutputs()) > 1: + mx_node.setType('multioutput') + return mx_node, nd_output + + return mx_node + + def _compute_node(self, node, out_key, **kwargs): + # checking if node is already in nodegraph + + doc = kwargs['doc'] + node_path = node.mx_node_path + mx_nodegraph = utils.get_nodegraph_by_node_path(doc, node_path) + if mx_nodegraph: + node_name = utils.get_node_name_by_node_path(node_path) + mx_node = mx_nodegraph.getNode(node_name) + if mx_node: + if mx_node.getType() == 'multioutput': + nd_output = node.get_nodedef_output(out_key) + return mx_node, nd_output + + return mx_node + + return node.compute(out_key, **kwargs) + + def get_input_link(self, in_key: [str, int], **kwargs): + """Returns linked parsed node or None if nothing is linked or not link is not valid""" + from ..bl_nodes import node_parser + + socket_in = self.inputs[in_key] + if not socket_in.links: + return None + + link = socket_in.links[0] + if not link.is_valid: + log.warn("Invalid link found", link, socket_in, self) + return None + + link = utils.pass_node_reroute(link) + if not link: + return None + + if isinstance(link.from_node, MxNode): + if not is_mx_node_valid(link.from_node): + log.warn(f"Ignoring unsupported node {link.from_node.bl_idname}", link.from_node, + link.from_node.id_data) + return None + + return self._compute_node(link.from_node, link.from_socket.name, **kwargs) + + NodeParser_cls = node_parser.NodeParser.get_node_parser_cls(link.from_node.bl_idname) + if not NodeParser_cls: + log.warn(f"Ignoring unsupported node {link.from_node.bl_idname}", link.from_node, self.material) + return None + + output_type = NodeParser_cls.get_output_type(link.to_socket) + + node_parser_cls = NodeParser_cls(node_parser.Id(), kwargs['doc'], None, link.from_node, None, + link.from_socket.name, output_type, {}) + node_item = node_parser_cls.export() + + return node_item + + def get_input_value(self, in_key: [str, int], **kwargs): + node = self.get_input_link(in_key, **kwargs) + if node: + return node + + return self.get_input_default(in_key) + + def get_input_default(self, in_key: [str, int]): + return getattr(self, self._input_prop_name(self.inputs[in_key].name)) + + def get_param_value(self, name): + return getattr(self, self._input_prop_name(name)) + + def get_nodedef_input(self, in_key: [str, int]): + return self.nodedef.getActiveInput(self.inputs[in_key].name) + + def get_nodedef_output(self, out_key: [str, int]): + return self.nodedef.getActiveOutput(self.outputs[out_key].name) + + def set_input_value(self, in_key, value): + setattr(self, self._input_prop_name(self.inputs[in_key].name), value) + + def set_param_value(self, name, value): + setattr(self, self._input_prop_name(name), value) + + @classmethod + def poll(cls, tree): + return tree.bl_idname == 'ShaderNodeTree' + + def update_ui_folders(self, context): + for i, nd_input in enumerate(utils.get_nodedef_inputs(self.nodedef, False)): + f = nd_input.getAttribute('uifolder') + if f: + self.inputs[i].hide = not getattr(self, self._folder_prop_name(f)) + + if context: + self.update_prop(context) + + def check_ui_folders(self): + if not self._ui_folders: + return + + for f in self._ui_folders: + setattr(self, self._folder_prop_name(f), False) + + for in_key, nd_input in enumerate(utils.get_nodedef_inputs(self.nodedef, False)): + f = nd_input.getAttribute('uifolder') + if not f: + continue + + if self.inputs[in_key].links: + setattr(self, self._folder_prop_name(f), True) + continue + + nd_input = self.get_nodedef_input(in_key) + val = self.get_input_default(in_key) + nd_val = nd_input.getValue() + if nd_val is None or utils.is_value_equal(nd_val, val, nd_input.getType()): + continue + + setattr(self, self._folder_prop_name(f), True) + + self.update_ui_folders(None) + + def create_input(self, nd_input): + input = self.inputs.new(MxNodeInputSocket.bl_idname, f'in_{len(self.inputs)}') + input.name = nd_input.getName() + return input + + def create_output(self, mx_output): + output = self.outputs.new(MxNodeOutputSocket.bl_idname, f'out_{len(self.outputs)}') + output.name = mx_output.getName() + return output + + +def is_mx_node_valid(node): + # handle MaterialX 1.37 nodes + return hasattr(node, 'nodedef') diff --git a/materialx/preferences.py b/materialx/preferences.py new file mode 100644 index 000000000..24d42a78e --- /dev/null +++ b/materialx/preferences.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +import bpy + +from . import logging, ADDON_ALIAS + + +class AddonPreferences(bpy.types.AddonPreferences): + bl_idname = ADDON_ALIAS + + def update_log_level(self, context): + logging.logger.setLevel(self.log_level) + + dev_tools: bpy.props.BoolProperty( + name="Developer Tools", + description="Enable developer tools", + default=False, + ) + log_level: bpy.props.EnumProperty( + name="Log Level", + description="Select logging level", + items=(('DEBUG', "Debug", "Log level DEBUG"), + ('INFO', "Info", "Log level INFO"), + ('WARNING', "Warning", "Log level WARN"), + ('ERROR', "Error", "Log level ERROR"), + ('CRITICAL', "Critical", "Log level CRITICAL")), + default='INFO', + update=update_log_level, + ) + + def draw(self, context): + layout = self.layout + col = layout.column() + col.prop(self, "dev_tools") + col.prop(self, "log_level") + + +def addon_preferences(): + if ADDON_ALIAS not in bpy.context.preferences.addons: + return None + + return bpy.context.preferences.addons[ADDON_ALIAS].preferences + + +register, unregister = bpy.utils.register_classes_factory([ + AddonPreferences, +]) diff --git a/materialx/ui.py b/materialx/ui.py new file mode 100644 index 000000000..d1a27cf8b --- /dev/null +++ b/materialx/ui.py @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +from pathlib import Path + +import traceback +import MaterialX as mx + +import bpy +from bpy_extras.io_utils import ImportHelper, ExportHelper + +from . import utils +from .preferences import addon_preferences + +from .utils import logging +log = logging.Log(tag='ui') + + +class MATERIALX_OP_import_file(bpy.types.Operator, ImportHelper): + bl_idname = utils.with_prefix('materialx_import_file') + bl_label = "Import from File" + bl_description = "Import MaterialX node tree from .mtlx file" + + filename_ext = ".mtlx" + filepath: bpy.props.StringProperty( + name="File Path", + description="File path used for importing MaterialX node tree from .mtlx file", + maxlen=1024, subtype="FILE_PATH" + ) + filter_glob: bpy.props.StringProperty(default="*.mtlx", options={'HIDDEN'}, ) + + def execute(self, context): + mx_node_tree = context.space_data.edit_tree + mtlx_file = Path(self.filepath) + + doc = mx.createDocument() + search_path = mx.FileSearchPath(str(mtlx_file.parent)) + search_path.append(str(utils.MX_LIBS_DIR)) + try: + mx.readFromXmlFile(doc, str(mtlx_file)) + utils.import_materialx_from_file(mx_node_tree, doc, mtlx_file) + + except Exception as e: + log.error(traceback.format_exc(), mtlx_file) + return {'CANCELLED'} + + return {'FINISHED'} + + +class MATERIALX_OP_export_file(bpy.types.Operator, ExportHelper): + bl_idname = utils.with_prefix('materialx_export_file') + bl_label = "Export to File" + bl_description = "Export material as MaterialX node tree to .mtlx file" + + filename_ext = ".mtlx" + + filepath: bpy.props.StringProperty( + name="File Path", + description="File path used for exporting material as MaterialX node tree to .mtlx file", + maxlen=1024, + subtype="FILE_PATH" + ) + filter_glob: bpy.props.StringProperty( + default="*.mtlx", + options={'HIDDEN'}, + ) + export_textures: bpy.props.BoolProperty( + name="Export Textures", + description="Export bound textures to corresponded folder", + default=True + ) + texture_dir_name: bpy.props.StringProperty( + name="Folder Name", + description="Texture folder name used for exporting files", + default='textures', + maxlen=1024, + ) + export_deps: bpy.props.BoolProperty( + name="Export Dependencies", + description="Export MaterialX library dependencies", + default=True + ) + + def execute(self, context): + doc = utils.export(context.material, None) + if not doc: + return {'CANCELLED'} + + utils.export_to_file(doc, self.filepath, + export_textures=self.export_textures, + texture_dir_name=self.texture_dir_name, + export_deps=self.export_deps, + copy_deps=self.export_deps) + + log.info(f"Succesfully exported material '{context.material.name}' into {self.filepath}") + return {'FINISHED'} + + def draw(self, context): + col = self.layout.column(align=False) + col.prop(self, 'export_textures') + + row = col.row() + row.enabled = self.export_textures + row.prop(self, 'texture_dir_name', text='') + + self.layout.prop(self, 'export_deps') + + +class MATERIALX_OP_export_console(bpy.types.Operator): + bl_idname = utils.with_prefix('materialx_export_console') + bl_label = "Export to Console" + bl_description = "Export material as MaterialX node tree to console" + + def execute(self, context): + doc = utils.export(context.material, context.object) + if not doc: + return {'CANCELLED'} + + print(mx.writeToXmlString(doc)) + return {'FINISHED'} + + +class MATERIALX_PT_tools(bpy.types.Panel): + bl_idname = utils.with_prefix('MATERIALX_PT_tools', '_', True) + bl_label = "MaterialX Tools" + bl_space_type = "NODE_EDITOR" + bl_region_type = "UI" + bl_category = "Tool" + + @classmethod + def poll(cls, context): + tree = context.space_data.edit_tree + + return tree and tree.bl_idname == 'ShaderNodeTree' + + def draw(self, context): + layout = self.layout + + layout.operator(MATERIALX_OP_import_file.bl_idname, icon='IMPORT') + layout.operator(MATERIALX_OP_export_file.bl_idname, icon='EXPORT') + + +class MATERIALX_PT_dev(bpy.types.Panel): + bl_idname = utils.with_prefix('MATERIALX_PT_dev', '_', True) + bl_label = "Dev" + bl_parent_id = MATERIALX_PT_tools.bl_idname + bl_space_type = "NODE_EDITOR" + bl_region_type = "UI" + + @classmethod + def poll(cls, context): + preferences = addon_preferences() + return preferences.dev_tools if preferences else True + + def draw(self, context): + layout = self.layout + layout.operator(MATERIALX_OP_export_console.bl_idname) + + +register, unregister = bpy.utils.register_classes_factory([ + MATERIALX_OP_import_file, + MATERIALX_OP_export_file, + MATERIALX_OP_export_console, + MATERIALX_PT_tools, + MATERIALX_PT_dev, +]) diff --git a/materialx/utils.py b/materialx/utils.py new file mode 100644 index 000000000..31ad5f168 --- /dev/null +++ b/materialx/utils.py @@ -0,0 +1,693 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright 2022, AMD + +import os +from pathlib import Path +import tempfile +import shutil +import platform + +import MaterialX as mx +import bpy + +from . import ADDON_ALIAS + +from . import logging +log = logging.Log('utils') + +ADDON_ROOT_DIR = Path(__file__).parent +ADDON_DATA_DIR = Path(bpy.utils.user_resource('SCRIPTS', path=f"addons/{ADDON_ALIAS}_data", create=True)) +BL_DATA_DIR = Path(bpy.utils.resource_path('LOCAL')).parent / "materialx" +if platform.system() == 'Windows': + BL_DATA_DIR = BL_DATA_DIR.parent / "blender.shared" / BL_DATA_DIR.name + +MX_LIBS_FOLDER = "libraries" +MX_LIBS_DIR = BL_DATA_DIR / MX_LIBS_FOLDER +MX_ADDON_LIBS_DIR = ADDON_ROOT_DIR / MX_LIBS_FOLDER + +NODE_CLASSES_FOLDER = "materialx_nodes" +NODE_CLASSES_DIR = ADDON_DATA_DIR / NODE_CLASSES_FOLDER + +TEMP_FOLDER = "bl-materialx" + +NODE_LAYER_SEPARATION_WIDTH = 280 +NODE_LAYER_SHIFT_X = 30 +NODE_LAYER_SHIFT_Y = 100 + + +def with_prefix(name, separator='.', upper=False): + return f"{ADDON_ALIAS.upper() if upper else ADDON_ALIAS}{separator}{name}" + + +def title_str(val): + s = val.replace('_', ' ') + return s[:1].upper() + s[1:] + + +def code_str(val): + return val.replace(' ', '_').replace('.', '_') + + +def set_param_value(mx_param, val, nd_type, nd_output=None): + from .bl_nodes.node_parser import NodeItem + + if isinstance(val, mx.Node): + param_nodegraph = mx_param.getParent().getParent() + val_nodegraph = val.getParent() + node_name = val.getName() + if val_nodegraph == param_nodegraph: + mx_param.setNodeName(node_name) + if nd_output: + mx_param.setAttribute('output', nd_output.getName()) + else: + # checking nodegraph paths + val_ng_path = val_nodegraph.getNamePath() + param_ng_path = param_nodegraph.getNamePath() + ind = val_ng_path.rfind('/') + ind = ind if ind >= 0 else 0 + if param_ng_path != val_ng_path[:ind]: + raise ValueError(f"Inconsistent nodegraphs. Cannot connect input " + f"{mx_param.getNamePath()} to {val.getNamePath()}") + + mx_output_name = f'out_{node_name}' + if nd_output: + mx_output_name += f'_{nd_output.getName()}' + + mx_output = val_nodegraph.getActiveOutput(mx_output_name) + if not mx_output: + mx_output = val_nodegraph.addOutput(mx_output_name, val.getType()) + mx_output.setNodeName(node_name) + if nd_output: + mx_output.setType(nd_output.getType()) + mx_output.setAttribute('output', nd_output.getName()) + + mx_param.setAttribute('nodegraph', val_nodegraph.getName()) + mx_param.setAttribute('output', mx_output.getName()) + + elif nd_type == 'filename': + if isinstance(val, bpy.types.Image): + image_path = cache_image_file(val) + if image_path: + mx_param.setValueString(str(image_path)) + else: + mx_param.setValueString(str(val)) + + elif hasattr(val, 'data') and isinstance(val.data, mx.Node): + set_param_value(mx_param, val.data, nd_type, nd_output) + + else: + mx_type = getattr(mx, title_str(nd_type), None) + if mx_type: + val = mx_type(val.data) if isinstance(val, NodeItem) else mx_type(val) + + elif nd_type == 'float': + if isinstance(val, NodeItem): + val = val.data + + if isinstance(val, tuple): + val = val[0] + + mx_param.setValue(val) + + +def is_value_equal(mx_val, val, nd_type): + if nd_type in ('string', 'float', 'integer', 'boolean', 'angle'): + if nd_type == 'filename' and val is None: + val = "" + + return mx_val == val + + if nd_type == 'filename': + val = "" if val is None else val + return mx_val == val + + return tuple(mx_val) == tuple(val) + + +def is_shader_type(mx_type): + return not (mx_type in ('string', 'float', 'integer', 'boolean', 'filename', 'angle') or + mx_type.startswith('color') or + mx_type.startswith('vector') or + mx_type.endswith('array')) + + +def get_attr(mx_param, name, else_val=None): + return mx_param.getAttribute(name) if mx_param.hasAttribute(name) else else_val + + +def parse_value(node, mx_val, mx_type, file_prefix=None): + if mx_type in ('string', 'float', 'integer', 'boolean', 'filename', 'angle'): + if file_prefix and mx_type == 'filename': + mx_val = str((file_prefix / mx_val).resolve()) + + if node.category in ('texture2d', 'texture3d') and mx_type == 'filename': + file_path = Path(mx_val) + if file_path.exists(): + image = bpy.data.images.get(file_path.name) + if image and image.filepath_from_user() == str(file_path): + return image + + image = bpy.data.images.load(str(file_path)) + return image + + return None + + return mx_val + + return tuple(mx_val) + + +def parse_value_str(val_str, mx_type, *, first_only=False, is_enum=False): + if mx_type == 'string': + if is_enum: + res = tuple(x.strip() for x in val_str.split(',')) + return res[0] if first_only else res + return val_str + + if mx_type == 'integer': + return int(val_str) + if mx_type in ('float', 'angle'): + return float(val_str) + if mx_type == 'boolean': + return val_str == "true" + if mx_type.endswith('array'): + return val_str + + if mx_type.startswith('color') or mx_type.startswith('vector') or mx_type.startswith('matrix'): + res = tuple(float(x) for x in val_str.split(',')) + return res[0] if first_only else res + + return val_str + + +def get_nodedef_inputs(nodedef, uniform=None): + for nd_input in nodedef.getActiveInputs(): + if (uniform is True and nd_input.getAttribute('uniform') != 'true') or \ + (uniform is False and nd_input.getAttribute('uniform') == 'true'): + continue + + yield nd_input + + +def get_file_prefix(mx_node, file_path): + file_prefix = file_path.parent + n = mx_node + while True: + n = n.getParent() + file_prefix /= n.getFilePrefix() + if isinstance(n, mx.Document): + break + + return file_prefix.resolve() + + +def get_nodegraph_by_path(doc, ng_path, do_create=False): + nodegraph_names = code_str(ng_path).split('/') if ng_path else () + mx_nodegraph = doc + for nodegraph_name in nodegraph_names: + next_mx_nodegraph = mx_nodegraph.getNodeGraph(nodegraph_name) + if not next_mx_nodegraph: + if do_create: + next_mx_nodegraph = mx_nodegraph.addNodeGraph(nodegraph_name) + else: + return None + + mx_nodegraph = next_mx_nodegraph + + return mx_nodegraph + + +def get_nodegraph_by_node_path(doc, node_path, do_create=False): + nodegraph_names = code_str(node_path).split('/')[:-1] + return get_nodegraph_by_path(doc, '/'.join(nodegraph_names), do_create) + + +def get_node_name_by_node_path(node_path): + return code_str(node_path.split('/')[-1]) + + +def get_socket_color(mx_type): + if mx_type.startswith('color'): + return (0.78, 0.78, 0.16, 1.0) + + if mx_type in ('integer', 'float', 'boolean'): + return (0.63, 0.63, 0.63, 1.0) + + if mx_type.startswith(('vector', 'matrix')) or mx_type in ('displacementshader'): + return (0.39, 0.39, 0.78, 1.0) + + if mx_type in ('string', 'filename'): + return (0.44, 0.7, 1.0, 1.0) + + if mx_type.endswith(('shader', 'material')) or mx_type in ('BSDF', 'EDF', 'VDF'): + return (0.39, 0.78, 0.39, 1.0) + + return (0.63, 0.63, 0.63, 1.0) + + +def export_to_file(doc, filepath, *, export_textures=False, texture_dir_name='textures', + export_deps=False, copy_deps=False): + root_dir = Path(filepath).parent + root_dir.mkdir(parents=True, exist_ok=True) + + if export_textures: + texture_dir = root_dir / texture_dir_name + image_paths = set() + mx_input_files = (v for v in doc.traverseTree() if isinstance(v, mx.Input) and v.getType() == 'filename') + for mx_input in mx_input_files: + texture_dir.mkdir(parents=True, exist_ok=True) + + val = mx_input.getValue() + if not val: + log.warn(f"Skipping wrong {mx_input.getType()} input value. Expected: path, got {val}") + continue + + source_path = Path(val) + if not source_path.is_file(): + log.warn("Image is missing", source_path) + continue + + if source_path in image_paths: + continue + + dest_path = texture_dir / source_path.name + + if source_path not in image_paths: + image_paths.add(source_path) + dest_path = texture_dir / source_path.name + shutil.copy(source_path, dest_path) + log(f"Export file {source_path} to {dest_path}: completed successfully") + + rel_dest_path = dest_path.relative_to(root_dir) + mx_input.setValue(rel_dest_path.as_posix(), mx_input.getType()) + + if export_deps: + from .nodes import get_mx_node_cls + + deps_files = {get_mx_node_cls(mx_node)[0]._file_path + for mx_node in (it for it in doc.traverseTree() if isinstance(it, mx.Node))} + + for deps_file in deps_files: + deps_file = Path(deps_file) + if copy_deps: + rel_path = deps_file.relative_to(deps_file.parent.parent) + dest_path = root_dir / rel_path + dest_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(deps_file, dest_path) + deps_file = rel_path + + mx.prependXInclude(doc, str(deps_file)) + + mx.writeToXmlFile(doc, str(filepath)) + log(f"Export MaterialX to {filepath}: completed successfully") + + +def temp_dir(): + d = Path(tempfile.gettempdir()) / TEMP_FOLDER + if not d.is_dir(): + log("Creating temp dir", d) + d.mkdir() + + return d + + +def clear_temp_dir(): + d = temp_dir() + paths = tuple(d.iterdir()) + if not paths: + return + + log("Clearing temp dir", d) + for path in paths: + if path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + else: + os.remove(path) + + +def get_temp_file(suffix, name=None, is_rand=False): + if not name: + return Path(tempfile.mktemp(suffix, "tmp", temp_dir())) + + if suffix: + if is_rand: + return Path(tempfile.mktemp(suffix, f"{name}_", temp_dir())) + + name += suffix + + return temp_dir() / name + + +SUPPORTED_FORMATS = {".png", ".jpeg", ".jpg", ".hdr", ".tga", ".bmp", + ".exr", ".open_exr", ".tif", ".tiff", ".zfile", ".tx"} +DEFAULT_FORMAT = ".hdr" +BLENDER_DEFAULT_FORMAT = "HDR" +BLENDER_DEFAULT_COLOR_MODE = "RGB" +READONLY_IMAGE_FORMATS = {".dds"} # blender can read these formats, but can't write + + +def cache_image_file(image: bpy.types.Image, cache_check=True): + try: + import _bpy_hydra + return _bpy_hydra.cache_or_get_image_file(bpy.context.as_pointer(), image.as_pointer()) + + except ImportError: + # without bpy_hydra we are going to cache image through python + pass + + image_path = Path(image.filepath_from_user()) + if not image.packed_file and image.source != 'GENERATED': + if not image_path.is_file(): + # log.warn("Image is missing", image, image_path) + return None + + image_suffix = image_path.suffix.lower() + + if image_suffix in SUPPORTED_FORMATS and \ + f".{image.file_format.lower()}" in SUPPORTED_FORMATS and not image.is_dirty: + return image_path + + if image_suffix in READONLY_IMAGE_FORMATS: + return image_path + + temp_path = get_temp_file(DEFAULT_FORMAT, image_path.stem, False) + if cache_check and image.source != 'GENERATED' and temp_path.is_file(): + return temp_path + + scene = bpy.context.scene + user_format = scene.render.image_settings.file_format + user_color_mode = scene.render.image_settings.color_mode + + # in some scenes the color_mode is undefined + # we can read it but unable to assign back, so switch it to 'RGB' if color_mode isn't selected + if not user_color_mode: + user_color_mode = 'RGB' + + scene.render.image_settings.file_format = BLENDER_DEFAULT_FORMAT + scene.render.image_settings.color_mode = BLENDER_DEFAULT_COLOR_MODE + + try: + image.save_render(filepath=str(temp_path)) + + finally: + scene.render.image_settings.file_format = user_format + scene.render.image_settings.color_mode = user_color_mode + + return temp_path + + +def cache_image_file_path(image_path, cache_check=True): + if image_path.suffix.lower() in SUPPORTED_FORMATS: + return image_path + + if cache_check: + temp_path = get_temp_file(DEFAULT_FORMAT, image_path.name) + if temp_path.is_file(): + return temp_path + + image = bpy.data.images.load(str(image_path)) + try: + return cache_image_file(image, cache_check) + + finally: + bpy.data.images.remove(image) + + +def pass_node_reroute(link): + while isinstance(link.from_node, bpy.types.NodeReroute): + if not link.from_node.inputs[0].links: + return None + + link = link.from_node.inputs[0].links[0] + + return link if link.is_valid else None + + +def update_ui(area_type='PROPERTIES', region_type='WINDOW'): + for window in bpy.context.window_manager.windows: + for area in window.screen.areas: + if area.type == area_type: + for region in area.regions: + if region.type == region_type: + region.tag_redraw() + + +def update_materialx_data(depsgraph, materialx_data): + if not depsgraph.updates: + return + + for node_tree in (upd.id for upd in depsgraph.updates if isinstance(upd.id, bpy.types.ShaderNodeTree)): + for material in bpy.data.materials: + if material.node_tree and material.node_tree.name == node_tree.name: + doc = export(material, None) + if not doc: + # log.warn("MX export failed", mat) + continue + + matx_data = next((mat for mat in materialx_data if mat[0] == material.name), None) + + if not matx_data: + mx_file = get_temp_file(".mtlx", + f'{material.name}{material.node_tree.name if material.node_tree else ""}', + False) + + mx.writeToXmlFile(doc, str(mx_file)) + surfacematerial = next((node for node in doc.getNodes() + if node.getCategory() == 'surfacematerial')) + materialx_data.append((material.name, str(mx_file), surfacematerial.getName())) + else: + mx.writeToXmlFile(doc, str(matx_data[1])) + + +def import_materialx_from_file(node_tree, doc: mx.Document, file_path): + def prepare_for_import(): + surfacematerial = next( + (n for n in doc.getNodes() if n.getCategory() == 'surfacematerial'), None) + if surfacematerial: + return + + mat = doc.getMaterials()[0] + sr = mat.getShaderRefs()[0] + + doc.removeMaterial(mat.getName()) + + node_name = sr.getName() + if not node_name.startswith("SR_"): + node_name = f"SR_{node_name}" + node = doc.addNode(sr.getNodeString(), node_name, 'surfaceshader') + for sr_input in sr.getBindInputs(): + input = node.addInput(sr_input.getName(), sr_input.getType()) + ng_name = sr_input.getNodeGraphString() + if ng_name: + input.setAttribute('nodegraph', ng_name) + input.setAttribute('output', sr_input.getOutputString()) + else: + input.setValue(sr_input.getValue()) + + surfacematerial = doc.addNode('surfacematerial', mat.getName(), 'material') + input = surfacematerial.addInput('surfaceshader', node.getType()) + input.setNodeName(node.getName()) + + def do_import(): + from .nodes import get_mx_node_cls + + node_tree.nodes.clear() + + def import_node(mx_node, mx_output_name=None, look_nodedef=True): + mx_nodegraph = mx_node.getParent() + node_path = mx_node.getNamePath() + file_prefix = get_file_prefix(mx_node, file_path) + + if node_path in node_tree.nodes: + return node_tree.nodes[node_path] + + try: + MxNode_cls, data_type = get_mx_node_cls(mx_node) + + except KeyError as e: + if not look_nodedef: + log.warn(e) + return None + + # looking for nodedef and switching to another nodegraph defined in doc + nodedef = next(nd for nd in doc.getNodeDefs() + if nd.getNodeString() == mx_node.getCategory() and + nd.getType() == mx_node.getType()) + new_mx_nodegraph = next(ng for ng in doc.getNodeGraphs() + if ng.getNodeDefString() == nodedef.getName()) + + mx_output = new_mx_nodegraph.getActiveOutput(mx_output_name) + node_name = mx_output.getNodeName() + new_mx_node = new_mx_nodegraph.getNode(node_name) + + return import_node(new_mx_node, None, False) + + node = node_tree.nodes.new(MxNode_cls.bl_idname) + node.name = node_path + node.data_type = data_type + nodedef = node.nodedef + + for mx_input in mx_node.getActiveInputs(): + input_name = mx_input.getName() + nd_input = nodedef.getActiveInput(input_name) + if nd_input.getAttribute('uniform') == 'true': + node.set_param_value(input_name, parse_value( + node, mx_input.getValue(), mx_input.getType(), file_prefix)) + continue + + if input_name not in node.inputs: + log.error(f"Incorrect input name '{input_name}' for node {node}") + continue + + val = mx_input.getValue() + if val is not None: + node.set_input_value(input_name, parse_value( + node, val, mx_input.getType(), file_prefix)) + continue + + node_name = mx_input.getNodeName() + + if node_name: + new_mx_node = mx_nodegraph.getNode(node_name) + if not new_mx_node: + log.error(f"Couldn't find node '{node_name}' in nodegraph '{mx_nodegraph.getNamePath()}'") + continue + + new_node = import_node(new_mx_node) + + out_name = mx_input.getAttribute('output') + if len(new_node.nodedef.getActiveOutputs()) > 1 and out_name: + new_node_output = new_node.outputs[out_name] + else: + new_node_output = new_node.outputs[0] + + node_tree.links.new(new_node_output, node.inputs[input_name]) + continue + + new_nodegraph_name = mx_input.getAttribute('nodegraph') + if new_nodegraph_name: + mx_output_name = mx_input.getAttribute('output') + new_mx_nodegraph = mx_nodegraph.getNodeGraph(new_nodegraph_name) + mx_output = new_mx_nodegraph.getActiveOutput(mx_output_name) + node_name = mx_output.getNodeName() + new_mx_node = new_mx_nodegraph.getNode(node_name) + new_node = import_node(new_mx_node, mx_output_name) + if not new_node: + continue + + out_name = mx_output.getAttribute('output') + if len(new_node.nodedef.getActiveOutputs()) > 1 and out_name: + new_node_output = new_node.outputs[out_name] + else: + new_node_output = new_node.outputs[0] + + node_tree.links.new(new_node_output, node.inputs[input_name]) + continue + + node.check_ui_folders() + return node + + mx_node = next(n for n in doc.getNodes() if n.getCategory() == 'surfacematerial') + output_node = import_node(mx_node, 0) + + if not output_node: + return + + # arranging nodes by layers + layer = {output_node} + layer_index = 0 + layers = {} + while layer: + new_layer = set() + for node in layer: + layers[node] = layer_index + for inp in node.inputs: + for link in inp.links: + new_layer.add(link.from_node) + layer = new_layer + layer_index += 1 + + node_layers = [[] for _ in range(max(layers.values()) + 1)] + for node in node_tree.nodes: + node_layers[layers[node]].append(node) + + # placing nodes by layers + loc_x = 0 + for i, nodes in enumerate(node_layers): + loc_y = 0 + for node in nodes: + node.location = (loc_x, loc_y) + loc_y -= NODE_LAYER_SHIFT_Y + loc_x -= NODE_LAYER_SHIFT_X + + loc_x -= NODE_LAYER_SEPARATION_WIDTH + + prepare_for_import() + do_import() + + +def export(material, obj: bpy.types.Object) -> [mx.Document, None]: + from .bl_nodes.output import ShaderNodeOutputMaterial + from .nodes.node import MxNode + + output_node = get_output_node(material) + + if not output_node: + return None + + doc = mx.createDocument() + + if isinstance(output_node, MxNode): + mx_node = output_node.compute('out', doc=doc) + return doc + + node_parser = ShaderNodeOutputMaterial(doc, material, output_node, obj) + if not node_parser.export(): + return None + + return doc + + +def get_materialx_data(material, obj: bpy.types.Object): + doc = export(obj) + if not doc: + return None, None + + mtlx_file = get_temp_file(".mtlx", f'{material.name}_{material.node_tree.name if material.node_tree else ""}') + mx.writeToXmlFile(doc, str(mtlx_file)) + + return mtlx_file, doc + + +def get_output_node(material): + if not material.node_tree: + return None + + bl_output_node = next((node for node in material.node_tree.nodes if + node.bl_idname == 'ShaderNodeOutputMaterial' and + node.is_active_output and node.inputs['Surface'].links), None) + + if bl_output_node: + return bl_output_node + + mx_output_node = next((node for node in material.node_tree.nodes if + node.bl_idname == with_prefix('MxNode_STD_surfacematerial') and + node.inputs['surfaceshader'].links), None) + + return mx_output_node + + +def get_mx_node_input_types(node_name, prefix=''): + from .nodes import mx_node_classes + + suffix = f'_{node_name}' + if prefix: + suffix = prefix + suffix + + input_types = set() + classes = tuple(cls for cls in mx_node_classes if cls.__name__.endswith(suffix)) + for cls in classes: + for nodedef, data_type in cls.get_nodedefs(): + input_types |= {p.getType() for p in nodedef.getActiveInputs()} + + return input_types