From b53a49883826425a9b6499a6327e51d08be04bf4 Mon Sep 17 00:00:00 2001 From: Ahmed Hindy Date: Sat, 30 May 2026 23:52:24 +0300 Subject: [PATCH] Add Blender adapters and USD-safe names --- .../dcc/blender/adapters.py | 110 +++++++++++ src/materials_processor/dcc/blender/addon.py | 23 +-- .../dcc/blender/runtime.py | 27 +-- src/materials_processor/mappings.py | 21 +++ src/materials_processor/usd/graph_builder.py | 42 ++++- tests/test_blender_support.py | 174 +++++++++++++++++- tests/test_public_imports.py | 1 + tests/test_usd_json_conversion.py | 38 ++++ 8 files changed, 385 insertions(+), 51 deletions(-) create mode 100644 src/materials_processor/dcc/blender/adapters.py diff --git a/src/materials_processor/dcc/blender/adapters.py b/src/materials_processor/dcc/blender/adapters.py new file mode 100644 index 0000000..0d3f0cd --- /dev/null +++ b/src/materials_processor/dcc/blender/adapters.py @@ -0,0 +1,110 @@ +"""Core adapter implementations for Blender materials.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from materials_processor.core.graph import MaterialData, MaterialGraph +from materials_processor.dcc.blender.recreator import BlenderNodeRecreator +from materials_processor.dcc.blender.traverser import BlenderNodeTraverser +from materials_processor.standardizer import NodeStandardizer + +try: + import bpy +except ImportError: + bpy = None + + +class BlenderMaterialReader: + """Read Blender shader node materials into the neutral material graph.""" + + def read(self, native_material: Any) -> MaterialGraph: + """Read a Blender material into a standardized material graph. + + Args: + native_material: Blender material object with a shader node tree. + + Returns: + Standardized material graph. + """ + traversed_nodes, output_nodes = BlenderNodeTraverser(native_material).run() + nodeinfo_list, output_connections = NodeStandardizer( + traversed_nodes_dict=traversed_nodes, + output_nodes_dict=output_nodes, + material_type="blender", + source_type="blender_shader_nodes", + ).run() + + material_name = getattr(native_material, "name", "blender_material") + return MaterialData( + material_name=material_name, + material_path=f"/mat/{material_name}", + nodeinfo_list=nodeinfo_list, + output_connections=output_connections, + ) + + +class BlenderMaterialWriter: + """Write neutral material graphs into Blender shader node materials.""" + + def write(self, graph: MaterialGraph, target_context: Any = None) -> Any: + """Write a material graph into a Blender material. + + Args: + graph: Standardized material graph to recreate. + target_context: Target Blender material, a mapping containing a + ``material`` or ``material_name`` entry, a material name string, + or ``None`` to create a material when running inside Blender. + + Returns: + The target Blender material. + + Raises: + RuntimeError: If recreation fails. + ValueError: If no target material can be resolved. + """ + target_material = self._resolve_target_material(graph, target_context) + recreated = BlenderNodeRecreator( + nodeinfo_list=graph.nodeinfo_list, + output_connections=graph.output_connections, + target_material=target_material, + material_name=target_material.name, + ).run() + + if not recreated: + raise RuntimeError(f"Failed to recreate Blender material '{target_material.name}'.") + + return target_material + + def _resolve_target_material(self, graph: MaterialGraph, target_context: Any) -> Any: + if self._looks_like_material(target_context): + return target_context + + if isinstance(target_context, Mapping): + material = target_context.get("material") + if self._looks_like_material(material): + return material + material_name = target_context.get("material_name") or graph.material_name + return self._create_material(str(material_name)) + + if isinstance(target_context, str): + return self._create_material(target_context) + + if target_context is None: + return self._create_material(graph.material_name) + + raise ValueError(f"Unsupported Blender target context: {target_context!r}") + + @staticmethod + def _looks_like_material(value: Any) -> bool: + return value is not None and hasattr(value, "name") and hasattr(value, "node_tree") + + @staticmethod + def _create_material(material_name: str) -> Any: + if bpy is None: + raise ValueError("A target Blender material is required outside Blender.") + + material = bpy.data.materials.new(material_name) + material.use_nodes = True + return material diff --git a/src/materials_processor/dcc/blender/addon.py b/src/materials_processor/dcc/blender/addon.py index 7c990bb..7f36fa7 100644 --- a/src/materials_processor/dcc/blender/addon.py +++ b/src/materials_processor/dcc/blender/addon.py @@ -2,10 +2,7 @@ import logging -from materials_processor.core.graph import MaterialData -from materials_processor.dcc.blender.recreator import BlenderNodeRecreator -from materials_processor.dcc.blender.traverser import BlenderNodeTraverser -from materials_processor.standardizer import NodeStandardizer +from materials_processor.dcc.blender.adapters import BlenderMaterialReader logger = logging.getLogger(__name__) @@ -48,23 +45,7 @@ def execute(self, context): return {'CANCELLED'} try: - traverser = BlenderNodeTraverser(material) - node_tree, output_tree = traverser.run() - - standardizer = NodeStandardizer( - traversed_nodes_dict=node_tree, - output_nodes_dict=output_tree, - material_type="blender", - source_type="blender_shader_nodes" - ) - nodeinfo_list, standardized_output = standardizer.run() - - mat_data = MaterialData( - material_name=material.name, - material_path=f"/mat/{material.name}", - nodeinfo_list=nodeinfo_list, - output_connections=standardized_output - ) + mat_data = BlenderMaterialReader().read(material) logger.info("Successfully ingested material: %s", mat_data) self.report({'INFO'}, f"Ingested material '{material.name}' successfully.") diff --git a/src/materials_processor/dcc/blender/runtime.py b/src/materials_processor/dcc/blender/runtime.py index f70125a..c74a5dd 100644 --- a/src/materials_processor/dcc/blender/runtime.py +++ b/src/materials_processor/dcc/blender/runtime.py @@ -165,9 +165,8 @@ def _material_smoke_code() -> str: import bpy -from materials_processor.dcc.blender.recreator import BlenderNodeRecreator -from materials_processor.dcc.blender.traverser import BlenderNodeTraverser -from materials_processor.standardizer import NodeStandardizer +from materials_processor.core.conversion import ConversionService +from materials_processor.dcc.blender.adapters import BlenderMaterialReader, BlenderMaterialWriter def socket(collection, name): @@ -186,27 +185,17 @@ def socket(collection, name): socket(bsdf_node.inputs, "Base Color").default_value = (0.8, 0.2, 0.1, 1.0) source_tree.links.new(socket(bsdf_node.outputs, "BSDF"), socket(output_node.inputs, "Surface")) -traversed_nodes, output_nodes = BlenderNodeTraverser(source).run() -nodeinfo_list, output_connections = NodeStandardizer( - traversed_nodes_dict=traversed_nodes, - output_nodes_dict=output_nodes, - material_type="blender", - source_type="blender_shader_nodes", -).run() - target = bpy.data.materials.new("materials_processor_smoke_target") target.use_nodes = True -recreated = BlenderNodeRecreator( - nodeinfo_list=nodeinfo_list, - output_connections=output_connections, - target_material=target, -).run() +graph = BlenderMaterialReader().read(source) +converted_material = ConversionService(BlenderMaterialReader(), BlenderMaterialWriter()).convert(source, target) target_node_types = sorted(node.bl_idname for node in target.node_tree.nodes) result = {{ - "node_count": len(nodeinfo_list), - "output_count": len(output_connections), - "recreated": recreated, + "material_name": graph.material_name, + "node_count": len(graph.nodeinfo_list), + "output_count": len(graph.output_connections), + "recreated": converted_material == target, "target_node_types": target_node_types, }} print({MATERIAL_SMOKE_RESULT_PREFIX!r} + json.dumps(result, sort_keys=True)) diff --git a/src/materials_processor/mappings.py b/src/materials_processor/mappings.py index 583d009..94de526 100644 --- a/src/materials_processor/mappings.py +++ b/src/materials_processor/mappings.py @@ -650,17 +650,38 @@ def convert_generic(node_type: str, # blender parms: 'ShaderNodeBsdfPrincipled': { + 'Weight': 'base', 'Base Color': 'base_color', + 'Diffuse Roughness': 'diffuse_roughness', 'Metallic': 'metalness', 'Roughness': 'specular_roughness', 'IOR': 'specular_IOR', 'Alpha': 'opacity', 'Normal': 'normal', + 'Subsurface Weight': 'subsurface', + 'Subsurface Radius': 'subsurface_radius', + 'Subsurface Scale': 'subsurface_scale', + 'Subsurface IOR': 'subsurface_IOR', + 'Subsurface Anisotropy': 'subsurface_anisotropy', + 'Specular IOR Level': 'specular', + 'Specular Tint': 'specular_color', + 'Anisotropic': 'specular_anisotropy', + 'Anisotropic Rotation': 'specular_rotation', + 'Tangent': 'tangent', + 'Transmission Weight': 'transmission', 'Emission Color': 'emission_color', 'Emission Strength': 'emission', 'Coat Weight': 'coat', 'Coat Roughness': 'coat_roughness', + 'Coat IOR': 'coat_IOR', + 'Coat Tint': 'coat_color', + 'Coat Normal': 'coat_normal', 'Sheen Weight': 'sheen', + 'Sheen Roughness': 'sheen_roughness', + 'Sheen Tint': 'sheen_color', + 'Thin Film Thickness': 'thin_film_thickness', + 'Thin Film IOR': 'thin_film_IOR', + 'BSDF': 'surface', }, 'ShaderNodeTexImage': { 'image': 'filename', diff --git a/src/materials_processor/usd/graph_builder.py b/src/materials_processor/usd/graph_builder.py index ba2897d..ebbb7b1 100644 --- a/src/materials_processor/usd/graph_builder.py +++ b/src/materials_processor/usd/graph_builder.py @@ -3,7 +3,7 @@ import logging import pprint -from pxr import Sdf, UsdGeom, UsdShade +from pxr import Sdf, Tf, UsdGeom, UsdShade from materials_processor.mappings import REGULAR_PARAM_NAMES_TO_GENERIC, convert_generic from materials_processor.usd.mappings import GENERIC_NODE_TYPES_TO_REGULAR_USD, OUT_PRIM_DICT, _ATTRIB_TYPE_CASTERS @@ -21,6 +21,12 @@ def _coerce_usd_value(value, generic_type): return value +def _usd_prim_name(name, fallback="prim"): + """Return a USD-safe prim name for DCC node/material names.""" + safe_name = Tf.MakeValidIdentifier(str(name or fallback)) + return safe_name or fallback + + class USDGraphBuilder: """Create and connect USD primitives from standardized generic node data.""" @@ -36,6 +42,21 @@ def __init__(self, stage, material_name, nodeinfo_list, output_connections, pare self.old_new_map = {} self.output_old_new_map = {} self.created_out_primpaths = [] + self._used_child_names = {} + + def _material_prim_path(self): + return Sdf.Path(f"{self.parent_scope_path}/{_usd_prim_name(self.material_name, 'material')}") + + def _unique_child_path(self, parent_path, node_name): + used_names = self._used_child_names.setdefault(parent_path, set()) + base_name = _usd_prim_name(node_name, "shader") + prim_name = base_name + suffix = 2 + while prim_name in used_names: + prim_name = f"{base_name}_{suffix}" + suffix += 1 + used_names.add(prim_name) + return f"{parent_path}/{prim_name}" def _create_shader_id(self, shader, generic_type): """ @@ -48,7 +69,7 @@ def _create_shader_id(self, shader, generic_type): Returns: bool: True if an ID was found and set, False otherwise. """ - mapping = GENERIC_NODE_TYPES_TO_REGULAR_USD.get(generic_type, {}) + mapping = GENERIC_NODE_TYPES_TO_REGULAR_USD.get(generic_type or 'GENERIC::null', {}) shader_id = mapping.get('info_id', {}).get(self.target_renderer) if shader_id: shader.CreateIdAttr(shader_id) @@ -77,6 +98,10 @@ def _apply_parameters(self, shader, node_type, parameters): return # look up standardized mapping for this node type + if not node_type: + logger.warning("No renderer node type found for shader: '%s'", shader.GetPath().pathString) + return + std_parm_map: dict = REGULAR_PARAM_NAMES_TO_GENERIC.get(node_type.replace('::', ':')) if not std_parm_map: logger.warning("No generic parameter mappings found for node type: '%s'", node_type) @@ -120,8 +145,7 @@ def create_material_prim(self): Populates self.output_old_new_map for each Houdini output node. """ for output_connection in self.orig_output_connections.values(): - mat_primname = self.material_name - mat_primpath = Sdf.Path(f"{self.parent_scope_path}/{mat_primname}") + mat_primpath = self._material_prim_path() UsdShade.Material.Define(self.stage, Sdf.Path(mat_primpath)) if mat_primpath not in self.created_out_primpaths: @@ -144,13 +168,15 @@ def create_child_shaders(self, nodeinfo_list): self.created_out_primpaths[0].pathString, ) elif not self.old_new_map.get(nodeinfo.node_path): - new_prim_path = nodeinfo.node_name.replace('/', '_') - shader_primpath = f"{self.created_out_primpaths[0].pathString}/{new_prim_path}" + shader_primpath = self._unique_child_path( + self.created_out_primpaths[0].pathString, + nodeinfo.node_name, + ) shader = UsdShade.Shader.Define(self.stage, Sdf.Path(shader_primpath)) self._create_shader_id(shader, nodeinfo.node_type) regular_node_type = convert_generic( - node_type=nodeinfo.node_type, + node_type=nodeinfo.node_type or 'GENERIC::null', target_renderer=self.target_renderer, profile='usd_prims' ) @@ -168,7 +194,7 @@ def set_output_connections(self): """ Wire core shaders to output material surface slots. """ - mat_primpath = Sdf.Path(f"{self.parent_scope_path}/{self.material_name}") + mat_primpath = self._material_prim_path() mat_usdshade = UsdShade.Material.Get(self.stage, mat_primpath) logger.debug("self.created_out_primpaths: %s", pprint.pformat(self.created_out_primpaths, sort_dicts=False)) diff --git a/tests/test_blender_support.py b/tests/test_blender_support.py index 01395c1..34f73e6 100644 --- a/tests/test_blender_support.py +++ b/tests/test_blender_support.py @@ -4,11 +4,14 @@ from materials_processor import mappings, standardizer from materials_processor.core.graph import ( ConnectionEndpoint, + MaterialGraph, NodeConnection, NodeInfo, NodeParameter, OutputConnection, ) +from materials_processor.core.conversion import ConversionService +from materials_processor.dcc.blender.adapters import BlenderMaterialReader, BlenderMaterialWriter from materials_processor.dcc.blender.recreator import BlenderNodeRecreator from materials_processor.dcc.blender.traverser import BlenderNodeTraverser @@ -121,8 +124,7 @@ def __init__(self, name, node_tree): self.use_nodes = True -def test_blender_traverser_simple(): - """Test that BlenderNodeTraverser processes Cycles material trees correctly.""" +def _make_simple_fake_blender_material(name="test_mat"): out_node = FakeNode("ShaderNodeOutputMaterial", "Material Output") bsdf_node = FakeNode("ShaderNodeBsdfPrincipled", "Principled BSDF") tex_node = FakeNode("ShaderNodeTexImage", "Image Texture") @@ -157,7 +159,12 @@ def test_blender_traverser_simple(): nodes=[out_node, bsdf_node, tex_node], links=[link1, link2] ) - material = FakeMaterial("test_mat", node_tree) + return FakeMaterial(name, node_tree) + + +def test_blender_traverser_simple(): + """Test that BlenderNodeTraverser processes Cycles material trees correctly.""" + material = _make_simple_fake_blender_material() traverser = BlenderNodeTraverser(material) nodes_dict, output_dict = traverser.run() @@ -225,3 +232,164 @@ def test_blender_recreator_simple(): created_nodes = list(material.node_tree.nodes) assert any(n.bl_idname == "ShaderNodeBsdfPrincipled" for n in created_nodes) + + +def test_blender_material_reader_returns_material_graph(): + material = _make_simple_fake_blender_material("adapter_source") + + graph = BlenderMaterialReader().read(material) + + assert isinstance(graph, MaterialGraph) + assert graph.material_name == "adapter_source" + assert graph.material_path == "/mat/adapter_source" + assert graph.nodeinfo_list[0].node_type == "GENERIC::standard_surface" + assert graph.output_connections["GENERIC::output_surface"].connected_node_name == "Principled BSDF" + + +def test_blender_material_writer_recreates_graph_into_target_material(): + node_info = NodeInfo( + node_type="GENERIC::standard_surface", + node_name="Principled_BSDF", + node_path="/mat/source/Principled_BSDF", + parameters=[ + NodeParameter( + generic_name="base_color", + generic_type="color3", + direction="input", + value=[0.2, 0.4, 0.8], + ) + ], + connection_info={}, + children_list=[], + is_output_node=False, + position=[100.0, 200.0], + ) + output_connection = OutputConnection( + node_name="Material Output", + node_path="/mat/source/Material Output", + connected_node_name="Principled_BSDF", + connected_node_path="/mat/source/Principled_BSDF", + connected_input_index=0, + connected_input_name="Surface", + connected_output_name="surface", + ) + graph = MaterialGraph( + material_name="source", + material_path="/mat/source", + nodeinfo_list=[node_info], + output_connections={"GENERIC::output_surface": output_connection}, + ) + out_node = FakeNode("ShaderNodeOutputMaterial", "Material Output") + target = FakeMaterial("adapter_target", FakeNodeTree(nodes=[out_node], links=[])) + + written_material = BlenderMaterialWriter().write(graph, target) + + assert written_material is target + assert any(node.bl_idname == "ShaderNodeBsdfPrincipled" for node in target.node_tree.nodes) + + +def test_blender_conversion_service_round_trips_through_adapters(): + source = _make_simple_fake_blender_material("conversion_source") + target = FakeMaterial( + "conversion_target", + FakeNodeTree(nodes=[FakeNode("ShaderNodeOutputMaterial", "Material Output")], links=[]), + ) + + converted = ConversionService(BlenderMaterialReader(), BlenderMaterialWriter()).convert(source, target) + + assert converted is target + assert any(node.bl_idname == "ShaderNodeBsdfPrincipled" for node in target.node_tree.nodes) + + +def test_blender_principled_mapping_covers_blender_4_inputs(caplog): + principled_inputs = [ + "Weight", + "Base Color", + "Diffuse Roughness", + "Metallic", + "Roughness", + "IOR", + "Alpha", + "Normal", + "Subsurface Weight", + "Subsurface Radius", + "Subsurface Scale", + "Subsurface IOR", + "Subsurface Anisotropy", + "Specular IOR Level", + "Specular Tint", + "Anisotropic", + "Anisotropic Rotation", + "Tangent", + "Transmission Weight", + "Emission Color", + "Emission Strength", + "Coat Weight", + "Coat Roughness", + "Coat IOR", + "Coat Tint", + "Coat Normal", + "Sheen Weight", + "Sheen Roughness", + "Sheen Tint", + "Thin Film Thickness", + "Thin Film IOR", + ] + parms = { + "input": [ + { + "generic_name": name, + "value": [0.25, 0.5, 0.75, 1.0] if name in {"Base Color", "Emission Color"} else 1.0, + "type": "color4" if name in {"Base Color", "Emission Color"} else "float1", + "direction": "input", + } + for name in principled_inputs + ], + "output": [ + { + "generic_name": "BSDF", + "value": None, + "type": "float1", + "direction": "output", + } + ], + } + + parameters = standardizer.NodeStandardizer.standardize_shader_parameters("ShaderNodeBsdfPrincipled", parms) + + generic_names = {parameter.generic_name for parameter in parameters} + assert { + "base", + "base_color", + "diffuse_roughness", + "metalness", + "specular_roughness", + "specular_IOR", + "opacity", + "normal", + "subsurface", + "subsurface_radius", + "subsurface_scale", + "subsurface_IOR", + "subsurface_anisotropy", + "specular", + "specular_color", + "specular_anisotropy", + "specular_rotation", + "tangent", + "transmission", + "emission_color", + "emission", + "coat", + "coat_roughness", + "coat_IOR", + "coat_color", + "coat_normal", + "sheen", + "sheen_roughness", + "sheen_color", + "thin_film_thickness", + "thin_film_IOR", + "surface", + } <= generic_names + assert "Unsupported parameters for node type 'ShaderNodeBsdfPrincipled'" not in caplog.text diff --git a/tests/test_public_imports.py b/tests/test_public_imports.py index 500f56a..4030d98 100644 --- a/tests/test_public_imports.py +++ b/tests/test_public_imports.py @@ -13,6 +13,7 @@ def test_public_core_and_houdini_modules_import(): "materials_processor.dcc", "materials_processor.dcc.blender", "materials_processor.dcc.blender.addon", + "materials_processor.dcc.blender.adapters", "materials_processor.dcc.blender.recreator", "materials_processor.dcc.blender.runtime", "materials_processor.dcc.blender.traverser", diff --git a/tests/test_usd_json_conversion.py b/tests/test_usd_json_conversion.py index b711ad0..1a7250a 100644 --- a/tests/test_usd_json_conversion.py +++ b/tests/test_usd_json_conversion.py @@ -8,6 +8,7 @@ from pxr import Sdf, Usd, UsdShade from materials_processor import io as material_io +from materials_processor.core.graph import NodeInfo, OutputConnection from materials_processor.standardizer import NodeStandardizer from materials_processor.usd.recreator import USDMaterialRecreator from materials_processor.usd.traverser import USDTraverser @@ -241,6 +242,43 @@ def test_houdini_json_converts_to_usd_renderer_matrix( assert json.loads(json.dumps(output_nodes)) == output_nodes +def test_usd_recreator_sanitizes_material_and_shader_prim_names(): + stage = Usd.Stage.CreateInMemory() + nodeinfo = NodeInfo( + node_type="GENERIC::standard_surface", + node_name="Principled BSDF", + node_path="/mat/My Material/Principled BSDF", + parameters=[], + connection_info={}, + children_list=[], + ) + output_connection = OutputConnection( + node_name="Material Output", + node_path="/mat/My Material/Material Output", + connected_node_name="Principled BSDF", + connected_node_path="/mat/My Material/Principled BSDF", + connected_input_index=0, + connected_input_name="Surface", + connected_output_name="surface", + ) + + USDMaterialRecreator( + stage=stage, + material_name="My Material", + nodeinfo_list=[nodeinfo], + output_connections={"GENERIC::output_surface": output_connection}, + target_renderer="mtlx", + ).run() + + material = UsdShade.Material.Get(stage, Sdf.Path("/materials/My_Material")) + shader = UsdShade.Shader.Get(stage, Sdf.Path("/materials/My_Material/Principled_BSDF")) + + assert material.GetPrim().IsValid() + assert shader.GetPrim().IsValid() + assert shader.GetIdAttr().Get() == "ND_standard_surface_surfaceshader" + assert "outputs:mtlx:surface" in {output.GetFullName() for output in material.GetOutputs()} + + def test_usd_recreator_legacy_texture_collect_builder_smoke(): stage = Usd.Stage.CreateInMemory() recreator = USDMaterialRecreator(