{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Graph Connectivity\n",
"\n",
"\n",
"\n",
"This notebook will look at how to extract out connectivity information from a MaterialX document. \n",
"\n",
"1. The first goal is to extract out the list of nodes for each node graph in a document, noting that the \"top-level\" document is itself a node graph (a GraphElement). Issues addressed include how to navigate the \"unduly complex\" representation of connections as the representation is geared towards\n",
"a text representation suitable for storage rather than a run-time representation. Note that both `compound` and `functional` graphs (used by definitions are handled).\n",
"\n",
"2. Once extracted the connectivity information can be used for various purposes. This book will examine the information to produce a node graph in Mermaid format. Issues addressed include how to map the node and connectivity information to a graph syntax which has no concept of \"ports\" or \"pins\". Additionally meta data is examined to provide for graphs which are visually easier to examine by adding attributes such as user coloring of different node types. \n",
"\n",
"The logic shown here has been encapsulated as two Python classes in the `mtlxutils` library insdie `traversal.py`:\n",
"1. `MtlxGraphBuilder` : Class which builds the connectivity information and allows for serialization to JSON format.\n",
"2. `MxMermaidGraphExporter` : Class which can read and parse the connectivity information to produce Mermaid graphs. \n",
"\n",
"For this site:\n",
"- The utilities (including Mermaid generation) in this tutorial are collected in the `mtlxutils` file: `mxtraversal.py`.\n",
"- The command `mxgraphio.py` found in the `pymaterialx` folder wraps up these utilities. \n",
"- All Mermaid diagrams on this site are generated using the `mxgraphio.py` command line utility or the `mtlxutils` library.\n",
"- The Javascript module `JsMaterialGraph` is used for interactive graph generation on the Graph Editing page."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Setup\n",
"\n",
"The basic setup includes loading MaterialX as well as support libraries. The assumption is that at least version 1.38.7 of MaterialX has been installed."
]
},
{
"cell_type": "code",
"execution_count": 113,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Using MaterialX version: 1.39.0\n"
]
}
],
"source": [
"# Helpers\n",
"import os\n",
"from IPython.display import display_markdown # For markdown display in Jupyter\n",
"\n",
"# MaterialX imports\n",
"import MaterialX as mx\n",
"from mtlxutils.mxbase import *\n",
"\n",
"# Do a version check\n",
"haveVersion1387 = haveVersion(1, 38, 7) \n",
"if not haveVersion1387:\n",
" print(\"** Warning: Recommended minimum version is 1.38.7 for tutorials. Have version: \", mx.__version__)\n",
"else:\n",
" print(\"Using MaterialX version:\", mx.__version__)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Definition Library Requirement\n",
"\n",
"To be able to handle any non-explicitly defined inputs and outputs and node information, the standard MaterialX node library is required. \n",
"As a first step we load in libraries and create a working document."
]
},
{
"cell_type": "code",
"execution_count": 114,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Create working document and loaded in: 750 standard library definitions\n"
]
}
],
"source": [
"def createWorkingDocument():\n",
" stdlib = mx.createDocument()\n",
" searchPath = mx.getDefaultDataSearchPath()\n",
" libraryFolders = mx.getDefaultDataLibraryFolders()\n",
" try:\n",
" libFiles = mx.loadLibraries(libraryFolders, searchPath, stdlib)\n",
" print('Create working document and loaded in: %s standard library definitions' % len(stdlib.getNodeDefs()))\n",
" except mx.Exception as err:\n",
" print('Failed to load standard library definitions: \"', err, '\"')\n",
"\n",
" doc = mx.createDocument()\n",
" doc.importLibrary(stdlib)\n",
"\n",
" return doc\n",
"\n",
"doc = createWorkingDocument()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Graph \"Dictionary\"\n",
"\n",
"As a first step a \"dictionary\" containing all the nodes in a document, grouped by graph is generated.\n",
"Each dictionary is of the form:\n",
"```\n",
" [ ... ]\n",
"```\n",
"such that for each graph (keyed by path), a list of node paths is kept.\n",
"As the root `Document` has no path name an emptry string indicates the root graph.\n",
"\n",
"Two functions are shown below to for graph dictionary building:\n",
"1. `updateGraphDictionaryPath()` : Add a child node path to the list of node paths for a given graph path.\n",
"2. `updateGraphDictionaryItem()` : Add a new graph / node pair. "
]
},
{
"cell_type": "code",
"execution_count": 115,
"metadata": {},
"outputs": [],
"source": [
"def updateGraphDictionaryPath(key, item, nodetype, type, value, graphDictionary):\n",
" '''\n",
" Add a parent / child to the GraphElement dictionary\n",
"\n",
" Arguments:\n",
" key: The parent graph path\n",
" value: The graph node path\n",
" nodetype: The type of the node\n",
" graphDictionary: The dictionary to add the Element to.\n",
" '''\n",
" if key in graphDictionary:\n",
" #print('add:', key, value, nodetype)\n",
" graphDictionary[key].append([item, nodetype, type, value])\n",
" else:\n",
" #print('add:', key, value, nodetype)\n",
" graphDictionary[key] = [[item, nodetype, type, value]]\n",
"\n",
"def updateGraphDictionaryItem(item, graphDictionary):\n",
" \"\"\"\n",
" Add a Element to the GraphElement dictionary, where the keys are the GraphElement's path, and the value\n",
" is a list of child Element paths\n",
" \"\"\"\n",
" if not item:\n",
" return\n",
"\n",
" parentElem = item.getParent()\n",
" if not parentElem or not parentElem.isA(mx.GraphElement):\n",
" return\n",
"\n",
" key = parentElem.getNamePath()\n",
" value = item.getNamePath()\n",
" itemType = item.getType()\n",
" itemCategory = item.getCategory()\n",
" itemValue = ''\n",
" if item.isA(mx.Node):\n",
" inputs = item.getInputs()\n",
" if len(inputs) == 1:\n",
" itemValue = inputs[0].getValueString()\n",
" elif item.isA(mx.Input):\n",
" itemValue = item.getValueString()\n",
"\n",
" updateGraphDictionaryPath(key, value, itemCategory, itemType, itemValue, graphDictionary)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"To examine the contents of the dictionay a `printGraphDictionary()` function is added. "
]
},
{
"cell_type": "code",
"execution_count": 116,
"metadata": {},
"outputs": [],
"source": [
"def printGraphDictionary(graphDictionary: dict):\n",
" \"\"\"\n",
" Print out the graph dictionary\n",
" \"\"\"\n",
" for graphPath in graphDictionary:\n",
" if graphPath == '':\n",
" print('Root Document:')\n",
" else:\n",
" print(graphPath + ':')\n",
"\n",
" filter = 'input'\n",
" # Top level document has not path, so just output some identifier string\n",
" for item in graphDictionary[graphPath]:\n",
" if item[1] != filter:\n",
" continue\n",
" print('- ', item)\n",
" filter = 'output'\n",
" # Top level document has not path, so just output some identifier string\n",
" for item in graphDictionary[graphPath]:\n",
" if item[1] != filter:\n",
" continue\n",
" print('- ', item)\n",
" filter = ['output', 'input']\n",
" # Top level document has not path, so just output some identifier string\n",
" for item in graphDictionary[graphPath]:\n",
" if item[1] not in filter:\n",
" print('- ', item)\n",
"\n",
"def getParentGraph(elem):\n",
" '''\n",
" Find the parent graph of the given element\n",
" '''\n",
" while (elem and not elem.isA(mx.GraphElement)):\n",
" elem = elem.getParent()\n",
" return elem\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Connection Utlities\n",
"\n",
"To aid with building connection information a few additional functions are added below."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"#### Finding Default Upstream Output\n",
"\n",
"To handle when an output is not explicitly specified for a graph or node, a utility function called `getDefaultOutput()` is added.\n",
"It will simply return the first output found. \n",
"\n",
"This is useful when trying to find the upstream output port connecged to downstream output, but the name of the output is not explicitly specified.\n",
"This is an inconsistency which must be handled where only for upstream nodes or graphs which have more than one output allows for a output to be specified -- otherwise validation fails."
]
},
{
"cell_type": "code",
"execution_count": 117,
"metadata": {},
"outputs": [],
"source": [
"\n",
"def getDefaultOutput(node: mx.Element) -> str:\n",
" '''\n",
" Get the default output of a node or nodegraph. Returns the first output found. \n",
" '''\n",
" if not node:\n",
" return ''\n",
"\n",
" defaultOutput = None\n",
" if node.isA(mx.Node):\n",
" nodedef = node.getNodeDef()\n",
" if nodedef:\n",
" defaultOutput = nodedef.getActiveOutputs()[0]\n",
" else:\n",
" print('Cannot find nodedef for node:', node.getNamePath())\n",
" elif node.isA(mx.NodeGraph):\n",
" defaultOutput = node.getOutputs()[0]\n",
"\n",
" if defaultOutput:\n",
" return defaultOutput.getName()\n",
" return '' \n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"#### Appending Path Identifiers\n",
"\n",
"A utility called `appendPath()` is added as a simple helper as there is no formal API for manipulating graph paths. It is assumed that `/` is always the path seperator."
]
},
{
"cell_type": "code",
"execution_count": 118,
"metadata": {},
"outputs": [],
"source": [
"def appendPath(p1: str, p2: str) -> str:\n",
" '''\n",
" Append two paths together, with a '/' separator.\n",
"\n",
" Arguments:\n",
" p1: The first path\n",
" p2: The second path\n",
"\n",
" Returns:\n",
" The appended path\n",
" '''\n",
" PATH_SEPARATOR = '/'\n",
"\n",
" if p2:\n",
" return p1 + PATH_SEPARATOR + p2\n",
" return p1\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Core Connection Logic\n",
"\n",
"The function `buildPortConnection()` contains the core logic to determine what output node / graph and port is connected to an downstream node / nodegraph port.\n",
"\n",
"Each connection is of the form:\n",
"```\n",
"[ , [], , , ]\n",
"```\n",
"where the `` may be path to `input` or `output` or other node type, `` a path to an `input`, `output` or other node type, and `JSON Export\n",
"\n",
"Below is the graph dictionary contents written to file:\n",
"\n",
" \n",
"\n",
"> Note that it is possible to view this output with better formatting by installing an appropriate plug-in\n",
"when viewed from a browser."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Mermaid Graph Visualization \n",
"\n",
"To demonstrate how to parse the dictionary and connections a sample `Mermaid` diagram generator is provided below.\n",
"\n",
"The general logic:\n",
"1. Outputs all nodes and graphs first, adding in various user formatting.\n",
"2. Outputs the connections, again adding in various user formatting.\n",
"\n",
"Of note is that the original path information is used as element identifiers with \"nice\" names being generated as necessary.\n",
"\n",
"> Note that there is no dependence on MaterialX for any of the parsing or display logic. "
]
},
{
"cell_type": "code",
"execution_count": 126,
"metadata": {},
"outputs": [],
"source": [
"class MxMermaidGraphExporter:\n",
" def __init__(self, graphDictionary, connections):\n",
" self.graphDictionary = graphDictionary\n",
" self.connections = connections\n",
" self.mermaid = []\n",
" self.orientation = 'LR'\n",
" self.emitCategory = False\n",
" self.emitType = False\n",
"\n",
" def setOrientation(self, orientation):\n",
" self.orientation = orientation\n",
"\n",
" def setEmitCategory(self, emitCategory):\n",
" self.emitCategory = emitCategory\n",
"\n",
" def setEmitType(self, emitType):\n",
" self.emitType = emitType\n",
"\n",
" def sanitizeString(self, path):\n",
" #return path\n",
" path = path.replace('/default', '/default1')\n",
" path = path.replace('/', '_')\n",
" path = path.replace(' ', '_')\n",
" return path\n",
"\n",
" def execute(self):\n",
" mermaid = []\n",
" mermaid.append('graph %s' % self.orientation)\n",
" for graphPath in self.graphDictionary:\n",
" isSubgraph = graphPath != ''\n",
" if isSubgraph:\n",
" mermaid.append(' subgraph %s' % graphPath)\n",
" for item in self.graphDictionary[graphPath]:\n",
" path = item[0]\n",
" # Get \"base name\" of the path\n",
" label = path.split('/')[-1]\n",
" # Sanitize the path name\n",
" path = self.sanitizeString(path)\n",
"\n",
" if self.emitCategory:\n",
" label = item[1]\n",
" if self.emitType:\n",
" label += \":\" + item[2]\n",
" if item[3]:\n",
" label += \":\" + item[3]\n",
" # Color nodes\n",
" if item[1] == 'input' or item[1] == 'output':\n",
" if item[1] == 'input':\n",
" mermaid.append(' %s([%s])' % (path, label))\n",
" mermaid.append(' style %s fill:#09D, color:#111' % path)\n",
" else:\n",
" mermaid.append(' %s([%s])' % (path, label))\n",
" mermaid.append(' style %s fill:#0C0, color:#111' % path)\n",
" elif item[1] == 'surfacematerial':\n",
" mermaid.append(' %s([%s])' % (path, label))\n",
" mermaid.append(' style %s fill:#090, color:#111' % path)\n",
" elif item[1] == 'nodedef':\n",
" mermaid.append(' %s[[%s]]' % (path, label))\n",
" mermaid.append(' style %s fill:#00C, color:#111' % path)\n",
" elif item[1] in ['ifequal', 'ifgreatereq', 'switch']:\n",
" mermaid.append(' %s{%s}' % (path, label))\n",
" mermaid.append(' style %s fill:#C72, color:#111' % path)\n",
" elif item[1] == 'token':\n",
" mermaid.append(' %s{{%s}}' % (path, label))\n",
" mermaid.append(' style %s fill:#222, color:#111' % path) \n",
" elif item[1] == 'constant':\n",
" mermaid.append(' %s([%s])' % (path, label))\n",
" mermaid.append(' style %s fill:#500, color:#111' % path) \n",
" else:\n",
" mermaid.append(' %s[%s]' % (path, label))\n",
"\n",
" if isSubgraph:\n",
" mermaid.append(' end')\n",
" self.mermaid = mermaid\n",
" \n",
" for connection in self.connections:\n",
" source = ''\n",
"\n",
" # Sanitize path names\n",
" connection[0] = self.sanitizeString(connection[0])\n",
" connection[2] = self.sanitizeString(connection[2])\n",
"\n",
" # Set source node. If nodes is in a graph then we use / as source\n",
" source = connection[0]\n",
" \n",
" # Set destination node\n",
" dest = connection[2]\n",
"\n",
" # Edge can be combo of source output port + destination input port\n",
" if len(connection[1]) > 0:\n",
" if len(connection[3]) > 0:\n",
" edge = connection[1] + '-->' + connection[3]\n",
" else:\n",
" edge = connection[1]\n",
" else:\n",
" edge = connection[3]\n",
"\n",
" if connection[4] == 'value':\n",
" sourceNode = mx.createValidName(source)\n",
" if len(edge) == 0: \n",
" connectString = ' %s[\"%s\"] --> %s' % (sourceNode, source, dest)\n",
" else:\n",
" connectString = ' %s[\"%s\"] --%s--> %s' % (sourceNode, source, edge, dest)\n",
" else:\n",
" if len(edge) > 0: \n",
" connectString = ' %s --\"%s\"--> %s' % (source, edge, dest)\n",
" else:\n",
" connectString = ' %s --> %s' % (source, dest)\n",
" mermaid.append(connectString)\n",
"\n",
" return mermaid\n",
"\n",
" def write(self, filename):\n",
" with open(filename, 'w') as f:\n",
" for line in self.export():\n",
" f.write('%s\\n' % line)\n",
"\n",
" def getGraph(self, wrap=True):\n",
" result = ''\n",
" if wrap:\n",
" result = '```mermaid\\n' + '\\n'.join(self.mermaid) + '\\n```'\n",
" else:\n",
" result = '\\n'.join(self.mermaid)\n",
" # Sanitize\n",
" result = result.replace('/default', '/default1')\n",
" return result\n",
"\n",
" def display(self):\n",
" display_markdown(self.getGraph(), raw=True)\n",
"\n",
" # Export mermaid\n",
" def export(self, filename):\n",
" mermaidGraph = self.getGraph()\n",
" with open(filename, 'w') as outFile:\n",
" outFile.write(mermaidGraph)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"To visualize the Mermaid graph we export the graph to Markdown within a HTML document."
]
},
{
"cell_type": "code",
"execution_count": 127,
"metadata": {},
"outputs": [
{
"data": {
"text/markdown": [
"```mermaid\n",
"graph TB\n",
" subgraph upstream3\n",
" upstream3_file([file:resources/Images/cloth.png])\n",
" style upstream3_file fill:#09D, color:#111\n",
" upstream3_file1([file1:resources/Images/grid.png])\n",
" style upstream3_file1 fill:#09D, color:#111\n",
" upstream3_out([out])\n",
" style upstream3_out fill:#0C0, color:#111\n",
" upstream3_out1([out1])\n",
" style upstream3_out1 fill:#0C0, color:#111\n",
" upstream3_upstream_image[upstream_image]\n",
" upstream3_upstream_image1[upstream_image1]\n",
" end\n",
" subgraph upstream2\n",
" upstream2_upstream2_in1([upstream2_in1])\n",
" style upstream2_upstream2_in1 fill:#09D, color:#111\n",
" upstream2_upstream2_in2([upstream2_in2])\n",
" style upstream2_upstream2_in2 fill:#09D, color:#111\n",
" upstream2_upstream2_out1([upstream2_out1])\n",
" style upstream2_upstream2_out1 fill:#0C0, color:#111\n",
" upstream2_upstream2_out2([upstream2_out2])\n",
" style upstream2_upstream2_out2 fill:#0C0, color:#111\n",
" upstream2_multiply_by_image[multiply_by_image]\n",
" upstream2_make_red[make_red]\n",
" upstream2_image[image:resources/Images/grid.png]\n",
" end\n",
" subgraph upstream1\n",
" upstream1_upstream1_in1([upstream1_in1])\n",
" style upstream1_upstream1_in1 fill:#09D, color:#111\n",
" upstream1_upstream1_in2([upstream1_in2])\n",
" style upstream1_upstream1_in2 fill:#09D, color:#111\n",
" upstream1_upstream1_out1([upstream1_out1])\n",
" style upstream1_upstream1_out1 fill:#0C0, color:#111\n",
" upstream1_upstream1_out2([upstream1_out2])\n",
" style upstream1_upstream1_out2 fill:#0C0, color:#111\n",
" upstream1_make_yellow[make_yellow]\n",
" upstream1_remove_red[remove_red]\n",
" end\n",
" top_upstream1_out1([top_upstream1_out1])\n",
" style top_upstream1_out1 fill:#0C0, color:#111\n",
" top_upstream1_out2([top_upstream1_out2])\n",
" style top_upstream1_out2 fill:#0C0, color:#111\n",
" standard_surface[standard_surface]\n",
" standard_surface1[standard_surface1]\n",
" surfacematerial([surfacematerial])\n",
" style surfacematerial fill:#090, color:#111\n",
" surfacematerial1([surfacematerial1])\n",
" style surfacematerial1 fill:#090, color:#111\n",
" upstream3_file --\"file\"--> upstream3_upstream_image\n",
" upstream3_file1 --\"file\"--> upstream3_upstream_image1\n",
" upstream3_upstream_image --> upstream3_out\n",
" upstream3_upstream_image1 --> upstream3_out1\n",
" upstream3_out --> upstream2_upstream2_in1\n",
" upstream3_out1 --> upstream2_upstream2_in2\n",
" upstream2_upstream2_in1 --\"in1\"--> upstream2_multiply_by_image\n",
" upstream2_image --\"in2\"--> upstream2_multiply_by_image\n",
" upstream2_upstream2_in2 --\"in1\"--> upstream2_make_red\n",
" upstream2_multiply_by_image --> upstream2_upstream2_out1\n",
" upstream2_make_red --> upstream2_upstream2_out2\n",
" upstream2_upstream2_out1 --> upstream1_upstream1_in1\n",
" upstream2_upstream2_out2 --> upstream1_upstream1_in2\n",
" upstream1_upstream1_in1 --\"in1\"--> upstream1_make_yellow\n",
" upstream1_upstream1_in2 --\"in1\"--> upstream1_remove_red\n",
" upstream1_make_yellow --> upstream1_upstream1_out1\n",
" upstream1_remove_red --> upstream1_upstream1_out2\n",
" upstream1_upstream1_out1 --> top_upstream1_out1\n",
" upstream1_upstream1_out2 --> top_upstream1_out2\n",
" upstream1_upstream1_out1 --\"base_color\"--> standard_surface\n",
" upstream1_upstream1_out2 --\"base_color\"--> standard_surface1\n",
" standard_surface --\"surfaceshader\"--> surfacematerial\n",
" standard_surface1 --\"surfaceshader\"--> surfacematerial1\n",
"```"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Write graph to HTML file: ./data/graphtest_output.html\n"
]
}
],
"source": [
"exporter = MxMermaidGraphExporter(graphDictionary, connections)\n",
"exporter.setOrientation('TB')\n",
"exporter.execute()\n",
"\n",
"exporter.display()\n",
"\n",
"# In order to get the proper mermaid rendering, we need to add the mermaid script, and write to another file.\n",
"result = exporter.getGraph()\n",
"result = result.replace('```mermaid', '