diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py new file mode 100644 index 00000000000..efa06795b04 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""Validator for Loaded Plugin.""" +import os +import pyblish.api +from pymxs import runtime as rt + +from openpype.pipeline.publish import ( + RepairAction, + OptionalPyblishPluginMixin, + PublishValidationError +) +from openpype.hosts.max.api.lib import get_plugins + + +class ValidateLoadedPlugin(OptionalPyblishPluginMixin, + pyblish.api.InstancePlugin): + """Validates if the specific plugin is loaded in 3ds max. + Studio Admin(s) can add the plugins they want to check in validation + via studio defined project settings + """ + + order = pyblish.api.ValidatorOrder + hosts = ["max"] + label = "Validate Loaded Plugins" + optional = True + actions = [RepairAction] + + family_plugins_mapping = {} + + @classmethod + def get_invalid(cls, instance): + """Plugin entry point.""" + family_plugins_mapping = cls.family_plugins_mapping + if not family_plugins_mapping: + return + + invalid = [] + # Find all plug-in requirements for current instance + instance_families = {instance.data["family"]} + instance_families.update(instance.data.get("families", [])) + cls.log.debug("Checking plug-in validation " + f"for instance families: {instance_families}") + all_required_plugins = set() + + for mapping in family_plugins_mapping: + # Check for matching families + if not mapping: + return + + match_families = {fam.strip() for fam in mapping["families"]} + has_match = "*" in match_families or match_families.intersection( + instance_families) + + if not has_match: + continue + + cls.log.debug( + f"Found plug-in family requirements: {match_families}") + required_plugins = [ + # match lowercase and format with os.environ to allow + # plugin names defined by max version, e.g. {3DSMAX_VERSION} + plugin.format(**os.environ).lower() + for plugin in mapping["plugins"] + # ignore empty fields in settings + if plugin.strip() + ] + + all_required_plugins.update(required_plugins) + + if not all_required_plugins: + # Instance has no plug-in requirements + return + + # get all DLL loaded plugins in Max and their plugin index + available_plugins = { + plugin_name.lower(): index for index, plugin_name in enumerate( + get_plugins()) + } + # validate the required plug-ins + for plugin in sorted(all_required_plugins): + plugin_index = available_plugins.get(plugin) + if plugin_index is None: + debug_msg = ( + f"Plugin {plugin} does not exist" + " in 3dsMax Plugin List." + ) + invalid.append((plugin, debug_msg)) + continue + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + debug_msg = f"Plugin {plugin} not loaded." + invalid.append((plugin, debug_msg)) + return invalid + + def process(self, instance): + if not self.is_active(instance.data): + self.log.debug("Skipping Validate Loaded Plugin...") + return + invalid = self.get_invalid(instance) + if invalid: + bullet_point_invalid_statement = "\n".join( + "- {}".format(message) for _, message in invalid + ) + report = ( + "Required plugins are not loaded.\n\n" + f"{bullet_point_invalid_statement}\n\n" + "You can use repair action to load the plugin." + ) + raise PublishValidationError( + report, title="Missing Required Plugins") + + @classmethod + def repair(cls, instance): + # get all DLL loaded plugins in Max and their plugin index + invalid = cls.get_invalid(instance) + if not invalid: + return + + # get all DLL loaded plugins in Max and their plugin index + available_plugins = { + plugin_name.lower(): index for index, plugin_name in enumerate( + get_plugins()) + } + + for invalid_plugin, _ in invalid: + plugin_index = available_plugins.get(invalid_plugin) + + if plugin_index is None: + cls.log.warning( + f"Can't enable missing plugin: {invalid_plugin}") + continue + + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + rt.pluginManager.loadPluginDll(plugin_index) diff --git a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py b/openpype/hosts/max/plugins/publish/validate_usd_plugin.py deleted file mode 100644 index 36c4291925b..00000000000 --- a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validator for USD plugin.""" -from pyblish.api import InstancePlugin, ValidatorOrder -from pymxs import runtime as rt - -from openpype.pipeline import ( - OptionalPyblishPluginMixin, - PublishValidationError -) - - -def get_plugins() -> list: - """Get plugin list from 3ds max.""" - manager = rt.PluginManager - count = manager.pluginDllCount - plugin_info_list = [] - for p in range(1, count + 1): - plugin_info = manager.pluginDllName(p) - plugin_info_list.append(plugin_info) - - return plugin_info_list - - -class ValidateUSDPlugin(OptionalPyblishPluginMixin, - InstancePlugin): - """Validates if USD plugin is installed or loaded in 3ds max.""" - - order = ValidatorOrder - 0.01 - families = ["model"] - hosts = ["max"] - label = "Validate USD Plugin loaded" - optional = True - - def process(self, instance): - """Plugin entry point.""" - - for sc in ValidateUSDPlugin.__subclasses__(): - self.log.info(sc) - - if not self.is_active(instance.data): - return - - plugin_info = get_plugins() - usd_import = "usdimport.dli" - if usd_import not in plugin_info: - raise PublishValidationError(f"USD Plugin {usd_import} not found") - usd_export = "usdexport.dle" - if usd_export not in plugin_info: - raise PublishValidationError(f"USD Plugin {usd_export} not found") diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 46641537862..771598f51fc 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -651,6 +651,13 @@ def _convert_3dsmax_project_settings(ayon_settings, output): attributes = {} ayon_publish["ValidateAttributes"]["attributes"] = attributes + if "ValidateLoadedPlugin" in ayon_publish: + loaded_plugin = ( + ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] + ) + for item in loaded_plugin: + item["families"] = item.pop("product_types") + output["max"] = ayon_max diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index fdaa8d2b910..97fcf69e311 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -51,6 +51,11 @@ "ValidateAttributes": { "enabled": false, "attributes": {} + }, + "ValidateLoadedPlugin": { + "enabled": false, + "optional": true, + "family_plugins_mapping": [] } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index c3b56bae5e6..c6d37ae9935 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -47,6 +47,49 @@ "label": "Attributes" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateLoadedPlugin", + "label": "Validate Loaded Plugin", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "list", + "collapsible": true, + "key": "family_plugins_mapping", + "label": "Family Plugins Mapping", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "families", + "label": "Famiies", + "type": "list", + "object_type": "text" + }, + { + "key": "plugins", + "label": "Plugins", + "type": "list", + "object_type": "text" + } + ] + } + } + ] } ] } diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index df8412391ad..b48f14a0646 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -27,6 +27,26 @@ def validate_json(cls, value): return value +class FamilyMappingItemModel(BaseSettingsModel): + product_types: list[str] = Field( + default_factory=list, + title="Product Types" + ) + plugins: list[str] = Field( + default_factory=list, + title="Plugins" + ) + + +class ValidateLoadedPluginModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + optional: bool = Field(title="Optional") + family_plugins_mapping: list[FamilyMappingItemModel] = Field( + default_factory=list, + title="Family Plugins Mapping" + ) + + class BasicValidateModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") @@ -44,6 +64,10 @@ class PublishersModel(BaseSettingsModel): title="Validate Attributes" ) + ValidateLoadedPlugin: ValidateLoadedPluginModel = Field( + default_factory=ValidateLoadedPluginModel, + title="Validate Loaded Plugin" + ) DEFAULT_PUBLISH_SETTINGS = { "ValidateFrameRange": { @@ -55,4 +79,9 @@ class PublishersModel(BaseSettingsModel): "enabled": False, "attributes": "{}" }, + "ValidateLoadedPlugin": { + "enabled": False, + "optional": True, + "family_plugins_mapping": [] + } }