diff --git a/src/materials_processor/dcc/blender/recreator.py b/src/materials_processor/dcc/blender/recreator.py index 1f52c60..56d8b86 100644 --- a/src/materials_processor/dcc/blender/recreator.py +++ b/src/materials_processor/dcc/blender/recreator.py @@ -1,6 +1,7 @@ """Recreate generic material graphs as Blender shader networks.""" import logging +import math from typing import List from materials_processor.core.graph import NodeInfo @@ -101,6 +102,26 @@ def _apply_parameters(self, node, parameters): node.inputs["Strength"].default_value = float(val) if isinstance(val, (int, float)) else 1.0 continue + if node_type == "ShaderNodeValue" and blender_name == "value": + value_socket = next((socket for socket in node.outputs if socket.name == "Value"), None) + if value_socket and hasattr(value_socket, "default_value"): + value_socket.default_value = float(val) if isinstance(val, (int, float)) else 0.0 + continue + + if node_type == "ShaderNodeMapping" and blender_name in {"Location", "Rotation", "Scale"}: + if hasattr(node, "inputs") and blender_name in node.inputs: + socket = node.inputs[blender_name] + try: + if blender_name == "Rotation": + socket.default_value = (0.0, 0.0, math.radians(float(val))) + else: + values = list(val) if isinstance(val, list) else [val] + z_default = 1.0 if blender_name == "Scale" else 0.0 + socket.default_value = tuple((values + [z_default])[:3]) + except Exception as exc: + logger.warning("Failed to set mapping parameter '%s' on node '%s': %s", blender_name, node.name, exc) + continue + # Default socket value assignment if hasattr(node, "inputs") and blender_name in node.inputs: socket = node.inputs[blender_name] diff --git a/src/materials_processor/dcc/blender/traverser.py b/src/materials_processor/dcc/blender/traverser.py index 1ab7bdf..7aea32c 100644 --- a/src/materials_processor/dcc/blender/traverser.py +++ b/src/materials_processor/dcc/blender/traverser.py @@ -1,6 +1,7 @@ """Traverse Blender shader node networks.""" import logging +import math logger = logging.getLogger(__name__) @@ -12,13 +13,30 @@ bpy = None +def _socket_parameter_name(socket, *, is_output=False, node=None): + """Return a stable Blender parameter key for sockets with ambiguous names.""" + node_type = getattr(node, "bl_idname", None) + if is_output and node_type == "ShaderNodeMapping" and socket.name == "Vector": + return "Vector Output" + return socket.name + + def _socket_generic_type(socket, *, is_output=False, node=None): """Return the closest generic parameter type for a Blender socket.""" + node_type = getattr(node, "bl_idname", None) + if node_type == "ShaderNodeMapping": + if socket.name in {"Vector", "Location", "Scale"}: + return "vector2" + if socket.name == "Rotation": + return "float1" + if is_output and node_type == "ShaderNodeTexCoord" and socket.name == "UV": + return "vector2" + socket_type = socket.type.lower() if socket_type == "value": return "float1" if socket_type == "vector": - if is_output and getattr(node, "bl_idname", None) == "ShaderNodeUVMap": + if is_output and node_type == "ShaderNodeUVMap": return "vector2" return "vector3" if socket_type == "rgba": @@ -28,6 +46,34 @@ def _socket_generic_type(socket, *, is_output=False, node=None): return "float1" +def _socket_default_value(socket, node): + """Return a JSON-friendly socket value, normalizing Blender mapping semantics.""" + val = socket.default_value + node_type = getattr(node, "bl_idname", None) + if node_type == "ShaderNodeMapping": + if socket.name in {"Vector", "Location", "Scale"}: + return list(val)[:2] + if socket.name == "Rotation": + try: + rotation_values = list(val) + except TypeError: + try: + rotation_values = [val[0], val[1], val[2]] + except (TypeError, IndexError): + rotation_values = [0.0, 0.0, val] + z_rotation = rotation_values[2] if len(rotation_values) > 2 else 0.0 + return math.degrees(z_rotation) + + # Convert math-types like Vector, Color, RGBA, and Blender arrays to standard lists. + if hasattr(val, "copy") or isinstance(val, (list, tuple, bytes, set)): + return list(val) + if type(val).__name__ in ("Vector", "Color", "bpy_prop_array"): + return list(val) + if not isinstance(val, str) and hasattr(val, "__iter__"): + return list(val) + return val + + def _resolve_blender_image_path(image): """Resolve Blender image paths relative to the current blend file when possible.""" filepath = getattr(image, "filepath", "") @@ -148,7 +194,7 @@ def _detect_node_connections(node, parent_node, material_name): "node_path": f"/mat/{material_name}/{node.name}", "node_type": node.bl_idname, "node_index": 0, - "parm_name": output_socket.name, + "parm_name": _socket_parameter_name(output_socket, is_output=True, node=node), "data_type": output_socket.type, }, "output": { @@ -156,7 +202,7 @@ def _detect_node_connections(node, parent_node, material_name): "node_path": f"/mat/{material_name}/{parent_node.name}", "node_type": parent_node.bl_idname, "node_index": 0, - "parm_name": link.to_socket.name, + "parm_name": _socket_parameter_name(link.to_socket, node=parent_node), "data_type": link.to_socket.type, } } @@ -186,17 +232,10 @@ def _convert_parms_to_dict(node): if not hasattr(socket, "default_value"): continue - val = socket.default_value - # Convert math-types like Vector, Color, RGBA, and Blender arrays to standard lists. - if hasattr(val, "copy") or isinstance(val, (list, tuple, bytes, set)): - val = list(val) - elif type(val).__name__ in ("Vector", "Color", "bpy_prop_array"): - val = list(val) - elif not isinstance(val, str) and hasattr(val, "__iter__"): - val = list(val) + val = _socket_default_value(socket, node) parms["input"].append({ - "generic_name": socket.name, + "generic_name": _socket_parameter_name(socket, node=node), "value": val, "type": _socket_generic_type(socket, node=node), "direction": "input", @@ -217,6 +256,21 @@ def _convert_parms_to_dict(node): "type": "string1", "direction": "input" }) + elif node.bl_idname == "ShaderNodeTexCoord": + parms["input"].append({ + "generic_name": "uv_map", + "value": "", + "type": "string1", + "direction": "input" + }) + elif node.bl_idname == "ShaderNodeValue": + value_socket = next((socket for socket in node.outputs if socket.name == "Value"), None) + parms["input"].append({ + "generic_name": "value", + "value": getattr(value_socket, "default_value", 0.0), + "type": "float1", + "direction": "input" + }) elif node.bl_idname == "ShaderNodeNormalMap": strength_val = 1.0 if hasattr(node, "inputs") and "Strength" in node.inputs: @@ -231,7 +285,7 @@ def _convert_parms_to_dict(node): # Node outputs mapped as output parameters for socket in node.outputs: parms["output"].append({ - "generic_name": socket.name, + "generic_name": _socket_parameter_name(socket, is_output=True, node=node), "value": None, "type": _socket_generic_type(socket, is_output=True, node=node), "direction": "output", diff --git a/src/materials_processor/mappings.py b/src/materials_processor/mappings.py index 1453cac..c1abd8d 100644 --- a/src/materials_processor/mappings.py +++ b/src/materials_processor/mappings.py @@ -259,6 +259,8 @@ 'ND_image_color3': 'GENERIC::image', 'ND_normalmap_vector3': 'GENERIC::normalmap', 'ND_geompropvalue_vector2': 'GENERIC::uvmap', + 'ND_place2d_vector2': 'GENERIC::mapping', + 'ND_constant_float': 'GENERIC::value', 'ND_separate3_color3': 'GENERIC::separate_color', 'ND_colorcorrect_color3': 'GENERIC::color_correct', 'ND_range_float': 'GENERIC::range', @@ -285,6 +287,8 @@ 'ND_image_color3': 'GENERIC::image', 'ND_normalmap_vector3': 'GENERIC::normalmap', 'ND_geompropvalue_vector2': 'GENERIC::uvmap', + 'ND_place2d_vector2': 'GENERIC::mapping', + 'ND_constant_float': 'GENERIC::value', 'ND_separate3_color3': 'GENERIC::separate_color', 'ND_colorcorrect_color3': 'GENERIC::color_correct', 'ND_range_float': 'GENERIC::range', @@ -313,7 +317,10 @@ 'blender_shader_nodes': { 'ShaderNodeBsdfPrincipled': 'GENERIC::standard_surface', 'ShaderNodeTexImage': 'GENERIC::image', + 'ShaderNodeTexCoord': 'GENERIC::uvmap', 'ShaderNodeUVMap': 'GENERIC::uvmap', + 'ShaderNodeMapping': 'GENERIC::mapping', + 'ShaderNodeValue': 'GENERIC::value', 'ShaderNodeSeparateColor': 'GENERIC::separate_color', 'ShaderNodeNormalMap': 'GENERIC::normalmap', 'ShaderNodeBump': 'GENERIC::displacement', @@ -498,6 +505,19 @@ def convert_generic(node_type: str, 'default': 'default', 'out': 'vector', }, + 'ND_place2d_vector2': { + 'texcoord': 'texcoord', + 'pivot': 'pivot', + 'scale': 'scale', + 'rotate': 'rotate', + 'offset': 'offset', + 'operationorder': 'operationorder', + 'out': 'out', + }, + 'ND_constant_float': { + 'value': 'value', + 'out': 'out', + }, 'ND_separate3_color3': { 'in': 'rgb', 'outr': 'r', @@ -726,6 +746,21 @@ def convert_generic(node_type: str, 'uv_map': 'uv_map', 'UV': 'vector', }, + 'ShaderNodeTexCoord': { + 'uv_map': 'uv_map', + 'UV': 'vector', + }, + 'ShaderNodeMapping': { + 'Vector': 'texcoord', + 'Location': 'offset', + 'Rotation': 'rotate', + 'Scale': 'scale', + 'Vector Output': 'out', + }, + 'ShaderNodeValue': { + 'value': 'value', + 'Value': 'out', + }, 'ShaderNodeSeparateColor': { 'Color': 'rgb', 'Red': 'r', diff --git a/src/materials_processor/usd/graph_builder.py b/src/materials_processor/usd/graph_builder.py index a26f309..0bf5088 100644 --- a/src/materials_processor/usd/graph_builder.py +++ b/src/materials_processor/usd/graph_builder.py @@ -15,7 +15,19 @@ def _coerce_usd_value(value, generic_type): """Shape JSON-friendly parameter values for USD's typed attribute setters.""" if isinstance(value, (list, tuple)) and len(value) == 1: return value[0] - if generic_type in {'float2', 'float3', 'float4', 'color3', 'rgba3', 'color4', 'rgba4', 'xyzw3'}: + if generic_type in { + 'float2', + 'float3', + 'float4', + 'vector2', + 'vector3', + 'vector4', + 'color3', + 'rgba3', + 'color4', + 'rgba4', + 'xyzw3', + }: if isinstance(value, (list, tuple)): return tuple(value) return value @@ -160,7 +172,7 @@ def _apply_parameters(self, shader, node_type, parameters): parm_new_name = parm_new_name[0] val = _coerce_usd_value(param.value, param.generic_type) - if not val: + if val is None or val == "": continue val_type = _ATTRIB_TYPE_CASTERS.get(param.generic_type) diff --git a/src/materials_processor/usd/mappings.py b/src/materials_processor/usd/mappings.py index 62b5dd1..287d791 100644 --- a/src/materials_processor/usd/mappings.py +++ b/src/materials_processor/usd/mappings.py @@ -59,6 +59,20 @@ 'openpbr': 'ND_geompropvalue_vector2', }, }, + 'GENERIC::mapping': { + 'prim_type': 'Shader', + 'info_id': { + 'mtlx': 'ND_place2d_vector2', + 'openpbr': 'ND_place2d_vector2', + }, + }, + 'GENERIC::value': { + 'prim_type': 'Shader', + 'info_id': { + 'mtlx': 'ND_constant_float', + 'openpbr': 'ND_constant_float', + }, + }, 'GENERIC::separate_color': { 'prim_type': 'Shader', 'info_id': { diff --git a/tests/test_blender_support.py b/tests/test_blender_support.py index 5f4584d..931aa0a 100644 --- a/tests/test_blender_support.py +++ b/tests/test_blender_support.py @@ -28,6 +28,16 @@ def test_blender_profile_maps_generic_nodes_without_becoming_houdini_target(): "blender", profile="blender_shader_nodes", ) == "ShaderNodeUVMap" + assert mappings.convert_generic( + "GENERIC::mapping", + "blender", + profile="blender_shader_nodes", + ) == "ShaderNodeMapping" + assert mappings.convert_generic( + "GENERIC::value", + "blender", + profile="blender_shader_nodes", + ) == "ShaderNodeValue" assert mappings.convert_generic( "GENERIC::separate_color", "blender", @@ -236,6 +246,55 @@ def _make_packed_texture_fake_blender_material(name="packed_mat"): return FakeMaterial(name, node_tree) +def _make_mapped_texture_fake_blender_material(name="mapped_mat"): + out_node = FakeNode("ShaderNodeOutputMaterial", "Material Output") + bsdf_node = FakeNode("ShaderNodeBsdfPrincipled", "Principled BSDF") + tex_node = FakeNode("ShaderNodeTexImage", "Image Texture") + texcoord_node = FakeNode("ShaderNodeTexCoord", "Texture Coordinate") + mapping_node = FakeNode("ShaderNodeMapping", "Mapping") + value_node = FakeNode("ShaderNodeValue", "Roughness Value") + + out_surf_socket = FakeSocket("Surface", "SHADER") + bsdf_out_socket = FakeSocket("BSDF", "SHADER") + bsdf_base_socket = FakeSocket("Base Color", "RGBA") + bsdf_roughness_socket = FakeSocket("Roughness", "VALUE") + tex_vector_socket = FakeSocket("Vector", "VECTOR") + tex_color_socket = FakeSocket("Color", "RGBA") + texcoord_uv_socket = FakeSocket("UV", "VECTOR", default_value=[0.0, 0.0, 0.0]) + mapping_vector_in_socket = FakeSocket("Vector", "VECTOR", default_value=[0.0, 0.0, 0.0]) + mapping_location_socket = FakeSocket("Location", "VECTOR", default_value=[0.25, 0.5, 0.0]) + mapping_rotation_socket = FakeSocket("Rotation", "VECTOR", default_value=[0.0, 0.0, 1.57079632679]) + mapping_scale_socket = FakeSocket("Scale", "VECTOR", default_value=[2.0, 3.0, 1.0]) + mapping_vector_out_socket = FakeSocket("Vector", "VECTOR") + value_socket = FakeSocket("Value", "VALUE", default_value=0.42) + + out_node.inputs = [out_surf_socket] + bsdf_node.outputs = [bsdf_out_socket] + bsdf_node.inputs = [bsdf_base_socket, bsdf_roughness_socket] + tex_node.inputs = [tex_vector_socket] + tex_node.outputs = [tex_color_socket] + texcoord_node.outputs = [texcoord_uv_socket] + mapping_node.inputs = [mapping_vector_in_socket, mapping_location_socket, mapping_rotation_socket, mapping_scale_socket] + mapping_node.outputs = [mapping_vector_out_socket] + value_node.outputs = [value_socket] + + links = [ + _link(bsdf_node, bsdf_out_socket, out_node, out_surf_socket), + _link(tex_node, tex_color_socket, bsdf_node, bsdf_base_socket), + _link(texcoord_node, texcoord_uv_socket, mapping_node, mapping_vector_in_socket), + _link(mapping_node, mapping_vector_out_socket, tex_node, tex_vector_socket), + _link(value_node, value_socket, bsdf_node, bsdf_roughness_socket), + ] + + return FakeMaterial( + name, + FakeNodeTree( + nodes=[out_node, bsdf_node, tex_node, texcoord_node, mapping_node, value_node], + links=links, + ), + ) + + def test_blender_traverser_simple(): """Test that BlenderNodeTraverser processes Cycles material trees correctly.""" material = _make_simple_fake_blender_material() @@ -312,6 +371,59 @@ def test_blender_traverser_preserves_packed_texture_graph(caplog): assert "No generic type was found for node type: 'ShaderNodeSeparateColor'" not in caplog.text +def test_blender_traverser_preserves_texcoord_mapping_and_value_nodes(caplog): + material = _make_mapped_texture_fake_blender_material() + + nodes_dict, output_dict = BlenderNodeTraverser(material).run() + nodeinfo_list, _ = standardizer.NodeStandardizer( + traversed_nodes_dict=nodes_dict, + output_nodes_dict=output_dict, + material_type="blender", + source_type="blender_shader_nodes", + ).run() + + all_nodes = list(_iter_nodeinfos(nodeinfo_list)) + assert {node.node_type for node in all_nodes} >= { + "GENERIC::standard_surface", + "GENERIC::image", + "GENERIC::uvmap", + "GENERIC::mapping", + "GENERIC::value", + } + + mapping_node = next(node for node in all_nodes if node.node_type == "GENERIC::mapping") + mapping_params = {param.generic_name: param for param in mapping_node.parameters} + assert mapping_params["offset"].value == [0.25, 0.5] + assert mapping_params["scale"].value == [2.0, 3.0] + assert mapping_params["rotate"].value == pytest.approx(90.0) + assert mapping_params["out"].generic_type == "vector2" + + value_node = next(node for node in all_nodes if node.node_type == "GENERIC::value") + value_params = {param.generic_name: param for param in value_node.parameters} + assert value_params["value"].value == 0.42 + + connections = [ + connection + for node in all_nodes + for connection in node.connection_info.values() + ] + assert any( + connection.input.parm_name == "vector" and connection.output.parm_name == "texcoord" + for connection in connections + ) + assert any( + connection.input.parm_name == "out" and connection.output.parm_name == "texcoord" + for connection in connections + ) + assert any( + connection.input.parm_name == "out" and connection.output.parm_name == "specular_roughness" + for connection in connections + ) + assert "No generic type was found for node type: 'ShaderNodeTexCoord'" not in caplog.text + assert "No generic type was found for node type: 'ShaderNodeMapping'" not in caplog.text + assert "No generic type was found for node type: 'ShaderNodeValue'" not in caplog.text + + def test_blender_recreator_simple(): """Test that BlenderNodeRecreator successfully reconstructs material nodes.""" surface_param = NodeParameter( diff --git a/tests/test_usd_json_conversion.py b/tests/test_usd_json_conversion.py index 8f106a2..9e91584 100644 --- a/tests/test_usd_json_conversion.py +++ b/tests/test_usd_json_conversion.py @@ -408,6 +408,173 @@ def test_usd_recreator_maps_blender_texture_coordinate_and_channel_sockets(): ) +def test_usd_recreator_maps_blender_texcoord_mapping_and_value_nodes(): + stage = Usd.Stage.CreateInMemory() + surface = NodeInfo( + node_type="GENERIC::standard_surface", + node_name="Principled BSDF", + node_path="/mat/mapped/Principled BSDF", + parameters=[], + connection_info={}, + children_list=[], + ) + image = NodeInfo( + node_type="GENERIC::image", + node_name="Image Texture", + node_path="/mat/mapped/Image Texture", + parameters=[ + NodeParameter("filename", "string1", "input", "C:/textures/diffuse.png"), + NodeParameter("signature", "string1", "input", "color3"), + ], + connection_info={ + "connection_0": NodeConnection( + input=ConnectionEndpoint( + node_name="Image Texture", + node_path="/mat/mapped/Image Texture", + node_type="ShaderNodeTexImage", + node_index=0, + parm_name="rgb", + ), + output=ConnectionEndpoint( + node_name="Principled BSDF", + node_path="/mat/mapped/Principled BSDF", + node_type="ShaderNodeBsdfPrincipled", + node_index=0, + parm_name="base_color", + ), + ) + }, + children_list=[], + ) + mapping = NodeInfo( + node_type="GENERIC::mapping", + node_name="Mapping", + node_path="/mat/mapped/Mapping", + parameters=[ + NodeParameter("offset", "vector2", "input", [0.25, 0.5]), + NodeParameter("scale", "vector2", "input", [2.0, 3.0]), + NodeParameter("rotate", "float1", "input", 90.0), + ], + connection_info={ + "connection_0": NodeConnection( + input=ConnectionEndpoint( + node_name="Mapping", + node_path="/mat/mapped/Mapping", + node_type="ShaderNodeMapping", + node_index=0, + parm_name="out", + ), + output=ConnectionEndpoint( + node_name="Image Texture", + node_path="/mat/mapped/Image Texture", + node_type="ShaderNodeTexImage", + node_index=0, + parm_name="texcoord", + ), + ) + }, + children_list=[], + ) + texcoord = NodeInfo( + node_type="GENERIC::uvmap", + node_name="Texture Coordinate", + node_path="/mat/mapped/Texture Coordinate", + parameters=[], + connection_info={ + "connection_0": NodeConnection( + input=ConnectionEndpoint( + node_name="Texture Coordinate", + node_path="/mat/mapped/Texture Coordinate", + node_type="ShaderNodeTexCoord", + node_index=0, + parm_name="vector", + ), + output=ConnectionEndpoint( + node_name="Mapping", + node_path="/mat/mapped/Mapping", + node_type="ShaderNodeMapping", + node_index=0, + parm_name="texcoord", + ), + ) + }, + children_list=[], + ) + value = NodeInfo( + node_type="GENERIC::value", + node_name="Roughness Value", + node_path="/mat/mapped/Roughness Value", + parameters=[NodeParameter("value", "float1", "input", 0.42)], + connection_info={ + "connection_0": NodeConnection( + input=ConnectionEndpoint( + node_name="Roughness Value", + node_path="/mat/mapped/Roughness Value", + node_type="ShaderNodeValue", + node_index=0, + parm_name="out", + ), + output=ConnectionEndpoint( + node_name="Principled BSDF", + node_path="/mat/mapped/Principled BSDF", + node_type="ShaderNodeBsdfPrincipled", + node_index=0, + parm_name="specular_roughness", + ), + ) + }, + children_list=[], + ) + mapping.children_list.append(texcoord) + image.children_list.append(mapping) + surface.children_list.extend([image, value]) + output_connection = OutputConnection( + node_name="Material Output", + node_path="/mat/mapped/Material Output", + connected_node_name="Principled BSDF", + connected_node_path="/mat/mapped/Principled BSDF", + connected_input_index=0, + connected_input_name="Surface", + connected_output_name="surface", + ) + + USDMaterialRecreator( + stage=stage, + material_name="mapped", + nodeinfo_list=[surface], + output_connections={"GENERIC::output_surface": output_connection}, + target_renderer="mtlx", + ).run() + + assert { + "ND_standard_surface_surfaceshader", + "ND_image_color3", + "ND_geompropvalue_vector2", + "ND_place2d_vector2", + "ND_constant_float", + } <= _shader_ids(stage) + + mapping_shader = UsdShade.Shader.Get(stage, Sdf.Path("/materials/mapped/Mapping")) + image_shader = UsdShade.Shader.Get(stage, Sdf.Path("/materials/mapped/Image_Texture")) + surface_shader = UsdShade.Shader.Get(stage, Sdf.Path("/materials/mapped/Principled_BSDF")) + + assert mapping_shader.GetInput("texcoord").GetAttr().GetConnections()[0].pathString.endswith( + "/Texture_Coordinate.outputs:out" + ) + assert image_shader.GetInput("texcoord").GetAttr().GetConnections()[0].pathString.endswith( + "/Mapping.outputs:out" + ) + assert surface_shader.GetInput("specular_roughness").GetAttr().GetConnections()[0].pathString.endswith( + "/Roughness_Value.outputs:out" + ) + assert mapping_shader.GetInput("offset").Get() == (0.25, 0.5) + assert mapping_shader.GetInput("scale").Get() == (2.0, 3.0) + assert mapping_shader.GetInput("rotate").Get() == 90.0 + assert UsdShade.Shader.Get(stage, Sdf.Path("/materials/mapped/Roughness_Value")).GetInput( + "value" + ).Get() == pytest.approx(0.42) + + def test_usd_recreator_legacy_texture_collect_builder_smoke(): stage = Usd.Stage.CreateInMemory() recreator = USDMaterialRecreator(