diff --git a/__init__.py b/__init__.py index f03e30c..06f76cd 100644 --- a/__init__.py +++ b/__init__.py @@ -86,7 +86,7 @@ from . import asset_helpers from . import preferences from . import panel - from . import mapr_browser + from . import browser from . import blend_maintenance from . import aquatiq @@ -104,12 +104,12 @@ bl_info = { "name": "engon", "author": "polygoniq xyz s.r.o.", - "version": (1, 0, 1), # bump doc_url as well! + "version": (1, 0, 2), # bump doc_url as well! "blender": (3, 3, 0), "location": "polygoniq tab in the sidebar of the 3D View window", "description": "", "category": "Object", - "doc_url": "https://docs.polygoniq.com/engon/1.0.1/", + "doc_url": "https://docs.polygoniq.com/engon/1.0.2/", "tracker_url": "https://polygoniq.com/discord/" } @@ -127,7 +127,7 @@ def register(): panel.register() scatter.register() blend_maintenance.register() - mapr_browser.register() + browser.register() aquatiq.register() botaniq.register() materialiq.register() @@ -137,7 +137,12 @@ def register(): # We need to call the first pack refresh manually, then it's called when paths change bpy.app.timers.register( lambda: preferences.get_preferences(bpy.context).refresh_packs(), - first_interval=0 + first_interval=0, + # This is important. If an existing blend file is opened with double-click or on command + # line with e.g. "blender.exe path/to/blend", this register() is called in the startup blend + # file but right after the target blend file is opened which would discards the callback + # without persistent=True. + persistent=True ) # Make engon preferences open after first registration @@ -153,7 +158,7 @@ def unregister(): materialiq.unregister() botaniq.unregister() aquatiq.unregister() - mapr_browser.unregister() + browser.unregister() blend_maintenance.unregister() scatter.unregister() panel.unregister() diff --git a/addon_updater_ops.py b/addon_updater_ops.py index 0e2daa0..1efa62c 100644 --- a/addon_updater_ops.py +++ b/addon_updater_ops.py @@ -528,7 +528,7 @@ def draw(self, context): else: col = layout.column() col.label( - text="Addon successfully installed", icon="FILE_TICK") + text="Addon successfully installed", icon='CHECKBOX_HLT') alert_row = col.row() alert_row.alert = True alert_row.operator( @@ -550,7 +550,7 @@ def draw(self, context): col = layout.column() col.scale_y = 0.7 col.label( - text="Addon successfully installed", icon="FILE_TICK") + text="Addon successfully installed", icon='CHECKBOX_HLT') col.label( text="Consider restarting blender to fully reload.", icon="BLANK1") diff --git a/aquatiq/panel.py b/aquatiq/panel.py index 2efc8c3..57960e7 100644 --- a/aquatiq/panel.py +++ b/aquatiq/panel.py @@ -85,7 +85,10 @@ def draw_header(self, context: bpy.types.Context): def draw_header_preset(self, context: bpy.types.Context) -> None: polib.ui_bpy.draw_doc_button( - self.layout, preferences.__package__, rel_url="panels/aquatiq/panel_overview") + self.layout, + polib.utils_bpy.get_top_level_package_name(__package__), + rel_url="panels/aquatiq/panel_overview" + ) def draw(self, context: bpy.types.Context): pass diff --git a/asset_pack_installer.py b/asset_pack_installer.py index b22bcf7..0f686f6 100644 --- a/asset_pack_installer.py +++ b/asset_pack_installer.py @@ -709,12 +709,45 @@ def draw_pack_info( if header != "": col.box().label(text=header, icon='INFO') - col = col.box().column(align=True) - col.label(text=f"Name: {instance.full_name}") - col.label(text=f"Version: {instance.version}") - col.label(text=f"Vendor: {instance.vendor}") + row = col.box().row(align=True) + label_col = row.column(align=True) + label_col.alignment = 'LEFT' + row.separator(factor=2.0) + value_col = row.column(align=True) + value_col.alignment = 'LEFT' + + label_col.label(text="Name:") + value_col.label(text=instance.full_name) + label_col.label(text="Version:") + value_col.label(text=instance.version) + label_col.label(text="Vendor:") + value_col.label(text=instance.vendor) if show_install_path: - col.label(text=f"Install Path: {instance.install_path}") + label_col.label(text="Install Path:") + value_col.label(text=instance.install_path) + + def draw_installer_info(self, layout: bpy.types.UILayout) -> None: + row = layout.row(align=True) + label_col = row.column(align=True) + label_col.alignment = 'LEFT' + row.separator(factor=2.0) + value_col = row.column(align=True) + value_col.alignment = 'LEFT' + + label_col.label(text=f"Pack Folder Name:") + value_col.label(text=instance.pack_root_directory) + if instance._operation == InstallerOperation.UNINSTALL: + label_col.label(text=f"Estimated Freed Disk Space:") + value_col.label(text=instance.pack_size) + else: + if instance._operation == InstallerOperation.UPDATE: + label_col.label(text=f"Estimated Extra Space Required:") + value_col.label(text=instance.pack_size) + else: + label_col.label(text=f"Estimated Pack Size:") + value_col.label(text=instance.pack_size) + label_col.label(text=f"Free Disk Space:") + value_col.label(text=instance.free_space) def check_should_dialog_close(self) -> bool: return not instance.can_installer_proceed diff --git a/asset_registry.py b/asset_registry.py index e711619..571208c 100644 --- a/asset_registry.py +++ b/asset_registry.py @@ -196,7 +196,7 @@ def check_pack_validity(self) -> None: raise ValueError( f"Given install_path {self.install_path} is not a valid directory!") - # Paths to MAPR index JSONs, this will be used by MAPR browser to browse this asset pack. + # Paths to MAPR index JSONs, this will be used by browser to browse this asset pack. # These paths should be relative to self.install_path for index_path in self.index_paths: if os.path.isabs(index_path): @@ -324,7 +324,7 @@ def __init__(self): collections.defaultdict(list) self._packs_by_pack_info_path: typing.Dict[str, AssetPack] = {} self.master_asset_provider: mapr.asset_provider.AssetProvider = \ - mapr.asset_provider.AssetProviderMultiplexer() + mapr.asset_provider.CachedAssetProviderMultiplexer() self.master_file_provider: mapr.file_provider.FileProvider = \ mapr.file_provider.FileProviderMultiplexer() self.on_refresh: typing.List[typing.Callable[[], None]] = [] diff --git a/blend_maintenance/migrator.py b/blend_maintenance/migrator.py index f8dfa0b..c0cd9f0 100644 --- a/blend_maintenance/migrator.py +++ b/blend_maintenance/migrator.py @@ -105,7 +105,11 @@ def execute(self, context: bpy.types.Context): datablocks_to_reload.append(datablock) for datablock in datablocks_to_reload: - datablock.reload() + try: + datablock.reload() + logger.info(f"Reloaded '{datablock.name}'") + except ReferenceError: + logger.error("ReferenceError: Failed to reload some data") return {'FINISHED'} diff --git a/botaniq/animations.py b/botaniq/animations.py index 537ad3f..499d737 100644 --- a/botaniq/animations.py +++ b/botaniq/animations.py @@ -34,7 +34,7 @@ MODULE_CLASSES: typing.List[typing.Type] = [] -DEFAULT_PRESET = preferences.WindPreset.WIND +DEFAULT_PRESET = preferences.botaniq_preferences.WindPreset.WIND DEFAULT_WIND_STRENGTH = 0.5 MODIFIER_STACK_NAME_PREFIX = "bq_Modifier-Stack" ANIMATION_INSTANCES_COLLECTION = "animation_instances" @@ -520,7 +520,7 @@ def set_animation_frame_range( keyframe.handle_right.x = keyframe.co.x - (r_handle_distance * multiplier) -def get_wind_style(action: bpy.types.Action) -> preferences.WindStyle: +def get_wind_style(action: bpy.types.Action) -> preferences.botaniq_preferences.WindStyle: """Infers animation style from looking at status of FCurve Noise modifier in stack. """ for fcurve in action.fcurves: @@ -533,16 +533,16 @@ def get_wind_style(action: bpy.types.Action) -> preferences.WindStyle: continue if noise.mute: - return preferences.WindStyle.LOOP - return preferences.WindStyle.PROCEDURAL + return preferences.botaniq_preferences.WindStyle.LOOP + return preferences.botaniq_preferences.WindStyle.PROCEDURAL - return preferences.WindStyle.UNKNOWN + return preferences.botaniq_preferences.WindStyle.UNKNOWN def change_anim_style( obj: bpy.types.Object, helper_objs: typing.Iterable[bpy.types.Object], - style: preferences.WindStyle + style: preferences.botaniq_preferences.WindStyle ) -> None: """Set animation style of given objects and its given helper empties. @@ -551,7 +551,7 @@ def change_anim_style( Fails if given 'obj' doesn't have animation_data.action and only logs warning if some of the 'helper_objs' doesn't have it. """ - def change_anim_style_of_action(action: bpy.types.Action, style: preferences.WindStyle) -> None: + def change_anim_style_of_action(action: bpy.types.Action, style: preferences.botaniq_preferences.WindStyle) -> None: """Set animation style of action to the provided one by changing the mute status on specific FCurves.""" for fcurve in action.fcurves: if len(fcurve.modifiers) < len(WIND_ANIMATION_FCURVE_STYLE_MODS): @@ -568,7 +568,7 @@ def change_anim_style_of_action(action: bpy.types.Action, style: preferences.Win loop_mod_type, loop_mod_status = LOOPING_STACK_STATUS[i] if loop_mod_type != modifier.type: continue - if style == preferences.WindStyle.LOOP: + if style == preferences.botaniq_preferences.WindStyle.LOOP: modifier.mute = not loop_mod_status else: modifier.mute = loop_mod_status @@ -601,7 +601,7 @@ def change_preset(obj: bpy.types.Object, preset: str, strength: float) -> int: if obj.animation_data is None: obj.animation_data_create() - animation_style = preferences.WindStyle.LOOP + animation_style = preferences.botaniq_preferences.WindStyle.LOOP else: old_action = obj.animation_data.action animation_style = get_wind_style(old_action) @@ -802,7 +802,7 @@ def build_animation_type_objs_map( """ # Return early with direct map of animation_type -> objects if selected animation type # is different from BEST_FIT - if animation_type != preferences.AnimationType.WIND_BEST_FIT.value: + if animation_type != preferences.botaniq_preferences.AnimationType.WIND_BEST_FIT.value: return {animation_type: list(objects)} animation_type_objs_map: typing.Dict[ @@ -878,7 +878,8 @@ def animate_objects( # Adjust the frame interval to scene fps to maintain default speed set_animation_frame_range(obj, fps, fps_adjusted_interval) helper_objs = get_animated_objects_hierarchy(obj, load_helper_object_names()) - change_anim_style(obj, helper_objs, preferences.WindStyle.PROCEDURAL) + change_anim_style(obj, helper_objs, + preferences.botaniq_preferences.WindStyle.PROCEDURAL) animated_object_names.append(obj.name) if make_instance: @@ -1260,17 +1261,17 @@ class AnimationSetAnimStyle(AnimationOperatorBase): description="Choose the desired animation style", items=[ ( - preferences.WindStyle.PROCEDURAL.name, - preferences.WindStyle.PROCEDURAL.value, + preferences.botaniq_preferences.WindStyle.PROCEDURAL.name, + preferences.botaniq_preferences.WindStyle.PROCEDURAL.value, "Procedural botaniq animation" ), ( - preferences.WindStyle.LOOP.name, - preferences.WindStyle.LOOP.value, + preferences.botaniq_preferences.WindStyle.LOOP.name, + preferences.botaniq_preferences.WindStyle.LOOP.value, "Looping botaniq animation" ), ], - default=preferences.WindStyle.PROCEDURAL.name, + default=preferences.botaniq_preferences.WindStyle.PROCEDURAL.name, ) def draw(self, context: bpy.types.Context) -> None: @@ -1279,10 +1280,10 @@ def draw(self, context: bpy.types.Context) -> None: super().draw(context) def execute(self, context: bpy.types.Context): - if self.style == preferences.WindStyle.PROCEDURAL.name: - style_enum = preferences.WindStyle.PROCEDURAL - elif self.style == preferences.WindStyle.LOOP.name: - style_enum = preferences.WindStyle.LOOP + if self.style == preferences.botaniq_preferences.WindStyle.PROCEDURAL.name: + style_enum = preferences.botaniq_preferences.WindStyle.PROCEDURAL + elif self.style == preferences.botaniq_preferences.WindStyle.LOOP.name: + style_enum = preferences.botaniq_preferences.WindStyle.LOOP else: raise ValueError(f"Unknown operation '{self.style}', expected LOOP or PROCEDURAL!") diff --git a/botaniq/panel.py b/botaniq/panel.py index a5cb149..773a077 100644 --- a/botaniq/panel.py +++ b/botaniq/panel.py @@ -150,7 +150,10 @@ def draw_header(self, context: bpy.types.Context): def draw_header_preset(self, context: bpy.types.Context) -> None: polib.ui_bpy.draw_doc_button( - self.layout, preferences.__package__, rel_url="panels/botaniq/panel_overview") + self.layout, + polib.utils_bpy.get_top_level_package_name(__package__), + rel_url="panels/botaniq/panel_overview" + ) def draw(self, context: bpy.types.Context): pass @@ -405,7 +408,7 @@ def draw_object_anim_details( split.label(text="Strength:") split.label(text=f"{wind_strength:.3f}x") - if animation_style == preferences.WindStyle.LOOP: + if animation_style == preferences.botaniq_preferences.WindStyle.LOOP: frame_range = animations.get_frame_range(action) if frame_range is not None: # Loop Interval diff --git a/mapr_browser/__init__.py b/browser/__init__.py similarity index 100% rename from mapr_browser/__init__.py rename to browser/__init__.py diff --git a/mapr_browser/browser.py b/browser/browser.py similarity index 98% rename from mapr_browser/browser.py rename to browser/browser.py index da1e20a..cc99224 100644 --- a/mapr_browser/browser.py +++ b/browser/browser.py @@ -195,7 +195,7 @@ def draw_asset_previews( mapr_prefs: preferences.MaprPreferences ) -> None: pm = previews.manager_instance - assets = filters.asset_repository.get_current_assets() + assets = filters.asset_repository.current_assets if len(asset_registry.instance.get_registered_packs()) == 0: col = layout.column() col.separator() @@ -313,7 +313,7 @@ def prefs_header_draw_override(self, context: bpy.types.Context): row.separator_spacer() sub = row.row(align=True) - sub.popover(panel=spawn.SpawnOptionsPopoverPanel.bl_idname, text="", icon='FILE_TICK') + sub.popover(panel=spawn.SpawnOptionsPopoverPanel.bl_idname, text="", icon='OPTIONS') sub.prop(mapr_filters, "sort_mode", text="", icon='SORTALPHA', icon_only=True) sub.prop(prefs, "preview_scale_percentage", slider=True, text="") sub.popover( @@ -377,6 +377,12 @@ def open_browser(cls, context: bpy.types.Context, area: bpy.types.Area) -> None: cls.prev_area_ui_types[area] = area.ui_type area.ui_type = 'PREFERENCES' preferences.get_preferences(context).mapr_preferences.prefs_hijacked = True + # If the asset repository doesn't contain any view (it wasn't queried previously) we + # query and reconstruct the filters manually within the root category. + if filters.asset_repository.last_view is None: + filters.get_filters(context).query_and_reconstruct( + mapr.category.DEFAULT_ROOT_CATEGORY.id_) + cls.hijack_preferences(context) diff --git a/mapr_browser/categories.py b/browser/categories.py similarity index 98% rename from mapr_browser/categories.py rename to browser/categories.py index c9703aa..ab4275e 100644 --- a/mapr_browser/categories.py +++ b/browser/categories.py @@ -66,7 +66,7 @@ def draw_category_pills_header( ui_scale = context.preferences.system.ui_scale estimated_row_width_px = 0 current_category = master_provider.get_category_id_from_string( - filters.asset_repository.get_current_category_id()) + filters.asset_repository.current_category_id) col = layout.column() row = col.row(align=True) row.alignment = 'LEFT' @@ -120,7 +120,7 @@ def draw_tree_category_navigation( context: bpy.types.Context, layout: bpy.types.UILayout, ) -> None: - current_category = filters.asset_repository.get_current_category_id() + current_category = filters.asset_repository.current_category_id child_categories = asset_registry.instance.master_asset_provider.list_sorted_categories( current_category) diff --git a/mapr_browser/dev.py b/browser/dev.py similarity index 91% rename from mapr_browser/dev.py rename to browser/dev.py index 376d2f9..ee97b97 100644 --- a/mapr_browser/dev.py +++ b/browser/dev.py @@ -32,8 +32,7 @@ class MAPR_BrowserDeleteCache(bpy.types.Operator): bl_label = "Delete Cache" def execute(self, context: bpy.types.Context): - if filters.asset_repository.data_view_cache is not None: - filters.asset_repository.data_view_cache.clear() + filters.asset_repository.clear_cache() return {'FINISHED'} @@ -58,7 +57,7 @@ class MAPR_BrowserReloadPreviews(bpy.types.Operator): bl_label = "Reload Previews (In Current View)" def execute(self, context: bpy.types.Context): - assets = filters.asset_repository.get_current_view().assets + assets = filters.asset_repository.current_assets previews.manager_instance.clear_ids({asset.id_ for asset in assets}) previews.ensure_loaded_previews(assets) return {'FINISHED'} diff --git a/mapr_browser/filters.py b/browser/filters.py similarity index 69% rename from mapr_browser/filters.py rename to browser/filters.py index 8819d13..e929f1c 100644 --- a/mapr_browser/filters.py +++ b/browser/filters.py @@ -18,14 +18,19 @@ # # ##### END GPL LICENSE BLOCK ##### +# This module provides UI implementation for the 'mapr.filters'. This adds state to the filters +# and the ability to draw them in the Blender UI based on what assets are present in current view. +# The core class is DynamicFilters - it holds all the filters and has the ability to reconstruct +# them. Individual filters are implemented as PropertyGroups and their properties OVERRIDE the +# properties of the base `mapr.filters` counterparts. + import bpy -import json import typing import logging import mapr import math +import mathutils import polib -import re import threading from .. import asset_registry from .. import preferences @@ -38,214 +43,35 @@ USE_THREADED_QUERY = True -class SortMode: - ALPHABETICAL_ASC = "ABC (A)" - ALPHABETICAL_DESC = "ABC (D)" - -# We cannot use abc.ABC here, as it interferes with bpy.types.PropertyGroup - - -class Filter: - enabled: bpy.props.BoolProperty(options={'HIDDEN'}) - name_without_type: bpy.props.StringProperty(options={'HIDDEN'}) - - def init(self, parameter_meta: typing.Any) -> None: - """Initializes this filter based on one parameter meta information.""" - raise NotImplementedError() - - def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: - raise NotImplementedError() - - def filter_(self, asset: mapr.asset.Asset) -> bool: - """Decides if the 'asset' should be filtered. - - Returns True if asset passes the filter, False otherwise. - NOTE: This should consider things in following order: - 1. If the filter is default, do not filter the asset -> to display all assets and filters - if user didn't ask for filtering yet. We narrow the selection only if user filters. - 2. Is the parameter present on the asset - 3. Lastly - does the asset parameter pass the filter - """ - raise NotImplementedError() - - def reset(self) -> None: - self.enabled = True - - def is_default(self) -> bool: - """Returns True if the filter has its values set to the default ones, False otherwise.""" - raise NotImplementedError() - - def is_applied(self) -> bool: - """Returns True if the filter is enabled and the values are not default.""" - if not self.enabled: - return False - - return not self.is_default() - - def is_drawn(self): - # The filter should be displayed if it is enabled or if the value is different from default - return self.enabled or not self.is_default() - - def as_dict(self) -> typing.Dict[str, typing.Any]: - """Returns a dict entry representing this filter - {key: filter-parameters}. - - The 'key' has to be unique across all the filters! - """ - raise NotImplementedError() - - def get_nice_name(self) -> str: - return mapr.known_metadata.format_parameter_name(self.name_without_type) - - -class Query: - def __init__( - self, - category_id: mapr.category.CategoryID, - filters: typing.Iterable[Filter], - sort_mode: SortMode | str, - recursive: bool = True, - ): - self.category_id = category_id - self.filters = list(filters) - self.sort_mode = sort_mode - self.recursive = recursive - # We need to construct the dict representation of the query when it is initialized - # because we reference the filters and those can change (mutate) after the Query is - # constructed. Resulting in values provided by the filters being always equal to the filters - # dict representation when the query would be converted to dict. - self._dict = self._as_dict() - - def _as_dict(self) -> typing.Dict: - ret = {} - ret["category_id"] = self.category_id - ret["recursive"] = self.recursive - ret["sort_mode"] = self.sort_mode - for filter_ in self.filters: - ret.update(filter_.as_dict()) - - return ret - - def __hash__(self) -> int: - return hash(json.dumps(self._dict)) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Query): - return self._dict == other._dict - - return False - - def __repr__(self) -> str: - return str(self._dict) - - -class DataView: - """One view of data - lists of assets based on provided Query and AssetProvider.""" - - def __init__(self, asset_provider: mapr.asset_provider.AssetProvider, query: Query): - self.assets = [] - for asset in asset_provider.list_assets(query.category_id, query.recursive): - if all(f.filter_(asset) for f in query.filters): - self.assets.append(asset) - - sort_lambda, reverse = self._get_sort_parameters(query.sort_mode) - self.assets.sort(key=lambda x: sort_lambda(x), reverse=reverse) - - self.parameters_meta = mapr.parameter_meta.AssetParametersMeta(self.assets) - self.used_query = query - logger.debug(f"Created DataView {self}") - - def _get_sort_parameters( - self, - sort_mode: SortMode | str - ) -> typing.Tuple[typing.Callable[[mapr.asset.Asset], bool], bool]: - """Return lambda and reverse boolean that should be passed into sort based on sort mode - - Returns tuple of (lambda, reverse) - """ - if sort_mode == SortMode.ALPHABETICAL_ASC: - return (lambda x: x.title, False) - elif sort_mode == SortMode.ALPHABETICAL_DESC: - return (lambda x: x.title, True) - else: - raise NotImplementedError(f"Unknown sort mode {sort_mode}") - - def __repr__(self) -> str: - return f"DataView at {id(self)} based on query:\n {self.used_query}" - - -class DataViewCache: - """Caches data based on LRU queries, using one data provider""" - - def __init__(self, asset_provider: mapr.asset_provider.AssetProvider, max_size: int = 128): - self.asset_provider = asset_provider - self.max_size = max_size - # The most recently used element is always the last element in the `self.cache` dictionary. - # We use our implementation, as `functools.lru_cache` doesn't work as expected with the - # Query object. - self.cache: typing.Dict[Query, DataView] = {} - self.current_view: DataView = self.query( - Query(asset_provider.get_root_category_id(), [], SortMode.ALPHABETICAL_ASC)) - - def query(self, query: Query) -> DataView: - cached_data = self.cache.get(query) - if cached_data is not None: - logger.debug(f"Repurposed {cached_data}") - self.current_view = cached_data - # Pop the item and enter it at the end again - self.cache.pop(query) - self.cache[query] = cached_data - return cached_data - - new_view = DataView(self.asset_provider, query) - self.cache[query] = new_view - self.current_view = new_view - - if len(self.cache) > self.max_size: - # Retrieve first key from the cache dict and pop it - least_used_query = next(iter(self.cache)) - self.cache.pop(least_used_query) - logger.debug(f"Cache was at its max size, removed {least_used_query}") - - return new_view - - def clear(self) -> None: - self.cache.clear() - - class DataRepository: - """Data repository encapsulates querying access providers and caching those queries. - - This is the main access point to access metadata and all polygoniq browsers should use this - as a central point of truth. + """Data repository encapsulates querying access providers for the browser. Queries are executed in a separate thread if `USE_THREADED_QUERY` is True, query being performed is indicated by `is_loading` member variable. - - Queries are cached to increase performance, for more details check `DataViewCache`. """ def __init__(self, asset_provider: mapr.asset_provider.AssetProvider): - self.data_view_cache = DataViewCache(asset_provider) + self.asset_provider = asset_provider self.is_loading = False - self.last_query: typing.Optional[Query] = None + self.last_view: typing.Optional[mapr.asset_provider.DataView] = None def query( self, - query: Query, - on_complete: typing.Optional[typing.Callable[[DataView], None]] = None + query: mapr.query.Query, + on_complete: typing.Optional[typing.Callable[[mapr.asset_provider.DataView], None]] = None ) -> None: def _query(): + logger.debug(f"Performing query against category {query.category_id}") # We are fine here in a separate thread if we don't access any Blender data, the only # thing how we touch blender is loading previews and tagging redraw - data_view = self.data_view_cache.query(query) - previews.ensure_loaded_previews(data_view.assets) + self.last_view = self.asset_provider.query(query) + previews.ensure_loaded_previews(self.last_view.assets) self.is_loading = False utils.tag_prefs_redraw(bpy.context) if on_complete is not None: - on_complete(data_view) + on_complete(self.last_view) self.is_loading = True - self.last_query = query utils.tag_prefs_redraw(bpy.context) if USE_THREADED_QUERY: thread = threading.Thread(target=_query) @@ -254,32 +80,87 @@ def _query(): _query() def update_provider(self, asset_provider: mapr.asset_provider.AssetProvider) -> None: - self.data_view_cache = DataViewCache(asset_provider) - if self.last_query is not None: - self.query(self.last_query) + """Updates the provider used for the repository, clears caches and reconstructs filters + + If there is a DataView saved from previous queries, it is queried again with the new + provider. + We don't query if there wasn't any DataView saved, as we don't want to query assets + if we know that the browser wasn't opened yet - this wastes resources and start-up time. + """ + self.asset_provider = asset_provider + self.clear_cache() + + if self.last_view is not None: + self.query(self.last_view.used_query, lambda _: get_filters( + bpy.context).clear_and_reconstruct()) - get_filters(bpy.context).clear() - get_filters(bpy.context).reconstruct() + def clear_cache(self) -> None: + if isinstance(self.asset_provider, mapr.asset_provider.CachedAssetProviderMultiplexer): + self.asset_provider.clear_cache() - def get_current_category_id(self) -> mapr.category.CategoryID: - return self.data_view_cache.current_view.used_query.category_id + @property + def current_category_id(self) -> mapr.category.CategoryID: + return self.last_view.used_query.category_id if self.last_view is not None \ + else mapr.category.DEFAULT_ROOT_CATEGORY.id_ - def get_current_view(self) -> DataView: - return self.data_view_cache.current_view + @property + def current_view(self) -> mapr.asset_provider.DataView: + return self.last_view if self.last_view is not None else mapr.asset_provider.EmptyDataView() - def get_current_assets(self) -> typing.List[mapr.asset.Asset]: - return self.data_view_cache.current_view.assets + @property + def current_assets(self) -> typing.List[mapr.asset.Asset]: + return self.last_view.assets if self.last_view is not None else [] asset_repository = DataRepository(asset_registry.instance.master_asset_provider) +class BrowserFilter: + """Mixin adding frontend functionality and controls for mapr filters""" + # Filter is enabled when it is relevant for the filtering - when the matching tags or + # parameters are in the current view, or all the time - special case like SearchFilter or + # AssetTypesFilter. + enabled: bpy.props.BoolProperty(options={'HIDDEN'}) + name_without_type: bpy.props.StringProperty(options={'HIDDEN'}) + + def init(self, parameter_meta: typing.Any) -> None: + """Initializes this filter based on one parameter meta information.""" + raise NotImplementedError() + + def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: + raise NotImplementedError() + + def reset(self) -> None: + self.enabled = True + + def is_default(self) -> bool: + """Returns True if the filter has its values set to the default ones, False otherwise.""" + raise NotImplementedError() + + def is_applied(self) -> bool: + """Returns True if the filter is enabled and the values are not default.""" + if not self.enabled: + return False + + return not self.is_default() + + def is_drawn(self): + """Returns True if the filter is drawn in the UI, False otherwise.""" + # The filter is drawn if it is enabled - it is relevant for the current view or if the + # filter values were changed from default - user needs to have ability to reset the filter + # in any view. + return self.enabled or not self.is_default() + + def get_nice_name(self) -> str: + return mapr.known_metadata.format_parameter_name(self.name_without_type) + + def filters_updated_bulk_query() -> None: filters_properties = get_filters() # Repurpose existing view category_id, as we know it didn't change asset_repository.query( - Query( - asset_repository.get_current_category_id(), + mapr.query.Query( + asset_repository.current_category_id, filters_properties.filters.values(), filters_properties.sort_mode ), @@ -296,27 +177,30 @@ def _any_filter_updated_event(): bpy.app.timers.register(filters_updated_bulk_query, first_interval=0.3) -class NumericParameterFilter(bpy.types.PropertyGroup, Filter): +class BrowserNumericParameterFilter( + bpy.types.PropertyGroup, + mapr.filters.NumericParameterFilter, + BrowserFilter +): is_int: bpy.props.BoolProperty() - range_start_float: bpy.props.FloatProperty( - get=lambda self: self._range_start_get(), + get=lambda self: self.range_start, set=lambda self, value: self._range_start_set(value), default=-1.0 ) range_end_float: bpy.props.FloatProperty( - get=lambda self: self._range_end_get(), + get=lambda self: self.range_end, set=lambda self, value: self._range_end_set(value), default=1.0 ) range_start_int: bpy.props.IntProperty( - get=lambda self: self._range_start_get(), + get=lambda self: self.range_start, set=lambda self, value: self._range_start_set(value), default=-1 ) range_end_int: bpy.props.IntProperty( - get=lambda self: self._range_end_get(), + get=lambda self: self.range_end, set=lambda self, value: self._range_end_set(value), default=1 ) @@ -362,8 +246,8 @@ def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: row.prop(self, self._range_end_name(), text="Max") def is_default(self): - return math.isclose(self._range_start_get(), self.range_min) and \ - math.isclose(self._range_end_get(), self.range_max) + return math.isclose(self.range_start, self.range_min) and \ + math.isclose(self.range_end, self.range_max) def reset(self): super().reset() @@ -371,19 +255,11 @@ def reset(self): self._range_end_set(self.range_max) def filter_(self, asset: mapr.asset.Asset) -> bool: - # Include asset if the values for this filter are close to default + # Include asset if the values for this filter are default if self.is_default(): return True - # If the asset doesn't contain this parameter and the value is different than default, then - # filter out the asset - if self.name_without_type not in asset.numeric_parameters: - return False - - return self._range_start_get() < asset.numeric_parameters[self.name_without_type] < self._range_end_get() - - def as_dict(self) -> typing.Dict: - return {self.name: {"min": self._range_start_get(), "max": self._range_end_get()}} + return super().filter_(asset) def _convert_to_num_type(self, value: typing.Union[int, float]) -> typing.Union[int, float]: return int(value) if self.is_int else float(value) @@ -406,9 +282,6 @@ def _range_start_set(self, value: float): self[self._range_start_name()] = self._convert_to_num_type(value) _any_filter_updated_event() - def _range_start_get(self): - return self._convert_to_num_type(self.get(self._range_start_name(), -1.0)) - def _range_end_set(self, value: float): if value > self.range_max: value = self.range_max @@ -418,14 +291,26 @@ def _range_end_set(self, value: float): self[self._range_end_name()] = self._convert_to_num_type(value) _any_filter_updated_event() - def _range_end_get(self): + @property + def range_start(self): + # OVERRIDES 'range_start' from 'mapr.filters.NumericParameterFilter' + return self._convert_to_num_type(self.get(self._range_start_name(), -1.0)) + + @property + def range_end(self): + # OVERRIDES 'range_end' from 'mapr.filters.NumericParameterFilter' return self._convert_to_num_type(self.get(self._range_end_name(), 1.0)) -MODULE_CLASSES.append(NumericParameterFilter) +MODULE_CLASSES.append(BrowserNumericParameterFilter) -class TagFilter(bpy.types.PropertyGroup, Filter): +class BrowserTagFilter( + bpy.types.PropertyGroup, + mapr.filters.TagFilter, + BrowserFilter +): + # OVERRIDES 'include' from 'mapr.filters.TagFilter' include: bpy.props.BoolProperty( update=lambda self, context: _any_filter_updated_event() ) @@ -448,17 +333,14 @@ def reset(self) -> None: self.include = False def filter_(self, asset: mapr.asset.Asset) -> bool: - # Include asset if the values for this filter are close to default + # Include asset if the values for this filter are default if self.is_default(): return True - return self.name_without_type in asset.tags + return super().filter_(asset) - def as_dict(self) -> typing.Dict: - return {self.name: self.include} - -MODULE_CLASSES.append(TagFilter) +MODULE_CLASSES.append(BrowserTagFilter) class TextParameterValue(bpy.types.PropertyGroup): @@ -472,7 +354,11 @@ class TextParameterValue(bpy.types.PropertyGroup): MODULE_CLASSES.append(TextParameterValue) -class TextParameterFilter(bpy.types.PropertyGroup, Filter): +class BrowserTextParameterFilter( + bpy.types.PropertyGroup, + mapr.filters.TextParameterFilter, + BrowserFilter +): # Number of items when the text parameter filter becomes collapsible COLLAPSIBLE_DISPLAY_MIN_COUNT = 5 # Max number of items before drawing is switched to column format @@ -511,11 +397,11 @@ def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: ).filter_name = self.name drawn_text_parameters = len(self.param_values) if not self.collapse else \ - TextParameterFilter.COLLAPSIBLE_DISPLAY_MIN_COUNT + BrowserTextParameterFilter.COLLAPSIBLE_DISPLAY_MIN_COUNT not_shown_text_parameters_count = len(self.param_values) - drawn_text_parameters # Switch between row and column for small number of items - if len(self.param_values) <= TextParameterFilter.ROW_DISPLAY_MAX_COUNT: + if len(self.param_values) <= BrowserTextParameterFilter.ROW_DISPLAY_MAX_COUNT: items_layout = col.row(align=True) else: items_layout = col.column(align=True) @@ -548,30 +434,52 @@ def filter_(self, asset: mapr.asset.Asset) -> bool: if self.is_default(): return True - value = asset.text_parameters.get(self.name_without_type, None) - if value is None: - return False + return super().filter_(asset) + + @property + def values(self): + # OVERRIDES the 'mapr.filters.TextParameterFilter.values' based on the current state + return {v.name for v in self.param_values.values() if v.include} - included_values = {v.name for v in self.param_values.values() if v.include} - return value in included_values - def as_dict(self) -> typing.Dict: - return {self.name: {name: param.include for name, param in self.param_values.items()}} +MODULE_CLASSES.append(BrowserTextParameterFilter) -MODULE_CLASSES.append(TextParameterFilter) +class BrowserVectorParameterFilter( + bpy.types.PropertyGroup, + mapr.filters.VectorParameterFilter, + BrowserFilter +): + """Filters vector parameters. Provides user interface for various vector parameter types. + Vectors are filtered differently based on the vector type: + - Color - filtered based on perceptual color distance from the desired color + - Float - individual components filtered separately + - Int - filtered lexicographically -class ColorParameterFilter(bpy.types.PropertyGroup, Filter): - DEFAULT_COLOR = (1.0, 1.0, 1.0) + As bpy doesn't allow to dynamically change size of defined vector property this parameter filter + is limited to Vec3 parameters only. To support other vector sizes this has to be extended with + properties matching the desired size and logic switching between them based on the actual + parameter size. + """ + DEFAULT_VALUE = (1.0, 1.0, 1.0) DEFAULT_DISTANCE = 0.2 - color: bpy.props.FloatVectorProperty( + # one of mapr.known_metadata.VectorType + type_: bpy.props.EnumProperty( + items=[ + (mapr.known_metadata.VectorType.FLOAT, "Float", "Float"), + (mapr.known_metadata.VectorType.INT, "Integer", "Integer"), + (mapr.known_metadata.VectorType.COLOR, "Color", "Color"), + ], + options={'HIDDEN'} + ) + color_value: bpy.props.FloatVectorProperty( name="Color", subtype='COLOR', min=0.0, max=1.0, - default=DEFAULT_COLOR, + default=DEFAULT_VALUE, update=lambda self, context: _any_filter_updated_event() ) distance: bpy.props.FloatProperty( @@ -583,9 +491,31 @@ class ColorParameterFilter(bpy.types.PropertyGroup, Filter): description="Distance from desired to compared color as computed by CIEDE2000 formula.", ) + range_start: bpy.props.FloatVectorProperty( + get=lambda self: self._range_start_get(), + set=lambda self, value: self._range_start_set(value) + ) + range_end: bpy.props.FloatVectorProperty( + get=lambda self: self._range_end_get(), + set=lambda self, value: self._range_end_set(value), + ) + + range_min: bpy.props.FloatVectorProperty(options={'HIDDEN'}) + range_max: bpy.props.FloatVectorProperty(options={'HIDDEN'}) + def init(self, parameter_meta: mapr.parameter_meta.VectorParameterMeta): self.name = parameter_meta.name self.name_without_type = mapr.parameter_meta.remove_type_from_name(self.name) + known_parameter = mapr.known_metadata.VECTOR_PARAMETERS.get(self.name_without_type, None) + if known_parameter is not None: + self.type_ = known_parameter.get("type", mapr.known_metadata.VectorType.FLOAT) + else: + self.type_ = mapr.known_metadata.VectorType.FLOAT + + self.range_min = parameter_meta.min_ + self.range_max = parameter_meta.max_ + self._range_start_set(self.range_min) + self._range_end_set(self.range_max) def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: col = layout.column() @@ -603,69 +533,129 @@ def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: emboss=False ).filter_name = self.name - col.prop(self, "color", text="") - col.prop(self, "distance") + if self.type_ == mapr.known_metadata.VectorType.COLOR: + col.prop(self, "color_value", text="") + col.prop(self, "distance") + elif self.type_ in {mapr.known_metadata.VectorType.FLOAT, mapr.known_metadata.VectorType.INT}: + col.prop(self, "range_start", text="Min") + col.prop(self, "range_end", text="Max") def is_default(self) -> bool: # For some reason I had to lower the 'rel_tol' for the self.distance 'isclose' check to pass - return tuple(self.color) == ColorParameterFilter.DEFAULT_COLOR and \ - math.isclose(self.distance, ColorParameterFilter.DEFAULT_DISTANCE, rel_tol=1e-6) + if self.type_ == mapr.known_metadata.VectorType.COLOR: + return tuple(self.color_value) == BrowserVectorParameterFilter.DEFAULT_VALUE and \ + math.isclose( + self.distance, BrowserVectorParameterFilter.DEFAULT_DISTANCE, rel_tol=1e-6) + elif self.type_ in {mapr.known_metadata.VectorType.FLOAT, mapr.known_metadata.VectorType.INT}: + return math.isclose(self.range_start[0], self.range_min[0]) and \ + math.isclose(self.range_start[1], self.range_min[1]) and \ + math.isclose(self.range_start[2], self.range_min[2]) and \ + math.isclose(self.range_end[0], self.range_max[0]) and \ + math.isclose(self.range_end[1], self.range_max[1]) and \ + math.isclose(self.range_end[2], self.range_max[2]) + else: + raise ValueError(f"Unknown vector type {self.type_}") def reset(self) -> None: super().reset() - self.color = ColorParameterFilter.DEFAULT_COLOR - self.distance = ColorParameterFilter.DEFAULT_DISTANCE + self.color_value = BrowserVectorParameterFilter.DEFAULT_VALUE + self.distance = BrowserVectorParameterFilter.DEFAULT_DISTANCE + self._range_start_set(self.range_min) + self._range_end_set(self.range_max) def filter_(self, asset: mapr.asset.Asset) -> bool: # Include asset if the values for this filter are close to default if self.is_default(): return True - asset_color = asset.color_parameters.get(self.name_without_type, None) - if asset_color is None: - return False + return super().filter_(asset) + + def _clamp_value(self, value: mathutils.Vector) -> mathutils.Vector: + # Clamp values to range min, max + for i in range(len(value)): + if value[i] < self.range_min[i]: + value[i] = self.range_min[i] + + if value[i] > self.range_max[i]: + value[i] = self.range_max[i] + + return value + + def _range_start_get(self): + return self.get("range_start", mathutils.Vector((-1.0, -1.0, -1.0))) - return polib.color_utils.perceptual_color_distance(self.color, asset_color) <= self.distance + def _range_end_get(self): + return self.get("range_end", mathutils.Vector((1.0, 1.0, 1.0))) + + def _range_start_set(self, value: typing.Tuple): + self["range_start"] = self._clamp_value(mathutils.Vector(value)) + _any_filter_updated_event() - def as_dict(self) -> typing.Dict: - return {self.name: (tuple(self.color), self.distance)} + def _range_end_set(self, value: typing.Tuple): + self["range_end"] = self._clamp_value(mathutils.Vector(value)) + _any_filter_updated_event() + + @property + def comparator(self) -> mapr.filters.VectorComparator: + # OVERRIDES 'comparator' from 'mapr.filters.VectorParameterFilter' + if self.type_ == mapr.known_metadata.VectorType.COLOR: + return mapr.filters.VectorDistanceComparator( + self.color_value, + self.distance, + (polib.color_utils.perceptual_color_distance, "perceptual_color") + ) + elif self.type_ == mapr.known_metadata.VectorType.INT: + return mapr.filters.VectorLexicographicComparator(self.range_start, self.range_end) + elif self.type_ == mapr.known_metadata.VectorType.FLOAT: + return mapr.filters.VectorComponentWiseComparator(self.range_start, self.range_end) + else: + raise ValueError(f"Unsupported vector type {self.type_}") -MODULE_CLASSES.append(ColorParameterFilter) +MODULE_CLASSES.append(BrowserVectorParameterFilter) -class AssetTypesFilter(bpy.types.PropertyGroup, Filter): +class BrowserAssetTypesFilter( + bpy.types.PropertyGroup, + mapr.filters.AssetTypesFilter, + BrowserFilter +): enabled: bpy.props.BoolProperty( get=lambda _: True, set=lambda _, __: None ) + + # OVERRIDES 'model' from 'mapr.filters.AssetTypesFilter' model: bpy.props.BoolProperty( name="Model", default=False, update=lambda self, context: _any_filter_updated_event() ) + # OVERRIDES 'material' from 'mapr.filters.AssetTypesFilter' material: bpy.props.BoolProperty( name="Material", default=False, update=lambda self, context: _any_filter_updated_event() ) + # OVERRIDES 'particle_system' from 'mapr.filters.AssetTypesFilter' particle_system: bpy.props.BoolProperty( name="Particle System", default=False, update=lambda self, context: _any_filter_updated_event() ) + # OVERRIDES 'scene' from 'mapr.filters.AssetTypesFilter' scene: bpy.props.BoolProperty( name="Scene", default=False, update=lambda self, context: _any_filter_updated_event() ) - + # OVERRIDES 'world' from 'mapr.filters.AssetTypesFilter' world: bpy.props.BoolProperty( name="World", default=False, update=lambda self, context: _any_filter_updated_event() ) - + # OVERRIDES 'geometry_nodes' from 'mapr.filters.AssetTypesFilter' geometry_nodes: bpy.props.BoolProperty( name="Geometry Nodes", default=False, @@ -673,9 +663,8 @@ class AssetTypesFilter(bpy.types.PropertyGroup, Filter): ) def init(self): - self.name = "builtin:asset_types" - self.name_without_type = "asset_types" self.enabled = True + self.name = "builtin:asset_types" def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: layout.prop(self, "model", icon_only=True, icon='OBJECT_DATA') @@ -696,15 +685,7 @@ def filter_(self, asset: mapr.asset.Asset) -> bool: if self.is_default(): return True - type_ = asset.type_ - return any([ - type_ == mapr.asset_data.AssetDataType.blender_model and self.model, - type_ == mapr.asset_data.AssetDataType.blender_material and self.material, - type_ == mapr.asset_data.AssetDataType.blender_particle_system and self.particle_system, - type_ == mapr.asset_data.AssetDataType.blender_scene and self.scene, - type_ == mapr.asset_data.AssetDataType.blender_world and self.world, - type_ == mapr.asset_data.AssetDataType.blender_geometry_nodes and self.geometry_nodes, - ]) + return super().filter_(asset) def reset(self): super().reset() @@ -718,30 +699,22 @@ def reset(self): def is_default(self) -> bool: return not any(self._all) - def as_dict(self) -> typing.Dict: - return {self.name: self._all} - - @property - def _all(self) -> typing.Tuple: - return ( - self.model, - self.material, - self.particle_system, - self.scene, - self.world, - self.geometry_nodes - ) - -MODULE_CLASSES.append(AssetTypesFilter) +MODULE_CLASSES.append(BrowserAssetTypesFilter) -class SearchFilter(bpy.types.PropertyGroup, Filter): +class BrowserSearchFilter( + bpy.types.PropertyGroup, + mapr.filters.SearchFilter, + BrowserFilter +): """Filters out items based on text input from user""" enabled: bpy.props.BoolProperty( get=lambda _: True, set=lambda _, __: None ) + + # OVERRIDES 'search' from 'mapr.filters.SearchFilter' search: bpy.props.StringProperty( name="Search", description="Space separated keywords to search for", @@ -756,9 +729,8 @@ class SearchFilter(bpy.types.PropertyGroup, Filter): ) def init(self): - self.name = "builtin:search" - self.name_without_type = "search" self.enabled = True + self.name = "builtin:search" def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: layout.prop_menu_enum(self, "recent_search", text="", icon='DOWNARROW_HLT') @@ -775,32 +747,6 @@ def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: emboss=False ).filter_name = self.name - def filter_(self, asset: mapr.asset.Asset) -> bool: - # we make sure all needle keywords are present in given haystack for the haystack not to be - # filtered - - needle_keywords: typing.Set[str] = getattr(type(self), "keywords", set()) - if len(needle_keywords) == 0: - return True - - match_found = False - for needle_keyword in needle_keywords: - for haystack_keyword, haystack_keyword_weight in asset.search_matter.items(): - # TODO: We want to do relevancy scoring in the future but for that the entire - # mechanism has be moved into MAPR API - - # this is guaranteed by the API - assert haystack_keyword_weight > 0.0 - - if haystack_keyword.find(needle_keyword) >= 0: - match_found = True - break - - if match_found: - break - - return match_found - def reset(self): super().reset() self.search = "" @@ -809,20 +755,6 @@ def is_default(self): return self.search == "" def search_updated(self, context: bpy.types.Context) -> None: - def translate_keywords(keywords: typing.Set[str]) -> typing.Set[str]: - # Be careful when adding new keywords as it will make impossible to find anything using the original keyword. - # E.g. if we'd have tag `hdr` it would not be possible to find it now. Or anything named `hdr_something` cannot be find by `hdr` - translator = { - "hdri": "world", - "hdr": "world" - } - - ret: typing.Set[str] = set() - for kw in keywords: - ret.add(translator.get(kw, kw)) - - return ret - # We store search history as class variable, we assume one instance of this class existing # at any point. cls = type(self) @@ -838,10 +770,6 @@ def translate_keywords(keywords: typing.Set[str]) -> typing.Set[str]: while len(type(self).search_history) > history_count: cls.search_history.pop(0) - # Build keywords when search is updated and store for filtering afterwards - cls.keywords = translate_keywords( - {kw.lower() for kw in re.split(r"[ ,_\-]+", self.search) if kw != ""} - ) _any_filter_updated_event() def get_recent_search_enum_items( @@ -861,11 +789,13 @@ def recent_search_updated(self, context: bpy.types.Context) -> None: if self.recent_search != "": self.search = self.recent_search - def as_dict(self) -> typing.Dict: - return {self.name: self.search} + @property + def needle_keywords(self): + # OVERRIDES 'needle_keywords' from 'mapr.filters.SearchFilter' + return mapr.filters.SearchFilter.keywords_from_search(self.search) -MODULE_CLASSES.append(SearchFilter) +MODULE_CLASSES.append(BrowserSearchFilter) class FilterGroup(bpy.types.PropertyGroup): @@ -875,7 +805,7 @@ class FilterGroup(bpy.types.PropertyGroup): group meta information and knows how to draw filter group given the filters. """ # name is a default parameter of PropertyGroup, so we don't define it - collapsed: bpy.props.BoolProperty(name="Collapsed", default=False) + collapsed: bpy.props.BoolProperty(name="Collapsed", default=True) def get_nice_name(self) -> str: return mapr.known_metadata.format_parameter_name(self.name) @@ -884,7 +814,7 @@ def draw( self, context: bpy.types.Context, layout: bpy.types.UILayout, - filters_: typing.List[Filter] + filters_: typing.List[BrowserFilter] ) -> None: box = layout.box() row = box.row() @@ -915,8 +845,8 @@ class GroupedParametrizationFilters: """ def __init__(self): - self.groups: typing.Dict[FilterGroup, Filter] = {} - self.ungrouped_filters: typing.List[Filter] = [] + self.groups: typing.Dict[FilterGroup, BrowserFilter] = {} + self.ungrouped_filters: typing.List[BrowserFilter] = [] def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: if len(self.filters) == 0: @@ -934,17 +864,17 @@ def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: filter_.draw(context, col.box()) @property - def filters(self) -> typing.List[Filter]: + def filters(self) -> typing.List[BrowserFilter]: return list(self.groups.values()) + self.ungrouped_filters class DynamicFilters(bpy.types.PropertyGroup): - numeric_filters: bpy.props.CollectionProperty(type=NumericParameterFilter) - tag_filters: bpy.props.CollectionProperty(type=TagFilter) - text_filters: bpy.props.CollectionProperty(type=TextParameterFilter) - color_filters: bpy.props.CollectionProperty(type=ColorParameterFilter) - search: bpy.props.PointerProperty(type=SearchFilter) - asset_types: bpy.props.PointerProperty(type=AssetTypesFilter) + numeric_filters: bpy.props.CollectionProperty(type=BrowserNumericParameterFilter) + tag_filters: bpy.props.CollectionProperty(type=BrowserTagFilter) + text_filters: bpy.props.CollectionProperty(type=BrowserTextParameterFilter) + vector_filters: bpy.props.CollectionProperty(type=BrowserVectorParameterFilter) + search: bpy.props.PointerProperty(type=BrowserSearchFilter) + asset_types: bpy.props.PointerProperty(type=BrowserAssetTypesFilter) filter_groups: bpy.props.CollectionProperty(type=FilterGroup) @@ -955,21 +885,21 @@ class DynamicFilters(bpy.types.PropertyGroup): name="Sort Mode", description="Select mode by which to sort the result", items=[ - (SortMode.ALPHABETICAL_ASC, "Name (A to Z)", + (mapr.query.SortMode.ALPHABETICAL_ASC, "Name (A to Z)", "Alphabetical order from A to Z", 'SORT_ASC', 0), - (SortMode.ALPHABETICAL_DESC, "Name (Z to A)", + (mapr.query.SortMode.ALPHABETICAL_DESC, "Name (Z to A)", "Reversed alphabetical order from Z to A", 'SORT_DESC', 1), ], update=lambda self, _: self._sort_mode_updated() ) def query_and_reconstruct(self, category_id: mapr.category.CategoryID) -> None: - def _on_complete(view: DataView): + def _on_complete(view: mapr.asset_provider.DataView): self.reconstruct() self.reenable() asset_repository.query( - Query( + mapr.query.Query( category_id, self.filters.values(), self.sort_mode @@ -988,11 +918,11 @@ def reconstruct(self): self.search.init() self.asset_types.init() - current_view = asset_repository.get_current_view() + current_view = asset_repository.current_view filters_def = [ (current_view.parameters_meta.numeric, self.numeric_filters, "NUMERIC_PARAMETERS"), (current_view.parameters_meta.text, self.text_filters, "TEXT_PARAMETERS"), - (current_view.parameters_meta.color, self.color_filters, "COLOR_PARAMETERS"), + (current_view.parameters_meta.vector, self.vector_filters, "VECTOR_PARAMETERS"), # Convert set of tags to mapping tag: tag, so we can use the same API ({tag: tag for tag in current_view.parameters_meta.unique_tags}, self.tag_filters, "TAGS") ] @@ -1010,7 +940,7 @@ def reconstruct(self): filter_.init(param_meta) def reenable(self): - current_view = asset_repository.get_current_view() + current_view = asset_repository.current_view for filter_ in self.filters.values(): filter_.enabled = filter_.name in current_view.parameters_meta.unique_parameter_names @@ -1019,14 +949,19 @@ def clear(self): self.numeric_filters.clear() self.tag_filters.clear() self.text_filters.clear() - self.color_filters.clear() + self.vector_filters.clear() + + def clear_and_reconstruct(self): + """Clears the parametrization filters and reconstructs based on last view in repository""" + self.clear() + self.reconstruct() def reset(self): """Resets all filters into the default state""" for filter_ in self.filters.values(): filter_.reset() - def get_param_filter(self, filter_name: str) -> typing.Optional[Filter]: + def get_param_filter(self, filter_name: str) -> typing.Optional[BrowserFilter]: return self.filters.get(filter_name, None) def get_grouped_parametrization_filters(self) -> GroupedParametrizationFilters: @@ -1068,8 +1003,7 @@ def get_grouped_parametrization_filters(self) -> GroupedParametrizationFilters: return grouped_filters @property - def filters(self) -> typing.Dict[str, Filter]: - # TODO: Use collections.ChainMap? + def filters(self) -> typing.Dict[str, BrowserFilter]: return { self.asset_types.name: self.asset_types, self.search.name: self.search, @@ -1078,11 +1012,11 @@ def filters(self) -> typing.Dict[str, Filter]: } @property - def parametrization_filters(self) -> typing.Dict[str, Filter]: + def parametrization_filters(self) -> typing.Dict[str, BrowserFilter]: return { **self.numeric_filters, **self.text_filters, - **self.color_filters + **self.vector_filters } def _sort_mode_updated(self) -> None: @@ -1094,7 +1028,7 @@ def _sort_mode_updated(self) -> None: return # Replicate last query with a different sorting method - asset_repository.query(Query( + asset_repository.query(mapr.query.Query( last_query.category_id, last_query.filters, sort_mode=self.sort_mode, @@ -1134,7 +1068,8 @@ def execute(self, context: bpy.types.Context): def _draw_tags(context: bpy.types.Context, layout: bpy.types.UILayout): """Draws dynamic filter tags to 'layout' as pills that adjust width based on the region size""" dyn_filters = get_filters(context) - tag_filters: typing.List[TagFilter] = [x for x in dyn_filters.tag_filters if x.is_drawn()] + tag_filters: typing.List[BrowserTagFilter] = [ + x for x in dyn_filters.tag_filters if x.is_drawn()] if len(tag_filters) == 0: row = layout.row() @@ -1200,11 +1135,10 @@ def on_load_post(_): # to the browser state. filters_ = get_filters() filters_.reset() - filters_.query_and_reconstruct(asset_repository.get_current_category_id()) + filters_.query_and_reconstruct(asset_repository.current_category_id) def register(): - for cls in MODULE_CLASSES: bpy.utils.register_class(cls) @@ -1219,6 +1153,5 @@ def unregister(): for cls in reversed(MODULE_CLASSES): bpy.utils.unregister_class(cls) - # TODO: Should we really clear here? asset_registry.instance.on_refresh.remove(on_registry_update) bpy.app.handlers.load_post.remove(on_load_post) diff --git a/mapr_browser/previews.py b/browser/previews.py similarity index 100% rename from mapr_browser/previews.py rename to browser/previews.py diff --git a/mapr_browser/spawn.py b/browser/spawn.py similarity index 98% rename from mapr_browser/spawn.py rename to browser/spawn.py index 513ec34..f8c719f 100644 --- a/mapr_browser/spawn.py +++ b/browser/spawn.py @@ -67,7 +67,7 @@ def draw(self, context: bpy.types.Context) -> None: box.row().label(text=str(reason)) box = layout.box() box.label(text=str(suggestion), icon='QUESTION') - box.label(text="Or adjust your spawning options.", icon='FILE_TICK') + box.label(text="Or adjust your spawning options.", icon='OPTIONS') @polib.utils_bpy.blender_cursor('WAIT') def execute(self, context: bpy.types.Context): @@ -138,7 +138,7 @@ def draw(self, context: bpy.types.Context): spawning_options = prefs.mapr_preferences.spawn_options layout = self.layout col = layout.column() - col.label(text="Asset Spawn Options", icon='FILE_TICK') + col.label(text="Asset Spawn Options", icon='OPTIONS') col.prop(spawning_options, "remove_duplicates") col.prop(spawning_options, "make_editable") col.separator() diff --git a/mapr_browser/utils.py b/browser/utils.py similarity index 100% rename from mapr_browser/utils.py rename to browser/utils.py diff --git a/materialiq/panel.py b/materialiq/panel.py index 9cab28e..e8606a2 100644 --- a/materialiq/panel.py +++ b/materialiq/panel.py @@ -105,7 +105,10 @@ def draw_header(self, context: bpy.types.Context) -> None: def draw_header_preset(self, context: bpy.types.Context) -> None: self.layout.prop(get_panel_props(context), "advanced_ui", text="", icon='MENU_PANEL') polib.ui_bpy.draw_doc_button( - self.layout, preferences.__package__, rel_url="panels/materialiq/panel_overview") + self.layout, + polib.utils_bpy.get_top_level_package_name(__package__), + rel_url="panels/materialiq/panel_overview" + ) def draw_material_list(self, context: bpy.types.Context) -> None: # We use similar code to draw material slots as blender does @@ -158,13 +161,13 @@ def draw_material_list(self, context: bpy.types.Context) -> None: row.operator("object.material_slot_deselect", text="Deselect") def draw(self, context: bpy.types.Context) -> None: - prefs = preferences.get_preferences(context) + prefs = preferences.get_preferences(context).mapr_preferences row = self.layout.row(align=True) row.label(text="Default Texture Size:") row = row.row() row.alignment = 'LEFT' row.scale_x = 0.9 - row.prop(prefs, "mq_global_texture_size", text="") + row.prop(prefs.spawn_options, "texture_size", text="") self.draw_material_list(context) diff --git a/materialiq/textures.py b/materialiq/textures.py index 6a6d9b0..2f661a8 100644 --- a/materialiq/textures.py +++ b/materialiq/textures.py @@ -45,10 +45,10 @@ class ChangeTextureSizeGlobal(bpy.types.Operator): ) def execute(self, context: bpy.types.Context): - prefs = preferences.get_preferences(context) + prefs = preferences.get_preferences(context).mapr_preferences hatchery.textures.change_texture_sizes(int(self.max_size)) self.report({"INFO"}, f"Changed global texture sizes to {self.max_size}") - prefs.mq_global_texture_size = self.max_size + prefs.spawn_options.texture_size = self.max_size return {'FINISHED'} diff --git a/pack_info_search_paths.py b/pack_info_search_paths.py index 8e1b450..b21f8ed 100644 --- a/pack_info_search_paths.py +++ b/pack_info_search_paths.py @@ -169,11 +169,11 @@ def clear_discovered_packs_cache(): def _generate_glob(path_type: str, file_path: str, directory_path: str, glob_expression) -> str: if path_type == PackInfoSearchPathType.SINGLE_FILE: # the glob is just the file path - return file_path + return glob.escape(file_path) elif path_type == PackInfoSearchPathType.INSTALL_DIRECTORY: - return os.path.join(directory_path, "*", "*.pack-info") + return os.path.join(glob.escape(directory_path), "*", "*.pack-info") elif path_type == PackInfoSearchPathType.RECURSIVE_SEARCH: - return os.path.join(directory_path, "**", "*.pack-info") + return os.path.join(glob.escape(directory_path), "**", "*.pack-info") elif path_type == PackInfoSearchPathType.GLOB: return glob_expression else: diff --git a/panel.py b/panel.py index 7bb6299..fa98c93 100644 --- a/panel.py +++ b/panel.py @@ -23,7 +23,7 @@ import logging import random import polib -from . import mapr_browser +from . import browser from . import asset_registry from . import preferences from . import blend_maintenance @@ -283,7 +283,7 @@ def draw_header(self, context: bpy.types.Context) -> None: def draw_header_preset(self, context: bpy.types.Context) -> None: self.layout.operator( - mapr_browser.browser.MAPR_BrowserOpenAssetPacksPreferences.bl_idname, text="", icon='SETTINGS') + browser.browser.MAPR_BrowserOpenAssetPacksPreferences.bl_idname, text="", icon='SETTINGS') polib.ui_bpy.draw_doc_button( self.layout, __package__, rel_url="panels/engon/panel_overview") @@ -292,16 +292,16 @@ def draw(self, context: bpy.types.Context): col = self.layout.column(align=True) row = col.row(align=True) row.scale_y = 1.5 - if mapr_browser.browser.MAPR_BrowserChooseArea.is_running: + if browser.browser.MAPR_BrowserChooseArea.is_running: row.label(text="Select area with mouse!", icon='RESTRICT_SELECT_ON') else: row.operator( - mapr_browser.browser.MAPR_BrowserChooseArea.bl_idname, + browser.browser.MAPR_BrowserChooseArea.bl_idname, text="Browse Assets", icon='RESTRICT_SELECT_OFF' ) row.operator( - mapr_browser.browser.MAPR_BrowserOpen.bl_idname, + browser.browser.MAPR_BrowserOpen.bl_idname, text="", icon='WINDOW' ) @@ -310,7 +310,7 @@ def draw(self, context: bpy.types.Context): row.scale_x = 1.2 row.alert = True row.operator( - mapr_browser.browser.MAPR_BrowserClose.bl_idname, + browser.browser.MAPR_BrowserClose.bl_idname, text="", icon='PANEL_CLOSE' ) @@ -344,6 +344,9 @@ class MaintenancePanel(EngonPanelMixin, bpy.types.Panel): # We want to display the maintenance sub-panel last, as it won't be a frequently used feature bl_order = 99 + def draw_header(self, context: bpy.types.Context): + self.layout.label(text="", icon='BLENDER') + def draw(self, context: bpy.types.Context): layout = self.layout col = layout.column(align=True) diff --git a/preferences.py b/preferences/__init__.py similarity index 73% rename from preferences.py rename to preferences/__init__.py index 1c6eb24..b55e6cd 100644 --- a/preferences.py +++ b/preferences/__init__.py @@ -18,24 +18,28 @@ # # ##### END GPL LICENSE BLOCK ##### -from . import addon_updater_ops +from .. import addon_updater_ops import bpy import bpy_extras import typing import os import glob import json +import math +import mathutils import logging -import enum import polib import hatchery import mapr -from . import asset_pack_installer -from . import pack_info_search_paths -from . import asset_registry -from . import asset_helpers -from . import keymaps -from . import ui_utils +from . import aquatiq_preferences +from . import botaniq_preferences +from . import traffiq_preferences +from .. import asset_pack_installer +from .. import pack_info_search_paths +from .. import asset_registry +from .. import asset_helpers +from .. import keymaps +from .. import ui_utils logger = logging.getLogger(f"polygoniq.{__name__}") @@ -59,10 +63,6 @@ ) -BUMPS_MODIFIER_NAME = "tq_bumps_displacement" -BUMPS_MODIFIERS_CONTAINER_NAME = "tq_Bump_Modifiers_Container" - - class ScatterProperties(bpy.types.PropertyGroup): max_particle_count: bpy.props.IntProperty( name="Maximum Particles", @@ -189,13 +189,12 @@ def remove_all_copies_of_pack_info_search_path( Does not reload Asset Packs. """ - filtered_out = [p for p in self.pack_info_search_paths - if not (p.path_type == path_type and - p.get_path_or_expression_by_type() == path_or_expression)] + filtered_out = [p.as_dict() for p in self.pack_info_search_paths if not ( + p.path_type == path_type and p.get_path_or_expression_by_type() == path_or_expression)] + self.pack_info_search_paths.clear() for sp in filtered_out: - self.add_new_pack_info_search_path(path_type=sp.path_type, file_path=sp.file_path, - directory_path=sp.directory_path, glob_expression=sp.glob_expression) + self.pack_info_search_paths.add().load_from_dict(sp) pack_info_search_path_list_ensure_valid_index(context) def get_search_paths_as_dict(self) -> typing.Dict: @@ -231,7 +230,11 @@ def draw_pack_info_search_paths(self, context: bpy.types.Context, layout: bpy.ty text="", emboss=False,) row.label(text="Asset Pack Search Paths (For Advanced Users)") - polib.ui_bpy.draw_doc_button(row, __package__, rel_url="advanced_topics/search_paths") + polib.ui_bpy.draw_doc_button( + row, + polib.utils_bpy.get_top_level_package_name(__package__), + rel_url="advanced_topics/search_paths" + ) pack_info_search_path_list_ensure_valid_index(context) if not self.show_pack_info_paths: @@ -526,10 +529,7 @@ def draw(self, context: bpy.types.Context): SelectAssetPackInstallPath.bl_idname, text="", icon='FILE_FOLDER').filepath = os.path.expanduser("~" + os.sep) split.prop(self, "install_path", text="") - col = box.column(align=True) - col.label(text=f"Pack Folder Name: {installer.pack_root_directory}") - col.label(text=f"Estimated Pack Size: {installer.pack_size}") - col.label(text=f"Free Disk Space: {installer.free_space}") + self.draw_installer_info(box) col = box.column() self.draw_status_and_messages(col) if installer.is_update_available: @@ -620,9 +620,7 @@ def draw(self, context: bpy.types.Context): layout, header="This Asset Pack will be uninstalled:", show_install_path=True) box = layout.box() - col = box.column(align=True) - col.label(text=f"Pack Folder Name: {installer.pack_root_directory}") - col.label(text=f"Estimated Freed Disk Space: {installer.pack_size}") + self.draw_installer_info(box) col = box.column() self.draw_status_and_messages(col) @@ -732,10 +730,7 @@ def draw(self, context: bpy.types.Context): layout, header="This Asset Pack will be updated:") box = layout.box() - col = box.column(align=True) - col.label(text=f"Pack Folder Name: {installer.pack_root_directory}") - col.label(text=f"Estimated Extra Space Required: {installer.pack_size}") - col.label(text=f"Free Disk Space: {installer.free_space}") + self.draw_installer_info(box) col = box.column() self.draw_status_and_messages(col) @@ -891,12 +886,17 @@ def get_spawn_options( ) -> hatchery.spawn.DatablockSpawnOptions: """Returns spawn options for given asset based on its type""" if asset.type_ == mapr.asset_data.AssetDataType.blender_model: + cursor_loc = mathutils.Vector(context.scene.cursor.location) + particle_spawn_location = cursor_loc - mathutils.Vector((0, 0, 10)) + particle_spawn_rotation = mathutils.Euler((0, math.radians(90), 0), 'XYZ') return hatchery.spawn.ModelSpawnOptions( self._get_model_parent_collection(asset, context), True, + # Spawn the asset on Z - 10 in particle systems + particle_spawn_location if self.use_collection == 'PARTICLE_SYSTEM' else None, # Rotate the spawned asset 90 around Y to make it straight in particle systems # that use Rotation around Z Axis - which are most of our particle systems. - (0, 90, 0) if self.use_collection == 'PARTICLE_SYSTEM' else None + particle_spawn_rotation if self.use_collection == 'PARTICLE_SYSTEM' else None ) elif asset.type_ == mapr.asset_data.AssetDataType.blender_material: return hatchery.spawn.MaterialSpawnOptions( @@ -1073,451 +1073,10 @@ class MaprPreferences(bpy.types.PropertyGroup): MODULE_CLASSES.append(MaprPreferences) -class AquatiqPreferences(bpy.types.PropertyGroup): - draw_mask_factor: bpy.props.FloatProperty( - name="Mask Factor", - description="Value of 1 means visible, value of 0 means hidden", - update=lambda self, context: self.update_mask_factor(context), - soft_max=1.0, - soft_min=0.0 - ) - - def update_mask_factor(self, context: bpy.types.Context): - context.tool_settings.vertex_paint.brush.color = [self.draw_mask_factor] * 3 - - -MODULE_CLASSES.append(AquatiqPreferences) - - -class WindPreset(enum.Enum): - BREEZE = "Breeze" - WIND = "Wind" - STORM = "Storm" - UNKNOWN = "Unknown" - - -class AnimationType(enum.Enum): - WIND_BEST_FIT = "Wind-Best-Fit" - WIND_TREE = "Wind-Tree" - WIND_PALM = "Wind-Palm" - WIND_LOW_VEGETATION = "Wind-Low-Vegetation" - WIND_LOW_VEGETATION_PLANTS = "Wind-Low-Vegetation-Plants" - WIND_SIMPLE = "Wind-Simple" - UNKNOWN = "Unknown" - - -class WindStyle(enum.Enum): - LOOP = "Loop" - PROCEDURAL = "Procedural" - UNKNOWN = "Unknown" - - -class WindAnimationProperties(bpy.types.PropertyGroup): - auto_make_instance: bpy.props.BoolProperty( - name="Automatic Make Instance", - description="Automatically make instance out of object when spawning animation. " - "Better performance, but assets share data, customization per instance", - default=False - ) - - animation_type: bpy.props.EnumProperty( - name="Wind animation type", - description="Select one of predefined animations types." - "This changes the animation and animation modifier stack", - items=( - (AnimationType.WIND_BEST_FIT.value, AnimationType.WIND_BEST_FIT.value, - "Different animation types based on the selection", 'SHADERFX', 0), - (AnimationType.WIND_TREE.value, AnimationType.WIND_TREE.value, - "Animation mostly suited for tree assets", 'BLANK1', 1), - (AnimationType.WIND_PALM.value, AnimationType.WIND_PALM.value, - "Animation mostly suited for palm assets", 'BLANK1', 2), - (AnimationType.WIND_LOW_VEGETATION.value, AnimationType.WIND_LOW_VEGETATION.value, - "Animation mostly suited for low vegetation assets", 'BLANK1', 3), - (AnimationType.WIND_LOW_VEGETATION_PLANTS.value, AnimationType.WIND_LOW_VEGETATION_PLANTS.value, - "Animation mostly suited for low vegetation plant assets", 'BLANK1', 4), - (AnimationType.WIND_SIMPLE.value, AnimationType.WIND_SIMPLE.value, - "Simple animation, works only on assets with Leaf_ or Grass_ materials", 'BLANK1', 5) - ) - ) - - preset: bpy.props.EnumProperty( - name="Wind animation preset", - description="Select one of predefined animations presets." - "This changes detail of animation and animation modifier stack", - items=( - (WindPreset.BREEZE.value, WindPreset.BREEZE.value, "Light breeze wind", 'BOIDS', 0), - (WindPreset.WIND.value, WindPreset.WIND.value, "Moderate wind", 'CURVES_DATA', 1), - (WindPreset.STORM.value, WindPreset.STORM.value, "Strong storm wind", 'MOD_NOISE', 2) - ) - ) - - strength: bpy.props.FloatProperty( - name="Wind strength", - description="Strength of the wind applied on the trees", - default=0.25, - min=0.0, - soft_max=1.0, - ) - - looping: bpy.props.IntProperty( - name="Loop time", - description="At how many frames should the animation repeat. Minimal value to ensure good " - "animation appearance is 80", - default=120, - min=80, - ) - - bake_folder: bpy.props.StringProperty( - name="Bake Folder", - description="Folder where baked .abc animations are saved", - default=os.path.realpath(os.path.expanduser("~/botaniq_animations/")), - subtype='DIR_PATH' - ) - - # Used to choose target of most wind animation operators but not all. - # It's not used in operators where it doesn't make sense, - # e.g. Add Animation works on selected objects. - operator_target: bpy.props.EnumProperty( - name="Target", - description="Choose to what objects the operator should apply", - items=[ - ('SELECTED', "Selected Objects", "All selected objects"), - ('SCENE', "Scene Objects", "All objects in current scene"), - ('ALL', "All Objects", "All objects in the .blend file"), - ], - default='SCENE', - ) - - -MODULE_CLASSES.append(WindAnimationProperties) - - -class BotaniqPreferences(bpy.types.PropertyGroup): - float_min: bpy.props.FloatProperty( - name="Min Value", - description="Miniumum float value", - default=0.0, - min=0.0, - max=1.0, - step=0.1 - ) - - float_max: bpy.props.FloatProperty( - name="Max Value", - description="Maximum float value", - default=1.0, - min=0.0, - max=1.0, - step=0.1 - ) - - brightness: bpy.props.FloatProperty( - name="Brightness", - description="Adjust assets brightness", - default=1.0, - min=0.0, - max=10.0, - soft_max=1.0, - step=0.1, - update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( - context, - self.get_adjustment_affected_objects(context), - CustomPropertyNames.BQ_BRIGHTNESS, - self.brightness - ), - ) - - hue_per_branch: bpy.props.FloatProperty( - name="Hue Per Branch", - description="Randomize hue per branch", - default=1.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( - context, - self.get_adjustment_affected_objects(context), - CustomPropertyNames.BQ_RANDOM_PER_BRANCH, - self.hue_per_branch - ), - ) - - hue_per_leaf: bpy.props.FloatProperty( - name="Hue Per Leaf", - description="Randomize hue per leaf", - default=1.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( - context, - self.get_adjustment_affected_objects(context), - CustomPropertyNames.BQ_RANDOM_PER_LEAF, - self.hue_per_leaf - ), - ) - - season_offset: bpy.props.FloatProperty( - name="Season Offset", - description="Change season of asset", - default=1.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( - context, - self.get_adjustment_affected_objects(context), - CustomPropertyNames.BQ_SEASON_OFFSET, - self.season_offset - ), - ) - - wind_anim_properties: bpy.props.PointerProperty( - name="Animation Properties", - description="Wind animation related property group", - type=WindAnimationProperties - ) - - def get_adjustment_affected_objects(self, context: bpy.types.Context): - extended_objects = set(context.selected_objects) - if context.active_object is not None: - extended_objects.add(context.active_object) - - return set(extended_objects).union( - asset_helpers.gather_instanced_objects(extended_objects)) - - @property - def animation_data_path(self) -> typing.Optional[str]: - # TODO: This is absolutely terrible and we need to replace it later, we assume one animation - # data library existing and that being used for everything. In the future each asset - # pack should be able to have its own - for pack in asset_registry.instance.get_packs_by_engon_feature("botaniq"): - library_path_candidate = \ - os.path.join( - pack.install_path, - "blends", - "models", - "bq_Library_Animation_Data.blend" - ) - if os.path.isfile(library_path_candidate): - return library_path_candidate - return None - - -MODULE_CLASSES.append(BotaniqPreferences) - - -class CarPaintProperties(bpy.types.PropertyGroup): - @staticmethod - def update_car_paint_color_prop(context, value: typing.Tuple[float, float, float, float]): - # Don't allow to accidentally set color to random - if all(v > 0.99 for v in value[:3]): - value = (0.99, 0.99, 0.99, value[3]) - - polib.asset_pack_bpy.update_custom_prop( - context, context.selected_objects, CustomPropertyNames.TQ_PRIMARY_COLOR, value) - - primary_color: bpy.props.FloatVectorProperty( - name="Color", - subtype='COLOR', - description="Changes primary color of assets", - min=0.0, - max=1.0, - default=(0.8, 0.8, 0.8, 1.0), - size=4, - update=lambda self, context: CarPaintProperties.update_car_paint_color_prop( - context, self.primary_color), - ) - flakes_amount: bpy.props.FloatProperty( - name="Flakes Amount", - description="Changes amount of flakes in the car paint", - default=0.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( - context, - context.selected_objects, - CustomPropertyNames.TQ_FLAKES_AMOUNT, - self.flakes_amount - ), - ) - clearcoat: bpy.props.FloatProperty( - name="Clearcoat", - description="Changes clearcoat property of car paint", - default=0.2, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( - context, - context.selected_objects, - CustomPropertyNames.TQ_CLEARCOAT, - self.clearcoat - ), - ) - - -MODULE_CLASSES.append(CarPaintProperties) - - -class WearProperties(bpy.types.PropertyGroup): - @staticmethod - def get_modifier_library_data_path() -> typing.Optional[str]: - # We don't cache this because in theory the registered packs could change - for pack in asset_registry.instance.get_packs_by_engon_feature("traffiq"): - modifier_library_path_candidate = os.path.join( - pack.install_path, "blends", "models", "Library_Traffiq_Modifiers.blend") - if os.path.isfile(modifier_library_path_candidate): - return modifier_library_path_candidate - - return None - - @staticmethod - def update_bumps_prop(context: bpy.types.Context, value: float): - prefs = get_preferences(context) - # Cache objects that support bumps - bumps_objs = [ - obj for obj in context.selected_objects if CustomPropertyNames.TQ_BUMPS in obj] - - modifier_library_path = None - - # Add bumps modifier that improves bumps effect on editable objects. - # Bumps work for linked assets but looks better on editable ones with added modifier - for obj in bumps_objs: - # Object is not editable mesh - if obj.data is None or obj.type != "MESH": - continue - # If modifier is not assigned to the object, append it from library - if BUMPS_MODIFIER_NAME not in obj.modifiers: - if modifier_library_path is None: - modifier_library_path = WearProperties.get_modifier_library_data_path() - polib.asset_pack_bpy.append_modifiers_from_library( - BUMPS_MODIFIERS_CONTAINER_NAME, modifier_library_path, [obj]) - logger.info(f"Added bumps modifier on: {obj.name}") - - assert BUMPS_MODIFIER_NAME in obj.modifiers - obj.modifiers[BUMPS_MODIFIER_NAME].strength = value - - polib.asset_pack_bpy.update_custom_prop( - context, - bumps_objs, - CustomPropertyNames.TQ_BUMPS, - value - ) - - dirt_wear_strength: bpy.props.FloatProperty( - name="Dirt", - description="Makes assets look dirty", - default=0.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( - context, - context.selected_objects, - CustomPropertyNames.TQ_DIRT, - self.dirt_wear_strength - ), - ) - scratches_wear_strength: bpy.props.FloatProperty( - name="Scratches", - description="Makes assets look scratched", - default=0.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( - context, - context.selected_objects, - CustomPropertyNames.TQ_SCRATCHES, - self.scratches_wear_strength - ), - ) - bumps_wear_strength: bpy.props.FloatProperty( - name="Bumps", - description="Makes assets look dented, appends displacement modifier for better effect if object is editable", - default=0.0, - min=0.0, - soft_max=1.0, - step=0.1, - update=lambda self, context: WearProperties.update_bumps_prop( - context, self.bumps_wear_strength), - ) - - -MODULE_CLASSES.append(WearProperties) - - -class RigProperties(bpy.types.PropertyGroup): - auto_bake_steering: bpy.props.BoolProperty( - name="Auto Bake Steering", - description="If true, follow path operator will automatically try to bake steering", - default=True - ) - auto_bake_wheels: bpy.props.BoolProperty( - name="Auto Bake Wheel Rotation", - description="If true, follow path operator will automatically try to bake wheel rotation", - default=True - ) - auto_reset_transforms: bpy.props.BoolProperty( - name="Auto Reset Transforms", - description="If true, follow path operator will automatically reset transforms" - "of needed objects to give the expected results", - default=True - ) - - -MODULE_CLASSES.append(RigProperties) - - -class LightsProperties(bpy.types.PropertyGroup): - main_lights_status: bpy.props.EnumProperty( - name="Main Lights Status", - items=( - ("0", "off", "Front and rear lights are off"), - ("0.25", "park", "Park lights are on"), - ("0.50", "low-beam", "Low-beam lights are on"), - ("0.75", "high-beam", "High-beam lights are on"), - ), - update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( - context, - [polib.asset_pack_bpy.find_traffiq_lights_container( - o) for o in context.selected_objects], - CustomPropertyNames.TQ_LIGHTS, - float(self.main_lights_status) - ), - ) - - -MODULE_CLASSES.append(LightsProperties) - - -class TraffiqPreferences(bpy.types.PropertyGroup): - car_paint_properties: bpy.props.PointerProperty( - type=CarPaintProperties - ) - - wear_properties: bpy.props.PointerProperty( - type=WearProperties - ) - - lights_properties: bpy.props.PointerProperty( - type=LightsProperties - ) - - rig_properties: bpy.props.PointerProperty( - type=RigProperties - ) - - -MODULE_CLASSES.append(TraffiqPreferences) - - @polib.log_helpers_bpy.logged_preferences @addon_updater_ops.make_annotations class Preferences(bpy.types.AddonPreferences): - bl_idname = __package__ + bl_idname = polib.utils_bpy.get_top_level_package_name(__package__) # Addon updater preferences. auto_check_update: bpy.props.BoolProperty( @@ -1557,11 +1116,6 @@ class Preferences(bpy.types.AddonPreferences): max=59 ) - mq_global_texture_size: bpy.props.EnumProperty( - items=lambda _, __: asset_helpers.get_materialiq_texture_sizes_enum_items(), - name="materialiq global maximum side size", - ) - general_preferences: bpy.props.PointerProperty( name="General Preferences", description="Preferences related to all asset packs", @@ -1576,20 +1130,20 @@ class Preferences(bpy.types.AddonPreferences): aquatiq_preferences: bpy.props.PointerProperty( name="Aquatiq Preferences", - description="Preferences related to the aquatiq addon", - type=AquatiqPreferences + description="Preferences related to the aquatiq asset pack", + type=aquatiq_preferences.AquatiqPreferences ) botaniq_preferences: bpy.props.PointerProperty( name="Botaniq Preferences", - description="Preferences related to the botaniq addon", - type=BotaniqPreferences + description="Preferences related to the botaniq asset pack", + type=botaniq_preferences.BotaniqPreferences ) traffiq_preferences: bpy.props.PointerProperty( name="Traffiq Preferences", - description="Preferences related to the traffiq addon", - type=TraffiqPreferences + description="Preferences related to the traffiq asset pack", + type=traffiq_preferences.TraffiqPreferences ) first_time_register: bpy.props.BoolProperty( @@ -1638,7 +1192,11 @@ def draw(self, context: bpy.types.Context) -> None: text="", emboss=False,) row.label(text="Asset Packs") - polib.ui_bpy.draw_doc_button(row, __package__, rel_url="getting_started/asset_packs") + polib.ui_bpy.draw_doc_button( + row, + polib.utils_bpy.get_top_level_package_name(__package__), + rel_url="getting_started/asset_packs" + ) if self.general_preferences.show_asset_packs: row = box.row() row.alignment = 'LEFT' @@ -1669,18 +1227,23 @@ def draw(self, context: bpy.types.Context) -> None: op = row.operator(UninstallAssetPack.bl_idname, text="", icon='TRASH') op.current_filepath = pack.install_path - sub_col = subbox.column(align=True) - sub_col.enabled = False - sub_col.label(text=f"Version: {pack.get_version_str()}") - sub_row = sub_col.row(align=True) + split = subbox.split(factor=0.20) + label_col = split.column(align=True) + label_col.enabled = False + value_col = split.column(align=True) + value_col.enabled = False + + label_col.label(text=f"Version:") + value_col.label(text=f"{pack.get_version_str()}") + label_col.label(text=f"Vendor:") + sub_row = value_col.row(align=True) sub_row.alignment = 'LEFT' - sub_row.label(text=f"Vendor:") vendor_icon_id = pack.get_vendor_icon_id() if vendor_icon_id is not None: sub_row.label(icon_value=vendor_icon_id) sub_row.label(text=pack.vendor) - - sub_col.label(text=f"Installation path: {pack.install_path}") + label_col.label(text=f"Installation path:") + value_col.label(text=f"{pack.install_path}") self.general_preferences.draw_pack_info_search_paths(context, box) @@ -1750,10 +1313,14 @@ def execute(self, context): def get_preferences(context: bpy.types.Context) -> Preferences: - return context.preferences.addons[__package__].preferences + engon_package = polib.utils_bpy.get_top_level_package_name(__package__) + return context.preferences.addons[engon_package].preferences def register(): + aquatiq_preferences.register() + botaniq_preferences.register() + traffiq_preferences.register() for cls in MODULE_CLASSES: bpy.utils.register_class(cls) @@ -1761,3 +1328,6 @@ def register(): def unregister(): for cls in reversed(MODULE_CLASSES): bpy.utils.unregister_class(cls) + traffiq_preferences.unregister() + botaniq_preferences.unregister() + aquatiq_preferences.unregister() diff --git a/preferences/aquatiq_preferences.py b/preferences/aquatiq_preferences.py new file mode 100644 index 0000000..54b12b7 --- /dev/null +++ b/preferences/aquatiq_preferences.py @@ -0,0 +1,53 @@ +# copyright (c) 2018- polygoniq xyz s.r.o. + +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import bpy +import typing +import logging +logger = logging.getLogger(f"polygoniq.{__name__}") + + +MODULE_CLASSES: typing.List[typing.Any] = [] + + +class AquatiqPreferences(bpy.types.PropertyGroup): + draw_mask_factor: bpy.props.FloatProperty( + name="Mask Factor", + description="Value of 1 means visible, value of 0 means hidden", + update=lambda self, context: self.update_mask_factor(context), + soft_max=1.0, + soft_min=0.0 + ) + + def update_mask_factor(self, context: bpy.types.Context): + context.tool_settings.vertex_paint.brush.color = [self.draw_mask_factor] * 3 + + +MODULE_CLASSES.append(AquatiqPreferences) + + +def register(): + for cls in MODULE_CLASSES: + bpy.utils.register_class(cls) + + +def unregister(): + for cls in reversed(MODULE_CLASSES): + bpy.utils.unregister_class(cls) diff --git a/preferences/botaniq_preferences.py b/preferences/botaniq_preferences.py new file mode 100644 index 0000000..406ac0c --- /dev/null +++ b/preferences/botaniq_preferences.py @@ -0,0 +1,260 @@ +# copyright (c) 2018- polygoniq xyz s.r.o. + +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import bpy +import typing +import os +import enum +import logging +import polib +from .. import asset_helpers +from .. import asset_registry +logger = logging.getLogger(f"polygoniq.{__name__}") + + +MODULE_CLASSES: typing.List[typing.Any] = [] + + +class WindPreset(enum.Enum): + BREEZE = "Breeze" + WIND = "Wind" + STORM = "Storm" + UNKNOWN = "Unknown" + + +class AnimationType(enum.Enum): + WIND_BEST_FIT = "Wind-Best-Fit" + WIND_TREE = "Wind-Tree" + WIND_PALM = "Wind-Palm" + WIND_LOW_VEGETATION = "Wind-Low-Vegetation" + WIND_LOW_VEGETATION_PLANTS = "Wind-Low-Vegetation-Plants" + WIND_SIMPLE = "Wind-Simple" + UNKNOWN = "Unknown" + + +class WindStyle(enum.Enum): + LOOP = "Loop" + PROCEDURAL = "Procedural" + UNKNOWN = "Unknown" + + +class WindAnimationProperties(bpy.types.PropertyGroup): + auto_make_instance: bpy.props.BoolProperty( + name="Automatic Make Instance", + description="Automatically make instance out of object when spawning animation. " + "Better performance, but assets share data, customization per instance", + default=False + ) + + animation_type: bpy.props.EnumProperty( + name="Wind animation type", + description="Select one of predefined animations types." + "This changes the animation and animation modifier stack", + items=( + (AnimationType.WIND_BEST_FIT.value, AnimationType.WIND_BEST_FIT.value, + "Different animation types based on the selection", 'SHADERFX', 0), + (AnimationType.WIND_TREE.value, AnimationType.WIND_TREE.value, + "Animation mostly suited for tree assets", 'BLANK1', 1), + (AnimationType.WIND_PALM.value, AnimationType.WIND_PALM.value, + "Animation mostly suited for palm assets", 'BLANK1', 2), + (AnimationType.WIND_LOW_VEGETATION.value, AnimationType.WIND_LOW_VEGETATION.value, + "Animation mostly suited for low vegetation assets", 'BLANK1', 3), + (AnimationType.WIND_LOW_VEGETATION_PLANTS.value, AnimationType.WIND_LOW_VEGETATION_PLANTS.value, + "Animation mostly suited for low vegetation plant assets", 'BLANK1', 4), + (AnimationType.WIND_SIMPLE.value, AnimationType.WIND_SIMPLE.value, + "Simple animation, works only on assets with Leaf_ or Grass_ materials", 'BLANK1', 5) + ) + ) + + preset: bpy.props.EnumProperty( + name="Wind animation preset", + description="Select one of predefined animations presets." + "This changes detail of animation and animation modifier stack", + items=( + (WindPreset.BREEZE.value, WindPreset.BREEZE.value, "Light breeze wind", 'BOIDS', 0), + (WindPreset.WIND.value, WindPreset.WIND.value, "Moderate wind", 'CURVES_DATA', 1), + (WindPreset.STORM.value, WindPreset.STORM.value, "Strong storm wind", 'MOD_NOISE', 2) + ) + ) + + strength: bpy.props.FloatProperty( + name="Wind strength", + description="Strength of the wind applied on the trees", + default=0.25, + min=0.0, + soft_max=1.0, + ) + + looping: bpy.props.IntProperty( + name="Loop time", + description="At how many frames should the animation repeat. Minimal value to ensure good " + "animation appearance is 80", + default=120, + min=80, + ) + + bake_folder: bpy.props.StringProperty( + name="Bake Folder", + description="Folder where baked .abc animations are saved", + default=os.path.realpath(os.path.expanduser("~/botaniq_animations/")), + subtype='DIR_PATH' + ) + + # Used to choose target of most wind animation operators but not all. + # It's not used in operators where it doesn't make sense, + # e.g. Add Animation works on selected objects. + operator_target: bpy.props.EnumProperty( + name="Target", + description="Choose to what objects the operator should apply", + items=[ + ('SELECTED', "Selected Objects", "All selected objects"), + ('SCENE', "Scene Objects", "All objects in current scene"), + ('ALL', "All Objects", "All objects in the .blend file"), + ], + default='SCENE', + ) + + +MODULE_CLASSES.append(WindAnimationProperties) + + +class BotaniqPreferences(bpy.types.PropertyGroup): + float_min: bpy.props.FloatProperty( + name="Min Value", + description="Miniumum float value", + default=0.0, + min=0.0, + max=1.0, + step=0.1 + ) + + float_max: bpy.props.FloatProperty( + name="Max Value", + description="Maximum float value", + default=1.0, + min=0.0, + max=1.0, + step=0.1 + ) + + brightness: bpy.props.FloatProperty( + name="Brightness", + description="Adjust assets brightness", + default=1.0, + min=0.0, + max=10.0, + soft_max=1.0, + step=0.1, + update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( + context, + self.get_adjustment_affected_objects(context), + polib.asset_pack_bpy.CustomPropertyNames.BQ_BRIGHTNESS, + self.brightness + ), + ) + + hue_per_branch: bpy.props.FloatProperty( + name="Hue Per Branch", + description="Randomize hue per branch", + default=1.0, + min=0.0, + max=1.0, + step=0.1, + update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( + context, + self.get_adjustment_affected_objects(context), + polib.asset_pack_bpy.CustomPropertyNames.BQ_RANDOM_PER_BRANCH, + self.hue_per_branch + ), + ) + + hue_per_leaf: bpy.props.FloatProperty( + name="Hue Per Leaf", + description="Randomize hue per leaf", + default=1.0, + min=0.0, + max=1.0, + step=0.1, + update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( + context, + self.get_adjustment_affected_objects(context), + polib.asset_pack_bpy.CustomPropertyNames.BQ_RANDOM_PER_LEAF, + self.hue_per_leaf + ), + ) + + season_offset: bpy.props.FloatProperty( + name="Season Offset", + description="Change season of asset", + default=1.0, + min=0.0, + max=1.0, + step=0.1, + update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( + context, + self.get_adjustment_affected_objects(context), + polib.asset_pack_bpy.CustomPropertyNames.BQ_SEASON_OFFSET, + self.season_offset + ), + ) + + wind_anim_properties: bpy.props.PointerProperty( + name="Animation Properties", + description="Wind animation related property group", + type=WindAnimationProperties + ) + + def get_adjustment_affected_objects(self, context: bpy.types.Context): + extended_objects = set(context.selected_objects) + if context.active_object is not None: + extended_objects.add(context.active_object) + + return set(extended_objects).union( + asset_helpers.gather_instanced_objects(extended_objects)) + + @property + def animation_data_path(self) -> typing.Optional[str]: + # TODO: This is absolutely terrible and we need to replace it later, we assume one animation + # data library existing and that being used for everything. In the future each asset + # pack should be able to have its own + for pack in asset_registry.instance.get_packs_by_engon_feature("botaniq"): + library_path_candidate = \ + os.path.join( + pack.install_path, + "blends", + "models", + "bq_Library_Animation_Data.blend" + ) + if os.path.isfile(library_path_candidate): + return library_path_candidate + return None + + +MODULE_CLASSES.append(BotaniqPreferences) + + +def register(): + for cls in MODULE_CLASSES: + bpy.utils.register_class(cls) + + +def unregister(): + for cls in reversed(MODULE_CLASSES): + bpy.utils.unregister_class(cls) diff --git a/preferences/traffiq_preferences.py b/preferences/traffiq_preferences.py new file mode 100644 index 0000000..8b28ebc --- /dev/null +++ b/preferences/traffiq_preferences.py @@ -0,0 +1,254 @@ +# copyright (c) 2018- polygoniq xyz s.r.o. + +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import bpy +import typing +import os +import logging +import polib +from .. import asset_registry +logger = logging.getLogger(f"polygoniq.{__name__}") + + +MODULE_CLASSES: typing.List[typing.Any] = [] + + +BUMPS_MODIFIER_NAME = "tq_bumps_displacement" +BUMPS_MODIFIERS_CONTAINER_NAME = "tq_Bump_Modifiers_Container" + + +class CarPaintProperties(bpy.types.PropertyGroup): + @staticmethod + def update_car_paint_color_prop(context, value: typing.Tuple[float, float, float, float]): + # Don't allow to accidentally set color to random + if all(v > 0.99 for v in value[:3]): + value = (0.99, 0.99, 0.99, value[3]) + + polib.asset_pack_bpy.update_custom_prop( + context, + context.selected_objects, + polib.asset_pack_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR, + value + ) + + primary_color: bpy.props.FloatVectorProperty( + name="Color", + subtype='COLOR', + description="Changes primary color of assets", + min=0.0, + max=1.0, + default=(0.8, 0.8, 0.8, 1.0), + size=4, + update=lambda self, context: CarPaintProperties.update_car_paint_color_prop( + context, self.primary_color), + ) + flakes_amount: bpy.props.FloatProperty( + name="Flakes Amount", + description="Changes amount of flakes in the car paint", + default=0.0, + min=0.0, + max=1.0, + step=0.1, + update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( + context, + context.selected_objects, + polib.asset_pack_bpy.CustomPropertyNames.TQ_FLAKES_AMOUNT, + self.flakes_amount + ), + ) + clearcoat: bpy.props.FloatProperty( + name="Clearcoat", + description="Changes clearcoat property of car paint", + default=0.2, + min=0.0, + max=1.0, + step=0.1, + update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( + context, + context.selected_objects, + polib.asset_pack_bpy.CustomPropertyNames.TQ_CLEARCOAT, + self.clearcoat + ), + ) + + +MODULE_CLASSES.append(CarPaintProperties) + + +class WearProperties(bpy.types.PropertyGroup): + @staticmethod + def get_modifier_library_data_path() -> typing.Optional[str]: + # We don't cache this because in theory the registered packs could change + for pack in asset_registry.instance.get_packs_by_engon_feature("traffiq"): + modifier_library_path_candidate = os.path.join( + pack.install_path, "blends", "models", "tq_Library_Modifiers.blend") + if os.path.isfile(modifier_library_path_candidate): + return modifier_library_path_candidate + + return None + + @staticmethod + def update_bumps_prop(context: bpy.types.Context, value: float): + # Cache objects that support bumps + bumps_objs = [ + obj for obj in context.selected_objects if polib.asset_pack_bpy.CustomPropertyNames.TQ_BUMPS in obj] + + modifier_library_path = None + + # Add bumps modifier that improves bumps effect on editable objects. + # Bumps work for linked assets but looks better on editable ones with added modifier + for obj in bumps_objs: + # Object is not editable mesh + if obj.data is None or obj.type != "MESH": + continue + # If modifier is not assigned to the object, append it from library + if BUMPS_MODIFIER_NAME not in obj.modifiers: + if modifier_library_path is None: + modifier_library_path = WearProperties.get_modifier_library_data_path() + polib.asset_pack_bpy.append_modifiers_from_library( + BUMPS_MODIFIERS_CONTAINER_NAME, modifier_library_path, [obj]) + logger.info(f"Added bumps modifier on: {obj.name}") + + assert BUMPS_MODIFIER_NAME in obj.modifiers + obj.modifiers[BUMPS_MODIFIER_NAME].strength = value + + polib.asset_pack_bpy.update_custom_prop( + context, + bumps_objs, + polib.asset_pack_bpy.CustomPropertyNames.TQ_BUMPS, + value + ) + + dirt_wear_strength: bpy.props.FloatProperty( + name="Dirt", + description="Makes assets look dirty", + default=0.0, + min=0.0, + max=1.0, + step=0.1, + update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( + context, + context.selected_objects, + polib.asset_pack_bpy.CustomPropertyNames.TQ_DIRT, + self.dirt_wear_strength + ), + ) + scratches_wear_strength: bpy.props.FloatProperty( + name="Scratches", + description="Makes assets look scratched", + default=0.0, + min=0.0, + max=1.0, + step=0.1, + update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( + context, + context.selected_objects, + polib.asset_pack_bpy.CustomPropertyNames.TQ_SCRATCHES, + self.scratches_wear_strength + ), + ) + bumps_wear_strength: bpy.props.FloatProperty( + name="Bumps", + description="Makes assets look dented, appends displacement modifier for better effect if object is editable", + default=0.0, + min=0.0, + soft_max=1.0, + step=0.1, + update=lambda self, context: WearProperties.update_bumps_prop( + context, self.bumps_wear_strength), + ) + + +MODULE_CLASSES.append(WearProperties) + + +class RigProperties(bpy.types.PropertyGroup): + auto_bake_steering: bpy.props.BoolProperty( + name="Auto Bake Steering", + description="If true, follow path operator will automatically try to bake steering", + default=True + ) + auto_bake_wheels: bpy.props.BoolProperty( + name="Auto Bake Wheel Rotation", + description="If true, follow path operator will automatically try to bake wheel rotation", + default=True + ) + auto_reset_transforms: bpy.props.BoolProperty( + name="Auto Reset Transforms", + description="If true, follow path operator will automatically reset transforms" + "of needed objects to give the expected results", + default=True + ) + + +MODULE_CLASSES.append(RigProperties) + + +class LightsProperties(bpy.types.PropertyGroup): + main_lights_status: bpy.props.EnumProperty( + name="Main Lights Status", + items=( + ("0", "off", "Front and rear lights are off"), + ("0.25", "park", "Park lights are on"), + ("0.50", "low-beam", "Low-beam lights are on"), + ("0.75", "high-beam", "High-beam lights are on"), + ), + update=lambda self, context: polib.asset_pack_bpy.update_custom_prop( + context, + [polib.asset_pack_bpy.find_traffiq_lights_container( + o) for o in context.selected_objects], + polib.asset_pack_bpy.CustomPropertyNames.TQ_LIGHTS, + float(self.main_lights_status) + ), + ) + + +MODULE_CLASSES.append(LightsProperties) + + +class TraffiqPreferences(bpy.types.PropertyGroup): + car_paint_properties: bpy.props.PointerProperty( + type=CarPaintProperties + ) + + wear_properties: bpy.props.PointerProperty( + type=WearProperties + ) + + lights_properties: bpy.props.PointerProperty( + type=LightsProperties + ) + + rig_properties: bpy.props.PointerProperty( + type=RigProperties + ) + + +MODULE_CLASSES.append(TraffiqPreferences) + + +def register(): + for cls in MODULE_CLASSES: + bpy.utils.register_class(cls) + + +def unregister(): + for cls in reversed(MODULE_CLASSES): + bpy.utils.unregister_class(cls) diff --git a/python_deps/hatchery/spawn.py b/python_deps/hatchery/spawn.py index e24d1c0..8a0349b 100644 --- a/python_deps/hatchery/spawn.py +++ b/python_deps/hatchery/spawn.py @@ -41,7 +41,8 @@ class ModelSpawnOptions(DatablockSpawnOptions): parent_collection: typing.Optional[bpy.types.Collection] = None # If present the spawned model instancer is selected, other objects are deselected select_spawned: bool = False - rotation_euler_override: typing.Optional[mathutils.Vector] = None + location_override: typing.Optional[mathutils.Vector] = None + rotation_euler_override: typing.Optional[mathutils.Euler] = None class ModelSpawnedData(SpawnedData): @@ -72,6 +73,8 @@ def spawn_model( root_collection = load.load_master_collection(path) root_empty = utils.create_instanced_object(root_collection.name) root_empty.location = context.scene.cursor.location + if options.location_override is not None: + root_empty.location = options.location_override if options.rotation_euler_override is not None: root_empty.rotation_euler = options.rotation_euler_override @@ -135,8 +138,11 @@ def spawn_material( # bpy.ops.ed.lib_id_generate_preview() can be called only from blender with GUI. # That's also reason why we can't call it in our build pipeline. A proper solution # would be to force this preview rendering once in our pipeline. - with context.temp_override(id=material): - bpy.ops.ed.lib_id_generate_preview() + # TODO: Subsequently spawning several materials and calling this function results in + # Blender crashing. + # with context.temp_override(id=material): + # bpy.ops.ed.lib_id_generate_preview() + pass for obj in options.target_objects: if not utils.can_have_materials_assigned(obj): diff --git a/python_deps/mapr/__init__.py b/python_deps/mapr/__init__.py index 6281ba1..61712c6 100644 --- a/python_deps/mapr/__init__.py +++ b/python_deps/mapr/__init__.py @@ -12,10 +12,12 @@ from . import blender_asset_data from . import blender_asset_spawner from . import category + from . import filters from . import file_provider from . import known_metadata from . import local_json_provider from . import parameter_meta + from . import query else: import importlib asset_data = importlib.reload(asset_data) @@ -24,10 +26,12 @@ blender_asset_data = importlib.reload(blender_asset_data) blender_asset_spawner = importlib.reload(blender_asset_spawner) category = importlib.reload(category) + filters = importlib.reload(filters) file_provider = importlib.reload(file_provider) known_metadata = importlib.reload(known_metadata) local_json_provider = importlib.reload(local_json_provider) parameter_meta = importlib.reload(parameter_meta) + query = importlib.reload(query) # fake bl_info so that this gets picked up by vscode blender integration bl_info = { @@ -43,8 +47,10 @@ "blender_asset_data", "blender_asset_spawner", "category", + "filters", "file_provider", "known_metadata", "local_json_provider", - "parameter_meta" + "parameter_meta", + "query" ] diff --git a/python_deps/mapr/asset.py b/python_deps/mapr/asset.py index 5253403..335bd25 100644 --- a/python_deps/mapr/asset.py +++ b/python_deps/mapr/asset.py @@ -1,9 +1,9 @@ #!/usr/bin/python3 # copyright (c) 2018- polygoniq xyz s.r.o. +import dataclasses import typing import functools -import collections from . import file_provider from . import asset_data from . import known_metadata @@ -18,15 +18,18 @@ # numeric parameters have values that can be sorted and compared - e.g. "Car Length" of 4.6 meters # then you can query all cars where a parameter is equal to something, in a certain range, lower # or higher than something, etc... For example I want a car with "Car Length" < 5 meters. -NumericParameters = typing.Dict[str, float] -# color parameters represent RGB (no alpha!) color. they cannot be sorted but can be queried for -# proximity to another color (give me all assets where color is close to red) -ColorParameters = typing.Dict[str, typing.Tuple[float, float, float]] +NumericParameters = typing.Dict[str, typing.Union[float, int]] +# vector parameters consist of same-length vector values for each parameter. Can be compared and +# sorted. For example "released_in" > (5, 4, 0). The vector parameters can also contain +# color parameters (RGB), sorting for those doesn't make sense, but proximity querying like (give +# me all assets where color is close to red) does. +VectorParameters = typing.Dict[str, typing.Union[typing.Tuple[float, ...], typing.Tuple[int, ...]]] # text parameters can have values that can only be compared for equality. for example # "Genus" = "Abies concolor", then you can query all assets where Genus = "Abies concolor". TextParameters = typing.Dict[str, str] +@dataclasses.dataclass(frozen=True) class Asset: """Asset represents metadata of one separated, reusable piece that can be spawned into a scene @@ -39,25 +42,24 @@ class Asset: Both Asset and AssetData instances are provided by the AssetProvider. """ - def __init__(self): - self.id_: AssetID = "" - self.title: str = "" - # TODO: type_ is here as well as in asset_data that's referencing this - self.type_: asset_data.AssetDataType = asset_data.AssetDataType.unknown - self.preview_file: typing.Optional[file_provider.FileID] = None + id_: AssetID = "" + title: str = "" + # TODO: type_ is here as well as in asset_data that's referencing this + type_: asset_data.AssetDataType = asset_data.AssetDataType.unknown + preview_file: typing.Optional[file_provider.FileID] = None - self.tags: typing.Set[Tag] = set() - self.numeric_parameters: NumericParameters = dict() - self.color_parameters: ColorParameters = dict() - self.text_parameters: TextParameters = dict() + tags: typing.Set[Tag] = dataclasses.field(default_factory=set) + numeric_parameters: NumericParameters = dataclasses.field(default_factory=dict) + vector_parameters: VectorParameters = dataclasses.field(default_factory=dict) + text_parameters: TextParameters = dataclasses.field(default_factory=dict) - @property + @functools.cached_property def parameters(self) -> typing.Dict[str, typing.Any]: - """Numeric, color and text parameters combined in one dictionary.""" + """Numeric, text and vector parameters combined in one dictionary.""" return { **self.numeric_parameters, - **self.color_parameters, - **self.text_parameters + **self.text_parameters, + **self.vector_parameters } @functools.cached_property @@ -96,9 +98,9 @@ def search_matter(self) -> typing.DefaultDict[str, float]: token = str(value).lower() ret[token] = max(search_weight, ret[token]) - for name, value in self.color_parameters.items(): + for name, value in self.vector_parameters.items(): search_weight = \ - float(known_metadata.COLOR_PARAMETERS.get(name, {}).get("search_weight", 1.0)) + float(known_metadata.VECTOR_PARAMETERS.get(name, {}).get("search_weight", 1.0)) if search_weight <= 0.0: continue token = str(value).lower() diff --git a/python_deps/mapr/asset_data.py b/python_deps/mapr/asset_data.py index 3758849..64d3270 100644 --- a/python_deps/mapr/asset_data.py +++ b/python_deps/mapr/asset_data.py @@ -2,6 +2,7 @@ # copyright (c) 2018- polygoniq xyz s.r.o. import abc +import dataclasses import enum import typing import collections @@ -49,7 +50,7 @@ def search_matter(self) -> typing.DefaultDict[str, float]: return ret +@dataclasses.dataclass(frozen=True) class AssetData(abc.ABC): - def __init__(self): - self.id_: AssetDataID = "" - self.type_: AssetDataType = "unknown" + id_: AssetDataID = "" + type_: AssetDataType = AssetDataType.unknown diff --git a/python_deps/mapr/asset_provider.py b/python_deps/mapr/asset_provider.py index 241eafe..18142de 100644 --- a/python_deps/mapr/asset_provider.py +++ b/python_deps/mapr/asset_provider.py @@ -3,13 +3,62 @@ import typing import abc +import functools from . import category from . import asset from . import asset_data +from . import query +from . import parameter_meta import logging logger = logging.getLogger(f"polygoniq.{__name__}") +class DataView: + """One view of data - lists of assets based on provided Query and AssetProvider.""" + + def __init__(self, asset_provider: 'AssetProvider', query_: query.Query): + self.assets: typing.List[asset.Asset] = [] + for asset_ in asset_provider.list_assets(query_.category_id, query_.recursive): + if all(f.filter_(asset_) for f in query_.filters): + self.assets.append(asset_) + + sort_lambda, reverse = self._get_sort_parameters(query_.sort_mode) + self.assets.sort(key=lambda x: sort_lambda(x), reverse=reverse) + + self.parameters_meta = parameter_meta.AssetParametersMeta(self.assets) + self.used_query = query_ + logger.debug(f"Created DataView {self}") + + def _get_sort_parameters( + self, + sort_mode: str + ) -> typing.Tuple[typing.Callable[[asset.Asset], str], bool]: + """Return lambda and reverse bool to pass into Python sort() based on sort mode + + Returns tuple of (lambda, reverse) + """ + if sort_mode == query.SortMode.ALPHABETICAL_ASC: + return (lambda x: x.title, False) + elif sort_mode == query.SortMode.ALPHABETICAL_DESC: + return (lambda x: x.title, True) + else: + raise NotImplementedError(f"Unknown sort mode {sort_mode}") + + def __repr__(self) -> str: + return f"DataView at {id(self)} based on query:\n {self.used_query} " \ + f"containing {len(self.assets)} assets" + + +class EmptyDataView(DataView): + """Data view containing no data - useful on places, where DataView cannot be constructed yet.""" + + def __init__(self): + self.assets = [] + self.parameters_meta = parameter_meta.AssetParametersMeta(self.assets) + self.used_query = None + logger.debug(f"Created EmptyDataView {self}") + + class AssetProvider(abc.ABC): def __init__(self): pass @@ -71,6 +120,13 @@ def get_asset_data(self, asset_data_id: asset_data.AssetDataID) -> typing.Option """ pass + def query(self, query_: query.Query) -> DataView: + """Queries the asset provider for assets based on given query + + This is a high level API, consider using this instead of list_assets. + """ + return DataView(self, query_) + def list_categories(self, parent_id: category.CategoryID, recursive: bool = False) -> typing.Iterable[category.Category]: """List child categories of a given parent category @@ -211,3 +267,35 @@ def get_asset_data(self, asset_data_id: asset_data.AssetDataID) -> typing.Option if ret is not None: return ret return None + + +class CachedAssetProviderMultiplexer(AssetProviderMultiplexer): + """Wraps the 'query' call and caches the results using LRU cache.""" + + def __init__(self, maxsize: int = 128): + super().__init__() + # Create 'cached_query' method wrapped with lru_cache for each instance of the + # cached asset provider, that wraps the actual call to self._cached_query. + # The decorator version keeps hard reference to self, which may cause memory leaks + # when the instances are deleted. + # More info: https://rednafi.com/python/lru_cache_on_methods/ + self.query = functools.lru_cache(maxsize=maxsize)(self._cached_query) + + def add_asset_provider(self, asset_provider: AssetProvider) -> None: + super().add_asset_provider(asset_provider) + self.clear_cache() + + def remove_asset_provider(self, asset_provider: AssetProvider) -> None: + super().remove_asset_provider(asset_provider) + self.clear_cache() + + def clear_providers(self) -> None: + super().clear_providers() + self.clear_cache() + + def _cached_query(self, query_: query.Query) -> DataView: + logger.debug(f"Cache miss for query {query_}, querying...") + return super().query(query_) + + def clear_cache(self) -> None: + self.query.cache_clear() diff --git a/python_deps/mapr/blender_asset_data.py b/python_deps/mapr/blender_asset_data.py index 68f0389..6ae5774 100644 --- a/python_deps/mapr/blender_asset_data.py +++ b/python_deps/mapr/blender_asset_data.py @@ -1,9 +1,10 @@ #!/usr/bin/python3 # copyright (c) 2018- polygoniq xyz s.r.o. -import typing import abc import bpy +import dataclasses +import typing from . import asset_data from . import file_provider try: @@ -12,11 +13,10 @@ from blender_addons import hatchery +@dataclasses.dataclass(frozen=True) class BlenderAssetData(asset_data.AssetData, abc.ABC): - def __init__(self): - super().__init__() - self.primary_blend_file: file_provider.FileID = "" - self.dependency_files: typing.Set[file_provider.FileID] = set() + primary_blend_file: file_provider.FileID = "" + dependency_files: typing.Set[file_provider.FileID] = dataclasses.field(default_factory=set) @abc.abstractmethod def spawn( @@ -28,11 +28,10 @@ def spawn( pass +@dataclasses.dataclass(frozen=True) class BlenderModelAssetData(BlenderAssetData): - def __init__(self): - super().__init__() - self.type_ = asset_data.AssetDataType.blender_model - self.lod_level: int = 0 + type_ = asset_data.AssetDataType.blender_model + lod_level: int = 0 def spawn( self, @@ -43,10 +42,9 @@ def spawn( return hatchery.spawn.spawn_model(path, context, options) +@dataclasses.dataclass(frozen=True) class BlenderMaterialAssetData(BlenderAssetData): - def __init__(self): - super().__init__() - self.type_ = asset_data.AssetDataType.blender_material + type_ = asset_data.AssetDataType.blender_material def spawn( self, @@ -57,10 +55,9 @@ def spawn( return hatchery.spawn.spawn_material(path, context, options) +@dataclasses.dataclass(frozen=True) class BlenderParticleSystemAssetData(BlenderAssetData): - def __init__(self): - super().__init__() - self.type_ = asset_data.AssetDataType.blender_particle_system + type_ = asset_data.AssetDataType.blender_particle_system def spawn( self, @@ -71,10 +68,9 @@ def spawn( return hatchery.spawn.spawn_particles(path, context, options) +@dataclasses.dataclass(frozen=True) class BlenderSceneAssetData(BlenderAssetData): - def __init__(self): - super().__init__() - self.type_ = asset_data.AssetDataType.blender_scene + type_ = asset_data.AssetDataType.blender_scene def spawn( self, @@ -85,10 +81,9 @@ def spawn( return hatchery.spawn.spawn_scene(path, context, options) +@dataclasses.dataclass(frozen=True) class BlenderWorldAssetData(BlenderAssetData): - def __init__(self): - super().__init__() - self.type_ = asset_data.AssetDataType.blender_world + type_ = asset_data.AssetDataType.blender_world def spawn( self, @@ -99,10 +94,9 @@ def spawn( return hatchery.spawn.spawn_world(path, context, options) +@dataclasses.dataclass(frozen=True) class BlenderGeometryNodesAssetData(BlenderAssetData): - def __init__(self): - super().__init__() - self.type_ = asset_data.AssetDataType.blender_geometry_nodes + type_ = asset_data.AssetDataType.blender_geometry_nodes def spawn( self, diff --git a/python_deps/mapr/category.py b/python_deps/mapr/category.py index ec5f5c1..b3f87c7 100644 --- a/python_deps/mapr/category.py +++ b/python_deps/mapr/category.py @@ -1,25 +1,24 @@ #!/usr/bin/python3 # copyright (c) 2018- polygoniq xyz s.r.o. +import dataclasses import typing -from . import file_provider import logging +from . import file_provider logger = logging.getLogger(f"polygoniq.{__name__}") CategoryID = str +@dataclasses.dataclass(frozen=True) class Category: - def __init__(self): - self.id_: CategoryID = "" - self.title: str = "" - self.preview_file: typing.Optional[file_provider.FileID] = None + id_: CategoryID = "" + title: str = "" + preview_file: typing.Optional[file_provider.FileID] = None -DEFAULT_ROOT_CATEGORY = Category() -DEFAULT_ROOT_CATEGORY.id_ = "/" -DEFAULT_ROOT_CATEGORY.title = "All" +DEFAULT_ROOT_CATEGORY = Category(id_="/", title="All") def infer_parent_category_id( diff --git a/python_deps/mapr/filters.py b/python_deps/mapr/filters.py new file mode 100644 index 0000000..4c6e0c9 --- /dev/null +++ b/python_deps/mapr/filters.py @@ -0,0 +1,306 @@ +# copyright (c) 2018- polygoniq xyz s.r.o. + +# Base classes for filters which can be used in a query against asset provider to get assets +# based on their parameters. For example one can use: +# - NumericParameterFilter("num:width", 0.0, 10.0) - assets with '0.0 < "width" < 10.0' +# - TextParameterFilter("text:country_of_origin", {"Czechia", "Poland"}) - assets containing +# "country_of_origin" either "Czechia" or "Poland" +# The filters defined here provide the minimal functionality needed for filtering and can be used +# without 'bpy'. The filters in `engon.browser` are their more complex implementation based +# on 'bpy' with UI state and other functionality + +import abc +import functools +import typing +import mathutils +import re +from . import asset +from . import asset_data +from . import parameter_meta + + +class Filter: + def __init__(self, name: str): + self.name = name + self.name_without_type = parameter_meta.remove_type_from_name(self.name) + + def filter_(self, asset_: asset.Asset) -> bool: + """Decides if the 'asset' should be filtered. + + Returns True if asset passes the filter, False otherwise. + NOTE: This should consider also filtering out assets which don't have any value of the + corresponding parameter present. + """ + raise NotImplementedError() + + def as_dict(self) -> typing.Dict: + """Returns a dict entry representing this filter - {key: filter-parameters}. + + The 'key' has to be unique across all the filters! The self.name is mostly used + for that as it is prefixed by the 'type:' based on the filter. + """ + raise NotImplementedError() + + +class NumericParameterFilter(Filter): + def __init__( + self, + name: str, + range_start: float, + range_end: float + ): + super().__init__(name) + self.range_start = range_start + self.range_end = range_end + + def filter_(self, asset_: asset.Asset) -> bool: + if self.name_without_type not in asset_.numeric_parameters: + return False + + return self.range_start < asset_.numeric_parameters[self.name_without_type] < self.range_end + + def as_dict(self) -> typing.Dict: + return {self.name: {"min": self.range_start, "max": self.range_end}} + + +class TagFilter(Filter): + def __init__(self, name: str): + super().__init__(name) + # If one instantiates the base TagFilter, they mean to filter by this tag. + self.include = True + + def filter_(self, asset_: asset.Asset) -> bool: + return self.name_without_type in asset_.tags + + def as_dict(self) -> typing.Dict: + return {self.name: self.include} + + +class TextParameterFilter(Filter): + def __init__(self, name: str, values: typing.Set[str]): + super().__init__(name) + self.values = values + + def filter_(self, asset_: asset.Asset) -> bool: + value = asset_.text_parameters.get(self.name_without_type, None) + if value is None: + return False + + return value in self.values + + def as_dict(self) -> typing.Dict: + return {self.name: list(self.values)} + + +class VectorComparator(abc.ABC): + @abc.abstractmethod + def compare(self, value: mathutils.Vector) -> bool: + pass + + @abc.abstractmethod + def as_dict(self) -> typing.Dict: + """The dict representation of the comparator, has to be unique for each comparator type.""" + pass + + +DistanceFunction = typing.Callable[[mathutils.Vector, mathutils.Vector], float] +NamedDistanceFunction = typing.Tuple[DistanceFunction, str] + + +class VectorDistanceComparator(VectorComparator): + """Compares distance of point to other point (represented as vectors) to a distance threshold. + + If the 'distance_function' is provided, then it is a tuple of the distance function and a name + of the function. The name is used in `as_dict` representation of the comparator which is used + for caching. Name needs to be unique otherwise cache can return incorrect results for queries + that filter out different assets but have the same `as_dict` representation. + """ + + def __init__( + self, + value: mathutils.Vector, + distance: float, + distance_function: typing.Optional[NamedDistanceFunction] = None, + ): + self.value = value + self.distance = distance + if distance_function is None: + self.distance_function = VectorDistanceComparator._euclidean_distance + self.distance_function_name = "euclidean" + else: + self.distance_function = distance_function[0] + self.distance_function_name = distance_function[1] + + def compare(self, value: mathutils.Vector) -> bool: + if len(value) != len(self.value): + raise ValueError("Vector length mismatch") + + return self.distance_function(value, self.value) <= self.distance + + def as_dict(self) -> typing.Dict: + return { + "value": tuple(self.value), + "distance": self.distance, + "function": self.distance_function_name + } + + @staticmethod + def _euclidean_distance(a: mathutils.Vector, b: mathutils.Vector) -> float: + return (a - b).length + + +class VectorLexicographicComparator(VectorComparator): + """Compares vectors lexicographically, the min_ and max_ are inclusive""" + + def __init__(self, min_: mathutils.Vector, max_: mathutils.Vector): + self.min_ = min_ + self.max_ = max_ + + def compare(self, value: mathutils.Vector) -> bool: + return tuple(self.min_) <= tuple(value) <= tuple(self.max_) + + def as_dict(self) -> typing.Dict: + return {"min": tuple(self.min_), "max": tuple(self.max_), "method": "lexicographic"} + + +class VectorComponentWiseComparator(VectorComparator): + """Compares vectors component-wise - each component separately, the min_ and max_ are inclusive""" + + def __init__(self, min_: mathutils.Vector, max_: mathutils.Vector): + self.min_ = min_ + self.max_ = max_ + + def compare(self, value: mathutils.Vector) -> bool: + if len(value) != len(self.min_) != len(self.max_): + raise ValueError("Vector length mismatch") + + # Use component wise comparison, the default comparison operators for vector compare lengths + for i in range(len(value)): + if not self.min_[i] <= value[i] <= self.max_[i]: + return False + + return True + + def as_dict(self) -> typing.Dict: + return {"min": tuple(self.min_), "max": tuple(self.max_), "method": "component-wise"} + + +class VectorParameterFilter(Filter): + def __init__( + self, + name: str, + comparator: VectorComparator, + ): + super().__init__(name) + self.comparator = comparator + + def filter_(self, asset_: asset.Asset) -> bool: + value = asset_.vector_parameters.get(self.name_without_type, None) + if value is None: + return False + + return self.comparator.compare(mathutils.Vector(value)) + + def as_dict(self) -> typing.Dict: + return {self.name: self.comparator.as_dict()} + + +class AssetTypesFilter(Filter): + def __init__( + self, + model: bool = True, + material: bool = True, + particle_system: bool = True, + scene: bool = True, + world: bool = True, + geometry_nodes: bool = True + ): + super().__init__("builtin:asset_types") + self.model = model + self.material = material + self.particle_system = particle_system + self.scene = scene + self.world = world + self.geometry_nodes = geometry_nodes + + def filter_(self, asset_: asset.Asset) -> bool: + type_ = asset_.type_ + return any([ + type_ == asset_data.AssetDataType.blender_model and self.model, + type_ == asset_data.AssetDataType.blender_material and self.material, + type_ == asset_data.AssetDataType.blender_particle_system and self.particle_system, + type_ == asset_data.AssetDataType.blender_scene and self.scene, + type_ == asset_data.AssetDataType.blender_world and self.world, + type_ == asset_data.AssetDataType.blender_geometry_nodes and self.geometry_nodes, + ]) + + def as_dict(self) -> typing.Dict: + return {self.name: self._all} + + @property + def _all(self) -> typing.Tuple: + return ( + self.model, + self.material, + self.particle_system, + self.scene, + self.world, + self.geometry_nodes + ) + + +class SearchFilter(Filter): + def __init__(self, search: str): + super().__init__("builtin:search") + self.search = search + self.needle_keywords = SearchFilter.keywords_from_search(search) + + def filter_(self, asset_: asset.Asset) -> bool: + # we make sure all needle keywords are present in given haystack for the haystack not to be + # filtered + + if len(self.needle_keywords) == 0: + return True + + match_found = False + for needle_keyword in self.needle_keywords: + for haystack_keyword, haystack_keyword_weight in asset_.search_matter.items(): + # TODO: We want to do relevancy scoring in the future but for that the entire + # mechanism has be moved into MAPR API + + # this is guaranteed by the API + assert haystack_keyword_weight > 0.0 + + if haystack_keyword.find(needle_keyword) >= 0: + match_found = True + break + + if match_found: + break + + return match_found + + @staticmethod + @functools.lru_cache(maxsize=128) + def keywords_from_search(search: str) -> typing.Set[str]: + """Returns a set of lowercase keywords to search for in the assets""" + def translate_keywords(keywords: typing.Set[str]) -> typing.Set[str]: + # Be careful when adding new keywords as it will make impossible to find anything using the original keyword. + # E.g. if we'd have tag `hdr` it would not be possible to find it now. Or anything named `hdr_something` cannot be find by `hdr` + translator = { + "hdri": "world", + "hdr": "world" + } + + ret: typing.Set[str] = set() + for kw in keywords: + ret.add(translator.get(kw, kw)) + + return ret + + return translate_keywords( + {kw.lower() for kw in re.split(r"[ ,_\-]+", search) if kw != ""} + ) + + def as_dict(self) -> typing.Dict: + return {self.name: self.search} diff --git a/python_deps/mapr/known_metadata.py b/python_deps/mapr/known_metadata.py index 42023e1..1b73348 100644 --- a/python_deps/mapr/known_metadata.py +++ b/python_deps/mapr/known_metadata.py @@ -2,7 +2,7 @@ # Definition of tags that can be manually added to the asset in grumpy_cat. Each tag maps to a # dictionary where more details can be specified. Including a description that is used as a tooltip. -# Keep in mind that in addition to these assets can have tags not on this list! +# Keep in mind that in addition to these, assets can have tags not on this list! TAGS = { "Bathroom": { "description": "" @@ -68,7 +68,7 @@ # Which numeric parameters can be added to assets in grumpy_cat. Each maps to a dictionary with more -# info about each parameter. Keep in mind that in addition to these assets can have parameters not +# info about each parameter. Keep in mind that in addition to these, assets can have parameters not # on this list! NUMERIC_PARAMETERS = { "model_year": { @@ -96,7 +96,7 @@ # Which text parameters can be added to assets in grumpy_cat. Each maps to a dictionary with more -# info about each parameter. Keep in mind that in addition to these assets can have parameters not +# info about each parameter. Keep in mind that in addition to these, assets can have parameters not # on this list! TEXT_PARAMETERS = { "license": { @@ -221,16 +221,33 @@ } -# Which color parameters can be added to assets in grumpy_cat. Each maps to a dictionary with more -# info about each parameter. Keep in mind that in addition to these assets can have parameters not -# on this list! -COLOR_PARAMETERS = { +class VectorType: + FLOAT = 'FLOAT' + INT = 'INT' + COLOR = 'COLOR' + + +# Which vector parameters can be added to assets in grumpy_cat. Each maps to a dictionary with more +# info about each parameter. Keep in mind that in addition to these, assets can have parameters not +# on this list! The type is used to determine how the vector should be shown and manipulated in the +# interfaces - possible values are VectorType.FLOAT (default), INT or COLOR. +# TODO: Currently only vec3 is supported. We are not able to define sizes of vectors for display +# in the UI dynamically, so we hardcode the size of the vector in the UI. For different sizes +# different unique properties with switching between them would be required. +VECTOR_PARAMETERS = { + "introduced_in": { + "description": "Version of asset pack this asset was introduced in", + "search_weight": 0.0, + "type": VectorType.INT, + }, "viewport_color": { "description": "", "search_weight": 0.0, - }, + "type": VectorType.COLOR, + } } + # Mapping of parameter name to unit. If the parameter is not specified here it is considered unitless. PARAMETER_UNITS = {param: info.get( "unit") for param, info in NUMERIC_PARAMETERS.items() if info.get("unit", None) is not None} @@ -273,7 +290,7 @@ "text:displacement_method", "num:metallic", "num:roughness", - "col:viewport_color" + "vec:viewport_color" ], "data_count": [ "num:triangle_count", diff --git a/python_deps/mapr/local_json_provider.py b/python_deps/mapr/local_json_provider.py index 6cedec6..756ab7d 100644 --- a/python_deps/mapr/local_json_provider.py +++ b/python_deps/mapr/local_json_provider.py @@ -88,24 +88,29 @@ def load_index(self): self.child_asset_data = index_json.get("child_asset_data", {}) for category_id, category_metadata_json in index_json.get("category_metadata", {}).items(): - category_metadata = category.Category() - category_metadata.id_ = category_id - category_metadata.title = category_metadata_json.get("title", "unknown") - self.categories[category_id] = category_metadata + self.categories[category_id] = category.Category( + id_=category_id, + title=category_metadata_json.get("title", "unknown"), + preview_file=category_metadata_json.get("preview_file", None) + ) for asset_id, asset_metadata_json in index_json.get("asset_metadata", {}).items(): - asset_metadata = asset.Asset() - asset_metadata.id_ = asset_id - asset_metadata.title = asset_metadata_json.get("title", "unknown") - asset_metadata.type_ = asset_data.AssetDataType[asset_metadata_json.get( - "type", "unknown")] - asset_metadata.preview_file = asset_metadata_json.get("preview_file", "") - asset_metadata.numeric_parameters.update( - asset_metadata_json.get("numeric_parameters", {})) - asset_metadata.color_parameters.update( - asset_metadata_json.get("color_parameters", {})) - asset_metadata.text_parameters.update(asset_metadata_json.get("text_parameters", {})) - asset_metadata.tags.update(asset_metadata_json.get("tags", [])) + # Update vector parameters with color parameters for older asset packs + # compatibility (prior to engon 1.2.0). Color parameters were defined solely prior to + # the introduction of vector parameters. + vector_parameters = asset_metadata_json.get("vector_parameters", {}) + vector_parameters.update(asset_metadata_json.get("color_parameters", {})) + + asset_metadata = asset.Asset( + id_=asset_id, + title=asset_metadata_json.get("title", "unknown"), + type_=asset_data.AssetDataType[asset_metadata_json.get("type", "unknown")], + preview_file=asset_metadata_json.get("preview_file", ""), + tags=asset_metadata_json.get("tags", []), + numeric_parameters=asset_metadata_json.get("numeric_parameters", {}), + vector_parameters=vector_parameters, + text_parameters=asset_metadata_json.get("text_parameters", {}) + ) # clear search matter cache since we updated search matter # we instantiated the class right here so this will do nothing but we include it for # people who will copy code from here @@ -113,31 +118,35 @@ def load_index(self): self.assets[asset_id] = asset_metadata for asset_data_id, asset_data_json in index_json.get("asset_data", {}).items(): - asset_data_instance: typing.Optional[blender_asset_data.BlenderAssetData] = None + asset_data_class: typing.Optional[ + typing.Type[blender_asset_data.BlenderAssetData]] = None asset_data_type = asset_data_json.get("type") if asset_data_type == "blender_model": - asset_data_instance = blender_asset_data.BlenderModelAssetData() + asset_data_class = blender_asset_data.BlenderModelAssetData elif asset_data_type == "blender_material": - asset_data_instance = blender_asset_data.BlenderMaterialAssetData() + asset_data_class = blender_asset_data.BlenderMaterialAssetData elif asset_data_type == "blender_world": - asset_data_instance = blender_asset_data.BlenderWorldAssetData() + asset_data_class = blender_asset_data.BlenderWorldAssetData elif asset_data_type == "blender_scene": - asset_data_instance = blender_asset_data.BlenderSceneAssetData() + asset_data_class = blender_asset_data.BlenderSceneAssetData elif asset_data_type == "blender_particle_system": - asset_data_instance = blender_asset_data.BlenderParticleSystemAssetData() + asset_data_class = blender_asset_data.BlenderParticleSystemAssetData elif asset_data_type == "blender_geometry_nodes": - asset_data_instance = blender_asset_data.BlenderGeometryNodesAssetData() + asset_data_class = blender_asset_data.BlenderGeometryNodesAssetData else: raise NotImplementedError() - if asset_data_instance is not None: - asset_data_instance.id_ = asset_data_id - asset_data_instance.primary_blend_file = asset_data_json.get( - "primary_blend_file", "") - self.record_file_id(asset_data_instance.primary_blend_file) - asset_data_instance.dependency_files = asset_data_json.get("dependency_files", []) - for dependency_file in asset_data_instance.dependency_files: - self.record_file_id(dependency_file) - self.asset_data[asset_data_id] = asset_data_instance + + assert asset_data_class is not None + asset_data_instance = asset_data_class( + id_=asset_data_id, + primary_blend_file=asset_data_json.get("primary_blend_file", ""), + dependency_files=asset_data_json.get("dependency_files", []) + ) + self.record_file_id(asset_data_instance.primary_blend_file) + for dependency_file in asset_data_instance.dependency_files: + self.record_file_id(dependency_file) + + self.asset_data[asset_data_id] = asset_data_instance def list_child_category_ids(self, parent_id: category.CategoryID) -> typing.Iterable[category.CategoryID]: yield from self.child_categories.get(parent_id, []) diff --git a/python_deps/mapr/parameter_meta.py b/python_deps/mapr/parameter_meta.py index 8cca384..2bb4714 100644 --- a/python_deps/mapr/parameter_meta.py +++ b/python_deps/mapr/parameter_meta.py @@ -32,13 +32,13 @@ def __repr__(self): class VectorParameterMeta: - def __init__(self, name: str, value: typing.List[int | float]): + def __init__(self, name: str, value: mathutils.Vector): self.name: str = name self.length = len(value) - self.min_ = mathutils.Vector(value) - self.max_ = mathutils.Vector(value) + self.min_ = value + self.max_ = value - def register_value(self, value: typing.List[int | float]) -> None: + def register_value(self, value: mathutils.Vector) -> None: # The vector length should be the same for all values of one parameter assert len(value) == self.length self.min_ = mathutils.Vector(min(value[i], self.min_[i]) for i in range(self.length)) @@ -61,7 +61,7 @@ class AssetParametersMeta: def __init__(self, assets: typing.Iterable[asset.Asset]): self.numeric: typing.Dict[str, NumericParameterMeta] = {} self.text: typing.Dict[str, TextParameterMeta] = {} - self.color: typing.Dict[str, VectorParameterMeta] = {} + self.vector: typing.Dict[str, VectorParameterMeta] = {} self.unique_tags: typing.Set[str] = set() self.unique_parameter_names: typing.Set[str] = set() @@ -80,25 +80,26 @@ def __init__(self, assets: typing.Iterable[asset.Asset]): else: self.text[unique_name].register_value(value) - for param, value in asset_.color_parameters.items(): - unique_name = f"col:{param}" - if unique_name not in self.color: - self.color[unique_name] = VectorParameterMeta(unique_name, value) + for param, value in asset_.vector_parameters.items(): + unique_name = f"vec:{param}" + if unique_name not in self.vector: + self.vector[unique_name] = VectorParameterMeta( + unique_name, mathutils.Vector(value)) else: - self.color[unique_name].register_value(value) + self.vector[unique_name].register_value(mathutils.Vector(value)) self.unique_tags.update({f"tag:{t}" for t in asset_.tags}) self.unique_parameter_names.update(self.text) self.unique_parameter_names.update(self.numeric) - self.unique_parameter_names.update(self.color) + self.unique_parameter_names.update(self.vector) self.unique_parameter_names.update(self.unique_tags) def __repr__(self) -> str: import pprint pp = pprint.PrettyPrinter(depth=4) return f"{self.__class__.__name__} at {id(self)}:\n" \ - f"{pp.pformat(self.numeric)}\n{pp.pformat(self.text)}\n{pp.pformat(self.color)}\n"\ + f"{pp.pformat(self.numeric)}\n{pp.pformat(self.text)}\n{pp.pformat(self.vector)}\n"\ f"{self.unique_tags}\nUnique Names: {self.unique_parameter_names}" diff --git a/python_deps/mapr/query.py b/python_deps/mapr/query.py new file mode 100644 index 0000000..9e80341 --- /dev/null +++ b/python_deps/mapr/query.py @@ -0,0 +1,52 @@ +# copyright (c) 2018- polygoniq xyz s.r.o. + +import typing +import json +from . import category +from . import filters + + +class SortMode: + ALPHABETICAL_ASC = "ABC (A)" + ALPHABETICAL_DESC = "ABC (D)" + + +class Query: + def __init__( + self, + category_id: category.CategoryID, + filters: typing.Iterable[filters.Filter], + sort_mode: str, + recursive: bool = True, + ): + self.category_id = category_id + self.filters = list(filters) + self.sort_mode = sort_mode + self.recursive = recursive + # We need to construct the dict representation of the query when it is initialized + # because we reference the filters and those can change (mutate) after the Query is + # constructed. Resulting in values provided by the filters being always equal to the filters + # dict representation when the query would be converted to dict. + self._dict = self._as_dict() + + def _as_dict(self) -> typing.Dict: + ret = {} + ret["category_id"] = self.category_id + ret["recursive"] = self.recursive + ret["sort_mode"] = self.sort_mode + for filter_ in self.filters: + ret.update(filter_.as_dict()) + + return ret + + def __hash__(self) -> int: + return hash(json.dumps(self._dict)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Query): + return self._dict == other._dict + + return False + + def __repr__(self) -> str: + return str(self._dict) diff --git a/python_deps/mapr/tests/mock_mapr_index.json b/python_deps/mapr/tests/mock_mapr_index.json new file mode 100644 index 0000000..1c2ce0f --- /dev/null +++ b/python_deps/mapr/tests/mock_mapr_index.json @@ -0,0 +1,220 @@ +{ + "asset_data": { + "data_id_1": { + "dependency_files": [], + "primary_blend_file": "/mock_pack:blends/models/category_2/data_id_1.blend", + "type": "blender_model" + }, + "data_id_2": { + "dependency_files": [], + "primary_blend_file": "/mock_pack:blends/models/category_1/data_id_2.blend", + "type": "blender_model" + }, + "data_id_3": { + "dependency_files": [], + "primary_blend_file": "/mock_pack:blends/models/category_2/data_id_3.blend", + "type": "blender_model" + }, + "data_id_4": { + "dependency_files": [], + "primary_blend_file": "/mock_pack:blends/models/category_1/data_id_4.blend", + "type": "blender_model" + } + }, + "asset_metadata": { + "asset_id_1": { + "vector_parameters": { + "introduced_in": [1, 2, 3] + }, + "color_parameters": { + "viewport_color": [1.0, 0.0, 0.0] + }, + "numeric_parameters": { + "depth": 1.0836272239685059, + "height": 0.7299996018409729, + "image_count": 2, + "material_count": 1, + "model_year": 2023, + "object_count": 1, + "price_usd": 500.0, + "triangle_count": 1148, + "triangle_count_applied": 1148, + "width": 2.096391201019287 + }, + "preview_file": "/mock_pack:previews/models/category_2/asset_id_1.png", + "tags": [ + "Dining", + "Indoor", + "Kitchen", + "Restaurant" + ], + "text_parameters": { + "bpy.data.version": "3.3.6", + "copyright": "(c) 2018- polygoniq xyz s.r.o.", + "country_of_origin": "USA", + "furniture_style": "Minimalist", + "license": "Royalty Free", + "mapr_asset_id": "asset_id_1", + "polygoniq_addon": "mock_pack" + }, + "title": "ST201 DINING Minimalist Rectangular Basic", + "type": "blender_model" + }, + "asset_id_2": { + "vector_parameters": { + "introduced_in": [2, 0, 0] + }, + "numeric_parameters": { + "depth": 1.0556060075759888, + "height": 0.8022251725196838, + "image_count": 6, + "material_count": 3, + "model_year": 2023, + "object_count": 1, + "price_usd": 1000.0, + "triangle_count": 107678, + "triangle_count_applied": 107678, + "width": 3.224522590637207 + }, + "preview_file": "/mock_pack:previews/models/category_1/asset_id_2.png", + "tags": [ + "Indoor", + "Living room", + "Lobby", + "Office" + ], + "text_parameters": { + "bpy.data.version": "3.3.6", + "copyright": "(c) 2018- polygoniq xyz s.r.o.", + "country_of_origin": "Poland", + "furniture_style": "Modern", + "license": "Royalty Free", + "mapr_asset_id": "asset_id_2", + "polygoniq_addon": "mock_pack" + }, + "title": "SF101 MULTISEAT Modern Upholstered", + "type": "blender_model" + }, + "asset_id_3": { + "vector_parameters": { + "introduced_in": [3, 0, 0] + }, + "numeric_parameters": { + "depth": 0.7002078294754028, + "height": 0.3697204291820526, + "image_count": 3, + "material_count": 4, + "model_year": 2023, + "object_count": 1, + "price_usd": 950.0, + "triangle_count": 2160, + "triangle_count_applied": 2160, + "width": 0.7002077102661133 + }, + "preview_file": "/mock_pack:previews/models/category_2/asset_id_3.png", + "tags": [ + "Indoor", + "Living room", + "Office" + ], + "text_parameters": { + "bpy.data.version": "3.3.6", + "brand": "Kart", + "copyright": "(c) 2018- polygoniq xyz s.r.o.", + "country_of_origin": "Germany", + "furniture_style": "Minimalist", + "license": "Royalty Free", + "mapr_asset_id": "asset_id_3", + "model": "14641", + "polygoniq_addon": "mock_pack" + }, + "title": "ST001 COFFEE Minimalist Square Small", + "type": "blender_model" + }, + "asset_id_4": { + "vector_parameters": { + "introduced_in": [4, 0, 0] + }, + "numeric_parameters": { + "depth": 2.060352325439453, + "height": 0.8315317034721375, + "image_count": 4, + "material_count": 2, + "model_year": 2023, + "object_count": 1, + "price_usd": 950.0, + "triangle_count": 88322, + "triangle_count_applied": 88322, + "width": 1.995913028717041 + }, + "preview_file": "/mock_pack:previews/models/category_1/asset_id_4.png", + "tags": [ + "Indoor", + "Living room", + "Lobby", + "Outdoor" + ], + "text_parameters": { + "bpy.data.version": "3.3.6", + "brand": "IKEA", + "copyright": "(c) 2018- polygoniq xyz s.r.o.", + "country_of_origin": "Romania", + "furniture_style": "Contemporary", + "license": "Royalty Free", + "mapr_asset_id": "asset_id_4", + "model": "Soderhamn", + "polygoniq_addon": "mock_pack" + }, + "title": "SF201 CORNER Modern Upholstered", + "type": "blender_model" + } + }, + "category_metadata": { + "/": { + "title": "all" + }, + "/mock_pack": { + "title": "mock_pack" + }, + "/mock_pack/category_1": { + "title": "category_1" + }, + "/mock_pack/category_2": { + "title": "category_2" + } + }, + "child_asset_data": { + "asset_id_1": [ + "data_id_1" + ], + "asset_id_2": [ + "data_id_4" + ], + "asset_id_3": [ + "data_id_3" + ], + "asset_id_4": [ + "data_id_2" + ] + }, + "child_assets": { + "/mock_pack/category_1": [ + "asset_id_2", + "asset_id_4" + ], + + "/mock_pack/category_2": [ + "asset_id_1", + "asset_id_3" + ] + }, + "child_categories": { + "/": [ + "/mock_pack" + ], + "/mock_pack": [ + "/mock_pack/category_1", + "/mock_pack/category_2" + ] + } +} \ No newline at end of file diff --git a/python_deps/mapr/tests/test_asset_provider.py b/python_deps/mapr/tests/test_asset_provider.py new file mode 100644 index 0000000..d826ffe --- /dev/null +++ b/python_deps/mapr/tests/test_asset_provider.py @@ -0,0 +1,161 @@ +#!/usr/bin/python3 +# copyright (c) 2018- polygoniq xyz s.r.o. + +import unittest +from blender_test_helpers import blender_addon_test_main +import mapr +import mathutils + + +class LocalJSONProviderTestCase(unittest.TestCase): + """Tests the API of the LocalJSONProvider. + + Doesn't test materializing files, we use mocked up `mock_mapr_index.json` for testing. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def setUp(self): + # The path to the provider is based on where the py_test rule is defined in + self.provider = mapr.local_json_provider.LocalJSONProvider( + "blender_addons/mapr/tests/mock_mapr_index.json", "/mock_pack", "/") + + def test_get_asset(self): + asset = self.provider.get_asset("asset_id_1") + self.assertIsNotNone(asset) + self.assertEqual(asset.id_, "asset_id_1") + + def test_get_category(self): + category = self.provider.get_category("/mock_pack/category_1") + self.assertIsNotNone(category) + self.assertEqual(category.id_, "/mock_pack/category_1") + + def test_list_assets(self): + assets = self.provider.list_assets("/", recursive=True) + self.assertEqual(len(list(assets)), 4) + assets = self.provider.list_assets("/mock_pack", recursive=True) + self.assertEqual(len(list(assets)), 4) + assets = self.provider.list_assets("/mock_pack/category_1") + self.assertEqual(len(list(assets)), 2) + assets = self.provider.list_assets("/mock_pack/category_2") + self.assertEqual(len(list(assets)), 2) + + def test_list_asset_data(self): + asset_data = list(self.provider.list_asset_data("asset_id_1")) + self.assertEqual(len(asset_data), 1) + self.assertEqual(asset_data[0].id_, "data_id_1") + + def test_list_categories(self): + categories = list(self.provider.list_categories("/mock_pack")) + self.assertEqual(len(categories), 2) + self.assertSetEqual({c.id_ for c in categories}, { + "/mock_pack/category_1", "/mock_pack/category_2"}) + + def test_query_all(self): + data_view = self.provider.query(mapr.query.Query( + "/", filters=[], sort_mode=mapr.query.SortMode.ALPHABETICAL_ASC)) + self.assertEqual(len(data_view.assets), 4) + self.assertSetEqual({a.id_ for a in data_view.assets}, { + "asset_id_1", "asset_id_2", "asset_id_3", "asset_id_4"}) + + def test_query_search(self): + data_view = self.provider.query(mapr.query.Query( + "/", filters=[mapr.filters.SearchFilter("Rectangular")], sort_mode=mapr.query.SortMode.ALPHABETICAL_ASC)) + self.assertEqual(len(data_view.assets), 1) + self.assertEqual(data_view.assets[0].id_, "asset_id_1") + + data_view = self.provider.query(mapr.query.Query( + "/", filters=[mapr.filters.SearchFilter("Minimalist")], sort_mode=mapr.query.SortMode.ALPHABETICAL_ASC)) + self.assertEqual(len(data_view.assets), 2) + self.assertSetEqual({a.id_ for a in data_view.assets}, {"asset_id_1", "asset_id_3"}) + + def test_color_to_vector_transition(self): + # We transitioned "color_parameters" to be a part of "vector_parameters", in order to ensure + # backward compatibility with asset packs released before engon 1.2.0, we test that + asset = self.provider.get_asset("asset_id_1") + self.assertIn("viewport_color", asset.vector_parameters) + + def test_query_filters(self): + data_view = self.provider.query(mapr.query.Query( + "/", filters=[mapr.filters.NumericParameterFilter("num:width", 0.0, 1.0)], sort_mode=mapr.query.SortMode.ALPHABETICAL_ASC)) + self.assertEqual(len(data_view.assets), 1) + self.assertEqual(data_view.assets[0].id_, "asset_id_3") + + data_view = self.provider.query(mapr.query.Query( + "/", + filters=[ + mapr.filters.NumericParameterFilter("num:width", 1.0, 10.0), + mapr.filters.NumericParameterFilter("num:price_usd", 500, 9999) + ], + sort_mode=mapr.query.SortMode.ALPHABETICAL_ASC + )) + self.assertEqual(len(data_view.assets), 2) + self.assertSetEqual({a.id_ for a in data_view.assets}, {"asset_id_2", "asset_id_4"}) + + def test_query_filters_vector_range(self): + data_view = self.provider.query(mapr.query.Query( + "/", + filters=[ + mapr.filters.VectorParameterFilter( + "vec:introduced_in", + mapr.filters.VectorLexicographicComparator( + mathutils.Vector((1, 0, 0)), + mathutils.Vector((2, 0, 0)) + )), + ], + sort_mode=mapr.query.SortMode.ALPHABETICAL_ASC + )) + + self.assertEqual(len(data_view.assets), 2) + self.assertSetEqual({a.id_ for a in data_view.assets}, {"asset_id_1", "asset_id_2"}) + + def test_query_filters_vector_distance(self): + data_view = self.provider.query(mapr.query.Query( + "/", + filters=[ + mapr.filters.VectorParameterFilter( + "vec:introduced_in", + mapr.filters.VectorDistanceComparator(mathutils.Vector((2, 0, 0)), 1) + ), + ], + sort_mode=mapr.query.SortMode.ALPHABETICAL_ASC + )) + + self.assertEqual(len(data_view.assets), 2) + self.assertSetEqual({a.id_ for a in data_view.assets}, {"asset_id_2", "asset_id_3"}) + + +class CachedAssetProviderTestCase(LocalJSONProviderTestCase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def setUp(self): + local_json_provider = mapr.local_json_provider.LocalJSONProvider( + "blender_addons/mapr/tests/mock_mapr_index.json", "/mock_pack", "/") + self.provider = mapr.asset_provider.CachedAssetProviderMultiplexer() + self.provider.add_asset_provider(local_json_provider) + + def test_query_cache_hit(self): + # Test two queries with the same parameters return the exactly same DataView object + query = mapr.query.Query( + "/", filters=[], sort_mode=mapr.query.SortMode.ALPHABETICAL_ASC) + + first_view = self.provider.query(query) + second_view = self.provider.query(query) + + self.assertEqual(id(first_view), id(second_view)) + + def test_query_cache_miss(self): + # Test different query parameters return different DataView object + first_view = self.provider.query(mapr.query.Query( + "/", filters=[], sort_mode=mapr.query.SortMode.ALPHABETICAL_ASC)) + + second_view = self.provider.query(mapr.query.Query( + "/", filters=[], sort_mode=mapr.query.SortMode.ALPHABETICAL_DESC)) + + self.assertNotEqual(id(first_view), id(second_view)) + + +if __name__ == "__main__": + blender_addon_test_main() diff --git a/python_deps/polib/installation_utils_bpy.py b/python_deps/polib/installation_utils_bpy.py index d48ab9a..3fca302 100644 --- a/python_deps/polib/installation_utils_bpy.py +++ b/python_deps/polib/installation_utils_bpy.py @@ -67,7 +67,8 @@ def refresh_and_enable(module_name: str): # we do the actual update in the blender event loop to avoid crashes in case # grumpy_cat is updating itself - bpy.app.timers.register(lambda: refresh_and_enable(module_name), first_interval=0) + bpy.app.timers.register( + lambda: refresh_and_enable(module_name), first_interval=0, persistent=True) def uninstall_addon_module_name(module_name: str) -> None: diff --git a/python_deps/polib/material_utils_bpy.py b/python_deps/polib/material_utils_bpy.py index e215b35..7e1ee60 100644 --- a/python_deps/polib/material_utils_bpy.py +++ b/python_deps/polib/material_utils_bpy.py @@ -1,6 +1,7 @@ # copyright (c) 2018- polygoniq xyz s.r.o. import bpy +import numpy import typing from . import node_utils_bpy @@ -70,11 +71,10 @@ def get_material_slots_used_by_mesh(obj: bpy.types.Object) -> typing.FrozenSet[i if not hasattr(obj.data, "polygons"): return frozenset() - seen_indices = set() - for face in obj.data.polygons: - seen_indices.add(face.material_index) - - return frozenset(seen_indices) + material_indices = numpy.zeros(len(obj.data.polygons), dtype=numpy.int32) + obj.data.polygons.foreach_get('material_index', material_indices) + unique_indices = numpy.unique(material_indices) + return frozenset(unique_indices) def get_material_slots_used_by_spline(obj: bpy.types.Object) -> typing.FrozenSet[int]: diff --git a/python_deps/polib/node_utils_bpy.py b/python_deps/polib/node_utils_bpy.py index 21c2ed5..8a30465 100644 --- a/python_deps/polib/node_utils_bpy.py +++ b/python_deps/polib/node_utils_bpy.py @@ -189,7 +189,8 @@ def find_incoming_nodes(node: bpy.types.Node) -> typing.Set[bpy.types.Node]: def find_link_connected_to( links: typing.Iterable[bpy.types.NodeLink], to_node: bpy.types.Node, - to_socket_name: str + to_socket_name: str, + skip_reroutes: bool = False ) -> typing.Optional[bpy.types.NodeLink]: """Find the link connected to given target node (to_node) to given socket name (to_socket_name) @@ -204,6 +205,9 @@ def find_link_connected_to( if to_socket_name != link.to_socket.name: continue + if skip_reroutes and isinstance(link.from_node, bpy.types.NodeReroute): + return find_link_connected_to(links, link.from_node, link.from_node.inputs[0].name) + ret.append(link) if len(ret) > 1: @@ -435,9 +439,9 @@ def draw_from_material( if i >= draw_max_first_occurrences: break - inputs_set = set(filter(is_drawable_node_input, group.inputs)) + inputs = list(filter(is_drawable_node_input, group.inputs)) self._draw_template( - inputs_set, + inputs, lambda input_: layout.row().prop(input_, "default_value", text=input_.name) ) @@ -451,20 +455,20 @@ def draw_from_geonodes_modifier( layout.label(text=f"No '{self.name}' nodegroup found", icon='INFO') return - inputs_set = set(filter(is_drawable_node_tree_input, get_node_tree_inputs(mod.node_group))) + inputs = list(filter(is_drawable_node_tree_input, get_node_tree_inputs(mod.node_group))) self._draw_template( - inputs_set, + inputs, lambda input_: draw_modifier_input(layout, mod, input_) ) def _draw_template( self, - inputs_set: typing.Set[NodeSocketInterfaceCompat] | typing.Set[bpy.types.NodeSocket], + inputs: typing.List[NodeSocketInterfaceCompat] | typing.List[bpy.types.NodeSocket], draw_function: typing.Callable[[NodeSocketInterfaceCompat | bpy.types.NodeSocket], None] ) -> None: already_drawn = set() if self.socket_names_drawn_first is not None: - socket_name_to_input_map = {input_.name.lower(): input_ for input_ in inputs_set} + socket_name_to_input_map = {input_.name.lower(): input_ for input_ in inputs} for name in self.socket_names_drawn_first: input_ = socket_name_to_input_map.get(name.lower(), None) if input_ is None: @@ -472,9 +476,8 @@ def _draw_template( already_drawn.add(input_) draw_function(input_) - to_be_drawn = inputs_set - already_drawn - for input_ in to_be_drawn: - if self.filter_(input_): + for input_ in inputs: + if input_ not in already_drawn and self.filter_(input_): draw_function(input_) diff --git a/python_deps/polib/telemetry_module_bpy.py b/python_deps/polib/telemetry_module_bpy.py index de36a5f..ebc77d9 100644 --- a/python_deps/polib/telemetry_module_bpy.py +++ b/python_deps/polib/telemetry_module_bpy.py @@ -260,7 +260,7 @@ def bootstrap_telemetry(): _log(Message(MessageType.MACHINE_REGISTERED, data=MACHINE, product="polib")) # wait 5 seconds to give all addons time to register - bpy.app.timers.register(lambda: log_installed_addons(), first_interval=5) + bpy.app.timers.register(lambda: log_installed_addons(), first_interval=5, persistent=True) BOOTSTRAPPED = True diff --git a/python_deps/polib/utils_bpy.py b/python_deps/polib/utils_bpy.py index 5d376db..a96cf48 100644 --- a/python_deps/polib/utils_bpy.py +++ b/python_deps/polib/utils_bpy.py @@ -133,6 +133,10 @@ def generate_unique_name(old_name: str, container: typing.Iterable[typing.Any]) return new_name +def get_top_level_package_name(package_name: str) -> str: + return package_name.split(".", 1)[0] + + def convert_size(size_bytes: int) -> str: if size_bytes == 0: return "0 B" @@ -258,6 +262,9 @@ def get_case_sensitive_path(path: str) -> str: # Using os.path.realpath is not reliable, as it does # not return case-sensitive paths for google drive files entries = os.listdir(case_sensitive_path) + # Path may contain current directory or up one level notation, we don't use realpath, + # because we don't want to change the format of the input path + entries.extend([".", ".."]) case_sensitive_entry = None for entry in entries: # pathlib makes sure correct case-sensitivity is used on every OS diff --git a/scatter.py b/scatter.py index b28e892..e9ac0d4 100644 --- a/scatter.py +++ b/scatter.py @@ -22,6 +22,8 @@ import typing import itertools import logging +import math +import mathutils import polib import hatchery from . import asset_helpers @@ -540,14 +542,22 @@ def execute(self, context: bpy.types.Context): for obj in selection: if obj.type != 'MESH': + msg = f"Cannot append {obj.name}, it is not a mesh." + logger.warning(msg) + self.report({'WARNING'}, msg) continue if obj == active_object: continue if obj.name in instance_collection.all_objects: + msg = f"Cannot append {obj.name}, it is already in the particle system." + logger.warning(msg) + self.report({'WARNING'}, msg) continue + # Rotate the spawned asset 90° around Y axis to make it straight in particle systems. + obj.rotation_euler = mathutils.Euler((0, math.radians(90), 0), 'XYZ') instance_collection.objects.link(obj) logger.info(f"Appended {obj.name}") diff --git a/traffiq/panel.py b/traffiq/panel.py index 00d1bc9..db4ff78 100644 --- a/traffiq/panel.py +++ b/traffiq/panel.py @@ -102,7 +102,10 @@ def draw_header(self, context: bpy.types.Context): def draw_header_preset(self, context: bpy.types.Context) -> None: polib.ui_bpy.draw_doc_button( - self.layout, preferences.__package__, rel_url="panels/traffiq/panel_overview") + self.layout, + polib.utils_bpy.get_top_level_package_name(__package__), + rel_url="panels/traffiq/panel_overview" + ) def draw(self, context: bpy.types.Context): # TODO: All that was formerly here was replaced with engon universal operators,