MaterialXMaterials 1.39.5
Utilities for retrieving materials from remote servers
Loading...
Searching...
No Matches
physicallyBasedMaterialX.py
1'''
2@brief Class to load Physically Based Materials from the PhysicallyBased site.
3and convert the materials to MaterialX format for given target shading models.
4'''
5
6#from numpy import source
7import requests, json, os, inspect # type: ignore
8import logging as lg
9from http import HTTPStatus
10import MaterialX as mx # type: ignore
11from typing import Optional
12import importlib.resources
13import json
14import datetime
15
17 '''
18 @brief Class to load Physically Based Materials from the PhysicallyBased site.
19 The class can convert the materials to MaterialX format for given target shading models.
20 '''
21 def __init__(self, mx_module, mx_stdlib : Optional[mx.Document] = None, materials_file : str = ''):
22 '''
23 @brief Constructor for the PhysicallyBasedMaterialLoader class.
24 Will initialize shader mappings and load the MaterialX standard library
25 if it is not passed in as an argument.
26 @param mx_module The MaterialX module. Required.
27 @param mx_stdlib The MaterialX standard library. Optional.
28 '''
29
30 self.logger = lg.getLogger('PBMXLoader')
31 lg.basicConfig(level=lg.INFO)
32
33
34 self.materials : dict = {}
35
36 self.materialNames : list[str]= []
37
38 self.uri = 'https://api.physicallybased.info/materials'
39
40 self.doc = None
41
42 self.mx = mx_module
43
44 self.stdlib = mx_stdlib
45
46 self.physlib = None
47
48 self.physlib_definition_name = "ND_PhysicallyBasedMaterial"
49
50 self.physlib_implementation_name = "NG_PhysicallyBasedMaterial"
51
52 self.physlib_category = "physbased_pbr_surface"
53
55
57
58 self.all_lib = None
59
60 self.MTLX_NODE_NAME_ATTRIBUTE = 'nodename'
61
62 self.support_openpbr = False
63
64 self.remapMap = {}
65
66 self.remapFile = 'PhysicallyBasedMaterialX/PhysicallyBasedToMtlxMappings.json'
67
68 if not mx_module:
69 self.logger.critical(f'> {self._getMethodName()}: MaterialX module not specified.')
70 return
71
72 # Check for OpenPBR support which is only available in 1.39 and above
73 version_major, version_minor, version_patch = self.mx.getVersionIntegers()
74 self.logger.debug(f'> MaterialX version: {version_major}.{version_minor}.{version_patch}')
75 if (version_major >=1 and version_minor >= 39) or version_major > 1:
76 self.logger.debug('> OpenPBR shading model supported')
77 self.support_openpbr = True
78
79 # Load the MaterialX standard library if not provided
80 if not self.stdlib:
81 self.stdlib = self.mx.createDocument()
82 libFiles = self.mx.loadLibraries(mx.getDefaultDataLibraryFolders(), mx.getDefaultDataSearchPath(), self.stdlib)
83 self.logger.debug(f'> Loaded standard library: {libFiles}')
84
85 # Initialize Physically Based MaterialX definitions, materials, remappings, and translators
87
88 def initialize_definitions_and_materials(self, materials_file : str = ''):
89 '''
90 @brief Initialize Physically Based MaterialX definitions, materials, remappings, and translators.
91 @return None
92 '''
93 # Load information from PhysicallyBased site, and initialize remappings
94 if materials_file and os.path.exists(materials_file):
95 self.loadMaterialsFromFile(materials_file)
96 else:
99
100 # Create Physically Based MaterialX definition library
101 self.physlib = self.create_definition(None)
102 self.logger.info('> Created Physically Based MaterialX definition library...')
103 status, error = self.physlib.validate()
104 if not status:
105 self.logger.info(mx.prettyPrint(self.physlib))
106 self.logger.error('> Error validating NodeDef document:')
107 self.logger.error(error)
108 else:
109 self.logger.info('> Definition documents passed validation.')
110
111 # Create all translators
112 self.physlib_translators = mx.createDocument()
114
115 if self.physlib:
116 filter_list = []
117 self.physlib_materials = self.create_definition_materials(None, filter_list)
118
119 def setDebugging(self, debug=True):
120 '''
121 @brief Set the debugging level for the logger.
122 @param debug True to set the logger to debug level, otherwise False.
123 @return None
124 '''
125 if debug:
126 self.logger.setLevel(lg.DEBUG)
127 else:
128 self.logger.setLevel(lg.INFO)
129
130 def get_stdlib(self) -> mx.Document:
131 '''
132 @brief Get the MaterialX standard library document.
133 @return The MaterialX standard library document.
134 '''
135 return self.stdlib
136
137 def get_physlib(self) -> mx.Document:
138 '''
139 @brief Get the Physically Based MaterialX definition library.
140 @return The Physically Based MaterialX definition library.
141 '''
142 return self.physlib
143
144 def get_physlib_definition(self) -> mx.NodeDef | None:
145 '''
146 @brief Get the Physically Based MaterialX definition NodeDef.
147 @return The Physically Based MaterialX definition NodeDef.
148 '''
149 if self.physlib:
150 return self.physlib.getNodeDef(self.get_physlib_definition_name())
151 return None
152
153 def get_physlib_category(self) -> str:
154 '''
155 @brief Get the Physically Based MaterialX surface category.
156 @return The Physically Based MaterialX surface category.
157 '''
158 return self.physlib_category
159
161 '''
162 @brief Get the Physically Based MaterialX definition name.
163 @return The Physically Based MaterialX definition name.
164 '''
165 return self.physlib_definition_name
166
168 '''
169 @brief Get the Physically Based MaterialX implementation (nodegraph) name.
170 @return The Physically Based MaterialX implementation (nodegraph) name.
171 '''
173
174 def get_physlib_materials(self) -> mx.Document:
175 '''
176 @brief Get the Physically Based MaterialX materials document.
177 @return The Physically Based MaterialX materials document.
178 '''
179 return self.physlib_materials
180
181 def get_definitions(self) -> mx.Document:
182 '''
183 @brief Get a combined MaterialX document containing the standard library and Physically Based MaterialX definition and translators.
184 @return The combined MaterialX document.
185 '''
186 if not self.all_lib:
187 self.all_lib = self.mx.createDocument()
188 self.all_lib.copyContentFrom(self.stdlib)
189 if self.physlib:
190 self.all_lib.copyContentFrom(self.physlib)
191 if self.physlib_translators:
192 self.all_lib.copyContentFrom(self.physlib_translators)
193 return self.all_lib
194
195 def get_translators(self) -> mx.Document:
196 '''
197 @brief Get the Physically Based MaterialX translators document.
198 @return The Physically Based MaterialX translators document.
199 '''
200 return self.physlib_translators
201
202 def getInputRemapping(self, shadingModel) -> dict:
203 '''
204 @brief Get the remapping keys for a given shading model.
205 @param shadingModel The shading model to get the remapping keys for.
206 @return A dictionary of remapping keys.
207 '''
208 if (shadingModel in self.remapMap):
209 return self.remapMap[shadingModel]
210
211 #self.logger.warning(f'> No remapping keys found for shading model: {shadingModel}')
212 return {}
213
215 '''
216 @brief Initialize remapping keys for different shading models.
217 See: https://api.physicallybased.info/operations/get-materials
218 for more information on material properties.
219
220 The JSON file PhysicallyBasedToMtlxMappings.json which is part of the package
221 will be used if it exists. Otherwise, default remapping keys will be used.
222
223 The currently supported shading models are:
224 - standard_surface
225 - open_pbr_surface
226 - gltf_pbr
227 @return None
228 '''
229 # Read PhysicallyBasedToMtlxMappings.json installed package
230 self.remapMap = {}
231
232 try:
233 with importlib.resources.files("materialxMaterials.data").joinpath(self.remapFile).open("r", encoding="utf-8") as json_file:
234 self.logger.info(f'> Load remapping from installed package: {self.remapFile}')
235 self.remapMap = json.load(json_file)
236 except FileNotFoundError:
237 self.logger.warning('> No remapping file found in installed package. Using default remapping keys.')
238
239 if self.remapMap:
240 return
241
242 # Remap keys for Autodesk Standard Surface shading model.
243 standard_surface_remapKeys = {
244 'color': 'base_color',
245 'specularColor': 'specular_color',
246 'roughness': 'specular_roughness',
247 'metalness': 'metalness',
248 'ior': 'specular_IOR',
249 'subsurfaceRadius': 'subsurface_radius',
250 'transmission': 'transmission',
251 'transmission_color': 'transmission_color', # 'color' remapping as needed
252 'transmissionDispersion' : 'transmission_dispersion',
253 'thinFilmThickness' : 'thin_film_thickness',
254 'thinFilmIor' : 'thin_film_IOR',
255 }
256 # Remap keys for OpenPBR shading model.
257 # Q: When to set geometry_thin_walled to true?
258 openpbr_remapKeys = {
259 'color': 'base_color',
260 'specularColor': 'specular_color',
261 'roughness': 'specular_roughness', # 'base_diffuse_roughness',
262 'metalness': 'base_metalness',
263 'ior': 'specular_ior',
264 'subsurfaceRadius': 'subsurface_radius',
265 'transmission': 'transmission_weight',
266 'transmission_color': 'transmission_color', # 'color' remapping as needed
267 'transmissionDispersion': 'transmission_dispersion_abbe_number',
268 #'complexIor' TODO : add in array remap
269 # Complex IOR values, n (refractive index), and k (extinction coefficient), for each color channel, in the following order:
270 # nR, kR, nG, kG, nB, kB.
271 'thinFilmThickness' : 'thin_film_thickness',
272 'thinFilmIor' : 'thin_film_ior',
273 }
274 # Remap keys for Khronos glTF shading model.
275 gltf_remapKeys = {
276 'color': 'base_color',
277 'specularColor': 'specular_color',
278 'roughness': 'roughness',
279 'metalness': 'metallic',
280 'ior': 'ior',
281 'transmission': 'transmission',
282 'transmission_color': 'attenuation_color', # Remap transmission color to attenuation color
283 'thinFilmThickness' : 'iridescence_thickness',
284 'thinFilmIor' : 'iridescence_ior',
285 }
286
287 self.remapMap = {}
288 self.remapMap['standard_surface'] = standard_surface_remapKeys;
289 self.remapMap['gltf_pbr'] = gltf_remapKeys;
290 if self.support_openpbr:
291 self.remapMap['open_pbr_surface'] = openpbr_remapKeys;
292
293 def writeRemappingFile(self, filepath):
294 '''
295 @brief Write the remapping keys to a JSON file.
296 @param filepath The filename to write the remapping keys to.
297 @return None
298 '''
299 if not self.remapMap:
300 self.logger.warning('No remapping keys to write')
301 return
302
303 with open(filepath, 'w') as json_file:
304 json.dump(self.remapMap, json_file, indent=4)
305
306 def readRemappingFile(self, filepath):
307 '''
308 @brief Read the remapping keys from a JSON file.
309 @param filepath The filename to read the remapping keys from.
310 @return A dictionary of remapping keys.
311 '''
312 if not os.path.exists(filepath):
313 self.logger.error(f'> File does not exist: {filepath}')
314 return {}
315
316 with open(filepath, 'r') as json_file:
317 self.remapMap = json.load(json_file)
318
319 return self.remapMap
320
321 def getJSON(self) -> dict:
322 ''' Get the JSON object representing the Physically Based Materials '''
323 return self.materials
324
325 def getJSONMaterialNames(self) -> list:
326 '''
327 Get the list of material names from the JSON object
328 @return The list of material names
329 '''
330 return self.materialNames
331
332 def getMaterialXDocument(self) -> mx.Document:
333 '''
334 Get the MaterialX document
335 @return The MaterialX document
336 '''
337 return self.doc
338
339 def loadMaterialsFromFile(self, fileName) -> dict:
340 '''
341 @brief Load the Physically Based Materials from a JSON file
342 @param fileName The filename to load the JSON file from
343 @return The JSON object representing the Physically Based Materials
344 '''
345 self.materials.clear()
346 self.materialNames.clear()
347 if not os.path.exists(fileName):
348 self.logger.error(f'> File does not exist: {fileName}')
349 return {}
350
351 with open(fileName, 'r') as json_file:
352 self.materials = json.load(json_file)
353 for mat in self.materials:
354 self.materialNames.append(mat['name'])
355
356 return self.materials
357
358 def loadMaterialsFromString(self, matString) -> dict:
359 '''
360 @brief Load the Physically Based Materials from a JSON string
361 @param matString The JSON string to load the Physically Based Materials from
362 @return The JSON object representing the Physically Based Materials
363 '''
364 self.materials.clear()
365 self.materialNames.clear()
366 self.materials = json.loads(matString)
367 for mat in self.materials:
368 self.materialNames.append(mat['name'])
369
370 return self.materials
371
372 def getMaterialsFromURL(self) -> dict:
373 '''
374 @brief Get the Physically Based Materials from the PhysicallyBased site
375 @return The JSON object representing the Physically Based Materials
376 '''
377
378 self.materials.clear()
379 self.materialNames.clear()
380 url = self.uri
381 headers = {
382 'Accept': 'application/json'
383 }
384
385 response = requests.get(url, headers=headers)
386
387 if response.status_code == HTTPStatus.OK:
388 self.materials = response.json()
389 for mat in self.materials:
390 self.materialNames.append(mat['name'])
391
392 else:
393 self.logger.error(f'> Status: {response.status_code}, {response.text}')
394
395 return self.materials
396
397 def printMaterials(self):
398 '''
399 @brief Print the materials to the console
400 @return None
401 '''
402 for mat in self.materials:
403 self.logger.info('Material name: ' + mat['name'])
404 # Print out each key and value
405 for key, value in mat.items():
406 if (key != 'name' and value):
407 self.logger.info(f'> - {key}: {value}')
408
409 def writeJSONToFile(self, filename):
410 '''
411 @brief Write the materials to a JSON file
412 @param filename The filename to write the JSON file to
413 @return True if the file was written successfully, otherwise False
414 '''
415 if not self.materials:
416 self.logger.warning('No materials to write')
417 return False
418
419 with open(filename, 'w') as json_file:
420 json.dump(self.materials, json_file, indent=4)
421 return True
422
423 return False
424
425 @staticmethod
426 def skipLibraryElement(elem) -> bool:
427 '''
428 @brief Utility to skip library elements when iterating over elements in a document.
429 @return True if the element is not in a library, otherwise False.
430 '''
431 return not elem.hasSourceUri()
432
433 def _getMethodName(self):
434 '''
435 @brief Get the name of the calling method for logging purposes.
436 @return The name of the calling method.
437 '''
438 frame = inspect.currentframe().f_back
439 method_name = frame.f_code.co_name
440 return method_name
441 #return inspect.currentframe().f_code.co_name
442
444 '''
445 @brief Validate the MaterialX document
446 @param doc The MaterialX document to validate
447 @return A tuple of (valid, errors) where valid is True if the document is valid, and errors is a list of errors if the document is invalid.
448 '''
449 if not self.mx:
450 self.logger.critical(f'> {self._getMethodName()}: MaterialX module is required')
451 return False, ''
452
453 if not doc:
454 self.logger.warning(f'> {self._getMethodName()}: MaterialX document is required')
455 return False, ''
456
457 valid, errors = doc.validate()
458 return valid, errors
459
460 def addComment(self, doc, commentString):
461 '''
462 @brief Add a comment to the MaterialX document
463 @param doc The MaterialX document to add the comment to
464 @param commentString The comment string to add
465 @return None
466 '''
467 comment = doc.addChildOfCategory('comment')
468 comment.setDocString(commentString)
469
470 def map_keys_to_definition(self, mat, ndef):
471 '''
472 @brief Map a key to a NodeDef input
473 @param mat Material to map keys from
474 @param ndef The definition to map the key to
475 @return The input name if the key was mapped, otherwise None
476 '''
477 for key, value in mat.items():
478 uifolder = None
479 if ndef.getInput(key) is None:
480 if key == 'name':
481 # Skip as these wil be node instance names
482 pass
483 #continue
484
485 #print('Add key to nodedef:', key)
486
487 self.logger.debug(f'> Add key as input: {key}')
488
489 input_type = "string"
490 if 'color' in key.lower():
491 input_type = "color3"
492 value = "1,1,1"
493 elif isinstance(value, float):
494 input_type = "float"
495 value = "0.0"
496 elif isinstance(value, int):
497 input_type = "float"
498 value = "0.0"
499 elif key == 'category':
500 #if isinstance(value, list) and len(value) > 0:
501 # uifolder = str(value[0])
502 #else:
503 # uifolder = str(value)
504 #value = None
505 pass
506 elif key in ['sources', 'reference', 'tags', 'group']:
507 value = ''
508
509 input = ndef.addInput(key, input_type)
510 if input:
511 if value is not None:
512 if isinstance(value, list):
513 # Split list into array
514 value_list = [str(x) for x in value]
515 # Check if values are numbers
516 is_number_list = all(isinstance(x, (int, float)) for x in value)
517 if is_number_list:
518 if len(value_list) > 4 :
519 input.setType('string') # floatarray will cause errors in shader generation !
520 elif len(value_list) > 3 :
521 input.setType('vector4')
522 elif len(value_list) > 2 :
523 input.setType('vector3')
524 elif len(value_list) > 1 :
525 input.setType('vector2')
526 else:
527 input.setType('float')
528 # Replace all numbers with 0.0
529 value_list = ['0.0' for x in value_list]
530 value = ', '.join(value_list)
531
532 #value = ','.join([str(x) for x in value])
533 input.setValueString(str(value))
534
535
536 uiname = key
537 # Split camel case names and separate by space
538 # e.g. specularColor -> Specular Color
539 uiname = ''.join([' ' + c if c.isupper() else c for c in key]).strip().title()
540 input.setAttribute("uiname", uiname)
541
542 uifolder = 'Base'
543 if key in ['description', 'sources', 'reference', 'tags']:
544 uifolder = 'Metadata'
545 input.setAttribute("uifolder", uifolder)
546
547 # Add doc string
548 #doc_string = ''
549 #if key == 'description':
550 # doc_string = str(value)
551 #if len(doc_string) > 0:
552 # input.setDocString(doc_string)
553
554 if uifolder is not None:
555 input.setAttribute("uifolder", uifolder)
556
557 def find_all_bxdf(self, doc : mx.Document) -> list[mx.NodeDef]:
558 '''
559 @brief Scan all nodedefs with output type of "surfaceshader"
560 doc : The MaterialX document to scan
561 @return A list of nodedefs found
562 '''
563 bxdfs : list[mx.NodeDef] = []
564 for nodedef in doc.getNodeDefs():
565 if nodedef.getType() == "surfaceshader":
566 if nodedef.getNodeString() not in ["convert", "surface"] and nodedef.getNodeGroup() == "pbr":
567 bxdfs.append(nodedef)
568 return bxdfs
569
570 def derive_translator_name_from_targets(self, source : str, target : str) -> str:
571 return f"ND_{source}_to_{target}"
572
573 def create_translator(self, doc : mx.Document,
574 source : str, target : str,
575 source_version = "", target_version = "",
576 mappings = None,
577 output_doc : mx.Document | None = None):
578 '''
579 @brief Create a translator nodedef and nodegraph from source to target definitions.
580 @param doc The source document containing the definition.
581 @param source The source definition category.
582 @param target The target definition category.
583 @param source_version The source version string. If empty, use the first source definition version found.
584 @param target_version The target version string. If empty, use the first target definition version found.
585 @param mappings A dictionary mapping source input names to target input names.
586 @param output_doc The document to add the translator to. If None, use the source doc.
587 @return The created translator definition.
588 '''
589 if not output_doc:
590 return None
591
592 # Get source and target nodedefs
593 nodedefs = self.find_all_bxdf(doc)
594
595 #nodedefs : list[mx.NodeDef] = doc.getNodeDefs()
596 #nodedefs_set = dict((nd.getNodeString() + nd.getVersionString(), nd) for nd in nodedefs)
597 #for key, value in nodedefs_set.items():
598 # print(f"Key: '{key}' -> Nodedef: '{value.getNodeString()}'")
599 source_nodedef = None
600 target_nodedef = None
601 for nodedef in nodedefs:
602 if nodedef.getNodeString() == source:
603 if source_version == "" or nodedef.getVersionString() == source_version:
604 source_nodedef = nodedef
605 if nodedef.getNodeString() == target:
606 if target_version == "" or nodedef.getVersionString() == target_version:
607 target_nodedef = nodedef
608
609 if not source_nodedef or not target_nodedef:
610 #raise ValueError(f"Source or target nodedef not found for '{source}' to '{target}'")
611 if not source_nodedef:
612 print(f"Source nodedef not found for '{source}' with version '{source_version}'")
613 if not target_nodedef:
614 print(f"Target nodedef not found for '{target}' with version '{target_version}'")
615 return None
616 #else:
617 # print("Found source nodedef:", source_nodedef.getNodeString(), "version:", source_nodedef.getVersionString())
618 # print("Found target nodedef:", target_nodedef.getNodeString(), "version:", target_nodedef.getVersionString())
619
620 # 1. Add a new nodedef for the translator
621 derived_name = self.derive_translator_name_from_targets(source, target)
622 nodename = derived_name[3:] if derived_name.startswith("ND_") else derived_name
623 translator_nodedef : mx.NodeDef = output_doc.getNodeDef(derived_name)
624 if translator_nodedef:
625 # Try to append the version string to make unique. Note that translators
626 # do not support versioning so this will allow creation but not usage.
627 # Thus we skip doing this for now...
628 #derived_name += mx.createValidName(target_nodedef.getVersionString())
629 #translator_nodedef = output_doc.getNodeDef(derived_name)
630 if translator_nodedef:
631 self.logger.warning(f'> Translator NodeDef already exists: {target_nodedef.getName()}')
632 #mx.prettyPrint(translator_nodedef)
633 return translator_nodedef
634
635 translator_nodedef = output_doc.addNodeDef(derived_name)
636 translator_nodedef.removeOutput("out")
637 translator_nodedef.setNodeString(nodename)
638 translator_nodedef.setNodeGroup("translation")
639 translator_nodedef.setDocString(f"Translator from '{source}' to '{target}'")
640
641 version1 = source_nodedef.getVersionString()
642 if not version1:
643 version1 = "1.0"
644 translator_nodedef.setAttribute('source_version', version1)
645 translator_nodedef.setAttribute('source', source)
646 version2 = target_nodedef.getVersionString()
647 if not version2:
648 version2 = "1.0"
649 translator_nodedef.setAttribute('target_version', version2)
650 translator_nodedef.setAttribute('target', target)
651
652 # Add inputs from source as inputs to the translator
653 comment = translator_nodedef.addChildOfCategory("comment")
654 comment.setDocString(f"Inputs (inputs from source '{source}')")
655 for input in source_nodedef.getActiveInputs():
656 #print('add input:', input.getName(), input.getType())
657 nodedef_input = translator_nodedef.addInput(input.getName(), input.getType())
658 if input.hasValueString():
659 nodedef_input.setValueString(input.getValueString())
660
661 # Add inputs from target as outputs to the translator
662 comment = translator_nodedef.addChildOfCategory("comment")
663 comment.setDocString(f"Outputs (inputs from target '{target}' with '_out' suffix)")
664 for input in target_nodedef.getActiveInputs():
665 output_name = input.getName() + "_out"
666 #print('add output:', output_name, input.getType())
667 translator_nodedef.addOutput(output_name, input.getType())
668
669 # 2 Create a new functional nodegraph
670 comment = output_doc.addChildOfCategory("comment")
671 comment.setDocString(f"NodeGraph implementation for translator '{nodename}'")
672 nodegraph_id = 'NG_' + nodename
673 nodegraph : mx.NodeGraph = output_doc.addNodeGraph(nodegraph_id)
674 nodegraph.setNodeDefString(derived_name)
675 nodegraph.setDocString(f"NodeGraph implementation of translator from '{source}' to '{target}'")
676 nodegraph.setAttribute('source_version', version1)
677 nodegraph.setAttribute('source', source)
678 nodegraph.setAttribute('target_version', version2)
679 nodegraph.setAttribute('target', target)
680 for output in translator_nodedef.getActiveOutputs():
681 nodegraph.addOutput(output.getName(), output.getType())
682
683 for source_input_name, target_input_name in mappings.items():
684 source_input = translator_nodedef.getInput(source_input_name)
685 output_name = target_input_name + "_out"
686 target_output = nodegraph.getOutput(output_name)
687 if source_input and target_output:
688 dot_name = nodegraph.createValidChildName(target_input_name)
689 comment = nodegraph.addChildOfCategory("comment")
690 comment.setDocString(f"Routing source input: '{source_input_name}' to target input: '{target_input_name}'")
691 dot_node = nodegraph.addNode('dot', dot_name)
692 dot_inpput = dot_node.addInput('in', source_input.getType())
693 dot_inpput.setInterfaceName(source_input.getName())
694 target_output.setNodeName(dot_node.getName())
695 #print(f" - Added connection from input '{source_input.getName()}' to output '{target_output.getName()}'")
696
697 return translator_nodedef, output_doc
698
699
700 def create_all_translators(self, definitions : mx.Document, output_doc : mx.Document | None = None) -> list[mx.NodeDef]:
701 '''
702 @brief Create translators for all supported shading models.
703 @param definitions The source document containing Physically Based MaterialX definitions.
704 @param output_doc The document to add the translators to. If None, use the source doc.
705 @return A list of created translator definitions.
706 '''
707 trans_nodedefs = []
708
709 # Create temporary doc with all standard library definitions
710 result = self.create_working_document()
711 trans_doc = result["doc"]
712
713 # Add Physically Based Material definitions to the temporary document
714 trans_doc.copyContentFrom(definitions)
715
716 self.add_copyright_comment(output_doc, None)
717
718 if not output_doc:
719 self.logger.error('No output document specified for translators')
720 return trans_nodedef
721
722 # Source BSDF is always physbased_pbr_surface
723 source_bsdf = self.physlib_category
724
725 # Iterate over all target BSDFs
726 bsdfs = self.find_all_bxdf(trans_doc)
727 for bsdf in bsdfs:
728 bsdf_name = bsdf.getNodeString()
729 if bsdf_name == self.physlib_category:
730 continue
731
732 target_bsdf = bsdf.getNodeString()
733 remapping = self.getInputRemapping(target_bsdf)
734 #print('Remapping:', remapping)
735 if len(remapping.items()) > 0:
736 trans_nodedef = self.create_translator(trans_doc,
737 source_bsdf, target_bsdf,
738 "", "",
739 remapping, output_doc)
740 if trans_nodedef:
741 self.logger.info(f'> Created translator to BSDF: {bsdf_name}')
742 trans_nodedefs.append(trans_nodedef)
743
744 return trans_nodedefs
745
746 def create_definition(self, doc : mx.Document | None) -> mx.Document:
747 '''
748 @brief Create a NodeDef for the Physically Based Material inputs
749 @param doc The MaterialX document to add the NodeDef to. If None, a new document will be created.
750 @return A tuple of the MaterialX document and the created definition.
751
752 @details The NodeDef will contain inputs for all the keys in the Physically Based Material JSON object.
753
754 The nodegraph is a placeholder with a simple diffuse shader accepting color as followe:
755 <pre>
756 <nodegraph name="NG_PhysicallyBasedMaterial" nodedef="ND_PhysicallyBasedMaterial">
757 <oren_nayar_diffuse_bsdf name="oren_nayar_diffuse_bsdf" type="BSDF" >
758 <output name="out" type="BSDF" />
759 <input name="color" type="color3" interfacename="color" />
760 <input name="roughness" type="color3" interfacename="roughness" />
761 </oren_nayar_diffuse_bsdf>
762
763 <surface name="surface" type="surfaceshader">
764 <input name="bsdf" type="BSDF" output="out" nodename="oren_nayar_diffuse_bsdf" />
765 </surface>
766
767 <output name="out" type="surfaceshader" nodename="surface"/>
768 </nodegraph>
769 </pre>
770 '''
771 if not doc:
772 doc = mx.createDocument()
773 self.add_copyright_comment(doc, None)
774
775 # Create placeholder nodegraph
776 graph = doc.addNodeGraph(self.get_physlib_implementation_name())
777 graph.setNodeDefString(self.get_physlib_definition_name())
778
779 node = graph.addNode('oren_nayar_diffuse_bsdf', 'oren_nayar_diffuse_bsdf', 'BSDF')
780 node_in = node.addInput('color', 'color3')
781 node_in.setInterfaceName('color')
782 node_in_rough = node.addInput('roughness', 'float')
783 node_in_rough.setInterfaceName('roughness')
784 node.addOutput('out', 'BSDF')
785
786 node = graph.addNode('surface', 'surface', 'surfaceshader')
787 node_in = node.addInput('bsdf', 'BSDF')
788 node_in.setAttribute('out', 'out')
789 node_in.setNodeName('oren_nayar_diffuse_bsdf')
790
791 node_out = graph.addOutput('out', 'surfaceshader')
792 node_out.setNodeName('surface')
793
794 # Create definition template
795 ndef = doc.addNodeDef(self.get_physlib_definition_name(), 'surfaceshader')
796 #graph.removeOutput('out')
797 ndef.setNodeString(self.physlib_category)
798 ndef.setNodeGroup("pbr")
799 ndef.setDocString("Node definitions for PhysicallyBased Material")
800 ndef.setVersionString("1.0")
801 ndef.setAttribute("isdefaultversion", "true")
802
803 for mat in self.materials:
804 # Map keys to definition inputs
805 self.map_keys_to_definition(mat, ndef)
806
807 return doc
808
809
810 def create_definition_materials(self, doc_mat, filter_list = None):
811 '''
812 @brief Create a MaterialX document containing Physically Based MaterialX materials
813 @param doc_mat The MaterialX document to add the materials to
814 @param filter_list A list of material names to filter. If None, all materials will be processed.
815 @return The MaterialX document containing the materials
816 '''
817 definitions = self.get_definitions()
818
819 if not doc_mat:
820 doc_mat = mx.createDocument()
821 self.add_copyright_comment(doc_mat, None)
822
823 # Reference the library definitions into the material document
824 doc_mat.setDataLibrary(definitions)
825
826 for mat in self.materials:
827
828 matName = mat['name']
829 if filter_list and matName not in filter_list:
830 #self.logger.info(f'> Skipping material: {matName}')
831 continue
832 #else:
833 # self.logger.info(f'> Creating material: {matName}')
834
835 shaderName = doc_mat.createValidChildName(matName + '_SHD_PBM')
836 shaderNode = doc_mat.addNode(self.physlib_category, shaderName, mx.SURFACE_SHADER_TYPE_STRING)
837 for key, value in mat.items():
838 if key == 'name':
839 new_name = doc_mat.createValidChildName(str(value))
840 shaderNode.setName(new_name)
841
842 input = shaderNode.addInputFromNodeDef(key)
843 if value is not None:
844 if isinstance(value, list):
845 # Split list into array
846 value_list = [str(x) for x in value]
847 # Check if values are numbers
848 #is_number_list = all(isinstance(x, (int, float)) for x in value)
849 #if is_number_list:
850 value = ', '.join(value_list)
851 input.setValueString(str(value))
852
853 # Add doc string
854 doc_string = ''
855 if key == 'description':
856 doc_string = str(value)
857 if len(doc_string) > 0:
858 shaderNode.setDocString(doc_string)
859
860 shaderNode.setAttribute('uiname', matName)
861
862 # Create a new material
863 materialName = doc_mat.createValidChildName(matName + '_MAT_PBM')
864 materialNode = doc_mat.addNode(mx.SURFACE_MATERIAL_NODE_STRING, materialName, mx.MATERIAL_TYPE_STRING)
865 shaderInput = materialNode.addInput(mx.SURFACE_SHADER_TYPE_STRING, mx.SURFACE_SHADER_TYPE_STRING)
866 shaderInput.setAttribute(self.MTLX_NODE_NAME_ATTRIBUTE, shaderNode.getName())
867
868 return doc_mat
869
870 def find_translator(self, doc : mx.Document, source : str, target : str) -> mx.NodeDef | None:
871 '''
872 @brief Find a translator nodedef from source to target in the document.
873 @param doc The MaterialX document to search.
874 @param source The source definition category.
875 @param target The target definition category.
876 @return The translator nodedef if found, otherwise None.
877 '''
878 derived_name = self.derive_translator_name_from_targets(source, target)
879 # Look for the translator in the document
880 translator_nodedef : mx.NodeDef = doc.getNodeDef(derived_name)
881 return translator_nodedef
882
883 def translate_node(self, doc : mx.Document, source_bxdf : str, target_bxdf : str, node : mx.Node) -> dict | None:
884 '''
885 @brief Translate a shader node of source_bxdf to target_bxdf using ungrouped nodes.
886 @detail This function creates a target node and a translation node based on the translator nodedef, then
887 makes upstream and downstream connections.
888 @param doc The document to operate on.
889 @param source_bxdf The source BXDF shading model name.
890 @param target_bxdf The target BXDF shading model name.
891 @param node The source shader node to translate.
892 @return A dictionary with 'translationNode' and 'targetNode' if successful, None otherwise.
893 '''
894
895 # Look for a translator if one exists.
896 nodedef : mx.NodeDef | None = self.find_translator(doc, source_bxdf, target_bxdf)
897 if not nodedef:
898 print(f"- No translator found from '{source_bxdf}' to '{target_bxdf}' for node '{node.getName()}'")
899 return None
900
901 # Create a target node of the target_bxdf category.
902 #print('> Add target node of category:', target_bxdf)
903 replace_name = node.getName()
904 target_node_name = doc.createValidChildName(f'{replace_name}_{target_bxdf}_SPB');
905
906 # Cleanup dowstream connections
907 downstream_ports = node.getDownstreamPorts()
908 for port in downstream_ports:
909 # print('Scan downstream port:', port.getName(), 'of node:', port.getParent().getName());
910 downstream_node = port.getParent()
911 downstream_input = downstream_node.getInput(port.getName())
912 if downstream_input:
913 #print(` - Reconnecting downstream node '${downstream_node.getName()}' input '${downstream_input.getName()}' from '${node.getName()}' to target node '${target_node_name}'`);
914 downstream_input.setNodeName(target_node_name);
915
916 targetNode = doc.addChildOfCategory(target_bxdf, target_node_name)
917 if not targetNode:
918 print(f"- Failed to create target node of category '{target_bxdf}' for node '{node.getName()}'")
919 return None
920 targetNode.setType("surfaceshader")
921 #targetNodeDef = targetNode.getNodeDef()
922
923 # Create a translation node based on the translator nodedef.
924 translationNode = doc.addNodeInstance(nodedef,
925 targetNode.getName() + "_translator")
926
927 # Copy over inputs from the source node to the translation node.
928 # Note that this will copy over all attributes including upstream connections.
929 #print('> Add translation inputs.')
930 num_overrides = 0
931 for input in node.getActiveInputs():
932 translationInput = translationNode.addInputFromNodeDef(input.getName()) #translationNode.getInput(input.getName())
933 #print('>> Overwrite input:', translationInput.getName())
934 if translationInput:
935 translationInput.copyContentFrom(input)
936 num_overrides += 1
937 #print(f'>> Overwrote {num_overrides} inputs on translation node.')
938
939 # Connect translation outputs to target inputs.
940 impl = nodedef.getImplementation();
941 for output in nodedef.getActiveOutputs():
942
943 # Avoid adding ports which do not route an input data
944 impl_output = impl.getOutput(output.getName())
945 if not impl_output.getConnectedNode():
946 continue
947
948 target_input_name = output.getName()
949 # Remove trailing '_out' from name
950 target_input_name = target_input_name[:-4] if target_input_name.endswith('_out') else target_input_name
951
952 translationOutput = translationNode.addOutput(output.getName(), output.getType())
953 translationOutput.copyContentFrom(output)
954
955 target_input = targetNode.addInputFromNodeDef(target_input_name);
956 if not target_input:
957 print(f" - Warning: Target node '{targetNode.getName()}' has no input named '{target_input_name}' for output '{output.getName()}'")
958 continue
959 else:
960 #print('Target input name:', target_input_name)
961 target_input.setNodeName(translationNode.getName())
962 target_input.setOutputString(translationOutput.getName())
963 target_input.removeAttribute('value')
964
965
966 # Remove original node
967 doc.removeNode(node.getName())
968
969 return {'translationNode' : translationNode, 'targetNode' : targetNode }
970
971 def add_copyright_comment(self, doc, shaderCategory, embedDate=False):
972 # Add header comments
973 self.addComment(doc, 'Physically Based Materials from https://api.physicallybased.info ')
974 self.addComment(doc, ' Content Author: Anton Palmqvist, https://antonpalmqvist.com/ ')
975 self.addComment(doc, f' Content processsed via REST API and mapped to MaterialX V{self.mx.getVersionString()} ')
976 if shaderCategory:
977 self.addComment(doc, f' Target Shading Model: {shaderCategory} ')
978 self.addComment(doc, ' Utility Author: Bernard Kwok. kwokcb@gmail.com ')
979 if embedDate:
980 now = datetime.datetime.now()
981 dt_string = now.strftime("%Y-%m-%d %H:%M:%S")
982 self.addComment(doc, f' Generated on: {dt_string} ')
983
984 def convertToMaterialX(self, materialNames = [], shaderCategory='standard_surface',
985 remapKeys = {}, shaderPreFix ='') -> mx.Document:
986 '''
987 @brief Convert the Physically Based Materials to MaterialX format for a given target shading model.
988 @param materialNames The list of material names to convert. If empty, all materials will be converted.
989 @param shaderCategory The target shading model to convert to. Default is 'standard_surface'.
990 @param remapKeys The remapping keys for the target shading model. If empty, the default remapping keys will be used.
991 @param shaderPreFix The prefix to add to the shader name. Default is an empty string.
992 @return The MaterialX document
993 '''
994 if not self.mx:
995 self.logger.critical(f'> {self._getMethodName()}: MaterialX module is required')
996 return None
997
998 if not self.support_openpbr and shaderCategory == 'open_pbr_surface':
999 self.logger.warning(f'> OpenPBR shading model not supported in MaterialX version {self.mx.getVersionString()}')
1000 return None
1001
1002 if not self.materials:
1003 self.logger.info('> No materials to convert')
1004 return None
1005
1006 if len(remapKeys) == 0:
1007 remapKeys = self.getInputRemapping(shaderCategory)
1008 #if len(remapKeys) == 0:
1009 # self.logger.warning(f'> No remapping keys found for shading model: {shaderCategory}')
1010
1011 # Create main document and import the library document
1012 self.doc = self.mx.createDocument()
1013 if not self.doc:
1014 return None
1015
1016 self.doc.importLibrary(self.stdlib)
1017
1018 self.add_copyright_comment(self.doc, shaderCategory)
1019
1020 # Add properties to the material
1021 for mat in self.materials:
1022 matName = mat['name']
1023 uiName = matName
1024
1025 # Filter by material name(s)
1026 if len(materialNames) > 0 and matName not in materialNames:
1027 #self.logger.debug('Skip material: ' + matName)
1028 continue
1029
1030 if (len(shaderPreFix) > 0):
1031 matName = matName + '_' + shaderPreFix
1032
1033 shaderName = self.doc.createValidChildName(matName + '_SHD_PBM')
1034 self.addComment(self.doc, ' Generated shader: ' + shaderName + ' ')
1035 shaderNode = self.doc.addNode(shaderCategory, shaderName, self.mx.SURFACE_SHADER_TYPE_STRING)
1036 shaderNode.setAttribute('uiname', uiName)
1037
1038 folderString = ''
1039 if 'category' in mat:
1040 folderString = mat['category'][0]
1041 if 'group' in mat:
1042 if len(folderString) > 0:
1043 folderString += '/'
1044 folderString += mat['group']
1045 if len(folderString) > 0:
1046 shaderNode.setAttribute("uifolder", folderString)
1047
1048 docString = mat['description']
1049 refString = mat['reference']
1050 if len(refString) > 0:
1051 if len(docString) > 0:
1052 docString += '. '
1053 docString += 'Reference: ' + refString[0]
1054 if len(docString) > 0:
1055 shaderNode.setDocString(docString)
1056
1057 # TODO: Add in option to add all inputs + add nodedef string
1058 #shaderNode.addInputsFromNodeDef()
1059 #shaderNode.setAttribute(self.mx.InterfaceElement.NODE_DEF_ATTRIBUTE, nodedefString)
1060
1061 # Create a new material
1062 materialName = self.doc.createValidChildName(matName + '_MAT_PBM')
1063 self.addComment(self.doc, ' Generated material: ' + materialName + ' ')
1064 materialNode = self.doc.addNode(self.mx.SURFACE_MATERIAL_NODE_STRING, materialName, self.mx.MATERIAL_TYPE_STRING)
1065 shaderInput = materialNode.addInput(self.mx.SURFACE_SHADER_TYPE_STRING, self.mx.SURFACE_SHADER_TYPE_STRING)
1066 shaderInput.setAttribute(self.MTLX_NODE_NAME_ATTRIBUTE, shaderNode.getName())
1067
1068 # Keys to skip.
1069 skipKeys = ['name', "density", "category", "description", "sources", "tags", "reference"]
1070
1071 metallness = None
1072 roughness = None
1073 color = None
1074 transmission = None
1075 for key, value in mat.items():
1076
1077 if (key not in skipKeys):
1078 # Keep track of these for possible transmission color remapping
1079 if key == 'metalness':
1080 metallness = value
1081 if key == 'roughness':
1082 roughness = value
1083 if key == 'transmission':
1084 transmission = value
1085 if key == 'color':
1086 color = value
1087
1088 if key in remapKeys:
1089 key = remapKeys[key]
1090 input = shaderNode.addInputFromNodeDef(key)
1091 if input:
1092 # Convert number vector to string
1093 if isinstance(value, list):
1094 value = ','.join([str(x) for x in value])
1095 # Convert number to string:
1096 elif isinstance(value, (int, float)):
1097 value = str(value)
1098 input.setValueString(value)
1099 else:
1100 self.logger.debug('Skip unsupported key: ' + key)
1101
1102 # Re-route color to mapped transmission_color if needed
1103 if (transmission != None) and (metallness != None) and (roughness != None) and (color != None):
1104 if (metallness == 0) and (roughness == 0):
1105 if 'transmission_color' in remapKeys:
1106 key = remapKeys['transmission_color']
1107 input = shaderNode.addInputFromNodeDef(key)
1108 if input:
1109 self.logger.debug(f'Set transmission color {key}: {color}')
1110 value = ','.join([str(x) for x in color])
1111 input.setValueString(value)
1112
1113 return self.doc
1114
1115 def writeMaterialXToFile(self, filename, doc = None):
1116 '''
1117 @brief Write the MaterialX document to disk
1118 @param filename The filename to write the MaterialX document to
1119 @return None
1120 '''
1121 if not self.mx:
1122 self.logger.critical(f'> {self._getMethodName()}: MaterialX module is required')
1123 return
1124
1125 output_doc = doc if doc else self.doc
1126 if not output_doc:
1127 self.logger.critical(f'> {self._getMethodName()}: No MaterialX document to write')
1128 return
1129
1130 writeOptions = self.mx.XmlWriteOptions()
1131 writeOptions.writeXIncludeEnable = False
1132 writeOptions.elementPredicate = self.skipLibraryElement
1133 self.mx.writeToXmlFile(output_doc, filename, writeOptions)
1134
1136 '''
1137 @brief Convert the MaterialX document to a string
1138 @return The MaterialX document as a string
1139 '''
1140 if not self.mx:
1141 self.logger.critical(f'> {self._getMethodName()}: MaterialX module is required')
1142 return
1143
1144 writeOptions = self.mx.XmlWriteOptions()
1145 writeOptions.writeXIncludeEnable = False
1146 writeOptions.elementPredicate = self.skipLibraryElement
1147 mtlx = self.mx.writeToXmlString(self.doc, writeOptions)
1148 return mtlx
1149
1150 @staticmethod
1151 def create_working_document() -> dict[str, mx.Document]:
1152 doc : mx.Document = mx.createDocument()
1153 stdlib : mx.Document = mx.createDocument()
1154
1155 searchPath : mx.FileSearchPath = mx.getDefaultDataSearchPath()
1156 libraryFolders : list[mx.FilePath]= mx.getDefaultDataLibraryFolders()
1157 libraryFiles : set[str] = mx.loadLibraries(libraryFolders, searchPath, stdlib)
1158 doc.setDataLibrary(stdlib)
1159 nodedefs : list[mx.NodeDef] = doc.getNodeDefs()
1160 #print(f"Created working doc with {len(nodedefs)} nodedefs from standard library.")
1161
1162 result = { "doc": doc, "stdlib": stdlib }
1163 return result
Class to load Physically Based Materials from the PhysicallyBased site.
dict|None translate_node(self, mx.Document doc, str source_bxdf, str target_bxdf, mx.Node node)
Translate a shader node of source_bxdf to target_bxdf using ungrouped nodes.
dict getInputRemapping(self, shadingModel)
Get the remapping keys for a given shading model.
__init__(self, mx_module, Optional[mx.Document] mx_stdlib=None, str materials_file='')
Constructor for the PhysicallyBasedMaterialLoader class.
list getJSONMaterialNames(self)
Get the list of material names from the JSON object.
dict loadMaterialsFromString(self, matString)
Load the Physically Based Materials from a JSON string.
dict loadMaterialsFromFile(self, fileName)
Load the Physically Based Materials from a JSON file.
physlib_materials
Document containing PhysicallyBased materials using PhysicallyBasedMaterial definition.
mx.Document get_physlib(self)
Get the Physically Based MaterialX definition library.
physlib_translators
Document containing PhysicallyBased MaterialX translators.
create_translator(self, mx.Document doc, str source, str target, source_version="", target_version="", mappings=None, mx.Document|None output_doc=None)
Create a translator nodedef and nodegraph from source to target definitions.
mx.NodeDef|None get_physlib_definition(self)
Get the Physically Based MaterialX definition NodeDef.
str get_physlib_definition_name(self)
Get the Physically Based MaterialX definition name.
initialize_definitions_and_materials(self, str materials_file='')
Initialize Physically Based MaterialX definitions, materials, remappings, and translators.
mx.Document create_definition(self, mx.Document|None doc)
Create a NodeDef for the Physically Based Material inputs.
list[mx.NodeDef] create_all_translators(self, mx.Document definitions, mx.Document|None output_doc=None)
Create translators for all supported shading models.
str get_physlib_implementation_name(self)
Get the Physically Based MaterialX implementation (nodegraph) name.
mx.NodeDef|None find_translator(self, mx.Document doc, str source, str target)
Find a translator nodedef from source to target in the document.
mx.Document get_definitions(self)
Get a combined MaterialX document containing the standard library and Physically Based MaterialX defi...
mx.Document get_physlib_materials(self)
Get the Physically Based MaterialX materials document.
writeMaterialXToFile(self, filename, doc=None)
Write the MaterialX document to disk.
list[mx.NodeDef] find_all_bxdf(self, mx.Document doc)
Scan all nodedefs with output type of "surfaceshader" doc : The MaterialX document to scan.
setDebugging(self, debug=True)
Set the debugging level for the logger.
str physlib_implementation_name
PhysicallyBased MaterialX surface implementation (nodegraph) name.
create_definition_materials(self, doc_mat, filter_list=None)
Create a MaterialX document containing Physically Based MaterialX materials.
_getMethodName(self)
Get the name of the calling method for logging purposes.
dict getMaterialsFromURL(self)
Get the Physically Based Materials from the PhysicallyBased site.
initializeInputRemapping(self)
Initialize remapping keys for different shading models.
writeRemappingFile(self, filepath)
Write the remapping keys to a JSON file.
str get_physlib_category(self)
Get the Physically Based MaterialX surface category.
mx.Document get_stdlib(self)
Get the MaterialX standard library document.
dict getJSON(self)
Get the JSON object representing the Physically Based Materials.
all_lib
All MaterialX definitions (standard library + PhysicallyBased definition + translators).
mx.Document get_translators(self)
Get the Physically Based MaterialX translators document.
bool skipLibraryElement(elem)
Utility to skip library elements when iterating over elements in a document.
mx.Document convertToMaterialX(self, materialNames=[], shaderCategory='standard_surface', remapKeys={}, shaderPreFix='')
Convert the Physically Based Materials to MaterialX format for a given target shading model.
readRemappingFile(self, filepath)
Read the remapping keys from a JSON file.
addComment(self, doc, commentString)
Add a comment to the MaterialX document.
mx.Document physlib
Document containing PhysicallyBased definition library.
str physlib_definition_name
PhysicallyBased MaterialX surface definition name.