{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ " ## Blender and MaterialX\n", "\n", "\n", "\n", " In this notebook, we take a look at MaterialX export from an application -- in this case Blender.\n", "\n", " The key items covered are:\n", "\n", " * Discovery of MaterialX package and version within an application.\n", "\n", " * Current bespoke conversion via code in lieu of any data driven option.\n", "\n", " * Recording the on-going progression of MaterialX integration into Blender as 3.5 is the first default inclusion of MaterialX. That is, this notebook will be updated as new versions of Blender come out with enhanced MaterialX support. This will include how to use custom MaterialX libraries for Blender nodes when available. *See the Libraries / Definitions notebook on current library integrations*\n", "\n", "\n", "\n", " The Python MaterialX module which is available as part of the distribution as of `Blender 3.5`.\n", "\n", " This can be found roughly here relative to the install location:\n", "\n", " ```\n", "\n", " /Blender Foundation/Blender 3.5/3.5/python/lib/site-packages/MaterialX\n", "\n", " ```\n", "\n", "\n", "\n", " The logic presented shows how usage of custom nodes can be converted back to a \"standard\" shader node representation (in this case MaterialX but USD could be another target). Native applications nodes such as found in Blender are not considered \"standard\". Naturally the long term ideal is that a MaterialX nodes are natively represented in an application like Blender in\n", "\n", " which case something like an export / import process is \"trivial\".\n", "\n", "\n", "\n", " The notebook thus shows the \"fragility\" of logic built on top of node and value types on one end, but it\n", "\n", " does reuse of MaterialX node graph utilities in `mxutils/mxnodegraph` (See the\n", "\n", " Nodegraph notebook)\n", "\n", "\n", "\n", " Plug-ins such as `AMD's ProRender` plug-in which includes native MaterialX nodes is a full integration with\n", "\n", " complete exporter code that can found here.\n", "\n", "\n", "\n", " The logic found here is however detached from any particular plug-in so can also be used as \"starter code\" if desired.\n", "\n", "\n", "\n", " The code can be run from within Blender itself or standalone if the user installs the Blender Python package\n", "\n", "\n", "\n", " > Note that:\n", "\n", " > * The minimal Python version is 3.10. This is built with Blender which is in progress but is not currently a Python version which is built as part of the MaterialX Release distribution.\n", "\n", " > * The MaterialX version is 1.38.6 at time of writing.\n", "\n", "\n", "\n", " As MaterialX support in Blender is in progress, so the only workflow\n", "\n", " that will be shown is to target the existing `Principal Material` as\n", "\n", " export to `UsePreviewSurface` to avoid writing a lot of code targeted at the short term.\n", "\n", "\n", "\n", " ### Target: USDPreviewSurface\n", "\n", " ```xml\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " ```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " ### Integration Targets\n", "\n", "\n", "\n", " Code shown here can be executed within Blender itself as shown below.\n", "\n", "\n", "\n", " \n", "\n", "\n", "\n", " or within Visual Studio Code.\n", "\n", "\n", "\n", " \n", "\n", "\n", "\n", " With the results available to use for any MaterialX integration such as MaterialXView below.\n", "\n", " \n", "\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " ## Blender Setup\n", "\n", "\n", "\n", " The Blender Python package is imported for usage after installation." ] }, { "cell_type": "code", "execution_count": 80, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Blender Package Version: 4.0.0\n" ] } ], "source": [ "# %%\n", "# Import blender package\n", "import bpy\n", "print('Blender Package Version:', bpy.app.version_string)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " ## MaterialX Setup\n", "\n", "\n", "\n", " The basic setup imports the MaterialX package and uses additional utilities introduced in other notebooks\n", "\n", " for node / nodegraph and file handling.\n", "\n" ] }, { "cell_type": "code", "execution_count": 81, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "MaterialX Package Version: 1.39.0\n" ] } ], "source": [ "# %%\n", "# Import MaterialX package\n", "import MaterialX as mx\n", "\n", "# Version check\n", "from mtlxutils.mxbase import * \n", "haveVersion1387 = haveVersion(1,38,7)\n", "if not haveVersion1387:\n", " print(\"** Warning: Recommended version is 1.38.7 for tutorials. Have version: \", mx.__version__)\n", "else:\n", " print('MaterialX Package Version:', mx.__version__)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " To test for the existence of MaterialX, it is possible to examine the site packages included with Blender.\n", "\n", " Blender versions 3.5 and above includes MaterialX so that package would be found. Otherwise, MaterialX\n", "\n", " needs to be installed as an extra step. As of 1.38.7, the data libraries are included as part of the MaterialX package\n", "\n", " but not be found in Blender 3.5.\n", "\n", "\n", "\n", " > Note that it is possible to simplify coding for the 3.5 and higher release by copying over the 1.38.6 `libraries` folder from a release into the Blender package location.\n", "\n", "\n", "\n", " > Also note that the code is run outside of Blender so will return the package location relative to this notebook." ] }, { "cell_type": "code", "execution_count": 82, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "MaterialX package location: c:\\Users\\home\\AppData\\Local\\Programs\\Python\\Python310\\lib\\site-packages\\MaterialX\n", "Default data libraries found at: c:\\Users\\home\\AppData\\Local\\Programs\\Python\\Python310\\lib\\site-packages\\MaterialX\\libraries\n" ] } ], "source": [ "# %%\n", "# This code needs to be run within Blender to return the appropriate result.\n", "\n", "# 1. Find the Blender Python site packages folder \n", "# When run inside Blender a path like this will be returned by default (on Windows)\n", "# ['C:\\\\Program Files\\\\Blender Foundation\\\\Blender 3.5\\\\3.5\\\\python', \n", "# 'C:\\\\Program Files\\\\Blender Foundation\\\\Blender 3.5\\\\3.5\\\\python\\\\lib\\\\site-packages'] \n", "import site, os\n", "packages = site.getsitepackages()\n", "\n", "foundPackage = False\n", "librariesRoot = ''\n", "materialXRoot = ''\n", "for package in packages:\n", " pythonRoot = mx.FilePath(package)\n", "\n", " # 2. Find the location of the MaterialX package\n", " materialXRoot = pythonRoot / mx.FilePath('MaterialX') \n", "\n", " if os.path.exists(materialXRoot.asString()):\n", " foundPackage = True\n", " librariesRoot = materialXRoot / \"libraries\"\n", " if not os.path.exists(librariesRoot.asString()):\n", " librariesRoot = ''\n", " break\n", "\n", "if foundPackage:\n", " print('MaterialX package location:', materialXRoot.asString())\n", " if librariesRoot:\n", " print('Default data libraries found at:', librariesRoot.asString())\n", " else:\n", " print('Default data libraries are not part of the MaterialX package.')\n", "else:\n", " print('MaterialX package not found') \n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "\n", "\n", "\n", "\n", " An additional utility is added to write the output to file and Markdown display." ] }, { "cell_type": "code", "execution_count": 83, "metadata": {}, "outputs": [], "source": [ "# %%\n", "# Import graph and file utiities\n", "from mtlxutils.mxnodegraph import MtlxNodeGraph as mxg\n", "from mtlxutils.mxfile import MtlxFile as mxf\n", "\n", "# For Markdown output display\n", "from IPython.display import display_markdown\n", "\n", "def writeMaterialX(doc, filePath, markdown_title):\n", " \"\"\"\n", " Simple utility to write a document to a Markdown section\n", " and or a to disk.\n", " \"\"\"\n", " writeOptions = mx.XmlWriteOptions()\n", " writeOptions.writeXIncludeEnable = False\n", " writeOptions.elementPredicate = mxf.skipLibraryElement\n", "\n", " if markdown_title:\n", " documentContents = mx.writeToXmlString(doc, writeOptions)\n", " display_markdown(markdown_title, raw=True)\n", " display_markdown('```xml\\n' + documentContents + '\\n```\\n', raw=True)\n", " \n", " if filePath:\n", " mx.writeToXmlFile(doc, filePath, writeOptions)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " ## Blender to MaterialX Conversion Utilities\n", "\n", "\n", "\n", " A series of bespoke utilities have been written to handle Blender based on the current version (3.5) used." ] }, { "cell_type": "markdown", "metadata": {}, "source": [] }, { "cell_type": "markdown", "metadata": {}, "source": [ " ### Blender Value to MaterialX Node Input\n", "\n", "\n", "\n", " * `blender_createMtlxInput()` : Handles creating an named input port on given shader node given the Blender value.\n", "\n", " * There is no explicit runtime type identification (`RTTI`) thus type is derived based MaterialX definition port type, and Python type.\n", "\n", " * Blender vector type length is sanity checked and clamped against MaterialX vector types.\n", "\n", " * Blender floats are replicated if the MaterialX port is a vector.\n", "\n", " * `floatToStr()` is a simple utility to format string output for floats with fixed precision" ] }, { "cell_type": "code", "execution_count": 84, "metadata": {}, "outputs": [], "source": [ "# %%\n", "def floatToStr(val):\n", " \"\"\" \n", " Emit formatted float value to string\n", " \"\"\"\n", " return f\"{val:.4g}\"\n", "\n", "def blender_createMtlxInput(portName, blenderVal, node, nodedef):\n", " \"\"\" \n", " Creat input on shader node based on blender value \n", " \"\"\"\n", " #print('------- add input: ', portName)\n", " nodedefInput = nodedef.getInput(portName)\n", " if not nodedefInput:\n", " return\n", "\n", " valueLen = dict()\n", " valueLen['color3'] = 3\n", " valueLen['color4'] = 4\n", " valueLen['vector2'] = 2\n", " valueLen['vector3'] = 3\n", " valueLen['vector4'] = 4\n", " valueLen['float'] = 1\n", "\n", " portType = nodedefInput.getType()\n", "\n", " # Check Python type to get string values\n", " # * Use nodedef port type to clamp vector inputs. For example\n", " # * Blender colors can be 4 float (rgba) in length, but the MaterialX port is only 3 float (rgb).\n", " # * Blender float can map to a MaterialX vector. The float is replicated as needed\n", " valueString = ''\n", " valueLength = valueLen[portType]\n", " if isinstance(blenderVal, float):\n", " if valueLength == 1:\n", " valueString = floatToStr(blenderVal) \n", " else:\n", " blenderValString = []\n", " for i in range(0,valueLength):\n", " blenderValString.append(floatToStr(blenderVal))\n", " valueString = ','.join(blenderValString)\n", " elif isinstance(blenderVal, int):\n", " valueString = str(blenderVal)\n", " elif isinstance(blenderVal, str):\n", " valueString = str(blenderVal)\n", " else:\n", " if len(blenderVal) in (2,3,4):\n", " blenderValString = []\n", " for i, c in enumerate(blenderVal):\n", " if i < valueLength: \n", " blenderValString.append(floatToStr(blenderVal[i]))\n", " valueString = ','.join(blenderValString)\n", "\n", " if len(valueString): \n", " newInput = node.addInput(portName, portType)\n", " if newInput:\n", " newInput.setValueString(valueString) \n", " \n", " return newInput\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " ### Mapping of Blender Nodes / Inputs to MaterialX\n", "\n", "\n", "\n", " * `blender_init_node_dictionary()` is used to create a dictionary (mapping) from specific Blender nodes to MaterialX nodes. There appears to be no schema to define Blender nodes so hard-coded port names use for port mapping.\n", "\n", " * `blender_createMtlxShaderNode()`is used to create a MaterialX shader node from a Blender shader node. As a Blender Material maps to a MaterialX shader, we create two nodes when a material is encountered. A proper mapping of `Blender Material Output` nodes is not performed here as these best match a MaterialX material node." ] }, { "cell_type": "code", "execution_count": 85, "metadata": {}, "outputs": [], "source": [ "# %%\n", "\n", "def blender_init_node_dictionary(targetBSDF):\n", "\n", " # Manual name mapping from Blender BSDF to USD Preview Surface\n", " PBSDF_USDPS_map = dict()\n", " PBSDF_USDPS_map['Base Color'] = 'diffuseColor'\n", " PBSDF_USDPS_map['Specular'] = 'specularColor'\n", " PBSDF_USDPS_map['IOR'] = 'ior'\n", " PBSDF_USDPS_map['Clearcoat'] = 'clearcoat'\n", " PBSDF_USDPS_map['Clearcoat Roughness'] = 'clearcoatRoughness'\n", " PBSDF_USDPS_map['Metallic'] = 'metallic'\n", " PBSDF_USDPS_map['Roughness'] = 'roughness'\n", " PBSDF_USDPS_map['Alpha'] = 'opacity'\n", " PBSDF_USDPS_map['Emission'] = 'emissiveColor' \n", " PBSDF_USDPS_map['Normal'] = 'normal' \n", "\n", " IMAGE_map = dict()\n", " NORMALMAP_map = dict()\n", "\n", " # Mapping from Blender nodes to MaterialX node definitions\n", " SHADER_NODE_map = dict()\n", " SHADER_NODE_map['BSDF_PRINCIPLED'] = targetBSDF\n", " SHADER_NODE_map['TEX_IMAGE'] = 'ND_image_'\n", " SHADER_NODE_map['NORMAL_MAP'] = 'ND_normalmap'\n", "\n", " SHADER_NODE_INPUTS_map = dict()\n", " SHADER_NODE_INPUTS_map['BSDF_PRINCIPLED'] = PBSDF_USDPS_map\n", " SHADER_NODE_INPUTS_map['TEX_IMAGE'] = IMAGE_map\n", " SHADER_NODE_INPUTS_map['NORMAL_MAP'] = NORMALMAP_map\n", "\n", " return [ SHADER_NODE_map, SHADER_NODE_INPUTS_map ]\n", "\n", "def blender_createMtlxShaderNode(doc, name, shaderNodeDefinition, isMaterial):\n", "\n", " mtlxShadername = name + ('_' + 'Shader' if isMaterial else '')\n", " mtlxShaderNode = mxg.addNode(doc, shaderNodeDefinition, mtlxShadername)\n", " if not mtlxShaderNode:\n", " return None\n", "\n", " # Create MaterialX material and shader for each Blender material\n", " if isMaterial:\n", " mtlxMaterialNode = mxg.addNode(doc, 'ND_surfacematerial', name)\n", " if mtlxMaterialNode:\n", " # Connect the material node to the output of the graph\n", " mxg.connectNodeToNode(mtlxMaterialNode, 'surfaceshader', mtlxShaderNode, '') \n", "\n", " return mtlxShaderNode\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " ## Main Blender to MaterialX Converter\n", "\n", "\n", "\n", " The main logic finds all root material nodes and converts that node and any directly connected upstream Blender `Texture Image` node, or `Normal Map` node. This is not in any way meant to be a full graph traverser, but there should be sufficient base logic to be able to create such a traverser." ] }, { "cell_type": "code", "execution_count": 86, "metadata": {}, "outputs": [], "source": [ "# %%\n", "def blender_connectImageNode(doc, SHADER_NODE_map, mtlxInput, blenderNode):\n", " nodeDefinition = SHADER_NODE_map['TEX_IMAGE']\n", " nodeDefinition = nodeDefinition + mtlxInput.getType() \n", " mtxImageNode = blender_createMtlxShaderNode(doc, blenderNode.label, nodeDefinition, False)\n", "\n", " # Connect input to new node\n", " if mtxImageNode:\n", " imagePath = ''\n", " if blenderNode.image:\n", " imagePath = blenderNode.image.filepath_from_user() \n", " fileInput = mtxImageNode.addInput('file', 'filename')\n", " fileInput.setValueString(imagePath)\n", " mxg.connectNodeToNode(mtlxInput.getParent(), mtlxInput.getName(), mtxImageNode, '')\n", " \n", " return mtxImageNode\n", "\n", "def blender_connectNormalMapNode(doc, SHADER_NODE_map, mtlxInput, blenderNode):\n", " \"\"\" \n", " Create a MaterialX normal map node from a Blender node\n", " Connected the new node to an downstream input \n", " \"\"\"\n", " nodeDefinition = SHADER_NODE_map['NORMAL_MAP']\n", " mtxNormalMap = blender_createMtlxShaderNode(doc, blenderNode.label, nodeDefinition, False) \n", " mxg.connectNodeToNode(mtlxInput.getParent(), mtlxInput.getName(), mtxNormalMap, '') \n", " return mtxNormalMap\n", "\n", "def blender_getUpstreamNode(blenderInput):\n", " if not blenderInput:\n", " return None\n", " link = blenderInput.links[0] if blenderInput.links else None\n", " if link and link.is_valid:\n", " return link.from_node\n", " return None\n", "\n", "def blender_materialx(doc, shaderNodeMappings):\n", " \"\"\"\n", " Simple Export of a few Blender nodes to MaterialX material nodes + shaders\n", " \"\"\"\n", " SHADER_NODE_map = shaderNodeMappings[0]\n", " SHADER_NODE_INPUTS_map = shaderNodeMappings[1]\n", "\n", " shaderType = 'BSDF_PRINCIPLED'\n", " for m in bpy.data.materials:\n", " if not m.node_tree:\n", " continue\n", "\n", " # Find the default material node type\n", " materialNode = None\n", " for node in m.node_tree.nodes:\n", " if node.type == shaderType:\n", " materialNode = node\n", " break\n", "\n", " if materialNode: \n", " # Creat a corresponding MaterialX material / shader node\n", " shaderNodeDefinition = SHADER_NODE_map[shaderType]\n", " if not shaderNodeDefinition:\n", " #print('Skip handling of node', materialNode)\n", " continue\n", "\n", " mtlxShaderNode = blender_createMtlxShaderNode(doc, m.name, shaderNodeDefinition, shaderType == 'BSDF_PRINCIPLED')\n", " if not mtlxShaderNode:\n", " continue\n", " mtlxShaderNodeDef = mtlxShaderNode.getNodeDef()\n", "\n", " # Nothing to do with outputs for now\n", " #for noutput in materialNode.outputs:\n", " # print(\" - Visit output: \", noutput.name)\n", "\n", " #print('Add inputs to node: ', mtlxShaderNode.getNamePath())\n", " PBSDF_USDPS_map = SHADER_NODE_INPUTS_map[shaderType]\n", " for ninput in materialNode.inputs:\n", " if not ninput.name in PBSDF_USDPS_map:\n", " #print('-- Skip translating input: ', ninput.name)\n", " continue \n", "\n", " # Add in inputs\n", " val = ninput.default_value\n", " portName = PBSDF_USDPS_map[ninput.name]\n", " newInput = None\n", " if portName:\n", " newInput = blender_createMtlxInput(portName, val, mtlxShaderNode, mtlxShaderNodeDef) \n", " if portName == 'normal':\n", " newInput.setValueString('0,0,1') \n", "\n", " # Check for upstream connections\n", " if not newInput:\n", " continue\n", "\n", " connectedNode = blender_getUpstreamNode(ninput)\n", " if connectedNode:\n", " mtxNormalMap = None\n", " # Add a MaterialX normal map node for each Blender normal map node\n", " if connectedNode.type == 'NORMAL_MAP': \n", " mtxNormalMap = blender_connectNormalMapNode(doc, SHADER_NODE_map, newInput, connectedNode)\n", " # Traverse upstream\n", " colorInput = connectedNode.inputs['Color']\n", " if colorInput:\n", " connectedNode = blender_getUpstreamNode(colorInput)\n", "\n", " # Add an MaterialX image node for each Blender texture image node\n", " mtxImageNode = None\n", " if connectedNode.type == 'TEX_IMAGE': \n", " mtxImageNode = blender_connectImageNode(doc, SHADER_NODE_map, newInput, connectedNode)\n", "\n", " # Connect normal map and image node if both found\n", " if mtxNormalMap and mtxImageNode:\n", " mxg.connectNodeToNode(mtxNormalMap, 'normal', mtxImageNode, '') \n" ] }, { "cell_type": "code", "execution_count": 87, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "**Blender To MaterialX Result**" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "text/markdown": [ "```xml\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "\n", "\n", "```\n" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# %%\n", "if __name__ == \"__main__\":\n", " bpy.ops.wm.open_mainfile(filepath=\"data/test.blend\")\n", " doc, libFiles, status = mxf.createWorkingDocument()\n", " shaderNodeMap = blender_init_node_dictionary('ND_UsdPreviewSurface_surfaceshader')\n", " blender_materialx(doc, shaderNodeMap)\n", " writeMaterialX(doc, 'data/blender_to_mtlx.mtlx', '**Blender To MaterialX Result**')\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " ### Diagram of Blender Graph\n", "\n", " Using the Mermaid graph utilities we can visualize the resulting graph:" ] }, { "cell_type": "code", "execution_count": 89, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "```mermaid\n", " graph LR;\n", " BlueMaterial_Shader([BlueMaterial_Shader]) --\".surfaceshader\"--> BlueMaterial([BlueMaterial])\n", " Normal_Map_Image([Normal_Map_Image]) --\".normal\"--> TexturedMaterial_Shader([TexturedMaterial_Shader])\n", " RoughnessTexture([RoughnessTexture]) --\".roughness\"--> TexturedMaterial_Shader([TexturedMaterial_Shader])\n", " DefaultMaterial_Shader([DefaultMaterial_Shader]) --\".surfaceshader\"--> DefaultMaterial([DefaultMaterial])\n", " MetallicTexture([MetallicTexture]) --\".metallic\"--> TexturedMaterial_Shader([TexturedMaterial_Shader])\n", " BaseColorTexture([BaseColorTexture]) --\".diffuseColor\"--> TexturedMaterial_Shader([TexturedMaterial_Shader])\n", " TexturedMaterial_Shader([TexturedMaterial_Shader]) --\".surfaceshader\"--> TexturedMaterial([TexturedMaterial])\n", " SliverMaterial_Shader([SliverMaterial_Shader]) --\".surfaceshader\"--> SliverMaterial([SliverMaterial])\n", "%% Subgraphs\n", "%% Style\n", " style DefaultMaterial fill:#0C0, color:#111\n", " style TexturedMaterial fill:#0C0, color:#111\n", " style BlueMaterial fill:#0C0, color:#111\n", " style SliverMaterial fill:#0C0, color:#111\n", "```\n" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "from mtlxutils.mxtraversal import *\n", "from mtlxutils.mxfile import *\n", "\n", "# Load in document and create a Mermaid graph\n", "doc, libFiles, status = MtlxFile.createWorkingDocument()\n", "mx.readFromXmlFile(doc, 'data/blender_to_mtlx.mtlx')\n", "roots = doc.getMaterialNodes()\n", "graph = MtlxMermaid.generateMermaidGraph(roots, 'LR')\n", "\n", "from IPython.display import display_markdown\n", "strgraph = '```mermaid\\n'\n", "for line in graph:\n", " if line:\n", " strgraph = strgraph + line + '\\n'\n", "strgraph = strgraph + '```\\n' \n", "display_markdown(strgraph, raw=True)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ " " ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.10.10" } }, "nbformat": 4, "nbformat_minor": 2 }