diff --git a/docs/send2ue/customize/extensions.md b/docs/send2ue/customize/extensions.md index c69e22c2..b2ae0cdd 100644 --- a/docs/send2ue/customize/extensions.md +++ b/docs/send2ue/customize/extensions.md @@ -80,7 +80,18 @@ Then in the Send to Unreal addon preferences set the `Extensions Repo Folder` to Alternatively, this can be installed with python: ```python # this is handy for reloading your changes as you develop extensions - bpy.context.preferences.addons['send2ue'].preferences.extensions_repo_path = 'C:\extension_repo' + import bpy + from pathlib import Path + + my_extension_folder = r'C:\extension_repo' + preferences = bpy.context.preferences.addons['send2ue'].preferences + for extension_folder in preferences.extension_folder_list: + if Path(extension_folder.folder_path) == Path(my_extension_folder): + break + else: + extension_folder = preferences.extension_folder_list.add() + extension_folder.folder_path = my_extension_folder + bpy.ops.send2ue.reload_extensions() ``` diff --git a/docs/send2ue/customize/images/extensions/2.png b/docs/send2ue/customize/images/extensions/2.png index 0b78bc02..36224386 100644 Binary files a/docs/send2ue/customize/images/extensions/2.png and b/docs/send2ue/customize/images/extensions/2.png differ diff --git a/src/addons/send2ue/core/extension.py b/src/addons/send2ue/core/extension.py index 06899cea..7b1c4325 100644 --- a/src/addons/send2ue/core/extension.py +++ b/src/addons/send2ue/core/extension.py @@ -344,14 +344,15 @@ def _get_extension_classes(self): # add in the additional extensions from the addons preferences addon = bpy.context.preferences.addons.get(base_package) if addon and addon.preferences: - if os.path.exists(addon.preferences.extensions_repo_path): - for file_name in os.listdir(addon.preferences.extensions_repo_path): - name, file_extension = os.path.splitext(file_name) - if file_extension == '.py': - extension_collector = ExtensionCollector( - os.path.join(addon.preferences.extensions_repo_path, file_name) - ) - extensions.extend(extension_collector.get_extension_classes()) + for extension_folder in addon.preferences.extension_folder_list: # type: ignore + if os.path.exists(extension_folder.folder_path): + for file_name in os.listdir(extension_folder.folder_path): + name, file_extension = os.path.splitext(file_name) + if file_extension == '.py': + extension_collector = ExtensionCollector( + os.path.join(extension_folder.folder_path, file_name) + ) + extensions.extend(extension_collector.get_extension_classes()) # add in the extensions that shipped with the addon for file_name in os.listdir(self.source_path): diff --git a/src/addons/send2ue/operators.py b/src/addons/send2ue/operators.py index 4f4704ab..925931b4 100644 --- a/src/addons/send2ue/operators.py +++ b/src/addons/send2ue/operators.py @@ -6,7 +6,7 @@ import threading from .constants import ToolInfo, ExtensionTasks from .core import export, utilities, settings, validations, extension -from .ui import file_browser, dialog +from .ui import file_browser, dialog, addon_preferences from .dependencies import unreal from .dependencies.rpc import blender_server from .properties import register_scene_properties, unregister_scene_properties @@ -254,13 +254,13 @@ class ReloadExtensions(bpy.types.Operator): def execute(self, context): addon = bpy.context.preferences.addons.get(base_package) if addon: - extensions_repo_path = addon.preferences.extensions_repo_path - if extensions_repo_path: - if not os.path.exists(extensions_repo_path) or not os.path.isdir( - extensions_repo_path - ): - self.report(f'"{extensions_repo_path}" is not a folder path on disk.') - return {'FINISHED'} + for extension_folder in addon.preferences.extension_folder_list: # type: ignore + if extension_folder.folder_path: + if not os.path.exists(extension_folder.folder_path) or not os.path.isdir( + extension_folder.folder_path + ): + self.report(f'"{extension_folder.folder_path}" is not a folder path on disk.') + return {'FINISHED'} extension_factory = extension.ExtensionFactory() @@ -303,6 +303,62 @@ class NullOperator(bpy.types.Operator): def execute(self, context): return {'FINISHED'} + + +class GenericUIListOperator: + """Mix-in class containing functionality shared by operators + that deal with managing Blender list entries.""" + bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} + + list_path: bpy.props.StringProperty() # type: ignore + active_index_path: bpy.props.StringProperty() # type: ignore + + def get_list(self, context): + return addon_preferences.get_context_attr(context, self.list_path) + + def get_active_index(self, context): + return addon_preferences.get_context_attr(context, self.active_index_path) + + def set_active_index(self, context, index): + addon_preferences.set_context_attr(context, self.active_index_path, index) + + +class UILIST_ADDON_PREFERENCES_OT_entry_remove(GenericUIListOperator, bpy.types.Operator): + """Remove the selected entry from the list""" + + bl_idname = "uilist.addon_preferences_entry_remove" + bl_label = "Remove Selected Entry" + + def execute(self, context): + addon_preferences = context.preferences.addons[ToolInfo.NAME.value] + my_list = self.get_list(addon_preferences) + active_index = self.get_active_index(addon_preferences) + + my_list.remove(active_index) + to_index = min(active_index, len(my_list) - 1) + self.set_active_index(addon_preferences, to_index) + + return {'FINISHED'} + + +class UILIST_ADDON_PREFERENCES_OT_entry_add(GenericUIListOperator, bpy.types.Operator): + """Add an entry to the list after the current active item""" + + bl_idname = "uilist.addon_preferences_entry_add" + bl_label = "Add Entry" + + def execute(self, context): + addon_preferences = context.preferences.addons[ToolInfo.NAME.value] + my_list = self.get_list(addon_preferences) + active_index = self.get_active_index(addon_preferences) + + to_index = min(len(my_list), active_index + 1) + + my_list.add() + my_list.move(len(my_list) - 1, to_index) + self.set_active_index(addon_preferences, to_index) + + return {'FINISHED'} operator_classes = [ @@ -316,6 +372,8 @@ def execute(self, context): ReloadExtensions, StartRPCServers, NullOperator, + UILIST_ADDON_PREFERENCES_OT_entry_remove, + UILIST_ADDON_PREFERENCES_OT_entry_add, ] diff --git a/src/addons/send2ue/properties.py b/src/addons/send2ue/properties.py index fd915808..e747a26c 100644 --- a/src/addons/send2ue/properties.py +++ b/src/addons/send2ue/properties.py @@ -2,11 +2,17 @@ import os import sys -import uuid import bpy from .constants import ToolInfo, PathModes, Template from .core import settings, formatting, extension +class ExtensionFolder(bpy.types.PropertyGroup): + folder_path: bpy.props.StringProperty( + default='', + description='The folder location of the extension repo.', + subtype='FILE_PATH' + ) # type: ignore + class Send2UeAddonProperties: """ @@ -17,14 +23,6 @@ class Send2UeAddonProperties: default=True, description=f"This automatically creates the pre-defined collection (Export)" ) - extensions_repo_path: bpy.props.StringProperty( - name="Extensions Repo Path", - default="", - description=( - "Set this path to the folder that contains your Send to Unreal python extensions. All extensions " - "in this folder will be automatically loaded" - ) - ) # ------------- Remote Execution settings ------------------ rpc_response_timeout: bpy.props.IntProperty( name="RPC Response Timeout", @@ -62,6 +60,9 @@ class Send2UeAddonProperties: ) ) + extension_folder_list: bpy.props.CollectionProperty(type=ExtensionFolder) # type: ignore + extension_folder_list_active_index: bpy.props.IntProperty() # type: ignore + class Send2UeWindowMangerProperties(bpy.types.PropertyGroup): """ diff --git a/src/addons/send2ue/ui/addon_preferences.py b/src/addons/send2ue/ui/addon_preferences.py index fb630771..c7b23192 100644 --- a/src/addons/send2ue/ui/addon_preferences.py +++ b/src/addons/send2ue/ui/addon_preferences.py @@ -1,11 +1,106 @@ # Copyright Epic Games, Inc. All Rights Reserved. import bpy -from ..properties import Send2UeAddonProperties +from pathlib import Path +from ..properties import Send2UeAddonProperties, ExtensionFolder from ..constants import ToolInfo from .. import __package__ +def get_context_attr(context, data_path): + """Return the value of a context member based on its data path.""" + return context.path_resolve(data_path) + +def set_context_attr(context, data_path, value): + """Set the value of a context member based on its data path.""" + owner_path, attr_name = data_path.rsplit('.', 1) + owner = context.path_resolve(owner_path) + setattr(owner, attr_name, value) + +def _draw_add_remove_buttons( + *, + layout, + list_path, + active_index_path, + list_length, +): + """Draw the +/- buttons to add and remove list entries.""" + props = layout.operator("uilist.addon_preferences_entry_add", text="", icon='ADD') + props.list_path = list_path + props.active_index_path = active_index_path + + row = layout.row() + row.enabled = list_length > 0 + props = row.operator("uilist.addon_preferences_entry_remove", text="", icon='REMOVE') + props.list_path = list_path + props.active_index_path = active_index_path + +def draw_ui_list( + layout, + context, + class_name="UI_UL_list", + *, + unique_id, + list_path, + active_index_path, + insertion_operators=True, + menu_class_name="", + **kwargs, +): + """ + This overrides the draw_ui_list function from the generic_ui_list module + so that we can draw the add and remove buttons for a list in the addon preferences. + By default, the generic_ui_list module buttons link to ops that receive the scene + context, which is not what we want in this case. So we had to create new ops that + do this job. + """ + + row = layout.row() + + list_owner_path, list_prop_name = list_path.rsplit('.', 1) + list_owner = get_context_attr(context, list_owner_path) + + index_owner_path, index_prop_name = active_index_path.rsplit('.', 1) + index_owner = get_context_attr(context, index_owner_path) + + list_to_draw = get_context_attr(context, list_path) + + row.template_list( + class_name, + unique_id, + list_owner, list_prop_name, + index_owner, index_prop_name, + rows=4 if list_to_draw else 1, + **kwargs, + ) + + col = row.column() + + if insertion_operators: + _draw_add_remove_buttons( + layout=col, + list_path=list_path, + active_index_path=active_index_path, + list_length=len(list_to_draw), + ) + layout.separator() + + if menu_class_name: + col.menu(menu_class_name, icon='DOWNARROW_HLT', text="") + col.separator() + + # Return the right-side column. + return col + + +class FOLDER_UL_extension_path(bpy.types.UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_prop_name): + row = layout.row() + row.alert = False + if item.folder_path and not Path(item.folder_path).exists(): + row.alert = True + row.prop(item, "folder_path", text="", emboss=False) + class SendToUnrealPreferences(Send2UeAddonProperties, bpy.types.AddonPreferences): """ This class creates the settings interface in the send to unreal addon. @@ -35,16 +130,26 @@ def draw(self, context): row.prop(self, 'command_endpoint', text='') row = self.layout.row() - row.label(text='Extensions Repo Path:') + row.label(text='Extensions Repo Paths:') + row = self.layout.row() + draw_ui_list( + row, + context=bpy.context.preferences.addons[ToolInfo.NAME.value], + class_name="FOLDER_UL_extension_path", + list_path="preferences.extension_folder_list", + active_index_path="preferences.extension_folder_list_active_index", + unique_id="extension_folder_list_id", + insertion_operators=True + ) # type: ignore row = self.layout.row() - row = row.split(factor=0.95, align=True) - row.prop(self, 'extensions_repo_path', text='') - row.operator('send2ue.reload_extensions', text='', icon='UV_SYNC_SELECT') + row.operator('send2ue.reload_extensions', text='Reload All Extensions', icon='FILE_REFRESH') def register(): """ Registers the addon preferences when the addon is enabled. """ + bpy.utils.register_class(ExtensionFolder) + bpy.utils.register_class(FOLDER_UL_extension_path) bpy.utils.register_class(SendToUnrealPreferences) @@ -53,3 +158,5 @@ def unregister(): Unregisters the addon preferences when the addon is disabled. """ bpy.utils.unregister_class(SendToUnrealPreferences) + bpy.utils.unregister_class(FOLDER_UL_extension_path) + bpy.utils.unregister_class(ExtensionFolder) diff --git a/tests/utils/base_test_case.py b/tests/utils/base_test_case.py index 5968b00f..e3c247dd 100644 --- a/tests/utils/base_test_case.py +++ b/tests/utils/base_test_case.py @@ -135,7 +135,8 @@ def __init__(self, *args, **kwargs): def setUp(self): super().setUp() - self.set_extension_repo('') + self.blender.clear_extension_repos() + self.blender.run_addon_operator(self.addon_name, 'reload_extensions') def set_extension_repo(self, path): self.log(f'Setting the addon extension repo to "{path}"') @@ -144,12 +145,7 @@ def set_extension_repo(self, path): if self.test_environment: path = os.path.normpath(path).replace(os.path.sep, '/') - self.blender.set_addon_property( - 'preferences', - self.addon_name, - 'extensions_repo_path', - path - ) + self.blender.add_extension_repo(path) self.blender.run_addon_operator(self.addon_name, 'reload_extensions') def assert_extension_operators(self, extension_name, extension_operators, exists=True): @@ -240,7 +236,8 @@ def run_extension_tests(self, extensions): self.assert_extension(extension_name, extensions_data) # check that external extensions are removed are being removed correctly - self.set_extension_repo('') + self.blender.clear_extension_repos() + self.blender.run_addon_operator(self.addon_name, 'reload_extensions') for extension_name, extensions_data in external_extensions.items(): self.assert_extension(extension_name, extensions_data, False) diff --git a/tests/utils/blender.py b/tests/utils/blender.py index b6987537..108863c1 100644 --- a/tests/utils/blender.py +++ b/tests/utils/blender.py @@ -4,6 +4,7 @@ import logging import importlib import tempfile +from pathlib import Path try: import bpy @@ -111,6 +112,21 @@ def set_addon_property(context_name, addon_name, property_name, value, data_type break properties = getattr(properties, sub_property_name) + @staticmethod + def add_extension_repo(file_path): + preferences = bpy.context.preferences.addons['send2ue'].preferences + for extension_folder in preferences.extension_folder_list: + if Path(extension_folder.folder_path) == Path(file_path): + break + else: + extension_folder = preferences.extension_folder_list.add() + extension_folder.folder_path = file_path + + @staticmethod + def clear_extension_repos(): + preferences = bpy.context.preferences.addons['send2ue'].preferences + preferences.extension_folder_list.clear() + @staticmethod def check_particles(mesh_name, particle_names): """