Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 40 additions & 14 deletions src/materials_processor/dcc/blender/traverser.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,36 @@
bpy = None


def _socket_generic_type(socket, *, is_output=False, node=None):
"""Return the closest generic parameter type for a Blender socket."""
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":
return "vector2"
return "vector3"
if socket_type == "rgba":
return "color3" if is_output else "color4"
if socket_type == "shader":
return "shader"
return "float1"


def _resolve_blender_image_path(image):
"""Resolve Blender image paths relative to the current blend file when possible."""
filepath = getattr(image, "filepath", "")
if not filepath:
return filepath
if bpy is None:
return filepath
try:
return bpy.path.abspath(filepath)
except Exception as exc:
logger.warning("Failed to resolve Blender image path '%s': %s", filepath, exc)
return filepath


class BlenderNodeTraverser:
"""Class for traversing Blender material node trees to extract their connections and output nodes."""

Expand Down Expand Up @@ -165,29 +195,25 @@ def _convert_parms_to_dict(node):
elif not isinstance(val, str) and hasattr(val, "__iter__"):
val = list(val)

# Map Blender socket type names to generic types
socket_type = socket.type.lower()
if socket_type == "value":
generic_type = "float1"
elif socket_type == "vector":
generic_type = "vector3"
elif socket_type == "rgba":
generic_type = "color4"
else:
generic_type = "float1"

parms["input"].append({
"generic_name": socket.name,
"value": val,
"type": generic_type,
"type": _socket_generic_type(socket, node=node),
"direction": "input",
})

# Internal node properties (e.g. image file path for ShaderNodeTexImage)
if node.bl_idname == "ShaderNodeTexImage" and getattr(node, "image", None):
parms["input"].append({
"generic_name": "image",
"value": node.image.filepath,
"value": _resolve_blender_image_path(node.image),
"type": "string1",
"direction": "input"
})
elif node.bl_idname == "ShaderNodeUVMap":
parms["input"].append({
"generic_name": "uv_map",
"value": getattr(node, "uv_map", ""),
"type": "string1",
"direction": "input"
})
Expand All @@ -207,7 +233,7 @@ def _convert_parms_to_dict(node):
parms["output"].append({
"generic_name": socket.name,
"value": None,
"type": "color3" if socket.type == "RGBA" else "float1",
"type": _socket_generic_type(socket, is_output=True, node=node),
"direction": "output",
})

Expand Down
35 changes: 35 additions & 0 deletions src/materials_processor/mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@
'ND_image_float': 'GENERIC::image',
'ND_image_color3': 'GENERIC::image',
'ND_normalmap_vector3': 'GENERIC::normalmap',
'ND_geompropvalue_vector2': 'GENERIC::uvmap',
'ND_separate3_color3': 'GENERIC::separate_color',
'ND_colorcorrect_color3': 'GENERIC::color_correct',
'ND_range_float': 'GENERIC::range',
'ND_bump_vector3': 'GENERIC::displacement',
Expand All @@ -282,6 +284,8 @@
'ND_image_float': 'GENERIC::image',
'ND_image_color3': 'GENERIC::image',
'ND_normalmap_vector3': 'GENERIC::normalmap',
'ND_geompropvalue_vector2': 'GENERIC::uvmap',
'ND_separate3_color3': 'GENERIC::separate_color',
'ND_colorcorrect_color3': 'GENERIC::color_correct',
'ND_range_float': 'GENERIC::range',
'ND_bump_vector3': 'GENERIC::displacement',
Expand Down Expand Up @@ -309,6 +313,8 @@
'blender_shader_nodes': {
'ShaderNodeBsdfPrincipled': 'GENERIC::standard_surface',
'ShaderNodeTexImage': 'GENERIC::image',
'ShaderNodeUVMap': 'GENERIC::uvmap',
'ShaderNodeSeparateColor': 'GENERIC::separate_color',
'ShaderNodeNormalMap': 'GENERIC::normalmap',
'ShaderNodeBump': 'GENERIC::displacement',
'ShaderNodeOutputMaterial': 'GENERIC::output_node',
Expand Down Expand Up @@ -377,6 +383,7 @@ def convert_generic(node_type: str,
'mtlximage': {
'signature': 'signature',
'file': 'filename',
'texcoord': 'texcoord',
'out': 'rgb',
},
'mtlxcolorcorrect': {
Expand Down Expand Up @@ -450,6 +457,8 @@ def convert_generic(node_type: str,
'ND_image_float': {
'signature': 'signature',
'file': 'filename',
'texcoord': 'texcoord',
'out': 'rgb',
},
'ND_range_float': {
'in': 'in',
Expand All @@ -470,6 +479,19 @@ def convert_generic(node_type: str,
'ND_image_color3': {
'signature': 'signature',
'file': 'filename',
'texcoord': 'texcoord',
'out': 'rgb',
},
'ND_geompropvalue_vector2': {
'geomprop': 'uv_map',
'default': 'default',
'out': 'vector',
},
'ND_separate3_color3': {
'in': 'rgb',
'outr': 'r',
'outg': 'g',
'outb': 'b',
},
'ND_normalmap_vector3': {
'in': 'in',
Expand Down Expand Up @@ -685,7 +707,20 @@ def convert_generic(node_type: str,
},
'ShaderNodeTexImage': {
'image': 'filename',
'Vector': 'texcoord',
'Color': 'rgb',
'Alpha': 'alpha',
},
'ShaderNodeUVMap': {
'uv_map': 'uv_map',
'UV': 'vector',
},
'ShaderNodeSeparateColor': {
'Color': 'rgb',
'Red': 'r',
'Green': 'g',
'Blue': 'b',
'Alpha': 'alpha',
},
'ShaderNodeNormalMap': {
'Color': 'in',
Expand Down
45 changes: 42 additions & 3 deletions src/materials_processor/usd/graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,43 @@ def _create_shader_id(self, shader, generic_type):
return True
return False

def _nodeinfo_by_path(self):
"""Return a flat lookup of source node paths to generic node infos."""
lookup = {}

def visit(nodes):
for nodeinfo in nodes:
lookup[nodeinfo.node_path] = nodeinfo
if nodeinfo.children_list:
visit(nodeinfo.children_list)

visit(self.nodeinfo_list)
return lookup

def _renderer_parm_name(self, generic_node_type, generic_parm_name):
"""Translate a generic socket name to the target USD renderer socket name."""
if not generic_node_type or not generic_parm_name:
return generic_parm_name

renderer_node_type = convert_generic(
node_type=generic_node_type,
target_renderer=self.target_renderer,
profile='usd_prims',
)
if not renderer_node_type:
return generic_parm_name

std_parm_map = REGULAR_PARAM_NAMES_TO_GENERIC.get(renderer_node_type.replace('::', ':'), {})
for renderer_name, mapped_generic_name in std_parm_map.items():
if mapped_generic_name == generic_parm_name:
return renderer_name
return generic_parm_name

def _connection_parm_name(self, endpoint, nodeinfo_by_path):
nodeinfo = nodeinfo_by_path.get(endpoint.node_path)
generic_node_type = nodeinfo.node_type if nodeinfo else endpoint.node_type
return self._renderer_parm_name(generic_node_type, endpoint.parm_name)

def _apply_parameters(self, shader, node_type, parameters):
"""
Map generic parameters over to renderer-specific USD inputs.
Expand Down Expand Up @@ -259,12 +296,13 @@ def set_shader_connections(self, nodeinfo_list, parent_node=None):
"""
Connect child shader prims based on stored connection_tasks.
"""
nodeinfo_by_path = self._nodeinfo_by_path()
for nodeinfo in nodeinfo_list:
for conn_index, conn in nodeinfo.connection_info.items():
src_path = self.old_new_map.get(conn.input.node_path)
dst_path = self.old_new_map.get(conn.output.node_path)
src_parm = conn.input.parm_name
dst_parm = conn.output.parm_name
src_parm = self._connection_parm_name(conn.input, nodeinfo_by_path)
dst_parm = self._connection_parm_name(conn.output, nodeinfo_by_path)
src_prim = self.stage.GetPrimAtPath(Sdf.Path(src_path)) if src_path else None
dst_prim = self.stage.GetPrimAtPath(Sdf.Path(dst_path)) if dst_path else None

Expand All @@ -288,7 +326,8 @@ def set_shader_connections(self, nodeinfo_list, parent_node=None):

logger.debug("new_src_prim=%s", new_src_prim)
logger.debug("new_conn: %s", pprint.pformat(new_conn, sort_dicts=False))
self._connect_pair(new_src_prim, dst_prim, new_conn.input.parm_name, dst_parm)
new_src_parm = self._connection_parm_name(new_conn.input, nodeinfo_by_path)
self._connect_pair(new_src_prim, dst_prim, new_src_parm, dst_parm)
continue


Expand Down
17 changes: 17 additions & 0 deletions src/materials_processor/usd/mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@
'rs_usd_material_builder': 'redshift::BumpMap',
},
},
'GENERIC::uvmap': {
'prim_type': 'Shader',
'info_id': {
'mtlx': 'ND_geompropvalue_vector2',
'openpbr': 'ND_geompropvalue_vector2',
},
},
'GENERIC::separate_color': {
'prim_type': 'Shader',
'info_id': {
'mtlx': 'ND_separate3_color3',
'openpbr': 'ND_separate3_color3',
},
},
'GENERIC::range': {
'prim_type': 'Shader',
'info_id': {
Expand Down Expand Up @@ -199,6 +213,9 @@
'float2': Sdf.ValueTypeNames.Float2,
'float3': Sdf.ValueTypeNames.Float3,
'float4': Sdf.ValueTypeNames.Float4,
'vector2': Sdf.ValueTypeNames.TexCoord2f,
'vector3': Sdf.ValueTypeNames.Vector3f,
'vector4': Sdf.ValueTypeNames.Float4,
'bool': Sdf.ValueTypeNames.Bool,
'bool1': Sdf.ValueTypeNames.Bool,
'str': Sdf.ValueTypeNames.String,
Expand Down
71 changes: 71 additions & 0 deletions tests/test_blender_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@
BlenderRuntime,
_default_package_src,
_default_blender_root,
_parse_prefixed_output,
_run_blender_python,
resolve_blender_runtime,
validate_blender_material_smoke,
validate_blender_runtime,
)

ROOT = Path(__file__).resolve().parents[1]
LOCAL_TIE_DEFENDER_BLEND = Path(
r"F:\Users\Ahmed Hindy\Downloads\vfx_tmp\X-Ripper Stuff\Tie Defender\Tie Defender.blend"
)
TIE_DEFENDER_RESULT_PREFIX = "MATERIALS_PROCESSOR_TIE_DEFENDER="


def _fake_blender_root(tmp_path):
Expand Down Expand Up @@ -162,3 +168,68 @@ def test_validate_local_blender_runtime_when_available():
assert validated.python_version
assert smoke["recreated"] is True
assert "ShaderNodeBsdfPrincipled" in smoke["target_node_types"]


@pytest.mark.blender
def test_ingests_local_tie_defender_packed_material_when_available():
if not LOCAL_TIE_DEFENDER_BLEND.is_file():
pytest.skip(f"Tie Defender blend file is missing: {LOCAL_TIE_DEFENDER_BLEND}")
try:
runtime = resolve_blender_runtime(version=None)
except FileNotFoundError as exc:
pytest.skip(str(exc))

code = f"""
import json

import bpy

from materials_processor.dcc.blender.adapters import BlenderMaterialReader


def iter_nodes(nodes):
for node in nodes:
yield node
yield from iter_nodes(node.children_list)


bpy.ops.wm.open_mainfile(filepath={str(LOCAL_TIE_DEFENDER_BLEND)!r})
material = bpy.data.materials["T_TieDefender_01_CS_Mat"]
graph = BlenderMaterialReader().read(material)
nodes = list(iter_nodes(graph.nodeinfo_list))
texture_paths = [
parameter.value
for node in nodes
for parameter in (node.parameters or [])
if parameter.generic_name == "filename"
]
result = {{
"material_name": graph.material_name,
"node_types": sorted(set(node.node_type for node in nodes)),
"output_keys": sorted(graph.output_connections),
"texture_paths": sorted(texture_paths),
}}
print({TIE_DEFENDER_RESULT_PREFIX!r} + json.dumps(result, sort_keys=True))
""".strip()

completed = _run_blender_python(runtime, code, ROOT / "src", timeout=180)
if completed.returncode != 0:
raise RuntimeError(
"Tie Defender Blender ingest failed with exit code "
f"{completed.returncode}.\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}"
)

result = _parse_prefixed_output(
completed.stdout,
completed.stderr,
TIE_DEFENDER_RESULT_PREFIX,
"Tie Defender ingest",
)
assert result["material_name"] == "T_TieDefender_01_CS_Mat"
assert "GENERIC::standard_surface" in result["node_types"]
assert "GENERIC::image" in result["node_types"]
assert "GENERIC::uvmap" in result["node_types"]
assert "GENERIC::separate_color" in result["node_types"]
assert "GENERIC::output_surface" in result["output_keys"]
assert result["texture_paths"]
assert all(not texture_path.startswith("//") for texture_path in result["texture_paths"])
Loading
Loading