1957 lines
72 KiB
Python
1957 lines
72 KiB
Python
# #### 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 #####
|
|
|
|
from typing import Callable, Dict, List, Optional, Tuple, Union
|
|
import atexit
|
|
import datetime
|
|
import faulthandler
|
|
import json
|
|
import os
|
|
import queue
|
|
import threading
|
|
import time
|
|
|
|
import bpy
|
|
from bpy.app.handlers import persistent
|
|
import bpy.utils.previews
|
|
|
|
from . import reporting
|
|
|
|
from .modules.poliigon_core.addon import PoliigonAddon
|
|
from .modules.poliigon_core.api import (
|
|
ApiStatus,
|
|
ERR_LIMIT_DOWNLOAD_RATE,
|
|
PoliigonConnector,
|
|
SOFTWARE_NAME_BLENDER)
|
|
from .modules.poliigon_core.api_remote_control import (
|
|
ApiJob,
|
|
ApiRemoteControl,
|
|
JobType)
|
|
from .modules.poliigon_core.api_remote_control_params import (
|
|
CATEGORY_ALL,
|
|
get_search_key,
|
|
KEY_TAB_IMPORTED,
|
|
KEY_TAB_MY_ASSETS,
|
|
KEY_TAB_RECENT_DOWNLOADS,
|
|
KEY_TAB_ONLINE,
|
|
KEY_TAB_LOCAL,
|
|
IDX_PAGE_ACCUMULATED,
|
|
PAGE_SIZE_ACCUMULATED)
|
|
from .modules.poliigon_core.assets import (
|
|
API_TYPE_TO_ASSET_TYPE,
|
|
AssetType,
|
|
ModelType)
|
|
from .modules.poliigon_core.assets import AssetData
|
|
from .modules.poliigon_core.logger import ( # noqa: F401
|
|
DEBUG,
|
|
INFO,
|
|
WARNING,
|
|
ERROR,
|
|
CRITICAL)
|
|
from .modules.poliigon_core.multilingual import _t
|
|
from .modules.poliigon_core.settings import PoliigonSettings
|
|
from .modules.poliigon_core.upgrade_content import UpgradeContent
|
|
from .modules.poliigon_core import env
|
|
from .modules.poliigon_core import updater
|
|
# from .thumb_cache import ThumbCache
|
|
from .constants import (
|
|
ADDON_NAME,
|
|
ICONS,
|
|
SUPPORTED_CONVENTION,
|
|
URLS_BLENDER)
|
|
from .material_importer import MaterialImporter
|
|
from .notifications import (
|
|
build_restart_notification,
|
|
add_survey_notification)
|
|
from .preferences_map_prefs_util import update_map_prefs_properties
|
|
from .toolbox_settings import (
|
|
get_settings,
|
|
get_settings_section_map_prefs,
|
|
save_settings)
|
|
from .utils import (
|
|
f_Ex,
|
|
f_MDir)
|
|
|
|
# TODO(Andreas): Just left these in for some quick notification testing
|
|
# from .notifications import (
|
|
# build_material_template_error_notification,
|
|
# build_writing_settings_failed_notification,
|
|
# build_lost_client_notification,
|
|
# build_new_catalogue_notification,
|
|
# build_no_refresh_notification,
|
|
# build_survey_notification
|
|
# )
|
|
|
|
ADDON_PATH = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
def panel_update(context=None) -> None:
|
|
"""Force a redraw of the 3D and preferences panel from operator calls."""
|
|
|
|
if context is None:
|
|
context = bpy.context
|
|
try:
|
|
for wm in bpy.data.window_managers:
|
|
for window in wm.windows:
|
|
for area in window.screen.areas:
|
|
if area.type not in ("VIEW_3D", "PREFERENCES"):
|
|
continue
|
|
for region in area.regions:
|
|
# Compare panel's bl_category
|
|
# TODO(Andreas): Disabled, due to missing progress
|
|
# bar refreshes
|
|
# if region.active_panel_category != "Poliigon":
|
|
# continue
|
|
region.tag_redraw()
|
|
except AttributeError:
|
|
pass # Startup condition, nothing to redraw anyways.
|
|
|
|
|
|
def get_prefs() -> Optional[bpy.types.Preferences]:
|
|
"""User preferences call wrapper, separate to support test mocking."""
|
|
|
|
# __spec__.parent since __package__ got deprecated
|
|
prefs = bpy.context.preferences.addons.get(__spec__.parent, None)
|
|
# Fallback, if command line and using the standard install name.
|
|
if prefs is None:
|
|
addons = bpy.context.preferences.addons
|
|
prefs = addons.get(ADDON_NAME, None)
|
|
if prefs is not None and hasattr(prefs, "preferences"):
|
|
return prefs.preferences
|
|
else:
|
|
return None
|
|
|
|
|
|
class c_Toolbox(PoliigonAddon):
|
|
|
|
# Container for any notifications to show user in the panel UI.
|
|
# Containers for errors to persist in UI for drawing, e.g. after dload err.
|
|
ui_errors = []
|
|
|
|
updater = None # Callable set up on register.
|
|
|
|
# Used to indicate if register function has finished for the first time
|
|
# or not, to differentiate initial register to future ones such as on
|
|
# toggle or update
|
|
initial_register_complete = False
|
|
# Container for the last time we performed a check for updated addon files,
|
|
# only triggered from UI code so it doesn't run when addon is not open.
|
|
last_update_addon_files_check = 0
|
|
|
|
# Icon containers.
|
|
ui_icons = None
|
|
thumbs = None
|
|
|
|
# Container for threads.
|
|
# Initialized here so it can be referenced before register completes.
|
|
threads = []
|
|
|
|
# Establish locks for general usage:
|
|
lock_thumbs = threading.Lock() # locks access to thumbs
|
|
lock_client_start = threading.Lock()
|
|
lock_settings_file = threading.Lock()
|
|
|
|
# Reporting sample rates, None until value read from remote json
|
|
reporting_error_rate = None
|
|
reporting_transaction_rate = None
|
|
|
|
def __init__(
|
|
self, addon_version: str, api_service: PoliigonConnector = None):
|
|
self.register_success = False
|
|
environment = env.PoliigonEnvironment(
|
|
addon_name=ADDON_NAME,
|
|
base=os.path.dirname(__file__)
|
|
)
|
|
if environment.env_name is not None and environment.env_name == "test":
|
|
settings_filename = "settings_test.ini"
|
|
else:
|
|
settings_filename = "settings.ini"
|
|
settings = PoliigonSettings(
|
|
addon_name=ADDON_NAME,
|
|
software_source=SOFTWARE_NAME_BLENDER,
|
|
settings_filename=settings_filename
|
|
)
|
|
|
|
addon_version_t = tuple([int(val) for val in addon_version.split(".")])
|
|
super(c_Toolbox, self).__init__(
|
|
addon_name=ADDON_NAME,
|
|
addon_version=addon_version_t,
|
|
software_source=SOFTWARE_NAME_BLENDER,
|
|
software_version=bpy.app.version,
|
|
addon_env=environment,
|
|
addon_settings=settings,
|
|
addon_convention=SUPPORTED_CONVENTION,
|
|
addon_supported_model=[ModelType.BLEND, ModelType.FBX],
|
|
addon_root_path=ADDON_PATH
|
|
)
|
|
|
|
if api_service is not None:
|
|
self._api = api_service
|
|
|
|
self.init_addon_parameters(
|
|
get_optin=reporting.get_optin,
|
|
callback_on_invalidated_token=self._callback_on_invalidated_token,
|
|
report_message=reporting.capture_message,
|
|
report_exception=reporting.capture_exception,
|
|
report_thread=reporting.handle_thread,
|
|
status_listener=self.api_status_listener,
|
|
get_renderer_name=self.api_get_renderer,
|
|
urls_dcc=URLS_BLENDER,
|
|
notify_icon_info="INFO",
|
|
notify_icon_no_connection="NONE",
|
|
notify_icon_survey="NONE",
|
|
notify_icon_warn="ERROR",
|
|
notify_icon_wm_onboarding="HIDE_OFF",
|
|
notify_update_body=_t("Download the {0} update"),
|
|
onboarding_wm_title=_t("Preview textures in 1K by clicking the eye icon"),
|
|
)
|
|
|
|
def register(self, version: str) -> None:
|
|
"""Deferred registration, to ensure properties exist."""
|
|
|
|
if self._env.env_name and "dev" in self._env.env_name.lower():
|
|
faulthandler.enable(all_threads=False)
|
|
|
|
self.vRunning = 0
|
|
self.quitting = False
|
|
|
|
self.version = version
|
|
software_version = ".".join([str(x) for x in bpy.app.version])
|
|
|
|
# TODO(Andreas): Also move into PoliigonAddon.init_addon()
|
|
page_size_get_assets = 500
|
|
self.api_rc = ApiRemoteControl(self)
|
|
addon_params = self.api_rc._addon_params
|
|
addon_params.online_assets_chunk_size = page_size_get_assets
|
|
addon_params.my_assets_chunk_size = page_size_get_assets
|
|
addon_params.callback_get_categories_done = self.callback_get_categories_done
|
|
addon_params.callback_get_asset_done = self.callback_get_asset_done
|
|
addon_params.callback_get_user_data_done = self.callback_get_user_data_done
|
|
addon_params.callback_get_download_prefs_done = self.callback_get_download_prefs
|
|
addon_params.callback_get_available_plans = self.callback_plan_upgrades
|
|
addon_params.callback_get_upgrade_plan = self.callback_plan_upgrades
|
|
addon_params.callback_put_upgrade_plan = self.callback_put_upgrade_plan
|
|
addon_params.callback_resume_plan = self.callback_put_upgrade_plan
|
|
|
|
self._api.register_update(self.version, software_version)
|
|
self._updater.last_check_callback = self._callback_last_update
|
|
|
|
self._init_directories()
|
|
self._init_logger()
|
|
|
|
self.mat_import = MaterialImporter(self) # implicitly sets Cycles
|
|
|
|
# Have defaults or forced sampling, regardless of any online info
|
|
self.reporting_error_rate = 1.0 if self._env.forced_sampling else 0.2
|
|
self.reporting_transaction_rate = 1.0 if self._env.forced_sampling else 0.2
|
|
|
|
self._init_reporting(has_updater=False) # init with defaults
|
|
|
|
# Pixel width, init to non-zero to avoid div by zero.
|
|
self.width_draw_ui = 1
|
|
self.ui_scale_checked = False
|
|
|
|
self.login_mode_browser = True
|
|
self.login_cancelled = False
|
|
self.login_time_start = 0
|
|
self.login_in_progress = False
|
|
self.login_is_signup = False # Track if signup or login was clicked
|
|
self.last_login_error = None
|
|
self.did_show_profile_survey = False # To avoid repeating survey
|
|
self.signal_for_profile_survey_emitted = False
|
|
|
|
self.msg_download_limit = None
|
|
|
|
self.vSearch = self._init_tabs_dict("")
|
|
self.vLastSearch = self._init_tabs_dict("")
|
|
self.vPage = self._init_tabs_dict(0)
|
|
self.vPages = self._init_tabs_dict(0)
|
|
|
|
self.vEditPreset = None
|
|
|
|
# TODO(Andreas): New importing code does not have any exceptions like this.
|
|
# Should it, though?
|
|
# self.vModSecondaries = ["Footrest", "Vase"]
|
|
|
|
# TODO(Andreas): Likely no longer needed, since there are only
|
|
# one-shot signalling events stored in here.
|
|
self.threads = []
|
|
|
|
# Note: When being called, the function will set this to None,
|
|
# in order to avoid burning additional CPU cycles on this
|
|
self.f_add_survey_notification_once = add_survey_notification
|
|
|
|
self.settings = {}
|
|
get_settings(self) # -> sets self.settings
|
|
self.prefs = get_prefs()
|
|
|
|
self.ui_errors = []
|
|
|
|
self.vActiveCat = self.settings["category"]
|
|
self.vAssetType = self.vActiveCat[0]
|
|
|
|
# Initial value to use for linking set by prefrences.
|
|
# This way, it initially will match the preferences setting on startup,
|
|
# but then changing this value will also persist with a single sesison
|
|
# without changing the saved value.
|
|
self.link_blend_session = self.settings["download_link_blend"]
|
|
|
|
self.vCategories = self._init_tabs_dict_dict()
|
|
self.vCategories["new"] = {} # TODO(Andreas): ???
|
|
self.category_ids = {}
|
|
self.num_assets = self._init_tabs_dict(0)
|
|
self.num_assets_current_query = 0
|
|
|
|
self.vActiveObjects = []
|
|
self.vActiveAsset = None
|
|
self.vActiveMat = None
|
|
self.vActiveMatProps = {}
|
|
self.vActiveTextures = {}
|
|
self.vActiveFaces = {}
|
|
self.vActiveMode = None
|
|
|
|
self.vActiveMixProps = {}
|
|
self.vActiveMix = None
|
|
self.vActiveMixMat = None
|
|
|
|
# Asset Browser synchronization
|
|
self.proc_blender_client = None
|
|
# blender_client_starting is True not only while Blender process starts,
|
|
# but also during asset data fetch phase
|
|
self.blender_client_starting = False
|
|
self.listener_running = False
|
|
self.thd_listener = None
|
|
self.sender_running = False
|
|
self.thd_sender = None
|
|
self.queue_send = queue.Queue()
|
|
self.queue_ack = queue.Queue()
|
|
self.event_hello = None
|
|
self.num_asset_browser_jobs = 0
|
|
self.num_jobs_ok = 0
|
|
self.num_jobs_error = 0
|
|
self.asset_browser_jobs_cancelled = False
|
|
self.asset_browser_quitting = False
|
|
|
|
# TODO(Andreas): Just left these in for some quick notification testing
|
|
# build_survey_notification(self)
|
|
# build_material_template_error_notification(self)
|
|
# build_writing_settings_failed_notification(self, "test error")
|
|
# build_lost_client_notification(self)
|
|
# build_new_catalogue_notification(self)
|
|
# build_no_refresh_notification(self)
|
|
# # Test these three separately, as they can not be dismissed:
|
|
# # build_no_internet_notification(self)
|
|
# # build_proxy_notification(self)
|
|
# # build_restart_notification(self)
|
|
|
|
# if no_startup:
|
|
# return
|
|
|
|
# Output used to recognize a fresh install (or update).
|
|
any_updated = self.update_files(self.dir_script)
|
|
self._init_icons()
|
|
|
|
self.vRunning = 1
|
|
|
|
# TODO(Andreas): If we want to use ThumbCache
|
|
# self.thumb_cache = ThumbCache(
|
|
# self,h
|
|
# self._asset_index,
|
|
# self.api_rc,
|
|
# path_cache=self.dir_online_previews,
|
|
# size_download=300,
|
|
# bmp_downloading=self.ui_icons["GET_preview"].icon_id,
|
|
# bmp_error=self.ui_icons["NO_preview"].icon_id,
|
|
# cleanup_check_interval_s=360.0,
|
|
# cleanup_not_used_for_s=360.0,
|
|
# num_load_threads=8,
|
|
# force_dummy=0
|
|
# )
|
|
with self.lock_thumbs:
|
|
if self.thumbs is None:
|
|
self.thumbs = bpy.utils.previews.new()
|
|
else:
|
|
self.thumbs.clear()
|
|
|
|
self._init_update_info()
|
|
|
|
if self.settings["last_update"]:
|
|
self._updater.last_check = self.settings["last_update"]
|
|
|
|
if any_updated and not self._api.token:
|
|
# This means this was a new install without a local login token.
|
|
# This setup won't pick up installs in new blender instances
|
|
# where no login event had to happen, but will pick up the first
|
|
# install on the same machine.
|
|
now = datetime.datetime.now()
|
|
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
|
self.settings["first_enabled_time"] = now_str
|
|
save_settings(self)
|
|
|
|
if self._api._is_opted_in():
|
|
self.logger.info(f"Sentry error rate: {self.reporting_error_rate}")
|
|
self.logger.info(
|
|
f"Sentry transaction rate: {self.reporting_transaction_rate}")
|
|
|
|
self._convert_legacy_library_directories()
|
|
|
|
# fetching_asset_data is used to disable last page and refresh data
|
|
# buttons during get assets job being processed.
|
|
self.fetching_asset_data = self._init_tabs_dict_dict()
|
|
key_fetch_all = ((CATEGORY_ALL, ), "")
|
|
self.fetching_asset_data[KEY_TAB_MY_ASSETS][key_fetch_all] = True
|
|
self.fetching_asset_data[KEY_TAB_ONLINE][key_fetch_all] = True
|
|
if self.user is not None:
|
|
user_name = self.user.user_name
|
|
user_id = self.user.user_id
|
|
else:
|
|
user_name = ""
|
|
user_id = 0
|
|
|
|
self._init_upgrade_content()
|
|
self.plan_upgrade_in_progress = False
|
|
self.plan_upgrade_finished = False
|
|
self.error_plan_upgrade = None
|
|
self.msg_plan_upgrade_finished = None
|
|
|
|
# If no token available, there is no reason to add a /me job - will
|
|
# be redirected to login page;
|
|
if self._api.token:
|
|
self.fetching_user_data = True
|
|
self.api_rc.add_job_get_user_data(
|
|
user_name,
|
|
user_id,
|
|
callback_cancel=None,
|
|
callback_progress=None,
|
|
do_fetch_asset_data=not self.all_assets_fetched,
|
|
callback_done=self.callback_get_user_data_done,
|
|
force=True
|
|
)
|
|
|
|
self.initial_screen_viewed = False
|
|
self.initial_register_complete = True
|
|
self.register_success = True
|
|
|
|
def _init_directories(self) -> None:
|
|
self.dir_script = os.path.join(os.path.dirname(__file__), "files")
|
|
|
|
# TODO(SOFT-58): Defer folder creation and prompt for user path.
|
|
dir_user_home = os.path.expanduser("~")
|
|
dir_base = os.path.join(dir_user_home.replace("\\", "/"), "Poliigon")
|
|
|
|
self.dir_settings = os.path.join(dir_base, "Blender")
|
|
f_MDir(self.dir_settings)
|
|
|
|
self.dir_online_previews = os.path.join(dir_base, "OnlinePreviews")
|
|
f_MDir(self.dir_online_previews)
|
|
|
|
if self._env.env_name is not None and self._env.env_name == "test":
|
|
filename_settings = "Poliigon_Blender_Settings_test.ini"
|
|
else:
|
|
filename_settings = "Poliigon_Blender_Settings.ini"
|
|
|
|
self.path_settings = os.path.join(self.dir_settings, filename_settings)
|
|
|
|
def _init_logger(self) -> None:
|
|
self.logger_ab = self.log_manager.initialize_logger("P4B.AB")
|
|
self.logger_ui = self.log_manager.initialize_logger("P4B.UI")
|
|
|
|
# TODO(Andreas): Maybe these should stay unconditional prints?
|
|
self.logger.info("\nStarting the Poliigon Addon for Blender...\n")
|
|
self.logger.info(self.path_settings)
|
|
self.logger.info("Toggle verbose logging in addon preferences")
|
|
|
|
def _init_icons(self) -> None:
|
|
# Separating UI icons from asset previews.
|
|
if self.ui_icons is None:
|
|
self.ui_icons = bpy.utils.previews.new()
|
|
else:
|
|
self.ui_icons.clear()
|
|
|
|
for _name, _filename, _icontype in ICONS:
|
|
path_icon = os.path.join(self.dir_script, _filename)
|
|
if not os.path.isfile(path_icon):
|
|
# Logging as critical, not because it is,
|
|
# but so we do not miss such errors upon release.
|
|
self.logger.critical(f"Icon file missing:\n {path_icon}")
|
|
continue
|
|
self.ui_icons.load(_name, path_icon, _icontype)
|
|
|
|
@staticmethod
|
|
def _init_tabs_dict(value: any) -> Dict:
|
|
"""Initializes a dictionary with the main tabs as keys and an arbitrary
|
|
value per tab.
|
|
"""
|
|
|
|
dict_tabs = {
|
|
KEY_TAB_ONLINE: value,
|
|
KEY_TAB_MY_ASSETS: value,
|
|
KEY_TAB_RECENT_DOWNLOADS: value,
|
|
KEY_TAB_IMPORTED: value,
|
|
KEY_TAB_LOCAL: value
|
|
}
|
|
return dict_tabs
|
|
|
|
@staticmethod
|
|
def _init_tabs_dict_dict() -> None:
|
|
"""Initializes a dictionary with the main tabs as keys and
|
|
a dict per tab.
|
|
"""
|
|
|
|
dict_tabs = {
|
|
KEY_TAB_ONLINE: {},
|
|
KEY_TAB_MY_ASSETS: {},
|
|
KEY_TAB_RECENT_DOWNLOADS: {},
|
|
KEY_TAB_IMPORTED: {},
|
|
KEY_TAB_LOCAL: {}
|
|
}
|
|
return dict_tabs
|
|
|
|
def _init_reporting(self, has_updater: bool = True) -> None:
|
|
"""(Re)Initialize sentry with current known sample rates.
|
|
|
|
Called once immediately on startup, and then again once we have the
|
|
latest remote reporting information.
|
|
"""
|
|
|
|
if has_updater:
|
|
# Get Sentry sampling rates from update info and store
|
|
# in settings, if needed
|
|
curr_ver = self._updater.current_version
|
|
env_force_sample = self._env.forced_sampling
|
|
if curr_ver.error_sample_rate is not None and not env_force_sample:
|
|
self.reporting_error_rate = curr_ver.error_sample_rate
|
|
self.settings["reporting_error_rate"] = self.reporting_error_rate
|
|
if curr_ver.traces_sample_rate is not None and not env_force_sample:
|
|
self.reporting_transaction_rate = curr_ver.traces_sample_rate
|
|
self.settings["reporting_transaction_rate"] = self.reporting_transaction_rate
|
|
|
|
save_settings(self)
|
|
|
|
reporting.register(
|
|
software_name=self._api.software_source,
|
|
software_version=self._api.software_version,
|
|
tool_version=self._api.version_str,
|
|
env=self._env,
|
|
error_rate=self.reporting_error_rate,
|
|
transaction_rate=self.reporting_transaction_rate)
|
|
|
|
def _init_update_info(self) -> None:
|
|
"""If we have no Sentry sample rates, do an inital synchronous update
|
|
check, otherwise just init the sample rates.
|
|
"""
|
|
|
|
is_new_version = self.settings["version"] != self.version
|
|
error_rate = self.settings["reporting_error_rate"]
|
|
transaction_rate = self.settings["reporting_transaction_rate"]
|
|
if error_rate != -1 and transaction_rate != -1 and not is_new_version:
|
|
self.reporting_error_rate = error_rate
|
|
self.reporting_transaction_rate = transaction_rate
|
|
self._init_reporting()
|
|
return
|
|
|
|
self.settings["version"] = self.version
|
|
|
|
# Synchronously get update info to get reporting rates
|
|
self._updater.check_for_update(
|
|
# Note: This "update available" callback is not needed here.
|
|
# The _callback_last_update does all we want.
|
|
callback=None,
|
|
create_notifications=False)
|
|
|
|
def _init_online_all_assets_page_1(self) -> None:
|
|
"""Clears out any user choices (search, category,...) and switches to
|
|
page 1 of online tab.
|
|
"""
|
|
|
|
self.settings["area"] = KEY_TAB_ONLINE
|
|
self.settings["show_settings"] = 0
|
|
self.settings["show_user"] = 0
|
|
self.vPage[KEY_TAB_ONLINE] = 0
|
|
self.vPages[KEY_TAB_ONLINE] = 1
|
|
|
|
self.settings["category"] = [CATEGORY_ALL]
|
|
self.vActiveAsset = None
|
|
self.vActiveMat = None
|
|
self.vActiveMode = None
|
|
|
|
props = bpy.context.window_manager.poliigon_props
|
|
props.search = ""
|
|
|
|
def _init_upgrade_content(self) -> None:
|
|
"""Initializes UpgradeContent with P4B specifics."""
|
|
|
|
if self.upgrade_manager is None:
|
|
return
|
|
upgrade_content = UpgradeContent(
|
|
self.upgrade_manager,
|
|
as_single_paragraph=True,
|
|
icons=("ICON_plan_upgrade_check",
|
|
"ICON_plan_upgrade_info",
|
|
"ICON_plan_upgrade_unlimited"))
|
|
self.upgrade_manager.content = upgrade_content
|
|
|
|
def _convert_legacy_library_directories(self) -> None:
|
|
"""Before we went addon-core, P4B stored library directories in its
|
|
own Poliigon_Blender_Settings.ini. This caused issues with addon-core
|
|
keeping track of library directories on its own in settings.ini.
|
|
Thus we convert from old to new in here, basically, after converting
|
|
the config, removing library dir config from Poliigon_Blender_Settings.ini.
|
|
"""
|
|
|
|
settings = self.settings
|
|
|
|
if "library" not in settings and "add_dirs" not in settings:
|
|
return
|
|
|
|
path_primary = ""
|
|
paths_additional = []
|
|
if "library" in settings:
|
|
path_primary = settings.get("library", "")
|
|
del settings["library"]
|
|
if "add_dirs" in settings:
|
|
paths_additional = settings.get("add_dirs", [])
|
|
del settings["add_dirs"]
|
|
|
|
save_settings(self)
|
|
|
|
if len(path_primary) == 0 and len(paths_additional) == 0:
|
|
return
|
|
|
|
# Overwrite anything addon-core might assume so far.
|
|
self.library_paths = []
|
|
self.add_library_path(path_primary, primary=True)
|
|
for _path_library in paths_additional:
|
|
self.add_library_path(_path_library, primary=False)
|
|
|
|
@staticmethod
|
|
def api_get_renderer() -> Optional[str]:
|
|
try:
|
|
renderer = bpy.context.scene.render.engine
|
|
except AttributeError: # Startup condition
|
|
renderer = None
|
|
return renderer
|
|
|
|
def api_status_listener(self, status: ApiStatus) -> None:
|
|
"""Updates notifications according to the form of the API event.
|
|
|
|
This is called by API's event_listener when API events occur.
|
|
"""
|
|
|
|
if self._api.status_notice is None:
|
|
return
|
|
|
|
if status == ApiStatus.CONNECTION_OK:
|
|
self.notify.dismiss_notice(self._api.status_notice, force=True)
|
|
# TODO(Andreas): We need to discuss, how to throttle in this case.
|
|
# The sleep would work, but is kind of ugly here.
|
|
# elif status == ApiStatus.NO_INTERNET:
|
|
# time.sleep(1.0) # just throttling further requests
|
|
|
|
def _callback_on_invalidated_token(self) -> None:
|
|
"""This function is passed to addon-core.
|
|
|
|
It gets called by api.request functions to invalidate the token upon
|
|
failed API requests.
|
|
|
|
Called on thread level!
|
|
"""
|
|
|
|
self._api.token = None
|
|
self.settings_config.set("user", "token", "")
|
|
reporting.assign_user(None)
|
|
save_settings(self)
|
|
|
|
self.login_in_progress = False
|
|
|
|
self._asset_index.flush()
|
|
self._asset_index.flush_is_local()
|
|
self._asset_index.flush_is_purchased()
|
|
self._asset_index.flush_state()
|
|
|
|
self.refresh_ui()
|
|
|
|
def _callback_last_update(self, value: any) -> None:
|
|
"""Called by the updated module to allow saving in local system."""
|
|
|
|
if self._updater is None:
|
|
return
|
|
|
|
self._init_reporting()
|
|
self.settings["last_update"] = self._updater.last_check
|
|
save_settings(self)
|
|
|
|
def callback_update_api_status_banners(self, arg) -> None:
|
|
# TODO(Andreas)
|
|
self.refresh_ui()
|
|
|
|
def callback_get_user_data_done(self, job: ApiJob) -> None:
|
|
"""DCC specific finalization of 'get user data' job."""
|
|
|
|
if not job.result.ok:
|
|
self.fetching_asset_data = self._init_tabs_dict_dict()
|
|
self.logger.warn("callback_get_user_data_done NOK")
|
|
self._callback_on_invalidated_token()
|
|
self.refresh_ui()
|
|
return
|
|
|
|
self.login_in_progress = False
|
|
self.fetching_user_data = False
|
|
|
|
if self.user is not None:
|
|
reporting.assign_user(self.user.user_id)
|
|
else:
|
|
reporting.assign_user(None)
|
|
reporting.capture_message(
|
|
"none_user_after_get_user_data_done",
|
|
"User is none after logging in with OK response",
|
|
"error")
|
|
|
|
self.refresh_ui()
|
|
|
|
def callback_plan_upgrades(self, job: ApiJob) -> None:
|
|
"""DCC specific finalization of 'get available plans' and
|
|
'get upgrade plan' jobs.
|
|
"""
|
|
|
|
if self.upgrade_manager is None:
|
|
return
|
|
self.refresh_ui()
|
|
|
|
def callback_put_upgrade_plan(self, job: ApiJob) -> None:
|
|
"""DCC specific finalization of 'put upgrade plan' and
|
|
'resume plan' jobs.
|
|
"""
|
|
|
|
if bpy.app.timers.is_registered(t_check_change_plan_response):
|
|
bpy.app.timers.unregister(t_check_change_plan_response)
|
|
|
|
if job.result.ok:
|
|
self.error_plan_upgrade = None
|
|
else:
|
|
self.error_plan_upgrade = job.result.error
|
|
|
|
self.plan_upgrade_finished = True
|
|
self.refresh_ui()
|
|
|
|
def callback_get_download_prefs(self, job: ApiJob) -> None:
|
|
if self.quitting:
|
|
# We are not allowed to set properties during shutdown
|
|
return
|
|
if self.user is None:
|
|
return
|
|
user_prefs = self.user.map_preferences
|
|
if user_prefs is None:
|
|
return
|
|
|
|
self.user.use_preferences_on_download = True
|
|
# At this point addon-core has server's map prefs.
|
|
# Now, read and override with any locally store settings.
|
|
get_settings_section_map_prefs(self)
|
|
# And transfer map prefs from addon-core to our properties
|
|
# used in prefs.
|
|
update_map_prefs_properties(self)
|
|
|
|
def callback_login_done(self, job: ApiJob) -> None:
|
|
"""Callback to be called after a login job has finished.
|
|
|
|
Informs the addon about the outcome of the login job.
|
|
|
|
Called on thread level!
|
|
"""
|
|
|
|
if not job.result.ok:
|
|
# TODO(Andreas): Proper error communication, as soon as the UI
|
|
# provides a common way of displaying errors
|
|
# (maybe like Notifications in P4B)
|
|
# NOTE: We end up here for cancelled logins, too.
|
|
if job.result.error != "Login cancelled":
|
|
self.logger.error("Login")
|
|
self.logger.error(job.result)
|
|
self.last_login_error = job.result.error
|
|
|
|
self._callback_on_invalidated_token()
|
|
return
|
|
|
|
self.login_in_progress = False
|
|
|
|
token = job.result.body.get("access_token", "")
|
|
self.settings_config.set("user", "token", token)
|
|
|
|
# Clear time since install since successful.
|
|
if self.login_elapsed_s is not None:
|
|
self.settings["first_enabled_time"] = ""
|
|
|
|
self._settings.save_settings()
|
|
|
|
user_info = job.result.body.get("user", {})
|
|
name_user = user_info.get("name", "")
|
|
id_user = user_info.get("id", -1)
|
|
|
|
if self.user is not None:
|
|
reporting.assign_user(self.user.user_id)
|
|
else:
|
|
reporting.assign_user(None)
|
|
|
|
key_fetch_all = ((CATEGORY_ALL, ), "")
|
|
self.fetching_asset_data[KEY_TAB_MY_ASSETS][key_fetch_all] = True
|
|
self.fetching_asset_data[KEY_TAB_ONLINE][key_fetch_all] = True
|
|
self.fetching_user_data = True
|
|
self.api_rc.add_job_get_user_data(
|
|
name_user,
|
|
id_user,
|
|
callback_cancel=None,
|
|
callback_progress=None,
|
|
do_fetch_asset_data=not self.all_assets_fetched,
|
|
callback_done=self.callback_get_user_data_done,
|
|
force=True
|
|
)
|
|
|
|
# TODO(Andreas): Check the first run mechanic (P4Cinema does below)
|
|
# ### set_pref(ID_INTERNAL_TIME_FIRST_RUN, "")
|
|
|
|
self._init_online_all_assets_page_1()
|
|
save_settings(self)
|
|
self.refresh_ui()
|
|
|
|
def callback_logout_done(self, job: ApiJob) -> None:
|
|
"""Callback to be called after a logout job has finished.
|
|
|
|
Informs the addon about the outcome of the logout job.
|
|
|
|
Called on thread level!
|
|
"""
|
|
|
|
if not job.result.ok:
|
|
# TODO(Andreas): Proper error communication,
|
|
# something like DisplayError
|
|
self.logger.error("Logout")
|
|
self.logger.error(job.result)
|
|
|
|
self.fetching_user_data = True
|
|
|
|
# TODO(Andreas): any_owned_brushes can likely be removed
|
|
self.prefs.any_owned_brushes = "undecided"
|
|
self._callback_on_invalidated_token()
|
|
|
|
self.refresh_ui()
|
|
|
|
def callback_login_cancel(self) -> bool:
|
|
"""Callback communicating the 'login cancel' button got pressed.
|
|
|
|
The flag gets set by execution of this login command in mode
|
|
'login cancel'.
|
|
|
|
Called on thread level!
|
|
"""
|
|
|
|
return self.login_cancelled
|
|
|
|
def callback_get_categories_done(self, job: ApiJob) -> None:
|
|
"""DCC specific finalization of 'get categories' job."""
|
|
|
|
if job.result.ok:
|
|
body = job.result.body
|
|
if not len(body):
|
|
error = job.result.error
|
|
self.logger.error(
|
|
f"callback_get_categories_done: ERROR {error}\n {body}")
|
|
|
|
for _category in body:
|
|
type_cat = _category.get("name", "Unknown Category")
|
|
self.logger.debug(
|
|
f"callback_get_categories_done: Type: {type_cat}")
|
|
if type_cat not in self.vCategories[KEY_TAB_ONLINE].keys():
|
|
self.vCategories[KEY_TAB_ONLINE][type_cat] = {}
|
|
|
|
id_category = _category.get("id", -1)
|
|
cat_path = _category.get("path", "")
|
|
cat_path = cat_path.replace("/hdrs", "/HDRIs")
|
|
self.category_ids[id_category] = cat_path
|
|
self.f_GetCategoryChildren(type_cat, _category)
|
|
|
|
path_category_file = os.path.join(
|
|
self.dir_settings, "TB_Categories.json")
|
|
with open(path_category_file, "w") as file_categories:
|
|
json.dump(self.vCategories, file_categories)
|
|
|
|
self.refresh_ui()
|
|
|
|
def _check_asset_browser(self, asset_id_list: List[str]) -> None:
|
|
"""Checks all assets in list for a _LIB.blend file and
|
|
accordingly marks them as 'in asset browser' (or not).
|
|
"""
|
|
|
|
asset_data_list = self._asset_index.get_asset_data_list(
|
|
asset_id_list)
|
|
for _asset_data in asset_data_list:
|
|
if not _asset_data.is_local:
|
|
continue
|
|
directory = _asset_data.get_asset_directory()
|
|
if directory is None:
|
|
continue
|
|
|
|
filename = f"{_asset_data.asset_name}_LIB.blend"
|
|
path_lib_file = os.path.join(directory, filename)
|
|
lib_file_exists = os.path.isfile(path_lib_file)
|
|
_asset_data.runtime.set_in_asset_browser(
|
|
in_asset_browser=lib_file_exists)
|
|
|
|
def callback_get_asset_done(self, job: ApiJob) -> None:
|
|
"""DCC specific finalization of 'get assets' job."""
|
|
|
|
params = job.params
|
|
key_fetch = (tuple(params.category_list), params.search)
|
|
|
|
if not job.result.ok:
|
|
if key_fetch in self.fetching_asset_data[params.tab]:
|
|
del self.fetching_asset_data[params.tab][key_fetch]
|
|
self.refresh_ui()
|
|
return
|
|
|
|
if params.already_in_index and not params.force_request:
|
|
if key_fetch in self.fetching_asset_data[params.tab]:
|
|
del self.fetching_asset_data[params.tab][key_fetch]
|
|
self.refresh_ui()
|
|
return
|
|
|
|
asset_id_list = params.asset_id_list
|
|
if asset_id_list is None:
|
|
asset_id_list = []
|
|
|
|
first_page = params.idx_page == 1
|
|
last_page = params.idx_page >= job.result.body.get("last_page", -1)
|
|
|
|
# self.thumb_cache.add_asset_list(
|
|
# asset_id_list,
|
|
# do_prefetch=first_page,
|
|
# callback_done=self.callback_asset_update_ui
|
|
# )
|
|
|
|
if first_page:
|
|
asset_data_list = self._asset_index.get_asset_data_list(
|
|
asset_id_list[:self.settings["page"]])
|
|
for _asset_data in asset_data_list:
|
|
path_thumb, url_thumb = self._asset_index.get_cf_thumbnail_info(
|
|
_asset_data.asset_id)
|
|
self.start_thumb_download(
|
|
_asset_data, path_thumb, url_thumb, idx_thumb=0)
|
|
|
|
category_list = params.category_list
|
|
if len(category_list) == 1 and category_list[0] == CATEGORY_ALL and len(params.search) == 0:
|
|
self.num_assets[params.tab] = job.result.body.get("total", -1)
|
|
|
|
tab_active = self.settings["area"]
|
|
is_imported_request = tab_active == KEY_TAB_IMPORTED
|
|
imported_refresh = is_imported_request and params.tab in [KEY_TAB_MY_ASSETS,
|
|
KEY_TAB_RECENT_DOWNLOADS]
|
|
tab_refresh = tab_active == params.tab or imported_refresh
|
|
|
|
idx_ui_page_current = self.vPage[tab_active] # this is a UI page index
|
|
num_per_ui_page = self.settings["page"]
|
|
idx_first_ui_asset = idx_ui_page_current * num_per_ui_page
|
|
idx_last_ui_asset = idx_first_ui_asset + num_per_ui_page
|
|
|
|
idx_first_job_asset = (params.idx_page - 1) * params.page_size
|
|
idx_last_job_asset = idx_first_job_asset + len(asset_id_list)
|
|
|
|
ui_page_in_job = idx_first_job_asset <= idx_first_ui_asset < idx_last_job_asset
|
|
# Note: If either UI page size is not a divisor of API page size or
|
|
# simply due to Substance assets being filtered the UI page can
|
|
# be spread across two API requests
|
|
ui_page_in_job |= idx_first_job_asset <= idx_last_ui_asset < idx_last_job_asset
|
|
|
|
user_assets_tab = params.tab in [KEY_TAB_MY_ASSETS, KEY_TAB_RECENT_DOWNLOADS]
|
|
if user_assets_tab or self.is_unlimited_user():
|
|
self.f_GetSceneAssets()
|
|
self._check_asset_browser(asset_id_list)
|
|
|
|
if last_page and key_fetch in self.fetching_asset_data[params.tab]:
|
|
del self.fetching_asset_data[params.tab][key_fetch]
|
|
|
|
if (tab_refresh and ui_page_in_job) or self.vPage[tab_active] == 0 or last_page:
|
|
self.refresh_ui()
|
|
|
|
def callback_asset_update_ui(self, job: ApiJob) -> None:
|
|
"""Triggers a redraw of an asset's thumb widget.
|
|
|
|
DCC specific progress and finalization of 'download thumb',
|
|
'purchase asset' and 'download asset' jobs.
|
|
"""
|
|
|
|
if job.job_type == JobType.DOWNLOAD_ASSET:
|
|
result = job.result
|
|
if not result.ok and result.error == ERR_LIMIT_DOWNLOAD_RATE:
|
|
# Have popup opened by main thread
|
|
self.msg_download_limit = job.params.download.res_error_message
|
|
# TODO(Andreas): Temporarily disable opening the popup,
|
|
# since it is not working reliably.
|
|
# bpy.app.timers.register(
|
|
# f_do_on_main_thread, first_interval=0.05, persistent=False)
|
|
|
|
# try:
|
|
# # Thumb download
|
|
# asset_id = job.params.asset_id
|
|
# except AttributeError:
|
|
# # Asset purchase/download
|
|
# asset_id = job.params.asset_data.asset_id
|
|
|
|
# TODO(Andreas): purchase error forwarding
|
|
# if job.job_type == JobType.PURCHASE_ASSET:
|
|
# state_purchase = job.params.asset_data.state.purchase
|
|
# if state_purchase.has_error():
|
|
# c4d.SpecialEventAdd(PLUGIN_ID_CMSG_NOTIFY,
|
|
# CMSG_NOTIFY_PURCHASE_ERROR,
|
|
# asset_id)
|
|
|
|
# TODO(Andreas): Would be super nice, if we could trigger a redraw
|
|
# of only a single thumb widget
|
|
self.refresh_ui()
|
|
|
|
def refresh_ui(self) -> None:
|
|
"""Wrapper to decouple blender UI drawing from callers of self."""
|
|
|
|
if self.quitting:
|
|
return
|
|
panel_update(bpy.context)
|
|
|
|
def user_invalidated(self) -> bool:
|
|
"""Returns whether or not the user token was invalidated."""
|
|
|
|
if self._api.invalidated:
|
|
self.prefs.any_owned_brushes = "undecided"
|
|
return self._api.invalidated
|
|
|
|
def clear_user_invalidated(self) -> None:
|
|
"""Clears any invalidation flag for a user."""
|
|
|
|
self._api.invalidated = False
|
|
|
|
def initial_view_screen(self) -> None:
|
|
"""Reports view from a draw panel, to avoid triggering until use."""
|
|
|
|
if self.initial_screen_viewed is True:
|
|
return
|
|
self.initial_screen_viewed = True
|
|
self.track_screen_from_area()
|
|
|
|
def track_screen_from_area(self) -> None:
|
|
"""Signals the active screen in background if opted in"""
|
|
|
|
area = self.settings["area"]
|
|
if area == KEY_TAB_ONLINE:
|
|
self.signal_view_screen("home")
|
|
elif area == KEY_TAB_MY_ASSETS:
|
|
self.signal_view_screen(KEY_TAB_MY_ASSETS)
|
|
elif area == KEY_TAB_IMPORTED:
|
|
self.signal_view_screen(KEY_TAB_IMPORTED)
|
|
elif area == KEY_TAB_LOCAL:
|
|
self.signal_view_screen(KEY_TAB_LOCAL)
|
|
elif area == KEY_TAB_RECENT_DOWNLOADS:
|
|
# Can't it be only "downloads", since that is the tab used for all requests?
|
|
self.signal_view_screen(KEY_TAB_RECENT_DOWNLOADS)
|
|
elif area == "account":
|
|
self.signal_view_screen("my_account")
|
|
|
|
def signal_popup(
|
|
self, *, popup: str, click: Optional[str] = None) -> None:
|
|
"""Signals an onboarding popup being viewed or clicked in the
|
|
background
|
|
"""
|
|
if click is not None:
|
|
self.signal_click_notification(popup, click)
|
|
else:
|
|
self.signal_view_notification(popup)
|
|
|
|
def f_GetCategoryChildren(self, type_cat: str, category: Dict) -> None:
|
|
children = category.get("children", [])
|
|
for _child in children:
|
|
cat_path = []
|
|
child_path = _child["path"]
|
|
for _path_parts in child_path.split("/"):
|
|
_path_parts = " ".join([_part.capitalize()
|
|
for _part in _path_parts.split("-")])
|
|
cat_path.append(_path_parts)
|
|
|
|
cat_path = ("/".join(cat_path)).replace("/" + type_cat + "/", "/")
|
|
cat_path = cat_path.replace("/Hdris/", "/")
|
|
|
|
if "Generators" in cat_path:
|
|
continue
|
|
|
|
self.logger.debug(f"f_GetCategoryChildren {cat_path}")
|
|
|
|
id_category = _child.get("id", -1)
|
|
child_path = child_path.replace("/hdrs", "/HDRIs")
|
|
|
|
self.category_ids[id_category] = child_path
|
|
self.vCategories[KEY_TAB_ONLINE][type_cat][cat_path] = []
|
|
if len(_child.get("children", [])) > 0:
|
|
self.f_GetCategoryChildren(type_cat, _child)
|
|
|
|
# @timer
|
|
def f_GetAssets(
|
|
self,
|
|
area: Optional[str] = None,
|
|
categories: Optional[List[str]] = None,
|
|
search: Optional[str] = None,
|
|
force: bool = False,
|
|
force_request: bool = False,
|
|
callback_done: Optional[Callable] = None
|
|
) -> None:
|
|
|
|
self.logger.debug(
|
|
f"f_GetAssets area={area}, force={force}")
|
|
self.logger.debug(
|
|
f" search={search}")
|
|
self.logger.debug(
|
|
f" categories={categories}")
|
|
|
|
if area is None:
|
|
area = self.settings["area"]
|
|
|
|
if categories is None:
|
|
categories = self.settings["category"].copy()
|
|
|
|
if area == KEY_TAB_LOCAL:
|
|
# For Local assets we should always force the request (and not rely
|
|
# on cached queries). There is no Api request involved - so there
|
|
# is no major counterpoint in this case;
|
|
force_request = True
|
|
|
|
if search is None:
|
|
search = self.vSearch[area]
|
|
if search != self.vLastSearch[area]:
|
|
self.flush_thumb_prefetch_queue()
|
|
|
|
if callback_done is None:
|
|
callback_done = self.callback_get_asset_done
|
|
|
|
do_my_assets = False
|
|
if area == KEY_TAB_IMPORTED:
|
|
if self.is_unlimited_user():
|
|
area = KEY_TAB_ONLINE
|
|
else:
|
|
area = KEY_TAB_RECENT_DOWNLOADS
|
|
do_my_assets = self.user_legacy_own_assets()
|
|
|
|
key_fetch = (tuple(categories), search)
|
|
if key_fetch in self.fetching_asset_data[area]:
|
|
return # Already waiting for results
|
|
|
|
self.fetching_asset_data[area][key_fetch] = True
|
|
self.api_rc.add_job_get_assets(
|
|
library_paths=self.get_library_paths(),
|
|
tab=area,
|
|
category_list=categories,
|
|
search=search,
|
|
idx_page=1, # Always fetch all beginning from page 1
|
|
page_size=None,
|
|
force_request=force_request,
|
|
do_get_all=True,
|
|
callback_cancel=None,
|
|
callback_progress=None,
|
|
callback_done=callback_done,
|
|
do_my_assets=do_my_assets,
|
|
force=force)
|
|
|
|
def flush_thumb_prefetch_queue(self) -> None:
|
|
self.api_rc.wait_for_all(JobType.DOWNLOAD_THUMB, do_wait=False)
|
|
|
|
# TODO(Andreas): prefetching
|
|
# # Flush prefetch queue, i.e. prefetch requests not yet in thread pool
|
|
# while not self.queue_thumb_prefetch.empty():
|
|
# try:
|
|
# self.queue_thumb_prefetch.get_nowait()
|
|
# except Exception:
|
|
# pass # not interested in exceptions in here
|
|
|
|
# # Try to cancel download threads in threadpool
|
|
# with self.lock_thumb_download_futures:
|
|
# # As the done callback of the futures removes from this list
|
|
# # (using the same lock) we need a copy
|
|
# futures_to_cancel = self.thumb_download_futures.copy()
|
|
# # Now cancel the futures without lock acquired
|
|
# for fut, asset_name in futures_to_cancel:
|
|
# if not fut.cancel():
|
|
# # Thread either executing or done already
|
|
# continue
|
|
# with self.lock_thumbs:
|
|
# if asset_name in self.thumbsDownloading:
|
|
# self.thumbsDownloading.remove(asset_name)
|
|
# with self.lock_thumbs:
|
|
# self.thumbsDownloading = []
|
|
|
|
# @timer
|
|
def f_GetPageAssets(self, idx_page: int) -> Tuple[Optional[List[int]], int]:
|
|
area = self.settings["area"]
|
|
search = self.vSearch[area]
|
|
num_per_page = self.settings["page"]
|
|
|
|
self.logger.debug(f"f_GetPageAssets area={area}, search={search}, "
|
|
f"idx_page={idx_page}")
|
|
|
|
category_list = self.settings["category"]
|
|
key = get_search_key(
|
|
tab=area, search=search, category_list=category_list)
|
|
|
|
if not self.all_assets_fetched:
|
|
return None, 0
|
|
|
|
if area != KEY_TAB_ONLINE and not self.are_user_assets_fetched():
|
|
return None, 0
|
|
|
|
asset_ids = self._asset_index.query(key_query=key,
|
|
chunk=IDX_PAGE_ACCUMULATED,
|
|
chunk_size=PAGE_SIZE_ACCUMULATED)
|
|
idx_first_asset = idx_page * num_per_page
|
|
idx_last_asset = idx_first_asset + num_per_page
|
|
|
|
try:
|
|
self.num_assets_current_query = len(asset_ids)
|
|
except TypeError:
|
|
self.num_assets_current_query = 0
|
|
|
|
if asset_ids is not None:
|
|
self.logger.debug(f"f_GetPageAssets num ids: {len(asset_ids)}")
|
|
asset_ids_page = asset_ids[idx_first_asset:idx_last_asset]
|
|
num_pages = len(asset_ids) // num_per_page
|
|
if len(asset_ids) % num_per_page > 0:
|
|
num_pages += 1
|
|
|
|
# An empty list here means, we're on a page, which has not been
|
|
# fetched, yet.
|
|
# But returning empty list leads to "No results" display",
|
|
# which we want to avoid in this situation.
|
|
# Lets double check, we are really still fetching and then
|
|
# return None instead:
|
|
if len(asset_ids_page) == 0:
|
|
key_fetch = (tuple(category_list), search)
|
|
if key_fetch in self.fetching_asset_data[area]:
|
|
asset_ids_page = None
|
|
else:
|
|
# This case is different from a query leading to no results.
|
|
# We'll return 'asset_ids_page = None', so UI can differentiate
|
|
# between a 'loading screen' and a 'no results screen'.
|
|
self.logger.debug("f_GetPageAssets: No query cache entry, yet!")
|
|
asset_ids_page = None
|
|
num_pages = 0
|
|
|
|
self.f_GetAssets()
|
|
return asset_ids_page, num_pages
|
|
|
|
# @timer
|
|
def f_GetAssetsSorted(self, idx_page: int) -> List[int]:
|
|
area = self.settings["area"]
|
|
self.logger.debug(
|
|
f"f_GetAssetsSorted idx_page={idx_page}, area={area}")
|
|
|
|
asset_ids_page, num_pages = self.f_GetPageAssets(idx_page)
|
|
if asset_ids_page is None:
|
|
self.logger.debug("f_GetAssetsSorted Dummy Assets")
|
|
return self._get_dummy_assets()
|
|
elif len(asset_ids_page) > 0:
|
|
self.vPages[area] = num_pages
|
|
return asset_ids_page
|
|
else:
|
|
self.logger.debug("f_GetAssetsSorted No Assets")
|
|
return []
|
|
|
|
def check_if_purchased(self, asset_id) -> bool:
|
|
"""Workaround to ensure only truly purchased assets are included here,
|
|
|
|
since asset_data.is_purchased actually means "purchased or downloads"
|
|
Old code: owned = bool(asset_data.is_purchased)
|
|
"""
|
|
query_key_all_user_assets = (
|
|
KEY_TAB_MY_ASSETS,
|
|
None,
|
|
None,
|
|
None,
|
|
IDX_PAGE_ACCUMULATED,
|
|
PAGE_SIZE_ACCUMULATED)
|
|
try:
|
|
asset_ids_my_assets = cTB._asset_index.cached_queries[
|
|
query_key_all_user_assets]
|
|
except KeyError:
|
|
asset_ids_my_assets = []
|
|
return asset_id in asset_ids_my_assets
|
|
|
|
def get_pref_size(self, asset_type: AssetType) -> str:
|
|
if asset_type == AssetType.TEXTURE:
|
|
return self.settings["res"]
|
|
elif asset_type == AssetType.MODEL:
|
|
return self.settings["mres"]
|
|
elif asset_type == AssetType.HDRI:
|
|
return self.settings["hdri"]
|
|
elif asset_type == AssetType.BRUSH:
|
|
return self.settings["brush"]
|
|
else:
|
|
self.logger.error("get_pref_size: UNKNOWN ASSET TYPE")
|
|
return ""
|
|
|
|
# TODO(Andreas): No longer in use, but may get revived, e.g. for getting
|
|
# asset_id for old Poliigon porperties storing
|
|
# asset name, only
|
|
# def get_data_for_asset_name(self,
|
|
# asset_name: str,
|
|
# *,
|
|
# area_order: List[str] = [KEY_TAB_ONLINE,
|
|
# KEY_TAB_MY_ASSETS,
|
|
# "local"]
|
|
# ) -> Dict:
|
|
# """Get the data structure for an asset by asset_name alone."""
|
|
# for area in area_order:
|
|
# with self.lock_assets:
|
|
# subcats = list(self.vAssets[area])
|
|
# for cat in subcats:
|
|
# for asset in self.vAssets[area][cat]:
|
|
# if asset == asset_name:
|
|
# return self.vAssets[area][cat][asset]
|
|
# # Failed to fetch asset, return empty structure.
|
|
# return {}
|
|
|
|
def _get_dummy_assets(self) -> List[int]:
|
|
self.logger.debug("_get_dummy_assets")
|
|
|
|
list_dummy_assets = []
|
|
|
|
for _ in range(self.settings["page"]):
|
|
list_dummy_assets.append(0) # asset ID 0 = dummy asset
|
|
|
|
return list_dummy_assets
|
|
|
|
def start_thumb_download(
|
|
self,
|
|
asset_data: AssetData,
|
|
path_thumb: str,
|
|
url_thumb: str,
|
|
idx_thumb: int = 0,
|
|
callback_done: Optional[Callable] = None
|
|
) -> None:
|
|
if callback_done is None:
|
|
callback_done = self.callback_asset_update_ui
|
|
|
|
asset_id = asset_data.asset_id
|
|
self.logger.debug(
|
|
f"start_thumb_download asset_id={asset_id}, "
|
|
f"path_thumb={path_thumb}, url_thumb={url_thumb}, "
|
|
f"idx_thumb={idx_thumb}")
|
|
|
|
asset_data.runtime.set_thumb_downloading(is_downloading=True)
|
|
self.api_rc.add_job_download_thumb(
|
|
asset_id,
|
|
url_thumb,
|
|
path_thumb,
|
|
callback_cancel=None,
|
|
callback_progress=None,
|
|
callback_done=callback_done,
|
|
force=False
|
|
)
|
|
|
|
@reporting.handle_function(silent=True)
|
|
def refresh_data(self, icons_only: bool = False) -> None:
|
|
"""Reload data structures of the addon to update UI and stale data.
|
|
|
|
This function could be called in main or background thread.
|
|
"""
|
|
self.ui_errors.clear()
|
|
|
|
# Clear out state variables.
|
|
with self.lock_thumbs:
|
|
self.thumbs.clear()
|
|
|
|
self._asset_index.flush()
|
|
|
|
if icons_only is True:
|
|
self.refresh_ui()
|
|
return
|
|
|
|
self.refresh_top_level_queries_flags()
|
|
|
|
# Get updated account data, fresh "All Assets" data for both tabs
|
|
# and category counts
|
|
key_fetch_all = ((CATEGORY_ALL, ), "")
|
|
self.fetching_asset_data[KEY_TAB_MY_ASSETS][key_fetch_all] = True
|
|
self.fetching_asset_data[KEY_TAB_ONLINE][key_fetch_all] = True
|
|
self.fetching_user_data = True
|
|
self.api_rc.add_job_get_user_data(
|
|
self.user.user_name,
|
|
self.user.user_id,
|
|
callback_cancel=None,
|
|
callback_progress=None,
|
|
do_fetch_asset_data=True,
|
|
callback_done=self.callback_get_user_data_done,
|
|
force=True
|
|
)
|
|
self.refresh_ui()
|
|
|
|
def get_accumulated_query_cache_key(self,
|
|
tab: str,
|
|
search: str = "",
|
|
category_list: List[str] = ["All Assets"]
|
|
) -> Tuple:
|
|
key = get_search_key(
|
|
tab=tab, search=search, category_list=category_list)
|
|
query_key = self._asset_index._query_key_to_tuple(
|
|
key, chunk=IDX_PAGE_ACCUMULATED, chunk_size=PAGE_SIZE_ACCUMULATED)
|
|
return query_key
|
|
|
|
@staticmethod
|
|
def _get_asset_type_from_property(
|
|
entity: Union[bpy.types.Image, bpy.types.Material, bpy.types.Object]
|
|
) -> AssetType:
|
|
|
|
try:
|
|
asset_type_name = entity.poliigon_props.asset_type
|
|
asset_type = AssetType[asset_type_name]
|
|
except KeyError:
|
|
asset_type = API_TYPE_TO_ASSET_TYPE[asset_type_name]
|
|
return asset_type
|
|
|
|
def _find_asset_ids_in_scene(self) -> List[int]:
|
|
"""Finds all asset IDs in scene."""
|
|
|
|
asset_ids_in_scene = []
|
|
for _entity_coll in [bpy.data.images,
|
|
bpy.data.materials,
|
|
bpy.data.objects]:
|
|
for _entity in _entity_coll:
|
|
try:
|
|
# TODO(Andreas): Issue HDRS vs HDRIs?
|
|
asset_id = _entity.poliigon_props.asset_id
|
|
asset_type = self._get_asset_type_from_property(_entity)
|
|
if asset_type not in [AssetType.HDRI,
|
|
AssetType.MODEL,
|
|
AssetType.TEXTURE]:
|
|
# Old projects could still contain BRUSH type
|
|
continue
|
|
if asset_id in asset_ids_in_scene:
|
|
continue
|
|
if asset_type == AssetType.TEXTURE:
|
|
size = _entity.poliigon_props.size
|
|
if size == "WM":
|
|
continue
|
|
asset_ids_in_scene.append(asset_id)
|
|
except Exception:
|
|
# Don't use this log message, if not actively debugging here!
|
|
# self.logger.exception(f"NO POLIIGON PROPS? {_entity.name}")
|
|
pass
|
|
return asset_ids_in_scene
|
|
|
|
def _get_tab_base_assets(self) -> List[int]:
|
|
# To the server "Imported" tab acts as if it was
|
|
# "My Assets" + "Downloads" tab.
|
|
# And its responses will be stored as accumulated "My Assets" and
|
|
# "Downloads"entries in query cache.
|
|
# These will then be used in here to create proper query cache entries
|
|
# for "Imported" tab, which will always be just filtered down versions
|
|
# of the "My Assets" requests.
|
|
#
|
|
# For unlimited users this needs to work a bit different, as local
|
|
# assets do not appear on My Assets tab, in this case the Online tab
|
|
# needs to be used as reference.
|
|
if self.is_unlimited_user():
|
|
tab_base = [KEY_TAB_ONLINE]
|
|
else:
|
|
tab_base = [KEY_TAB_MY_ASSETS, KEY_TAB_RECENT_DOWNLOADS]
|
|
|
|
base_ids = []
|
|
for _tab in tab_base:
|
|
query_base = self.get_accumulated_query_cache_key(
|
|
tab=_tab,
|
|
search=self.vSearch[KEY_TAB_IMPORTED],
|
|
category_list=self.settings["category"])
|
|
|
|
if query_base not in self._asset_index.cached_queries:
|
|
continue
|
|
|
|
base_ids += self._asset_index.cached_queries[query_base]
|
|
return base_ids
|
|
|
|
def f_GetSceneAssets(self) -> None:
|
|
self.logger.debug("f_GetSceneAssets")
|
|
|
|
user_asset_ids = self._get_tab_base_assets()
|
|
try:
|
|
asset_ids_in_scene = self._find_asset_ids_in_scene()
|
|
except AttributeError as e:
|
|
if "_RestrictData" in str(e):
|
|
# Blender still starting up, can't access local data yet
|
|
return
|
|
raise e
|
|
|
|
# Filter My Assets IDs by asset IDs in scene
|
|
# (so we maintain order and category/search filtering)
|
|
asset_ids_in_query = []
|
|
for _asset_id in user_asset_ids:
|
|
if _asset_id not in asset_ids_in_scene:
|
|
continue
|
|
asset_ids_in_query.append(_asset_id)
|
|
|
|
# Finally store result in query cache
|
|
query_key_imported = self.get_accumulated_query_cache_key(
|
|
tab=KEY_TAB_IMPORTED,
|
|
search=self.vSearch[KEY_TAB_IMPORTED],
|
|
category_list=self.settings["category"])
|
|
self._asset_index.cached_queries[query_key_imported] = asset_ids_in_query
|
|
|
|
# TODO(Andreas): f_GetActiveData is in dire need for some love
|
|
def f_GetActiveData(self) -> None:
|
|
self.logger.debug("f_GetActiveData")
|
|
|
|
self.vActiveMatProps = {}
|
|
self.vActiveTextures = {}
|
|
self.vActiveMixProps = {}
|
|
|
|
if self.vActiveMat is None:
|
|
return
|
|
|
|
vMat = bpy.data.materials[self.vActiveMat]
|
|
|
|
if self.vActiveMode == "mixer":
|
|
vMNodes = vMat.node_tree.nodes
|
|
vMLinks = vMat.node_tree.links
|
|
for vN in vMNodes:
|
|
if vN.type == "GROUP":
|
|
if "Mix Texture Value" in [vI.name for vI in vN.inputs]:
|
|
vMat1 = None
|
|
vMat2 = None
|
|
vMixTex = None
|
|
for vL in vMLinks:
|
|
if vL.to_node == vN:
|
|
if vL.to_socket.name in ["Base Color1",
|
|
"Base Color2"]:
|
|
vProps = {}
|
|
for vI in vL.from_node.inputs:
|
|
if vI.is_linked:
|
|
continue
|
|
if vI.type == "VALUE":
|
|
vProps[vI.name] = vL.from_node
|
|
|
|
if vL.to_socket.name == "Base Color1":
|
|
vMat1 = [vL.from_node, vProps]
|
|
elif vL.to_socket.name == "Base Color2":
|
|
vMat2 = [vL.from_node, vProps]
|
|
elif vL.to_socket.name == "Mix Texture":
|
|
if vN.inputs["Mix Texture"].is_linked:
|
|
vMixTex = vL.from_node
|
|
|
|
vProps = {}
|
|
for vI in vN.inputs:
|
|
if vI.is_linked:
|
|
continue
|
|
if vI.type == "VALUE":
|
|
vProps[vI.name] = vN
|
|
|
|
self.vActiveMixProps[vN.name] = [
|
|
vN,
|
|
vMat1,
|
|
vMat2,
|
|
vProps,
|
|
vMixTex,
|
|
]
|
|
|
|
if self.settings["mix_props"] == []:
|
|
vK = list(self.vActiveMatProps.keys())[0]
|
|
self.settings["mix_props"] = list(self.vActiveMatProps[vK][3].keys())
|
|
else:
|
|
vMNodes = vMat.node_tree.nodes
|
|
for vN in vMNodes:
|
|
if vN.type == "GROUP":
|
|
for vI in vN.inputs:
|
|
if vI.type == "VALUE":
|
|
self.vActiveMatProps[vI.name] = vN
|
|
elif vN.type == "BUMP" and vN.name == "Bump":
|
|
for vI in vN.inputs:
|
|
if vI.type == "VALUE" and vI.name == "Strength":
|
|
self.vActiveMatProps[vI.name] = vN
|
|
|
|
if self.settings["mat_props"] == []:
|
|
self.settings["mat_props"] = list(self.vActiveMatProps.keys())
|
|
|
|
if vMat.use_nodes:
|
|
for vN in vMat.node_tree.nodes:
|
|
if vN.type == "TEX_IMAGE":
|
|
if vN.image is None:
|
|
continue
|
|
vFile = vN.image.filepath.replace("\\", "/")
|
|
if f_Ex(vFile):
|
|
vType = vN.name
|
|
if vType == "COLOR":
|
|
vType = "COL"
|
|
elif vType == "DISPLACEMENT":
|
|
vType = "DISP"
|
|
elif vType == "NORMAL":
|
|
vType = "NRM"
|
|
elif vType == "OVERLAY":
|
|
vType = "OVERLAY"
|
|
|
|
self.vActiveTextures[vType] = vN
|
|
|
|
elif vN.type == "GROUP":
|
|
for vN1 in vN.node_tree.nodes:
|
|
if vN1.type == "TEX_IMAGE":
|
|
if vN1.image is None:
|
|
continue
|
|
vFile = vN1.image.filepath.replace("\\", "/")
|
|
if f_Ex(vFile):
|
|
vType = vN1.name
|
|
if vType == "COLOR":
|
|
vType = "COL"
|
|
if vType == "OVERLAY":
|
|
vType = "OVERLAY"
|
|
elif vType == "DISPLACEMENT":
|
|
vType = "DISP"
|
|
elif vType == "NORMAL":
|
|
vType = "NRM"
|
|
self.vActiveTextures[vType] = vN1
|
|
elif vN1.type == "BUMP" and vN1.name == "Bump":
|
|
for vI in vN1.inputs:
|
|
if vI.type == "VALUE" and vI.name == "Distance":
|
|
self.vActiveMatProps[vI.name] = vN1
|
|
|
|
def f_GetPreview(self,
|
|
asset_data: AssetData,
|
|
idx_thumb: int = 0,
|
|
load_image: bool = True
|
|
) -> Optional[int]:
|
|
"""Queue download for a preview if not already local.
|
|
|
|
Use a non-zero index to fetch another preview type thumbnail.
|
|
"""
|
|
|
|
asset_id = asset_data.asset_id
|
|
asset_name = asset_data.asset_name
|
|
self.logger.debug(
|
|
f"f_GetPreview asset_id={asset_id}, idx_thumb={idx_thumb}, "
|
|
f"load_image={load_image}")
|
|
|
|
if asset_name == "dummy" or asset_id < 0:
|
|
# asset_id < 0 means backdoor imported asset with no thumb info
|
|
return None
|
|
|
|
with self.lock_thumbs:
|
|
if asset_name in self.thumbs:
|
|
# TODO(SOFT-447): See if there's another way at this moment to
|
|
# inspect whether the icon we are returning
|
|
# here is gray or not.
|
|
# TODO(Andreas): While SOFT-447 is marked done, the actual
|
|
# problem of grey thumbs still persists.
|
|
# Thus this TODO is still valid.
|
|
# print(
|
|
# "Returning icon id",
|
|
# asset_name,
|
|
# self.thumbs[asset_name].image_size[:])
|
|
return self.thumbs[asset_name].icon_id
|
|
|
|
f_MDir(self.dir_online_previews)
|
|
|
|
path_thumb, url_thumb = self._asset_index.get_cf_thumbnail_info(
|
|
asset_id)
|
|
if path_thumb is not None and os.path.isfile(path_thumb):
|
|
if not load_image: # special case used by thumb prefetcher
|
|
return None
|
|
|
|
with self.lock_thumbs:
|
|
try:
|
|
self.thumbs.load(asset_name, path_thumb, "IMAGE")
|
|
except KeyError:
|
|
self.thumbs[asset_name].reload()
|
|
|
|
self.logger.debug(f"f_GetPreview {path_thumb}")
|
|
|
|
return self.thumbs[asset_name].icon_id
|
|
|
|
self.start_thumb_download(asset_data, path_thumb, url_thumb, idx_thumb)
|
|
return None
|
|
|
|
def get_verbose(self) -> bool:
|
|
"""Returns verbosity setting from prefs."""
|
|
|
|
prefs = self.prefs
|
|
if prefs is None:
|
|
prefs = get_prefs()
|
|
if prefs is not None:
|
|
return prefs.verbose_logs
|
|
else:
|
|
return False
|
|
|
|
def interval_check_update(self) -> None:
|
|
"""Checks with an interval delay for any updated files.
|
|
|
|
Used to identify if an update has occurred. Note: If the user installs
|
|
and updates by manually pasting files in place, or even from install
|
|
addon via zip in preferences, and the addon is already active, there
|
|
is no event-based function ran to let us know. Hence we use this
|
|
polling method instead.
|
|
"""
|
|
|
|
interval = 10
|
|
now = time.time()
|
|
if self.last_update_addon_files_check + interval > now:
|
|
return
|
|
self.last_update_addon_files_check = now
|
|
self.update_files(self.dir_script)
|
|
|
|
def update_files(self, path: str) -> bool:
|
|
"""Updates files in the specified path within the addon."""
|
|
|
|
update_key = "_update"
|
|
files_to_update = [f for f in os.listdir(path)
|
|
if os.path.isfile(os.path.join(path, f))
|
|
and os.path.splitext(f)[0].endswith(update_key)]
|
|
|
|
for f in files_to_update:
|
|
f_split = os.path.splitext(f)
|
|
tgt_file = f_split[0][:-len(update_key)] + f_split[1]
|
|
|
|
try:
|
|
os.replace(os.path.join(path, f), os.path.join(path, tgt_file))
|
|
self.logger.debug(f"update_files Updated {tgt_file}")
|
|
except PermissionError as e:
|
|
reporting.capture_message("file_permission_error", e, "error")
|
|
except OSError as e:
|
|
reporting.capture_message("os_error", e, "error")
|
|
|
|
# If the intial register already completed, then this must be the
|
|
# second time we have run the register function. If files were updated,
|
|
# it means this was a fresh update install.
|
|
# Thus: We must notify users to restart.
|
|
any_updates = len(files_to_update) > 0
|
|
if any_updates and self.initial_register_complete:
|
|
self.notify_restart_required()
|
|
|
|
return any_updates
|
|
|
|
def notify_restart_required(self) -> None:
|
|
"""Creates a UI-blocking banner telling users they need to restart.
|
|
|
|
This will occur if the user has installed an updated version of the
|
|
addon but has not yet restarted Blender. This is important to avoid
|
|
errors caused by only paritally reloaded modules.
|
|
"""
|
|
|
|
build_restart_notification(self)
|
|
|
|
def check_update_callback(self) -> None:
|
|
"""Callback run by the updater instance."""
|
|
|
|
# Hack to force it to think update is available
|
|
fake_update = False
|
|
if fake_update:
|
|
self._updater.update_ready = True
|
|
self._updater.update_data = updater.VersionData(
|
|
version=(1, 0, 0),
|
|
url="https://poliigon.com/blender")
|
|
|
|
self.refresh_ui()
|
|
|
|
def _any_local_assets(self) -> bool:
|
|
"""Returns True, if there are local assets"""
|
|
|
|
asset_ids_local = self._asset_index.get_asset_id_list(
|
|
purchased=True, local=True)
|
|
return len(asset_ids_local) > 0
|
|
|
|
|
|
def f_tick_handler() -> float:
|
|
"""Called on by blender timer handlers to check toolbox status.
|
|
|
|
The returned value signifies how long until the next execution.
|
|
"""
|
|
|
|
next_call_s = 60 # Long to prevent frequent checks for updates.
|
|
if not cTB.vRunning:
|
|
return next_call_s
|
|
|
|
# Thread cleanup.
|
|
for vT in list(cTB.threads):
|
|
if not vT.is_alive():
|
|
cTB.threads.remove(vT)
|
|
|
|
# Updater callback.
|
|
if cTB.prefs and cTB.prefs.auto_check_update:
|
|
if cTB._updater.has_time_elapsed(hours=24):
|
|
cTB._updater.async_check_for_update(
|
|
cTB.check_update_callback,
|
|
create_notifications=True)
|
|
|
|
return next_call_s
|
|
|
|
|
|
def f_do_on_main_thread() -> float:
|
|
"""Called on by blender timer handlers to allow execution on main thread.
|
|
|
|
The returned value signifies how long until the next execution.
|
|
"""
|
|
|
|
if cTB.msg_download_limit is not None:
|
|
bpy.ops.poliigon.popup_download_limit(
|
|
"INVOKE_DEFAULT", msg=cTB.msg_download_limit)
|
|
cTB.msg_download_limit = None
|
|
|
|
return None # Auto disarm, one-shot timer
|
|
|
|
|
|
@persistent
|
|
def f_load_handler(*args) -> None:
|
|
"""Runs when a new file is opened to refresh data."""
|
|
|
|
if cTB.vRunning:
|
|
cTB.f_GetSceneAssets()
|
|
|
|
|
|
def t_check_change_plan_response() -> float:
|
|
"""Timer used for 'plan upgrading', just in case API RC doesn't call
|
|
our callback (not very likely...).
|
|
"""
|
|
|
|
if cTB.plan_upgrade_in_progress and not cTB.plan_upgrade_finished:
|
|
cTB.error_plan_upgrade = "Timeout, please try again later"
|
|
return None # Auto disarm, one-shot timer
|
|
|
|
|
|
cTB = None # TODO(Andreas): At some point rename!
|
|
|
|
|
|
def init_context(addon_version: str) -> None:
|
|
global cTB
|
|
|
|
cTB = c_Toolbox(addon_version)
|
|
|
|
|
|
def get_context(addon_version: str) -> c_Toolbox:
|
|
global cTB
|
|
|
|
if cTB is None:
|
|
init_context(addon_version)
|
|
return cTB
|
|
|
|
|
|
def shutdown_asset_browser_client() -> None:
|
|
"""Shuts down client Blender process"""
|
|
|
|
cTB.asset_browser_jobs_cancelled = True
|
|
cTB.asset_browser_quitting = True
|
|
|
|
# Needed for some unit tests to not cause exception on shutdown
|
|
# if ran in "single" mode
|
|
if not cTB.register_success:
|
|
return
|
|
|
|
if cTB.proc_blender_client is not None:
|
|
cTB.proc_blender_client.terminate()
|
|
|
|
|
|
def shutdown_thumb_prefetch() -> None:
|
|
# TODO(Andreas): Thumb prefetch still WIP
|
|
# cTB.thread_prefetch_running = False
|
|
|
|
# Avoid issues with Blender exit during unit tests
|
|
# if not hasattr(cTB, "queue_thumb_prefetch"):
|
|
# return
|
|
# if not hasattr(cTB, "dir_online_previews"):
|
|
# return
|
|
# Just put something into queue, in order to have
|
|
# thread return immediately, instead of waiting for timeout
|
|
# cTB.enqueue_thumb_prefetch("quit")
|
|
|
|
# TODO(Andreas): If we use ThumbCache we need to do:
|
|
# cTB.thumb_cache.shutdown()
|
|
pass
|
|
|
|
|
|
def shutdown_addon() -> None:
|
|
"""Shuts down all addon services.
|
|
_Afterwards_ everything is ready for unregister.
|
|
"""
|
|
|
|
cTB.quitting = True
|
|
shutdown_thumb_prefetch()
|
|
shutdown_asset_browser_client()
|
|
cTB.api_rc.shutdown()
|
|
|
|
cTB.vRunning = 0
|
|
|
|
|
|
@atexit.register
|
|
def blender_quitting() -> None:
|
|
# CAREFUL! When this exit handler gets called, many Blender data structures
|
|
# are already destructed. We must not use any Blender resources inside here.
|
|
global cTB
|
|
|
|
if cTB is None:
|
|
return
|
|
|
|
if cTB.vRunning == 1 and not cTB.quitting:
|
|
shutdown_addon()
|
|
|
|
|
|
def register(addon_version: str) -> None:
|
|
cTB.register(addon_version)
|
|
|
|
bpy.app.timers.register(
|
|
f_tick_handler, first_interval=0.05, persistent=True)
|
|
|
|
if f_load_handler not in bpy.app.handlers.load_post:
|
|
bpy.app.handlers.load_post.append(f_load_handler)
|
|
|
|
|
|
def unregister() -> None:
|
|
global cTB
|
|
|
|
if cTB is None:
|
|
print("cTB is None during unregister")
|
|
return
|
|
|
|
shutdown_addon()
|
|
|
|
if cTB.log_manager.file_handler is not None:
|
|
cTB.log_manager.file_handler.close()
|
|
|
|
reporting.unregister()
|
|
|
|
if bpy.app.timers.is_registered(f_do_on_main_thread):
|
|
bpy.app.timers.unregister(f_do_on_main_thread)
|
|
|
|
if bpy.app.timers.is_registered(f_tick_handler):
|
|
bpy.app.timers.unregister(f_tick_handler)
|
|
|
|
if f_load_handler in bpy.app.handlers.load_post:
|
|
bpy.app.handlers.load_post.remove(f_load_handler)
|
|
|
|
# Don't block unregister or closing blender.
|
|
# for vT in cTB.threads:
|
|
# vT.join()
|
|
|
|
cTB.ui_icons.clear()
|
|
try:
|
|
bpy.utils.previews.remove(cTB.ui_icons)
|
|
except KeyError:
|
|
pass
|
|
|
|
with cTB.lock_thumbs:
|
|
cTB.thumbs.clear()
|
|
|
|
try:
|
|
bpy.utils.previews.remove(cTB.thumbs)
|
|
except KeyError:
|
|
pass
|
|
cTB = None
|