QuiltiX Plugins 0.0.1
Custom Plugins for QuiltiX
Loading...
Searching...
No Matches
materialxgltf/plugin.py
Go to the documentation of this file.
1'''
2@file materialxgltf/plugin.py
3@namespace materialxgtf/plugin
4'''
5
6import logging
7import os
8import base64
9
10# Optional syntax highlighting if pygments is installed
11have_highliting = True
12try:
13 from pygments import highlight
14 from pygments.lexers import JsonLexer
15 from pygments.formatters import HtmlFormatter
16except ImportError:
17 have_highliting = False
18
19from typing import TYPE_CHECKING
20
21from qtpy import QtCore # type: ignore
22from qtpy.QtWidgets import ( # type: ignore
23 QAction,
24 QDockWidget,
25 QVBoxLayout,
26 QTextEdit,
27 QWidget
28)
29from QuiltiX import constants, qx_plugin
30from QuiltiX.constants import ROOT
31
32#from qtpy.QtWebEngineCore import QWebEnginePage, QWebEngineUrlRequestInterceptor, QWebEngineProfile, QWebEngineSettings
33from qtpy.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
34from qtpy.QtQuick import QQuickWindow
35from qtpy.QtQuick import QSGRendererInterface
36# Ensure the application uses OpenGL for rendering
37QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.OpenGL)
38
39logger = logging.getLogger(__name__)
40
41# MaterialX and related modules
42import MaterialX as mx
43
44haveGLTF = False
45try:
46 import materialxgltf.core as core
47 import materialxgltf
48 haveGLTF = True
49except ImportError:
50 logger.error("materialxgltf module is not installed.")
51
52# Optional syntax highlighting if pygments is installed
53have_highliting = True
54try:
55 from pygments import highlight
56 from pygments.lexers import JsonLexer
57 from pygments.formatters import HtmlFormatter
58except ImportError:
59 have_highliting = False
60
61# Use to allow access to resources in the package
62import pkg_resources
63
65 '''
66 @brief Highlighter for glTF text
67 '''
68 def __init__(self):
69 '''
70 @brief Initialize the highlighter
71 '''
72 self.lexer = JsonLexer()
73 # We don't add line numbers since this get's copied with
74 # copy-paste.
75 self.formatter = HtmlFormatter(linenos=False, style='github-dark')
76
77 def highlight(self, text):
78 '''
79 @brief Highlight the text
80 @param text: The text to highlight
81 @return: The highlighted text
82 '''
83 highlighted_html = highlight(text, self.lexer, self.formatter)
84 styles = (
85 f"<style>"
86 f"{self.formatter.get_style_defs('.highlight')}"
87 f"pre {{ line-height: 1.0; margin: 0; }}"
88 f"</style>"
89 )
90 full_html = f"<html><head>{styles}</head><body>{highlighted_html}</body></html>"
91 return full_html
92
93#class MyRequestInterceptor(QWebEngineUrlRequestInterceptor):
94# def interceptRequest(self, info):
95# info.setHttpHeader(b'Permissions-Policy', b'interest-cohort=()')
96
97class glTFEnginePage(QWebEnginePage):
98 '''
99 @brief Custom web engine page for the glTF viewer
100 '''
101 def __init__(self, parent=None):
102 '''
103 @brief Initialize the custom web engine page
104 @param parent: The parent widget
105 '''
106 super().__init__(parent)
107 self.debug = False
108
109 def setDebug(self, debug):
110 '''
111 @brief Set the debug flag
112 @param debug: The debug flag
113 '''
114 self.debug = debug
115
116 def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
117 '''
118 @brief Handle JavaScript console messages
119 '''
120 if self.debug:
121 print(f"JS: {message} (line: {lineNumber}, source: {sourceID})")
122
124 '''
125 Add interestCohort function to the document to disable FLoC
126 '''
127 script = """
128 document.addEventListener('DOMContentLoaded', (event) => {
129 document.interestCohort = function() { return false; };
130 });
131 """
132 self.runJavaScript(script)
133
134 def load_glb(self, glb_file_path):
135 # Read the .glb file in binary mode
136 #glb_file_path = "/path/to/your/model.glb" # Change this to your .glb file path
137 if (not os.path.exists(glb_file_path)):
138 print(f"Error: glTF file not found: {glb_file_path}")
139 return
140
141 with open(glb_file_path, "rb") as f:
142 binary_data = f.read()
143
144 # Encode the binary data as Base64
145 base64_data = base64.b64encode(binary_data).decode('utf-8')
146
147 # Run the JavaScript code to store the data in the browser's local storage
148 # Inject the Base64 encoded data into local storage using JavaScript
149 script = f"localStorage.setItem('glbData', '{base64_data}');"
150 self.runJavaScript(script)
151
152 print("GLB data injected into localStorage.")
153
154 self.runJavaScript('loadFromLocalStorage()')
155 print("Update viewer from localStorage.")
156
157class glTFWidget(QDockWidget):
158 '''
159 @brief glTF Viewer widget
160 '''
161 def __init__(self, parent=None):
162 '''
163 @brief Sets up a web view and loads in a sample glTF viewer page.
164 '''
165 super(glTFWidget, self).__init__(parent)
166
167 self.setWindowTitle("glTF Viewer")
168
169 self.setFloating(False)
170
171 # Create a web view.
172 self.web_view = QWebEngineView()
173 self.viewer_options = '?hideSave=true'
174 self.viewer_options += '&env=https://kwokcb.github.io/MaterialXLab/resources/Lights/dreifaltigkeitsberg_1k.hdr'
175 self.viewer_address = 'https://kwokcb.github.io/MaterialXLab/documents/gltfViewer_simple.html'
176 # e.g. For debugging can set to local host if you want to run a local page
177 #self.viewer_address = 'http://localhost:8000/documents/gltfViewer_simple.html'
178 self.viewer_address += self.viewer_options
179 #self.logger.debug('glTF Viewer address: ' + self.viewer_address)
180
181 # Create a custom page and set it to the web view
183 self.web_view.setPage(self.page)
184
185 self.web_view.setUrl(QtCore.QUrl(self.viewer_address))
186
187 # Apply the request interceptor
188 #interceptor = MyRequestInterceptor()
189 #QWebEngineProfile.defaultProfile().setUrlRequestInterceptor(interceptor)
190
191 #settings = self.web_view.settings()
192 # Disable features that might trigger the error (general approach)
193 #settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, False)
194 #settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, False)
195 #settings.setAttribute(QWebEngineSettings.WebAttribute.XSSAuditingEnabled, False)
196
197 # Set up the layout
198 layout = QVBoxLayout()
199 layout.addWidget(self.web_view)
200
201 # Create a central widget to hold the layout
202 central_widget = QWidget()
203 central_widget.setLayout(layout)
204
205 # Inject JavaScript after the page is loaded
206 #self.web_view.loadFinished.connect(self.page.injectJavaScript)
207
208 # Set the central widget of the dock widget
209 self.setWidget(central_widget)
210
212 '''
213 @brief glTF serializer for MaterialX
214 '''
215
216 def __init__(self, editor, root) -> None:
217 '''
218 Initialize the plugin. Adds in:
219 - Menu items for loading and saving glTF files
220 - Menu items for setting options for glTF export
221 - Menu item for showing the current graph as glTF text
222
223 @param editor: The QuiltiX editor
224 @param root: The root path of QuitiX
225 '''
226 self.editor = editor
227 self.root = root
228
229 # glTF menu items
230 # ----------------------------------------
231 # Update 'File' menu
232
233 editor.file_menu.addSeparator()
234 gltfMenu1 = self.editor.file_menu.addMenu("glTF")
235
236 # Load menu item
237 import_gltf_item = QAction("Load glTF...", editor)
238 import_gltf_item.triggered.connect(self.import_gltf_triggeredimport_gltf_triggered)
239 gltfMenu1.addAction(import_gltf_item)
240
241 # Save menu item
242 export_gltf_item = QAction("Save glTF...", editor)
243 export_gltf_item.triggered.connect(lambda: self.export_gltf_triggered(False))
244 gltfMenu1.addAction(export_gltf_item)
245
246 # Export to Viewer menu item
247 export_view_gltf_item = QAction("Export to Viewer...", editor)
248 export_view_gltf_item.triggered.connect(lambda: self.export_gltf_triggered(True))
249 gltfMenu1.addAction(export_view_gltf_item)
250
251 # Show glTF text. Does most of export, except does not write to file
252 show_gltf_text = QAction("Show glTF as text...", editor)
253 show_gltf_text.triggered.connect(self.show_gltf_text_triggeredshow_gltf_text_triggered)
254 gltfMenu1.addAction(show_gltf_text)
255
256 # Update 'Options' menu
257
258 editor.options_menu.addSeparator()
259 gltfMenu2 = editor.options_menu.addMenu("glTF Options")
260
261 self.bake_textures_option = QAction("Always Bake Textures", editor)
262 self.bake_textures_option.setCheckable(True)
263 self.bake_textures_option.setChecked(False)
264 gltfMenu2.addAction(self.bake_textures_option)
265
266 #version = 'materialxgltf version: ' + materialxgltf.__version__
267 #version_action = QAction(version, self.editor)
268 #version_action.setEnabled(False)
269 #gltfMenu.addAction(version_action)
270
271 # Add glTF Viewer
273
274 # Update 'View' menu
275
277 self.act_gltf_viewer = QAction("glTF Viewer", editor)
278 self.act_gltf_viewer.setCheckable(True)
280 editor.view_menu.addSeparator()
281 editor.view_menu.addAction(self.act_gltf_viewer)
282
283 # Override about to show event to update the gltf viewer toggle
285
287 '''
288 Custom about to show event for the view menu. Updates the glTF viewer toggle.
289 '''
290 self.editor.on_view_menu_showing()
291 self.act_gltf_viewer.setChecked(self.web_dock_widget.isVisible())
292
293 def on_gltf_viewer_toggled(self, checked) -> None:
294 '''
295 Toggle the glTF viewer dock widget.
296 '''
297 self.web_dock_widget.setVisible(checked)
298
299 def setup_gltf_viewer_doc(self) -> None:
300 '''
301 Set up the glTF viewer dock widget.
302 '''
304 self.editor.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.web_dock_widget)
305
306
307 def show_text_box(self, text, title="") -> None:
308 '''
309 Show a text box with the given text.
310 @param text: The text to show
311 @param title: The title of the text box. Default is empty string.
312 '''
313 te_text = QTextEdit()
314
315 if have_highliting:
316 jsonHighlighter = GLTFHighlighter()
317 highlighted_html = jsonHighlighter.highlight(text)
318 te_text.setHtml(highlighted_html)
319 else:
320 te_text.setText(text)
321 te_text.setReadOnly(True)
322 te_text.setParent(self.editor, QtCore.Qt.Window)
323 te_text.setWindowTitle(title)
324 te_text.resize(1000, 800)
325 te_text.show()
326
327 def import_gltf_triggered(self) -> None:
328 '''
329 Import a glTF file into the current graph.
330 '''
331 start_path = self.editor.mx_selection_path
332 if not start_path:
333 start_path = self.editor.geometry_selection_path
334
335 if not start_path:
336 start_path = os.path.join(ROOT, "resources", "materials")
337
338 path = self.editor.request_filepath(
339 title="Load glTF file", start_path=start_path, file_filter="glTF files (*.gltf)", mode="open",
340 )
341
342 if not os.path.exists(path):
343 logger.error('Cannot find input file: ' + path)
344 return
345
346 # Setup defaults for glTF import and create the MaterialX document
347 # - Do not create assignments
348 # - Do not add all inputs
349 # - Add extract nodes. Only required for pre 1.39 MaterialX
350 options = core.GLTF2MtlxOptions()
351 options['createAssignments'] = False
352 options['addAllInputs'] = False
353 options['addExtractNodes'] = True
354 gltf2MtlxReader = core.GLTF2MtlxReader()
355 gltf2MtlxReader.setOptions(options)
356 doc = gltf2MtlxReader.convert(path)
357
358 success = True
359 if not doc:
360 success = False
361 logger.error('Error converting glTF file to MaterialX file')
362 else:
363 success , err = doc.validate()
364 if not success:
365 logger.error(err)
366 else:
367 # Extract out the appropriate data from the original MaterialX document
368 docString = core.Util.writeMaterialXDocString(doc)
369 doc = mx.createDocument()
370 mx.readFromXmlString(doc, docString)
371
372 # Auto ng creation can cause issues for nodes with multiple outputs. Turn off on import
373 ng_abstraction = self.editor.act_ng_abstraction.isChecked()
374 self.editor.act_ng_abstraction.setChecked(False)
375
376 self.editor.mx_selection_path = path
377 self.editor.qx_node_graph.load_graph_from_mx_doc(doc)
378 # Blank out as there is no MaterialX file this was read from
379 self.editor.qx_node_graph.mx_file_loaded.emit("")
380
381 self.editor.act_ng_abstraction.setChecked(ng_abstraction)
382
383 def setup_default_export_options(self, path, bakeFileName, bakeResolution=1024, embed_geometry=False) -> dict:
384 '''
385 Set up the default export options for gltf output.
386 @param path (str): path to the gltf file
387 @param bakeFileName (str): path to the baked file
388 @param bakeResolution (int): resolution of the baked textures. Default is 1024.
389 @param embed_geometry (bool): whether to embed the geometry in the gltf file. Default is False.
390 @return options (dict): Dictionary of options for the conversion
391 '''
392 options = core.MTLX2GLTFOptions()
393
394 options['debugOutput'] = False
395 options['bakeFileName'] = bakeFileName
396 # Clamp to a minimum power-of-2 resolution
397 bakeResolution = max(bakeResolution, 16)
398 options['bakeResolution'] = bakeResolution
399
400 # TODO: Expose these options to the user
401 if embed_geometry:
402 # By default uses the "MaterialX" shader ball provided as part of
403 # the materialxgltf package for binary export.
404 gltfGeometryFile = pkg_resources.resource_filename('materialxgltf', 'data/shaderBall.gltf')
405 msg = '> Load default geometry: %s' % mx.FilePath(gltfGeometryFile).getBaseName()
406 logger.info(msg)
407 options['geometryFile'] = gltfGeometryFile
408 options['primsPerMaterial'] = True
409 options['writeDefaultInputs'] = False
410 options['translateShaders'] = True
411 options['bakeTextures'] = True
412 # Always should be set
413 options['addExtractNodes'] = True
414
415 searchPath = mx.getDefaultDataSearchPath()
416 if not mx.FilePath(path).isAbsolute():
417 path = os.path.abspath(path)
418 searchPath.append(mx.FilePath(path).getParentPath())
419 searchPath.append(mx.FilePath.getCurrentPath())
420 if embed_geometry:
421 searchPath.append(mx.FilePath(gltfGeometryFile).getParentPath())
422 options['searchPath'] = searchPath
423
424 return options
425
426 def create_baked_path(self, path):
427 '''
428 Create a baked path name from an original path
429 @param path (str): The original path
430 @return: The baked path
431 '''
432 # If is a directory, add a file name
433 if os.path.isdir(path):
434 path = os.path.join(path, 'temp_baked.mtlx')
435 else:
436 path = path.replace('.mtlx', '_baked.mtlx')
437 return path
438
439 def export_gltf_triggered(self, writeToTemp=False) -> None:
440 '''
441 Export the current graph to a glTF file in binary format (glb)
442 - Will perform shader translation if needed to glTF
443 - Will perform baking if needed
444 - Will package to a binary file
445 @param writeToTemp (bool): Whether to write to a temporary file
446 @return: None
447 '''
448 if writeToTemp:
449 start_path = os.environ["TEMP"]
450 if not start_path:
451 start_path = os.path.join(ROOT, "resources", "materials")
452 path = os.path.join(start_path, "_tmp_quiltix.gltf")
453 else:
454 start_path = self.editor.mx_selection_path
455 if not start_path:
456 start_path = self.editor.geometry_selection_path
457
458 if not start_path:
459 start_path = os.path.join(ROOT, "resources", "materials")
460
461 path = self.editor.request_filepath(
462 title="Save glTF file", start_path=start_path, file_filter="glTF files (*.gltf)", mode="save",
463 )
464
465 # Set up export options
466 bakeFileName = self.create_baked_path(start_path)
467 options = self.setup_default_export_options(path, bakeFileName, bakeResolution=1024, embed_geometry=True)
468 gltf_string = self.convert_graph_to_gltf(options)
469
470 if gltf_string == '{}':
471 return
472
473 #self.editor.set_current_filepath("")
474
475 # To be able to view the exported file in the glTF viewer, we need to package to a binary file
476 options['packageBinary'] = True
477 try:
478 with open(path, "w") as f:
479 f.write(gltf_string)
480 logger.info(f"Wrote .gltf file to {path}")
481
482 # Package binary file
483 binaryFileName = str(path)
484 binaryFileName = binaryFileName.replace('.gltf', '.glb')
485 logger.debug('- Packaging GLB file...')
486 mtlx2glTFWriter = core.MTLX2GLTFWriter()
487 mtlx2glTFWriter.setOptions(options)
488 saved, images, buffers = mtlx2glTFWriter.packageGLTF(path, binaryFileName)
489 logger.info('- Save GLB file:' + binaryFileName + '. Status:' + str(saved))
490 for image in images:
491 logger.debug(' - Embedded image: ' + image)
492 for buffer in buffers:
493 logger.debug(' - Embedded buffer: ' + buffer)
494 logger.debug('- Packaging GLB file... finished.')
495
496 # Load the glb file into the glTF viewer
497 logger.debug(f'Loading GLB file into glTF viewer: {binaryFileName}')
498 if self.web_dock_widget.page:
499 self.web_dock_widget.page.load_glb(binaryFileName)
500
501 except Exception as e:
502 logger.error(e)
503
504 def convert_graph_to_gltf(self, options) -> str:
505 '''
506 Convert the current graph to a glTF document string.
507 Will perform:
508 - Shader translation if needed (not that only standard surface is supported)
509 - Baking if needed. Note that this writes local files.
510 - Uses the materialgltf package to perform conversion
511
512 @param options (dict): Dictionary of options for the conversion
513 @return The glTF string.
514 '''
515 # Disable auto nodegraph creation during
516 ng_abstraction = self.editor.act_ng_abstraction.isChecked()
517 self.editor.act_ng_abstraction.setChecked(False)
518
519 doc = self.editor.qx_node_graph.get_current_mx_graph_doc()
520
521 self.editor.act_ng_abstraction.setChecked(ng_abstraction)
522
523 mtlx2glTFWriter = core.MTLX2GLTFWriter()
524 mtlx2glTFWriter.setOptions(options)
525
526 # Need to load in definition libraries to get translation graphs
527 stdlib = mx.createDocument()
528 searchPath = mx.getDefaultDataSearchPath()
529 libraryFolders = []
530 libraryFolders.extend(mx.getDefaultDataLibraryFolders())
531 mx.loadLibraries(libraryFolders, searchPath, stdlib)
532 doc.importLibrary(stdlib)
533
534 # Perform shader translation if needed
535 translatedCount = 0
536 if options['translateShaders']:
537 translatedCount = mtlx2glTFWriter.translateShaders(doc)
538 logger.debug('- Translated shaders: ' + str(translatedCount))
539
540 forceBake = False
541 if self.bake_textures_option.isChecked():
542 logger.debug('--- Forcing baking of textures')
543 forceBake = True
544
545 # Perform baking if needed
546 if forceBake or (translatedCount > 0 and options['bakeTextures']):
547 bakedFileName = options['bakeFileName']
548 bakeResolution = 1024
549 if options['bakeResolution']:
550 bakeResolution = options['bakeResolution']
551 logger.debug(f'- START baking to {bakedFileName}. Resolution: {bakeResolution} ...')
552 mtlx2glTFWriter.bakeTextures(doc, False, bakeResolution, bakeResolution, True,
553 False, False, bakedFileName)
554 if os.path.exists(bakedFileName):
555 logger.debug(' - Baked textures to: ' + bakedFileName)
556 doc, libFiles = core.Util.createMaterialXDoc()
557 mx.readFromXmlFile(doc, bakedFileName, options['searchPath'])
558 remappedUris = core.Util.makeFilePathsRelative(doc, bakedFileName)
559 for uri in remappedUris:
560 logger.debug(' - Remapped URI: ' + uri[0] + ' to ' + uri[1])
561 core.Util.writeMaterialXDoc(doc, bakedFileName)
562 logger.debug('- ... END baking.')
563
564 gltfString = mtlx2glTFWriter.convert(doc)
565 return gltfString
566
568 '''
569 Show the current graph as glTF text popup.
570 '''
571 path = self.editor.mx_selection_path
572 if not path:
573 path = self.editor.geometry_selection_path
574 if not path:
575 path = os.path.join(ROOT, "resources", "materials")
576
577 # Setup export options
578 bakeFileName = self.create_baked_path(path)
579 options = self.setup_default_export_options(path, bakeFileName, bakeResolution=64, embed_geometry=False)
580
581 logger.debug('Show glTF text triggered. Path:' + path + '. bakeFileName: ' + bakeFileName)
582
583 # Convert and display the text
584 text = self.convert_graph_to_gltf(options)
585 self.show_text_box(text, 'glTF Representation')
586
587@qx_plugin.hookimpl
588def after_ui_init(editor: "quiltix.QuiltiXWindow"):
589 '''
590 @brief After UI initialization, add the MaterialX glTF serializer to the editor.
591 '''
592 logger.debug("Adding MaterialX glTF serializer")
593 editor.gltf_serializer = QuiltiX_glTF_serializer(editor, constants.ROOT)
594
595def plugin_name() -> str:
596 '''
597 @brief Get the name of the plugin.
598 @return The name of the plugin.'''
599 if haveGLTF:
600 return "MaterialX glTF Serializer"
601 return ""
602
603def is_valid() -> bool:
604 '''
605 @brief Check if the plugin is valid. That is the glTF serializer module is installed.
606 @return True if the plugin is valid), False otherwise.
607 '''
608 return haveGLTF
Highlighter for glTF text.
__init__(self)
Initialize the highlighter.
highlight(self, text)
Highlight the text.
glTF serializer for MaterialX
None export_gltf_triggered(self, writeToTemp=False)
Export the current graph to a glTF file in binary format (glb)
dict setup_default_export_options(self, path, bakeFileName, bakeResolution=1024, embed_geometry=False)
Set up the default export options for gltf output.
create_baked_path(self, path)
Create a baked path name from an original path.
str convert_graph_to_gltf(self, options)
Convert the current graph to a glTF document string.
None on_gltf_viewer_toggled(self, checked)
Toggle the glTF viewer dock widget.
None import_gltf_triggered(self)
Import a glTF file into the current graph.
None __init__(self, editor, root)
Initialize the plugin.
None setup_gltf_viewer_doc(self)
Set up the glTF viewer dock widget.
show_gltf_text_triggered(self)
Show the current graph as glTF text popup.
None show_text_box(self, text, title="")
Core utilities.
custom_on_view_menu_about_to_show(self)
Custom about to show event for the view menu.
Custom web engine page for the glTF viewer.
setDebug(self, debug)
Set the debug flag.
__init__(self, parent=None)
Initialize the custom web engine page.
injectJavaScript(self)
Add interestCohort function to the document to disable FLoC.
javaScriptConsoleMessage(self, level, message, lineNumber, sourceID)
Handle JavaScript console messages.
load_glb(self, glb_file_path)
__init__(self, parent=None)
Sets up a web view and loads in a sample glTF viewer page.
after_ui_init("quiltix.QuiltiXWindow" editor)
After UI initialization, add the MaterialX glTF serializer to the editor.
bool is_valid()
Check if the plugin is valid.
str plugin_name()
Get the name of the plugin.