WIP: MaterialX addon #104594
50
materialx/__init__.py
Normal file
50
materialx/__init__.py
Normal file
@ -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()
|
38
materialx/bl_nodes/__init__.py
Normal file
38
materialx/bl_nodes/__init__.py
Normal file
@ -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,
|
||||
)
|
76
materialx/bl_nodes/color.py
Normal file
76
materialx/bl_nodes/color.py
Normal file
@ -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
|
71
materialx/bl_nodes/converter.py
Normal file
71
materialx/bl_nodes/converter.py
Normal file
@ -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
|
18
materialx/bl_nodes/input.py
Normal file
18
materialx/bl_nodes/input.py
Normal file
@ -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()
|
390
materialx/bl_nodes/node_parser.py
Normal file
390
materialx/bl_nodes/node_parser.py
Normal file
@ -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
|
30
materialx/bl_nodes/output.py
Normal file
30
materialx/bl_nodes/output.py
Normal file
@ -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
|
349
materialx/bl_nodes/shader.py
Normal file
349
materialx/bl_nodes/shader.py
Normal file
@ -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
|
32
materialx/bl_nodes/texture.py
Normal file
32
materialx/bl_nodes/texture.py
Normal file
@ -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
|
34
materialx/bl_nodes/vector.py
Normal file
34
materialx/bl_nodes/vector.py
Normal file
@ -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
|
67
materialx/logging.py
Normal file
67
materialx/logging.py
Normal file
@ -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
|
74
materialx/nodes/__init__.py
Normal file
74
materialx/nodes/__init__.py
Normal file
@ -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}")
|
38
materialx/nodes/categories.py
Normal file
38
materialx/nodes/categories.py
Normal file
@ -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
|
354
materialx/nodes/generate_node_classes.py
Normal file
354
materialx/nodes/generate_node_classes.py
Normal file
@ -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.
|
||||
""")
|
419
materialx/nodes/node.py
Normal file
419
materialx/nodes/node.py
Normal file
@ -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')
|
48
materialx/preferences.py
Normal file
48
materialx/preferences.py
Normal file
@ -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,
|
||||
])
|
166
materialx/ui.py
Normal file
166
materialx/ui.py
Normal file
@ -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,
|
||||
])
|
693
materialx/utils.py
Normal file
693
materialx/utils.py
Normal file
@ -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
|
Loading…
Reference in New Issue
Block a user