├── .gitignore ├── LICENSE ├── README.md ├── deploy.bat ├── plugins └── materialx-export │ ├── ExportTools.js │ ├── MaterialXExport.qml │ ├── Style.qml │ ├── main.qml │ └── tool-bar.qml └── python ├── materialx_export.py ├── matxtools ├── __init__.py └── matxtools.py └── write_sample.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Allegorithmic SAS, an Adobe Company. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MaterialX export for Substance Painter 2 | 3 | This plugin adds support for simple MaterialX support for Substance Painter. 4 | It currently only support arnold and Metallic/Roughness workflows 5 | 6 | ## Prerequisites 7 | * Substance Painter 8 | * Python in path (currently only tested with python 2.7) 9 | * MaterialX installed for the python interpreter in path (only tested with 1.36) 10 | 11 | ## Installation 12 | Install the plugin using the deploy.bat script 13 | A correctly installed plugin should look something like this in the Painter documents directory: 14 | ``` 15 | plugins 16 | └───materialx-export 17 | │ ExportTools.js 18 | │ main.qml 19 | │ MaterialXExport.qml 20 | │ Style.qml 21 | │ tool-bar.qml 22 | │ 23 | └───python 24 | │ materialx_export.py 25 | │ write_sample.py 26 | │ 27 | └───matxtools 28 | matxtools.py 29 | __init__.py 30 | ``` 31 | 32 | ## Running 33 | A correctly installed plugin will show up as a button in painter. 34 | 35 | To export: 36 | * Click the plugin button 37 | * Type in/browse for the location and file you want to export the textures. 38 | The expected input is the MaterialX file to write, the textures for it will be written in the same 39 | directory as the MaterialX file 40 | * Click the export button 41 | 42 | When the path is setup you can easily reexport the textures by clicking export again 43 | -------------------------------------------------------------------------------- /deploy.bat: -------------------------------------------------------------------------------- 1 | xcopy.exe .\plugins\materialx-export "%USERPROFILE%\Documents\Allegorithmic\Substance Painter\plugins\materialx-export" /I /Y 2 | xcopy.exe .\python "%USERPROFILE%\Documents\Allegorithmic\Substance Painter\plugins\materialx-export\python" /I /Y /S -------------------------------------------------------------------------------- /plugins/materialx-export/ExportTools.js: -------------------------------------------------------------------------------- 1 | function exportMaps(target_file) { 2 | var filename = target_file.replace(/^.*[\\\/]/, '') 3 | var base_path = target_file.substr(0, target_file.length - filename.length) 4 | alg.log.info(filename) 5 | alg.log.info(base_path) 6 | if(!alg.fileIO.exists(base_path)) { 7 | throw "Target directory " + base_path + " doesn't exist"; 8 | } 9 | var res = alg.mapexport.exportDocumentMaps("Arnold 5 (AiStandard)", base_path, "png") 10 | alg.log.info(res) 11 | return res 12 | } 13 | 14 | function writeMtlx(target_file, map_data, onDone) { 15 | alg.log.info('Starting conversion') 16 | // Subprocess seem to run from the directory of the plugin 17 | alg.subprocess.start(['python', 'python/materialx_export.py', target_file, escape(JSON.stringify(map_data))], onDone) 18 | } 19 | -------------------------------------------------------------------------------- /plugins/materialx-export/MaterialXExport.qml: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Allegorithmic 2 | // 3 | // This software may be modified and distributed under the terms 4 | // of the MIT license. See the LICENSE file for details. 5 | 6 | import QtQuick 2.3 7 | import QtQuick.Layouts 1.2 8 | import QtQuick.Dialogs 1.0 9 | import QtQuick.Controls 1.4 10 | import QtQuick.Controls.Styles 1.4 11 | import AlgWidgets 1.0 12 | import AlgWidgets.Style 1.0 13 | import "." 14 | import "./ExportTools.js" as ExportTools 15 | 16 | AlgWindow 17 | { 18 | id: window 19 | title: "Substance Painter - MaterialX Export" 20 | visible: false 21 | width: 300 22 | height: mainLayout.height 23 | 24 | //Flags to keep the window on top 25 | flags: Qt.Window 26 | | Qt.WindowTitleHint // title 27 | | Qt.WindowSystemMenuHint // Recquired to add buttons 28 | | Qt.WindowMinMaxButtonsHint // minimize and maximize button 29 | | Qt.WindowCloseButtonHint // close button 30 | 31 | 32 | ColumnLayout { 33 | id: mainLayout 34 | anchors { 35 | left: parent.left; 36 | right: parent.right; 37 | } 38 | RowLayout { 39 | anchors { 40 | left: parent.left; 41 | right: parent.right; 42 | } 43 | AlgLabel { 44 | anchors { 45 | left: parent.left; 46 | } 47 | text: "Conflict Policy" 48 | } 49 | AlgComboBox 50 | { 51 | anchors { 52 | right: parent.right; 53 | } 54 | id: updatePolicy 55 | model: ListModel { 56 | id: updatePolicyModel 57 | ListElement { text: "Update" } 58 | ListElement { text: "Import" } 59 | ListElement { text: "Fail" } 60 | } 61 | } 62 | } 63 | RowLayout { 64 | AlgTextInput { 65 | id: filenameField 66 | width: 150 67 | Layout.preferredWidth: 150 68 | anchors { 69 | left: parent.left; 70 | } 71 | } 72 | AlgButton { 73 | FileDialog 74 | { 75 | id: fileDialog 76 | title: "Select Target File" 77 | nameFilters: ["MaterialX files (*.mtlx)", "All files (*)"] 78 | selectedNameFilter: "MaterialX files (*.mtlx)" 79 | selectExisting: false 80 | folder: "c:\\temp\\matxexport" 81 | onAccepted: { 82 | var filename = alg.fileIO.urlToLocalFile(fileUrl.toString()); 83 | filenameField.text = filename 84 | } 85 | } 86 | onClicked: { 87 | fileDialog.open(); 88 | } 89 | text: "Select File" 90 | anchors { 91 | right: parent.right; 92 | } 93 | 94 | } 95 | } 96 | AlgButton 97 | { 98 | anchors { 99 | left: parent.left; 100 | right: parent.right; 101 | } 102 | AlgWindow 103 | { 104 | id: progressWindow 105 | title: "Exporting" 106 | modality: WindowModal 107 | width: 600 108 | height: progressLayout.height 109 | ColumnLayout { 110 | id: progressLayout 111 | anchors { 112 | left: parent.left; 113 | right: parent.right; 114 | } 115 | AlgLabel 116 | { 117 | id: statusLabel 118 | text: "Idle" 119 | anchors { 120 | left: parent.left; 121 | right: parent.right; 122 | } 123 | } 124 | ProgressBar 125 | { 126 | indeterminate: true 127 | anchors { 128 | left: parent.left; 129 | right: parent.right; 130 | } 131 | } 132 | } 133 | } 134 | id: exportButton 135 | text: "Export" 136 | onClicked: { 137 | statusLabel.text = "Exporting MaterialX file: " + filenameField.text; 138 | progressWindow.open() 139 | var map_data = ExportTools.exportMaps(filenameField.text) 140 | ExportTools.writeMtlx(filenameField.text, map_data, window.emitPythonDone) 141 | } 142 | 143 | } 144 | } 145 | 146 | signal pythonDone(var data) 147 | 148 | 149 | onPythonDone: { 150 | alg.log.info("Python done"); 151 | alg.log.info(data.cerr); 152 | progressWindow.close(); 153 | } 154 | // Trampoline functions to emit events 155 | function emitPythonDone(data) { 156 | pythonDone(data) 157 | } 158 | 159 | } 160 | -------------------------------------------------------------------------------- /plugins/materialx-export/Style.qml: -------------------------------------------------------------------------------- 1 | pragma Singleton 2 | import QtQuick 2.7 3 | 4 | QtObject { 5 | readonly property QtObject window: QtObject { 6 | readonly property int width: 750 7 | readonly property int height: 500 8 | readonly property int minimumWidth: 450 9 | readonly property int minimumHeight: 300 10 | } 11 | 12 | readonly property QtObject widgets: QtObject { 13 | readonly property int barHeight: 30 14 | readonly property int resourceItemHeight: 80 15 | readonly property int buttonWidth: 100 16 | } 17 | 18 | readonly property int margin: 8 19 | readonly property int borderWidth: 2 20 | readonly property int radius: 4 21 | } -------------------------------------------------------------------------------- /plugins/materialx-export/main.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.2 2 | import Painter 1.0 3 | 4 | PainterPlugin { 5 | 6 | MaterialXExport 7 | { 8 | id: window 9 | } 10 | 11 | /* called after the object has been instantiated. Used to execute script code at startup, 12 | once the full QML environment has been established. */ 13 | Component.onCompleted:{ 14 | //create a toolbar button 15 | var toolbar = alg.ui.addToolBarWidget( "tool-bar.qml" ) 16 | toolbar.windowReference = window 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /plugins/materialx-export/tool-bar.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.7 2 | import QtQuick.Controls 2.0 3 | import QtQuick.Window 2.2 4 | import QtQuick.Layouts 1.2 5 | import AlgWidgets 1.0 6 | import AlgWidgets.Style 1.0 7 | 8 | Rectangle 9 | { 10 | id: materialXExportButton 11 | width: 50 12 | height: 30 13 | border.color: "white" 14 | property var windowReference : null 15 | color: buttonMouseArea.containsMouse ? "grey" : "black" 16 | 17 | Text { 18 | id: buttonLabel 19 | anchors.centerIn: parent 20 | text: "MaterialX\nExport" 21 | color: "white" 22 | } 23 | signal buttonClick() 24 | 25 | onButtonClick: 26 | { 27 | try 28 | { 29 | windowReference.visible = true 30 | //windowReference.refreshInterface() 31 | windowReference.raise() 32 | windowReference.requestActivate() 33 | } 34 | catch(err) 35 | { 36 | alg.log.exception(err) 37 | } 38 | } 39 | MouseArea { 40 | id: buttonMouseArea 41 | //anchor all sides of the mouse area to the rectangle's anchors 42 | anchors.fill: parent 43 | 44 | //onClicked handles valid mouse button clicks 45 | onClicked: buttonClick() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /python/materialx_export.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from urllib import unquote 4 | from matxtools import MatXExporter 5 | import json 6 | 7 | def _guess_mesh_name(material_data): 8 | def _extract_mesh_name(ts, pattern, path): 9 | base_name = os.path.splitext(os.path.basename(path))[0] 10 | expanded_pattern = pattern.replace('$textureSet', ts) 11 | suffix_start = 1 12 | while suffix_start < min(len(base_name), len(expanded_pattern)): 13 | if base_name[-suffix_start] != expanded_pattern[-suffix_start]: 14 | break 15 | suffix_start = suffix_start + 1 16 | return base_name[0:len(base_name) - suffix_start + 1] 17 | for ts_name, ts_data in material_data.items(): 18 | for pattern, path in ts_data.items(): 19 | if ts_name != '' and pattern != '' and path != '': 20 | return _extract_mesh_name(ts_name, pattern, path) 21 | 22 | channel_mapping = { 23 | 'BaseColor' : {'name':'base_color', 'type': 'color3', 'value':[0.0, 0.0, 0.0], 'colorspace':'sRGB'}, 24 | 'Emissive' : {'name':'emissive', 'type': 'color3', 'value':[0.0, 0.0, 0.0], 'colorspace':'sRGB'}, 25 | 'Height' : {'name':'height', 'type': 'float', 'value':.5, 'colorspace':'Raw'}, 26 | 'Metalness' : {'name':'metalness', 'type': 'float', 'value':0.0, 'colorspace':'Raw'}, 27 | 'Normal' : {'name':'normal', 'type': 'vector3', 'value':[0.0, 1.0, 0.0], 'colorspace':'Raw'}, 28 | 'Roughness' : {'name':'specular_roughness', 'type': 'float', 'value':0.0, 'colorspace':'Raw'}, 29 | 'Tangent' : {'name':'tangent', 'type': 'vector3', 'value': [0, 0, 0]}, 30 | 'Coat_normal': {'name':'coat_normal', 'type': 'vector3', 'value': [0, 0, 0]}, 31 | 32 | } 33 | 34 | def _export_texture_set(exporter, ts_name, ts_data): 35 | print('Starting export: ' + ts_name) 36 | matx_channel_data = {} 37 | for channel_name, channel_data in channel_mapping.items(): 38 | sp_key = '$mesh_$textureSet_' + channel_name 39 | print(sp_key) 40 | sp_data = ts_data.get(sp_key, None) 41 | if sp_data and sp_data != "": 42 | # We have data from sp 43 | matx_channel_data[channel_data['name']] = { 44 | 'type': channel_data['type'], 45 | 'filename': os.path.basename(sp_data), 46 | 'colorspace': channel_data['colorspace'] 47 | } 48 | else: 49 | # Use defaults if no data exists 50 | matx_channel_data[channel_data['name']] = { 51 | 'type': channel_data['type'], 52 | 'value': channel_data['value'], 53 | } 54 | print('Writing material: ' + ts_name) 55 | exporter.materialx_write_material(ts_name, 56 | 'standard_surface', 57 | matx_channel_data) 58 | 59 | def main(): 60 | target_file_name = sys.argv[1] 61 | material_data = json.loads(unquote(sys.argv[2])) 62 | mesh_name = _guess_mesh_name(material_data) 63 | exporter = MatXExporter(target_file_name) 64 | for ts_name, ts_data in material_data.items(): 65 | _export_texture_set(exporter, ts_name, ts_data) 66 | exporter.write() 67 | return 0 68 | 69 | if __name__ == '__main__': 70 | main() -------------------------------------------------------------------------------- /python/matxtools/__init__.py: -------------------------------------------------------------------------------- 1 | from matxtools import MatXExporter 2 | -------------------------------------------------------------------------------- /python/matxtools/matxtools.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import MaterialX as mx 3 | 4 | 5 | def _add_value_input(bi, data): 6 | if data['type'] == 'color3': 7 | bi.setValue(mx.Color3(data['value'])) 8 | if data['type'] == 'vector3': 9 | bi.setValue(mx.Vector3(data['value'])) 10 | elif data['type'] == 'float': 11 | bi.setValue(float(data['value'])) 12 | 13 | def _add_file_input(bi, input_name, data, node_graph, material_name): 14 | decorated_input = input_name + '_' + material_name 15 | sys.stderr.write(decorated_input + '\n') 16 | image_node = node_graph.addNode('image', decorated_input, data['type']) 17 | image_node.setColorSpace(data['colorspace']) 18 | file_param = image_node.addParameter('file', 'filename') 19 | file_param.setValue(data['filename'], 'filename') 20 | if input_name == 'normal': 21 | # Normal map need to go through the normal map node 22 | normal_node = node_graph.addNode('normalmap', decorated_input + '_normal', data['type']) 23 | normal_node.setConnectedNode('in', image_node) 24 | next_node = normal_node 25 | else: 26 | next_node = image_node 27 | print(data['type']) 28 | output = node_graph.addOutput(decorated_input + '_output', data['type']) 29 | output.setConnectedNode(next_node) 30 | bi.setConnectedOutput(output) 31 | bi.setType(data['type']) 32 | 33 | class MatXExporter: 34 | def __init__(self, filename): 35 | self.mDoc = mx.createDocument() 36 | self.mFilename = filename 37 | 38 | def materialx_write_material(self, material_name, shader_ref, bindings): 39 | material = self.mDoc.addMaterial(material_name) 40 | sr = material.addShaderRef(material_name, shader_ref) 41 | ng_name = material_name + '_node_graph' 42 | for binding_name, binding_data in bindings.items(): 43 | bi = sr.addBindInput(binding_name) 44 | if 'value' in binding_data: 45 | _add_value_input(bi, binding_data) 46 | elif 'filename' in binding_data: 47 | node_graph = self.mDoc.addNodeGraph(ng_name) if self.mDoc.getNodeGraph(ng_name) is None else self.mDoc.getNodeGraph(ng_name) 48 | _add_file_input(bi, binding_name, binding_data, node_graph, material_name) 49 | 50 | def write(self): 51 | mx.writeToXmlFile(self.mDoc, self.mFilename) 52 | -------------------------------------------------------------------------------- /python/write_sample.py: -------------------------------------------------------------------------------- 1 | from matxtools import materialx_write_material 2 | 3 | def main(): 4 | materialx_write_material('test.mtlx', 5 | 'test_material', 6 | 'test_shader', 7 | { 8 | 'base': {'type': 'float', 'value': 1}, 9 | 'base_color': {'type': 'color3', 'value':[1, 0, 1]}, 10 | 'specular_roughness': {'type': 'float', 'filename':'c:\\temp\\apa.png', 'colorspace':'Raw'}, 11 | 'metalness': {'type': 'float', 'value':.5}, 12 | 'normal': {'type': 'vector3', 'filename':'c:\\temp\\apa2.png', 'colorspace':'Raw'}, 13 | 'tangent': {'type': 'vector3', 'value': [0, 0, 0]}, 14 | 'coat_normal': {'type': 'vector3', 'value': [0, 0, 0]}, 15 | }) 16 | 17 | if __name__ == '__main__': 18 | main() --------------------------------------------------------------------------------