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
110 changes: 110 additions & 0 deletions src/materials_processor/dcc/blender/adapters.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 2 additions & 21 deletions src/materials_processor/dcc/blender/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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.")
Expand Down
27 changes: 8 additions & 19 deletions src/materials_processor/dcc/blender/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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))
Expand Down
21 changes: 21 additions & 0 deletions src/materials_processor/mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
42 changes: 34 additions & 8 deletions src/materials_processor/usd/graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand All @@ -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):
"""
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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'
)
Expand All @@ -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))
Expand Down
Loading
Loading