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