Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugin Logic Proposal #75

Merged
merged 9 commits into from
Jul 18, 2024
153 changes: 153 additions & 0 deletions sample_plugins/materialjson/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#
# Sample plug-in for QuiltiX which adds import, export, and preview functionality for MaterialX in JSON format
#
import logging, os
from qtpy import QtCore # type: ignore
from qtpy.QtWidgets import ( # type: ignore
QAction,
QTextEdit )

logger = logging.getLogger(__name__)

try:
import materialxjson.core as jsoncore
except ImportError:
logger.error("materialxjson.core not found")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This plugin isn't usable without jsoncore. Would it make sense to stop loading the plugin at this point and somehow disable it after logging the error?
Some logic for plugins to define requirements, which need to be met before the plugin can be loaded, could be handy. This could be used to define plugins, which only work on a specific OS or with a specific MaterialX versions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @RichardFrangenberg
There used to be a validity check before the refactor. I've added this back in with an explicit `is_valid()1 check on the plugin. It leaves it up to the plugin to decide where it can work.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed it so a plugin doesn't need to implement is_valid, but it can choose to do so.


class QuiltiX_JSON_serializer():
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no encapsulation of the core code as is the case with the current way of just overriding the entire application.


def __init__(self, editor, root):
'''
Initialize the JSON serializer.
'''
self.editor = editor
self.root = root
self.indent = 4

# Add JSON menu to the file menu
# ----------------------------------------
editor.file_menu.addSeparator()
gltfMenu = editor.file_menu.addMenu("JSON")

# Export JSON item
export_json = QAction("Save JSON...", editor)
export_json.triggered.connect(self.export_json_triggered)
gltfMenu.addAction(export_json)

# Import JSON item
import_json = QAction("Load JSON...", editor)
import_json.triggered.connect(self.import_json_triggered)
gltfMenu.addAction(import_json)

# Show JSON text. Does most of export, except does not write to file
show_json_text = QAction("Show as JSON...", editor)
show_json_text.triggered.connect(self.show_json_triggered)
gltfMenu.addAction(show_json_text)

def set_indent(self, indent):
'''
Set the indent for the JSON output.
'''
self.indent = indent

def get_json_from_graph(self):
'''
Get the JSON for the given MaterialX document.
'''
doc = self.editor.qx_node_graph.get_current_mx_graph_doc()
if doc:
exporter = jsoncore.MaterialXJson()
json_result = exporter.documentToJSON(doc)
return json_result
return None

def show_json_triggered(self):
'''
Show the current USD Stage.
kwokcb marked this conversation as resolved.
Show resolved Hide resolved
'''
json_result = self.get_json_from_graph()

# Write JSON UI text box
if json_result:
text = jsoncore.Util.jsonToJSONString(json_result, self.indent)
self.show_text_box(text, 'JSON Representation')

def export_json_triggered(self):
'''
Export the current graph to a JSON file.
'''
start_path = self.editor.mx_selection_path
if not start_path:
start_path = self.editor.geometry_selection_path

if not start_path:
start_path = os.path.join(self.root, "resources", "materials")

path = self.editor.request_filepath(
title="Save JSON file", start_path=start_path, file_filter="JSON files (*.json)", mode="save",
)

if not path:
return

json_result = self.get_json_from_graph()

# Write JSON to file
if json_result:
with open(path, 'w') as outfile:
jsoncore.Util.writeJson(json_result, path, 2)
logger.info('Wrote JSON file: ' + path)

self.editor.set_current_filepath(path)

def import_json_triggered(self):
'''
Import a JSON file into the current graph.
'''
start_path = self.editor.mx_selection_path
if not start_path:
start_path = self.editor.geometry_selection_path

if not start_path:
start_path = os.path.join(self.root, "resources", "materials")

path = self.editor.request_filepath(
title="Load JSON file", start_path=start_path, file_filter="JSON files (*.json)", mode="open",
)

if not os.path.exists(path):
logger.error('Cannot find input file: ' + path)
return

doc = jsoncore.Util.jsonFileToXml(path)
if doc:
logger.info('Loaded JSON file: ' + path)
self.editor.mx_selection_path = path
self.editor.qx_node_graph.load_graph_from_mx_doc(doc)
self.editor.qx_node_graph.mx_file_loaded.emit(path)

# Helper functions
def show_text_box(self, text, title=""):
'''
Show a text box with the given text.
'''
te_text = QTextEdit()
te_text.setText(text)
te_text.setReadOnly(True)
te_text.setParent(self.editor, QtCore.Qt.Window)
te_text.setWindowTitle(title)
te_text.resize(1000, 800)
te_text.show()

def install_plugin(editor, root, result):
'''
Plugin entry point.
'''
if not jsoncore:
logger.error("MaterialX JSON plugin not installed")
return [None, None]

plugin = QuiltiX_JSON_serializer(editor, root)

result.plugin = plugin
result.id = "MaterialX JSON Serializer"
4 changes: 4 additions & 0 deletions src/QuiltiX/quiltix.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from QuiltiX.constants import ROOT
from QuiltiX.qx_node_property import PropertiesBinWidget
from QuiltiX.qx_nodegraph import QxNodeGraph
from QuiltiX.qx_plugin import QuiltiXPluginManager


logging.basicConfig()
Expand All @@ -64,6 +65,9 @@ def __init__(self, load_style_sheet=True, load_shaderball=True, load_default_gra
self.init_ui()
self.init_menu_bar()

self.plugin_manaager = QuiltiXPluginManager(self, ROOT)
self.plugin_manaager.install_plugins()

if load_style_sheet:
self.loadStylesheet()

Expand Down
61 changes: 61 additions & 0 deletions src/QuiltiX/qx_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import importlib.util, os, logging

logger = logging.getLogger(__name__)

class QuiltiXPlugin():
def __init__(self):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More fields can be added. It's simple for now with a key and implementation.

self.id = ""
self.plugin = None

class QuiltiXPluginManager():
def __init__(self, editor, root):
self.editor = editor
self.root = root
self.plugins = []

def install_plugins(self):
self.plugins = []
plugin_roots = [os.path.join(self.root, "plugins"), os.getenv("QUILTIX_PLUGIN_FOLDER", "")]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gives an initial package and env var location.

for plugin_folder in plugin_roots:
if os.path.exists(plugin_folder):
absolute_plugin_folder = os.path.abspath(plugin_folder)
logger.debug(f"Loading plugin from {absolute_plugin_folder}...")
self.install_plugins_from_folder(plugin_folder)

def install_plugins_from_folder(self, plugin_folder):
if not os.path.isdir(plugin_folder):
logger.warning(f"Plugin folder {plugin_folder} not found.")
return

# Get the list of all subfolders in the plugin_folder
for entry in os.listdir(plugin_folder):
entry_path = os.path.join(plugin_folder, entry)

if os.path.isdir(entry_path):
# Check for the presence of plugin.py in the subfolder
plugin_file = os.path.join(entry_path, 'plugin.py')
if os.path.isfile(plugin_file):
module_name = f"{entry}.plugin"

# Dynamically import the module
spec = importlib.util.spec_from_file_location(module_name, plugin_file)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

# Call module install_plugin function if it exists
if hasattr(module, "install_plugin"):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A specific entry point is looked for.

pluginInfo = QuiltiXPlugin()
module.install_plugin(self.editor, self.root, pluginInfo)
if pluginInfo.id and pluginInfo.plugin:
pluginExists = None
for installed_plugin in self.plugins:
if installed_plugin.id == pluginInfo.id:
pluginExists = installed_plugin
break
if pluginExists:
logger.warning(f"Plugin with id {pluginInfo.id} already installed.")
else:
self.plugins.append(pluginInfo)
logger.debug(f"Installed plugin {pluginInfo.id} from {plugin_file}.")
else:
logger.warning(f"No installPlugin function found in {plugin_file}.")