2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
# #### 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 bpy.types import Operator
from ..modules.poliigon_core.assets import AssetType
from ..modules.poliigon_core.multilingual import _t
from .. import reporting
from ..toolbox import get_context
from . import asset_browser as ab
# https://blender.stackexchange.com/questions/249837/how-do-i-get-the-selected-assets-in-the-asset-browser-using-the-api
class POLIIGON_OT_asset_browser_import(Operator):
bl_idname = "poliigon.asset_browser_import"
bl_label = _t("Import Selected Assets")
bl_space_type = "FILE_BROWSER"
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def poll(cls, context):
is_poliigon_lib = ab.is_poliigon_library(context)
assets_selected = ab.get_num_selected_assets(context) > 0
return is_poliigon_lib and assets_selected
@classmethod
def description(cls, context, properties):
num_selected = ab.get_num_selected_assets(context)
if num_selected > 0:
return _t("Import selected assets (default parameters)")
else:
return _t("No asset selected.\nPlease, select an asset")
@reporting.handle_operator(silent=True)
def execute(self, context):
if not ab.is_poliigon_library(context):
# As the operator should be shown for Poliigon Library, only
# we shouldn't be here
error_msg = ("POLIIGON_OT_asset_browser_import(): "
"Poliigon library not selected!")
reporting.capture_message(
"asset_browser_lib_not_sel", error_msg, "error")
return {"CANCELLED"}
asset_files = ab.get_selected_assets(context)
for _asset_file in asset_files:
asset_name = ab.get_asset_name_from_browser_asset(_asset_file)
asset_data = ab.get_asset_data_from_browser_asset(_asset_file)
if asset_data is None:
error_msg = ("POLIIGON_OT_asset_browser_import(): "
f"Asset {asset_name} not found!")
reporting.capture_message(
"asset_browser_asset_not_found", error_msg, "error")
cTB.logger_ab.error(error_msg)
# TODO(Andreas): user notification
continue
asset_type = asset_data.asset_type
if asset_type == AssetType.HDRI:
# TODO(Andreas): Do actual import
pass
elif asset_type == AssetType.MODEL:
# TODO(Andreas): Do actual import
pass
elif asset_type == AssetType.TEXTURE:
# TODO(Andreas): Do actual import
pass
else:
error_msg = ("POLIIGON_OT_asset_browser_import():"
f" Unexpected asset type: {asset_name} "
f"{asset_type}")
reporting.capture_message(
"asset_browser_unexpected_type", error_msg, "error")
cTB.logger_ab.error(error_msg)
# TODO(Andreas): user notification
continue
return {"FINISHED"}
@@ -0,0 +1,84 @@
# #### 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 bpy.types import Operator
from ..modules.poliigon_core.multilingual import _t
from ..dialogs.dlg_quickmenu import show_quick_menu
from ..toolbox import get_context
from .. import reporting
from . import asset_browser as ab
# https://blender.stackexchange.com/questions/249837/how-do-i-get-the-selected-assets-in-the-asset-browser-using-the-api
class POLIIGON_OT_asset_browser_quick_menu(Operator):
bl_idname = "poliigon.asset_browser_quick_menu"
bl_label = _t("Show additional import options")
bl_space_type = "FILE_BROWSER"
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def poll(cls, context):
is_poliigon_lib = ab.is_poliigon_library(context)
one_asset_selected = ab.get_num_selected_assets(context) == 1
return is_poliigon_lib and one_asset_selected
@classmethod
def description(cls, context, properties):
num_selected = ab.get_num_selected_assets(context)
if num_selected == 1:
return _t("Show additional import options")
elif num_selected == 0:
return _t("No asset selected.\nPlease, select a single asset")
else:
return _t("Multiple assets selected.\nPlease, "
"select a single asset, only")
@reporting.handle_operator(silent=True)
def execute(self, context):
if not ab.is_poliigon_library(context):
# As the operator should be shown for Poliigon Library
# we shouldn't be here
error_msg = ("POLIIGON_OT_asset_browser_quick_menu(): "
"Poliigon library not selected!")
reporting.capture_message(
"asset_browser_lib_not_sel", error_msg, "error")
return {"CANCELLED"}
# poll() makes sure, there's exactly one
asset_file = ab.get_selected_assets(context)[0]
asset_name = ab.get_asset_name_from_browser_asset(asset_file)
asset_data = ab.get_asset_data_from_browser_asset(asset_file)
if asset_data is None:
error_msg = ("POLIIGON_OT_asset_browser_import(): "
f"Asset {asset_name} not found!")
reporting.capture_message(
"asset_browser_asset_not_found", error_msg, "error")
self.report({"ERROR"}, f"Asset {asset_name} not found!")
return {"CANCELLED"}
show_quick_menu(cTB, asset_data=asset_data)
return {"FINISHED"}
@@ -0,0 +1,88 @@
# #### 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 bpy.types import Operator
import bpy
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
from . import asset_browser as ab
class POLIIGON_OT_asset_browser_reprocess(Operator):
bl_idname = "poliigon.asset_browser_reprocess"
bl_label = _t("Show additional import options")
bl_space_type = "FILE_BROWSER"
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def poll(cls, context):
if not ab.is_asset_browser(context):
return False
if not ab.is_poliigon_library(context):
return False
if not ab.is_only_poliigon_selected(context):
return False
if ab.get_num_selected_assets(context) == 0:
return False
return True
@classmethod
def description(cls, context, properties):
num_selected = ab.get_num_selected_assets(context)
if num_selected == 0:
return _t("No asset selected.\nPlease, select a single asset")
else:
return _t("Re-process selected assets")
@reporting.handle_operator(silent=True)
def execute(self, context):
if not ab.is_poliigon_library(context):
# As the operator should be shown for Poliigon Library
# we shouldn't be here
error_msg = ("POLIIGON_OT_asset_browser_reprocess(): "
"Poliigon library not selected!")
reporting.capture_message(
"asset_browser_reproc_not_sel", error_msg, "error")
return {"CANCELLED"}
asset_files = ab.get_selected_assets(context)
for asset_file in asset_files:
asset_name = ab.get_asset_name_from_browser_asset(asset_file)
asset_data = ab.get_asset_data_from_browser_asset(asset_file)
if asset_data is None:
error_msg = ("POLIIGON_OT_asset_browser_reprocess(): "
f"Asset {asset_name} not found!")
reporting.capture_message(
"asset_browser_reproc_asset_missing", error_msg, "error")
self.report({"ERROR"}, f"Asset {asset_name} not found!")
continue
bpy.ops.poliigon.update_asset_browser(
asset_id=asset_data.asset_id, force=True)
return {"FINISHED"}
@@ -0,0 +1,44 @@
# #### 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 bpy.types import Operator
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_cancel_asset_browser_sync(Operator):
bl_idname = "poliigon.cancel_asset_browser"
bl_label = _t("Cancel Asset Sync")
bl_category = "Poliigon"
bl_description = _t("Cancel synchronization of local assets with the "
"Asset Browser")
bl_options = {"INTERNAL"}
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@reporting.handle_operator(silent=True)
def execute(self, context):
cTB.asset_browser_jobs_cancelled = True
return {"FINISHED"}
@@ -0,0 +1,963 @@
# #### 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 dataclasses import dataclass
import json
import os
from queue import (
Empty,
Queue)
import shutil
import sys
from threading import Thread
import time
from typing import Any, Dict, List, Optional, Tuple
from uuid import uuid4
from bpy.types import Operator
from bpy.props import StringProperty
import bpy
from ..modules.poliigon_core.assets import (
AssetData,
AssetType)
from ..modules.poliigon_core.multilingual import _t
from ..material_import_utils import ASSET_TYPE_TO_IMPORTED_TYPE
from ..toolbox import get_context
from .. import reporting
from .asset_browser_sync_commands import (
CMD_MARKER_START,
CMD_MARKER_END,
SyncCmd,
SyncAssetBrowserCmd)
@dataclass
class ScriptContext():
path_cat: Optional[str] = None # from command line args
path_categories: Optional[str] = None # from command line args
poliigon_categories: Optional[Dict] = None
listener_running: bool = False
thd_listener: Optional[Thread] = None
sender_running: bool = False
thd_sender: Optional[Thread] = None
queue_cmd: Optional[Queue] = None
queue_send: Optional[Queue] = None
queue_ack: Optional[Queue] = None
main_running: bool = False
class POLIIGON_OT_sync_client(Operator):
bl_idname = "poliigon.asset_browser_sync_client"
bl_label = _t("Sync Client")
bl_category = "Poliigon"
bl_description = _t("To be used in client Blender process to work on "
"commands sent by host blender.")
bl_options = {"INTERNAL"}
path_catalog: StringProperty(options={"HIDDEN"}) # noqa: F821
path_categories: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@staticmethod
def _check_command(ctx: ScriptContext,
buf: str
) -> Tuple[Optional[SyncAssetBrowserCmd], str]:
"""Returns a valid command, otherwise None.
Upon detecting a corrupted command, CMD_ERROR gets sent.
Return value:
Tuple with two entries:
Tuple[0]: A valid command or None
Tuple[1]: Remaining buf after either a valid command got detected or
an broken command got removed
"""
if CMD_MARKER_END not in buf:
return None, buf
pos_delimiter = buf.find(CMD_MARKER_END, 1)
cmd_json = buf[:pos_delimiter]
buf = buf[pos_delimiter + len(CMD_MARKER_END):]
if CMD_MARKER_START in cmd_json:
pos_marker_start = cmd_json.find(CMD_MARKER_START, 1)
cmd_json = cmd_json[pos_marker_start + len(CMD_MARKER_START):]
else:
ctx.queue_send.put(SyncAssetBrowserCmd(code=SyncCmd.CMD_ERROR))
cmd_json = None
return cmd_json, buf
@staticmethod
def _thread_listener(ctx: ScriptContext) -> None:
"""Listens to commands sent by host and checks their integrity.
In case of error requests a command to be re-send from host via
CMD_ERROR.
Valid commands are then sorted into two queues, one for received acks
(forwarding them to unblock sender), one for job commands (forwarding
them to main loop).
"""
cTB.logger_ab.debug("thread_listener")
ctx.listener_running = True
buf = ""
while ctx.listener_running:
# Wait for messages from host, concatenating received lines
# into buf
try:
buf += sys.stdin.readline()
except KeyboardInterrupt:
time.sleep(0.5)
if ctx.listener_running:
continue
if not ctx.listener_running:
break
cmd_json, buf = POLIIGON_OT_sync_client._check_command(ctx, buf)
if cmd_json is None:
continue
try:
cmd_from_host = SyncAssetBrowserCmd.from_json(cmd_json)
if cmd_from_host.code == SyncCmd.CMD_ERROR:
# Forward ack to thread_sender
ctx.queue_ack.put(cmd_from_host)
else:
# Forward job command to main loop
ctx.queue_cmd.put(cmd_from_host)
except Exception:
ctx.queue_send.put(SyncAssetBrowserCmd(code=SyncCmd.CMD_ERROR))
cTB.logger_ab.exception(f"CMD ERROR {cmd_json}")
cTB.logger_ab.debug("thread_listener EXIT")
ctx.thd_listener = None
@staticmethod
def _start_listener(ctx: ScriptContext) -> None:
"""Starts thread_listener()"""
ctx.thd_listener = Thread(
target=POLIIGON_OT_sync_client._thread_listener,
args=(ctx, ),
daemon=True)
ctx.thd_listener.start()
@staticmethod
def _flush_queue_ack(ctx: ScriptContext) -> None:
"""Removes all content from ack queue"""
while not ctx.queue_ack.empty():
try:
ctx.queue_ack.get_nowait()
except ctx.queue_ack.Empty:
break
@staticmethod
def _shutdown_on_error(ctx: ScriptContext) -> None:
"""Shuts the client down"""
# The client quits and host will pick up the "client loss" due to
# timeouts
ctx.sender_running = False
ctx.listener_running = False
ctx.main_running = False
sys.stdin.close() # unblock listener
@staticmethod
def _thread_sender(ctx: ScriptContext) -> None:
"""Sends commands to host.
For commands expecting an acknowledge message the thread will then
block until the ack is received (or possibly resend the command if
CMD_ERROR is received).
"""
cTB.logger_ab.debug("thread_sender")
ctx.sender_running = True
while ctx.sender_running:
# Get rid of any unwanted acks from previous commands
POLIIGON_OT_sync_client._flush_queue_ack(ctx)
# Wait for something to send
try:
cmd_send = ctx.queue_send.get(timeout=1.0)
ctx.queue_send.task_done()
except Empty:
if ctx.sender_running:
continue
if not ctx.sender_running:
break
cTB.logger_ab.debug(f"Send: {cmd_send.code.name}")
cmd_send.send_to_stdio()
# Depending on sent command code, we are already done
if cmd_send.code in [SyncCmd.ASSET_OK, SyncCmd.ASSET_ERROR]:
# ASSET_OK, ASSET_ERROR are fire and forget,
# just proceed with next command
continue
elif cmd_send.code == SyncCmd.EXIT_ACK:
# EXIT_ACK is fire and forget, we are done here
ctx.sender_running = False
break
# Wait for acknowledge message
# TODO(Andreas): Currently this low retry count causes issues with
# Patrick's library on a NAS and then likely exposes
# a bug in timeout handling.
retries = 3
while retries > 0 and ctx.sender_running:
try:
cmd_ack = ctx.queue_ack.get(timeout=15.0)
ctx.queue_ack.task_done()
except Empty:
cmd_ack = None
retries -= 1
if cmd_ack is None:
# queue timeout,
# unless retries are exhausted continue to wait
if retries == 0:
# Unlikely we can gracefully recover
POLIIGON_OT_sync_client._shutdown_on_error(ctx)
break
elif cmd_ack.code == SyncCmd.CMD_ERROR:
# last sent command was not received well -> resend
if retries > 0:
cmd_send.send_to_stdio()
else:
# Unlikely we can gracefully recover
POLIIGON_OT_sync_client._shutdown_on_error(ctx)
elif cmd_ack.code == SyncCmd.CMD_DONE:
# last sent command was ok, continue with next
break
cTB.logger_ab.debug("thread_sender EXIT")
ctx.thd_sender = None
@staticmethod
def _start_sender(ctx: ScriptContext) -> None:
"""Starts thread_sender()"""
ctx.thd_sender = Thread(
target=POLIIGON_OT_sync_client._thread_sender,
args=(ctx, ),
daemon=True)
ctx.thd_sender.start()
@staticmethod
def _startup(ctx: ScriptContext) -> None:
cTB.logger_ab.debug("waiting for asset data...")
bpy.ops.poliigon.get_local_asset_sync(
await_startup_poliigon=False,
await_startup_my_assets=True,
get_poliigon=False,
get_my_assets=False,
abort_ongoing_jobs=False)
cTB.logger_ab.debug("...done.")
if not POLIIGON_OT_sync_client._read_poliigon_categories(ctx):
return False
ctx.queue_cmd = Queue()
ctx.queue_send = Queue()
ctx.queue_ack = Queue()
POLIIGON_OT_sync_client._start_listener(ctx)
POLIIGON_OT_sync_client._start_sender(ctx)
ctx.queue_send.put(SyncAssetBrowserCmd(code=SyncCmd.HELLO))
return True
@staticmethod
def _reset_blend():
"""Prepares a fresh blend file for stuff to be imported into."""
bpy.ops.wm.read_homefile(use_empty=True)
# To be safe deselect all
for obj in bpy.data.objects:
obj.select_set(False)
@staticmethod
def _save_blend(path: str) -> bool:
"""Saves the current blend file."""
# Remove previous file
# (host will re-process assets only, if force parameter was set)
if os.path.exists(path):
os.remove(path)
path_norm = os.path.normpath(path)
result = bpy.ops.wm.save_mainfile(filepath=path_norm,
check_existing=False,
exit=False)
return result == {"FINISHED"}
@staticmethod
def _get_unique_uuid(catalog_dict: Dict) -> str:
"""Returns a new, random UUID, which does not already exist
in catalog.
"""
uuid_is_unique = False
while not uuid_is_unique:
uuid_result = str(uuid4())
uuid_is_unique = True
for uuid_existing, _, _ in catalog_dict.values():
if uuid_result == uuid_existing:
uuid_is_unique = False
break
return uuid_result
# Based on code from:
# https://blender.stackexchange.com/questions/249316/python-set-asset-library-tags-and-catalogs
@staticmethod
def _get_catalog_dict(ctx: ScriptContext) -> Dict:
"""Reads blender's catalogue and returns a dictionary with its content.
Return value:
Dict: {catalog tree path: (uuid, catalog tree path, catalog name)}
"""
if not os.path.exists(ctx.path_cat):
return {}
catalogs = {}
with open(ctx.path_cat, "r") as file_catalogs:
for line in file_catalogs.readlines():
if line.startswith(("#", "VERSION", "\n")):
continue
# Each line contains:
# 'uuid:catalog_tree:catalog_name' + eol ('\n')
uuid, tree_path, name = line.split(":")
name = name.split("\n")[0]
catalogs[tree_path] = (uuid, tree_path, name)
return catalogs
@staticmethod
def _catalog_file_header(version: int = 1):
"""Returns the standard header of a catalog file."""
header = (
"# This is an Asset Catalog Definition file for Blender.\n"
"#\n"
"# Empty lines and lines starting with `#` will be ignored.\n"
"# The first non-ignored line should be the version indicator.\n"
'# Other lines are of the format "UUID:catalog/path/for/assets:simple catalog name"\n'
"\n"
f"VERSION {version}\n"
"\n")
return header
@staticmethod
def _write_catalog_file(ctx: ScriptContext, catalog_dict: Dict) -> bool:
"""Writes a catalog dict into a new catalog file,
replacing the old file upon success.
"""
path_cat_temp = ctx.path_cat + ".TEMP"
path_cat_bak = ctx.path_cat + ".BAK"
try:
# Write into temporary file
with open(path_cat_temp, "w") as file_catalogs:
header = POLIIGON_OT_sync_client._catalog_file_header()
file_catalogs.write(header)
for _uuid, tree_path, name in catalog_dict.values():
file_catalogs.write(f"{_uuid}:{tree_path}:{name}\n")
# Replace existing catalog file (if any) with above temporary file
if os.path.exists(ctx.path_cat):
shutil.move(ctx.path_cat, path_cat_bak)
shutil.move(path_cat_temp, ctx.path_cat)
if os.path.exists(path_cat_bak):
os.remove(path_cat_bak)
except IsADirectoryError:
# Should not occur, it's our files
cTB.logger_ab.exception("IsADirectoryError")
return False
except FileNotFoundError:
# Should not occur, it's being tested above
cTB.logger_ab.exception("FileNotFoundError")
return False
except OSError:
# Faied to create file
cTB.logger_ab.exception("OSError")
return False
except Exception:
cTB.logger_ab.exception("Unexpected exception!")
return False
return True
@staticmethod
def _read_poliigon_categories(ctx: ScriptContext) -> bool:
"""Reads all Poliigon categories into a dict in context."""
if not os.path.exists(ctx.path_categories):
cTB.logger_ab.debug("Poliigon categories file missing")
ctx.poliigon_categories = {"HDRIs": [],
"Models": [],
"Textures": []
}
return False
with open(ctx.path_categories, "r") as file_categories:
try:
ctx.poliigon_categories = json.load(file_categories)
except json.JSONDecodeError:
cTB.logger_ab.debug("Poliigon's category file is corrupt!")
return False
ctx.poliigon_categories = ctx.poliigon_categories["poliigon"]
return True
@staticmethod
def _get_unique_category_list(
ctx: ScriptContext, asset_data: AssetData) -> List[str]:
"""Returns a list of categories matching the first (alphabetically)
matching branch in Poliigon's category tree."""
asset_type = asset_data.asset_type
asset_name = asset_data.asset_name
asset_type_cat = ASSET_TYPE_TO_IMPORTED_TYPE[asset_type]
if asset_type_cat not in ctx.poliigon_categories:
cTB.logger_ab.debug("!!! Asset type not found "
f"{asset_name} {asset_type}")
cTB.logger_ab.debug(" Category types "
f"{list(ctx.poliigon_categories.keys())}")
return [asset_type.name]
# Have copy, as we are removing some categorie during the process
asset_categories = asset_data.categories.copy()
if "free" in asset_categories:
asset_categories.remove("free")
if asset_type_cat in asset_categories:
# It gets prepended anyway in next step
asset_categories.remove(asset_type_cat)
category_list = [asset_type_cat]
cat_slug = ""
for cat in asset_categories:
cat = cat.title()
cat_slug += "/" + cat
if cat_slug not in ctx.poliigon_categories[asset_type_cat]:
break
category_list.append(cat)
return category_list
@staticmethod
def _add_catalog(
ctx: ScriptContext, asset_data: AssetData, entity: Any) -> bool:
"""Assigns a catalog to an entity (object, collection, material,
world,...).
If needed, the catalog file will be extended with additional catalogs
based on the categories of the asset.
"""
catalog_dict = POLIIGON_OT_sync_client._get_catalog_dict(ctx)
asset_categories = POLIIGON_OT_sync_client._get_unique_category_list(
ctx, asset_data)
# After this loop uuid_result contains the UUID of the leaf catalog
for idx_cat, category in enumerate(asset_categories):
category_path = "/".join(asset_categories[:idx_cat + 1])
if category_path not in catalog_dict:
uuid_result = POLIIGON_OT_sync_client._get_unique_uuid(
catalog_dict)
catalog_dict[category_path] = (uuid_result,
category_path,
category)
else:
uuid_result, _, _ = catalog_dict[category_path]
if not POLIIGON_OT_sync_client._write_catalog_file(
ctx, catalog_dict):
cTB.logger_ab.debug("add_catalog(): Failed to write catalog file")
return False
# Finally assign the determined UUID to the entity
entity.asset_data.catalog_id = uuid_result
return True
@staticmethod
def _assign_asset_tags(
asset_data: AssetData, entity: Any, params: Dict) -> None:
"""Assigns tags to an entity (object, collection, material, world,...).
NOTE: This function requires entity.asset_mark() to be called
beforehand.
Args:
asset_data: AssetData
params: Populated by host in function
asset_browser.py:get_asset_job_parameters()
"""
asset_name = asset_data.asset_name
asset_display_name = asset_data.display_name
asset_type = asset_data.asset_type
entity.asset_data.tags.new(asset_display_name)
entity.asset_data.tags.new(asset_name) # unique name
entity.asset_data.tags.new("Poliigon")
for category in asset_data.categories:
# TODO(Andreas): maybe we want to filter free?
entity.asset_data.tags.new(category.title())
if asset_type == AssetType.HDRI:
entity.asset_data.tags.new(params["size"])
entity.asset_data.tags.new(params["size_bg"])
elif asset_type == AssetType.MODEL:
entity.asset_data.tags.new(params["size"])
entity.asset_data.tags.new(params["lod"])
elif asset_type == AssetType.TEXTURE:
entity.asset_data.tags.new(params["size"])
else:
raise NotImplementedError(f"Unsupported asset type: {asset_type}")
@staticmethod
def _assign_asset_preview(
asset_data: AssetData, entity: Any, params: Dict) -> None:
"""Assigns a preview image to an entity (object, collection, material,
world,...).
NOTE: This function requires entity.asset_mark() to be called
beforehand.
Args:
asset_data: AssetData
entity: Blender's object, collection, material, ...
params: Populated by host in function
asset_browser.py:get_asset_job_parameters()
"""
path_thumb = params["thumb"]
is_path = path_thumb is not None and len(path_thumb) > 2
if is_path and os.path.exists(path_thumb):
# From: https://blender.stackexchange.com/questions/6101/poll-failed-context-incorrect-example-bpy-ops-view3d-background-image-add
# and: https://blender.stackexchange.com/questions/245397/batch-assign-pre-existing-image-files-as-asset-previews
# equal to: if bpy.app.version >= (3, 2, 0)
if hasattr(bpy.context, "temp_override"):
with bpy.context.temp_override(id=entity):
bpy.ops.ed.lib_id_load_custom_preview(
filepath=path_thumb)
else:
bpy.ops.ed.lib_id_load_custom_preview(
{"id": entity}, filepath=path_thumb)
else:
# TODO(Andreas): Not working as expected
# Maybe https://developer.blender.org/T93893 ?
entity.asset_generate_preview()
@staticmethod
def _assign_asset_meta_data(ctx: ScriptContext,
asset_data: AssetData,
entity: Any,
params: Dict) -> bool:
"""Assigns all meta data (e.g. author, tags, preview, catalog...) to an
entity (object, collection, material, world,...).
Args:
ctx: ScriptContext instance created upon script start
asset_data: AssetData
entity: Blender's object, collection, material, ...
params: Populated by host in function
asset_browser.py:get_asset_job_parameters()
"""
if hasattr(entity, "type"):
type_label = f", type: {entity.type}"
elif isinstance(entity, bpy.types.Material):
type_label = ", type: Material"
else:
type_label = ", type: UNKNOWN"
cTB.logger_ab.debug(f"Marking {entity.name} {type_label}")
entity.asset_mark()
entity.asset_data.author = "Poliigon"
entity.asset_data.description = asset_data.display_name
try:
POLIIGON_OT_sync_client._assign_asset_tags(
asset_data, entity, params)
except NotImplementedError:
cTB.logger_ab.exception("Unsupported Asset Type")
return False
POLIIGON_OT_sync_client._assign_asset_preview(
asset_data, entity, params)
if not POLIIGON_OT_sync_client._add_catalog(ctx, asset_data, entity):
cTB.logger_ab.debug(
"assign_asset_meta_data(): Failed to add catalog")
return False
return True
@staticmethod
def _process_hdri(
ctx: ScriptContext, asset_data: AssetData, params: Dict) -> bool:
"""Processes an HDRI asset.
Args:
ctx: ScriptContext instance created upon script start
asset_data: An asset data dict passed down from P4B host.
params: Populated by host in function
asset_browser.py:get_asset_job_parameters()
"""
if "size" not in params or "thumb" not in params:
cTB.logger_ab.debug(
"Missing required parameter (size and/or thumb) to process "
"HDRI")
return False
asset_id = asset_data.asset_id
asset_name = asset_data.asset_name
size = params["size"]
size_bg = params["size_bg"]
cTB.logger_ab.debug(f"process_hdri {asset_name} {size}")
try:
result = bpy.ops.poliigon.poliigon_hdri(
asset_id=asset_id,
size=size,
size_bg=size_bg)
except Exception:
cTB.logger_ab.exception("HDRI ERROR")
return False
if result != {"FINISHED"}:
return False
# Rename world,
# otherwise the asset would appear as "World" in the Asset Browser.
world = bpy.context.scene.world
world.name = asset_name
if not POLIIGON_OT_sync_client._assign_asset_meta_data(
ctx, asset_data, world, params):
return False
return True
@staticmethod
def _process_model(
ctx: ScriptContext, asset_data: AssetData, params: Dict) -> bool:
"""Processes a Model asset.
Args:
ctx: ScriptContext instance created upon script start
asset_data: AssetData
params: Populated by host in function
asset_browser.py:get_asset_job_parameters()
"""
has_size = "size" in params
has_lod = "lod" in params
has_thumb = "thumb" in params
if not has_size or not has_lod or not has_thumb:
cTB.logger_ab.debug(
"Missing required parameter (size, lod and/or thumb) to "
"process Model")
return False
asset_id = asset_data.asset_id
asset_name = asset_data.asset_name
size = params["size"]
lod = params["lod"]
cTB.logger_ab.debug(f"process_model {asset_name} {size} {lod}")
try:
result = bpy.ops.poliigon.poliigon_model(
asset_id=asset_id,
size=size,
lod=lod,
do_use_collection=True,
do_link_blend=True,
do_reuse_materials=False)
except Exception:
cTB.logger_ab.exception("MODEL ERROR")
return False
if result != {"FINISHED"}:
cTB.logger_ab.error("LOAD FAILURE")
return False
# Mark the object instancing our collection
found = False
error = False
for obj in bpy.data.objects:
if obj.type != "EMPTY":
continue
if obj.parent is not None:
continue
if not obj.name.startswith(asset_name):
continue
if obj.instance_collection is None:
continue
if POLIIGON_OT_sync_client._assign_asset_meta_data(
ctx, asset_data, obj, params):
found = True
else:
error = True
break
return found and not error
@staticmethod
def _process_texture(
ctx: ScriptContext, asset_data: AssetData, params: Dict) -> bool:
"""Processes a Texture asset (including backplates and backdrops).
Args:
ctx: ScriptContext instance created upon script start
asset_data: AssetData
params: Populated by host in function
asset_browser.py:get_asset_job_parameters()
"""
if "size" not in params or "thumb" not in params:
cTB.logger_ab.debug(
"Missing required parameter (size and/or thumb) to process "
"Texture")
return False
asset_id = asset_data.asset_id
asset_name = asset_data.asset_name
size = params["size"]
cTB.logger_ab.debug(f"process_texture {asset_name} {size}")
try:
result = bpy.ops.poliigon.poliigon_material(
asset_id=asset_id, size=size)
except Exception:
cTB.logger_ab.exception("MATERIAL ERROR")
return False
if result != {"FINISHED"}:
cTB.logger_ab.debug(
f"Operator poliigon_material returned: {result}")
return False
found = False
error = False
for mat in bpy.data.materials:
if not mat.name.startswith(asset_name):
continue
if POLIIGON_OT_sync_client._assign_asset_meta_data(
ctx, asset_data, mat, params):
found = True
else:
error = True
cTB.logger_ab.debug(
f"Failed to assign meta data to material: {mat.name}")
break
if not found:
cTB.logger_ab.debug("Found no entity to mark")
return found and not error
@staticmethod
def _process_asset(
ctx: ScriptContext, asset_data: AssetData, params: Dict) -> bool:
"""Creates and saves an Asset Browser-marked asset to a new blend file.
Args:
ctx: ScriptContext instance created upon script start
asset_data: AssetData
params: Populated by host in function
asset_browser.py:get_asset_job_parameters()
"""
if "path_result" not in params:
cTB.logger_ab.debug("process_asset(): Lacking result path!")
return False
path_result = params["path_result"]
POLIIGON_OT_sync_client._reset_blend()
asset_name = asset_data.asset_name
asset_type = asset_data.asset_type
cTB.logger_ab.debug(f"process_asset() {asset_name}")
for _param, value in params.items():
cTB.logger_ab.debug(f" {_param} {value}")
if asset_type == AssetType.HDRI:
result = POLIIGON_OT_sync_client._process_hdri(
ctx, asset_data, params)
elif asset_type == AssetType.MODEL:
result = POLIIGON_OT_sync_client._process_model(
ctx, asset_data, params)
elif asset_type == AssetType.TEXTURE:
result = POLIIGON_OT_sync_client._process_texture(
ctx, asset_data, params)
else:
cTB.logger_ab.debug("process_asset(): Unknown asset type")
return False
if result:
result = POLIIGON_OT_sync_client._save_blend(path_result)
return result
@staticmethod
def _cmd_asset(ctx: ScriptContext, cmd: SyncAssetBrowserCmd) -> None:
"""Handle an ASSET command"""
asset_id = cmd.data["asset_id"]
asset_data = cTB._asset_index.get_asset(asset_id)
if asset_data is None:
bpy.ops.poliigon.get_local_asset_sync(
await_startup_poliigon=False,
await_startup_my_assets=False,
get_poliigon=False,
get_my_assets=True,
asset_id=asset_id,
abort_ongoing_jobs=False)
asset_data = cTB._asset_index.get_asset(asset_id)
if asset_data is not None:
result = POLIIGON_OT_sync_client._process_asset(
ctx, asset_data, cmd.params)
else:
cTB.logger_ab.error(f"process_asset(): No asset data for {asset_id}.")
result = False
if result:
ctx.queue_send.put(SyncAssetBrowserCmd(code=SyncCmd.ASSET_OK,
data=cmd.data))
else:
ctx.queue_send.put(SyncAssetBrowserCmd(code=SyncCmd.ASSET_ERROR,
data=cmd.data))
cTB.logger_ab.debug(f"cmd_asset exit {asset_data.asset_name}")
@staticmethod
def _cmd_hello_ok(ctx: ScriptContext, cmd: SyncAssetBrowserCmd) -> None:
"""Handle a HELLO_OK command"""
cTB.logger_ab.debug("cmd_hello_ok")
ctx.queue_ack.put(SyncAssetBrowserCmd(code=SyncCmd.CMD_DONE))
@staticmethod
def _cmd_still_there(ctx: ScriptContext, cmd: SyncAssetBrowserCmd) -> None:
"""Handle a STILL_THERE command"""
cTB.logger_ab.debug("cmd_still_there")
bpy.ops.poliigon.get_local_asset_sync(
await_startup_poliigon=False,
await_startup_my_assets=True,
get_poliigon=False,
get_my_assets=False,
abort_ongoing_jobs=False)
ctx.queue_send.put(SyncAssetBrowserCmd(code=SyncCmd.HELLO))
@staticmethod
def _cmd_exit(ctx: ScriptContext, cmd: SyncAssetBrowserCmd) -> None:
"""Handle an EXIT command"""
cTB.logger_ab.debug("cmd_exit")
# Notify host, we are going to exit
ctx.queue_send.put(SyncAssetBrowserCmd(code=SyncCmd.EXIT_ACK))
# Tear down everything
ctx.listener_running = False
if ctx.thd_listener is not None:
ctx.thd_listener.join()
if ctx.thd_sender is not None:
ctx.thd_sender.join()
ctx.main_running = False
def _init_settings(self) -> None:
"""Changes settings to what is needed by sync client
(backing up the original settings).
"""
self.settings_backup = {}
for _key in ["download_prefer_blend",
"download_link_blend"]:
self.settings_backup[_key] = cTB.settings[_key]
cTB.settings["download_prefer_blend"] = 1
cTB.settings["download_link_blend"] = 0
def _restore_settings(self) -> None:
"""Restores settings from backup."""
for _key in ["download_prefer_blend",
"download_link_blend"]:
cTB.settings[_key] = self.settings_backup[_key]
@reporting.handle_operator(silent=True)
def execute(self, context):
has_catalog = self.path_catalog is not None
has_categories = self.path_categories is not None
if not has_catalog or not has_categories:
return {"CANCELLED"}
ctx = ScriptContext(
path_cat=self.path_catalog,
path_categories=self.path_categories)
if not self._startup(ctx):
return {"CANCELLED"}
ctx.main_running = True
while ctx.main_running:
try:
cmd_recv = ctx.queue_cmd.get(timeout=1.0)
ctx.queue_cmd.task_done()
except Empty:
continue
if cmd_recv is None:
continue
if cmd_recv.code == SyncCmd.EXIT:
self._cmd_exit(ctx, cmd_recv)
elif cmd_recv.code == SyncCmd.ASSET:
self._init_settings()
self._cmd_asset(ctx, cmd_recv)
self._restore_settings()
elif cmd_recv.code == SyncCmd.STILL_THERE:
self._cmd_still_there(ctx, cmd_recv)
elif cmd_recv.code == SyncCmd.HELLO_OK:
self._cmd_hello_ok(ctx, cmd_recv)
return {"FINISHED"}
@@ -0,0 +1,90 @@
# #### 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 threading import Thread
from bpy.types import Operator
from bpy.props import (
BoolProperty,
IntProperty
)
import bpy
from ..modules.poliigon_core.multilingual import _t
from ..constants import ASSET_ID_ALL
from ..toolbox import get_context
from .. import reporting
from . import asset_browser as ab
class POLIIGON_OT_update_asset_browser(Operator):
bl_idname = "poliigon.update_asset_browser"
bl_label = _t("Sync Local Assets")
bl_category = "Poliigon"
bl_description = _t("Synchronize local assets with Asset Browser")
bl_options = {"INTERNAL"}
asset_id: IntProperty(options={"HIDDEN"}, default=ASSET_ID_ALL) # noqa: F821
force: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@reporting.handle_operator(silent=True)
def execute(self, context):
if bpy.app.version < (3, 0):
self.report(
{"ERROR"},
"Asset browser not available in this blender version")
return {"CANCELLED"}
bpy.ops.poliigon.get_local_asset_sync(
await_startup_poliigon=False,
await_startup_my_assets=False,
get_poliigon=False,
get_my_assets=True,
asset_id=self.asset_id)
if self.asset_id != ASSET_ID_ALL:
asset_ids = cTB._asset_index.get_asset_id_list(
purchased=True, local=True)
if self.asset_id not in asset_ids:
self.report(
{"ERROR"},
f"Asset ID {self.asset_id} not found")
return {"CANCELLED"}
if ab.create_poliigon_library() is None:
cTB.logger_ab.debug("HOST: No Poliigon library in Asset Browser!")
error_msg = "No Poliigon library in Asset."
reporting.capture_message(
"asset_browser_no_polii_lib", error_msg, "error")
self.report({"ERROR"}, error_msg)
return {"CANCELLED"}
thd_init_sync = Thread(
target=ab.thread_initiate_asset_synchronization,
args=(self.asset_id, self.force, ))
thd_init_sync.start()
cTB.threads.append(thd_init_sync)
return {"FINISHED"}
@@ -0,0 +1,47 @@
# #### 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
from .asset_browser_operator_import import POLIIGON_OT_asset_browser_import
from .asset_browser_operator_quick_menu import POLIIGON_OT_asset_browser_quick_menu
from .asset_browser_operator_reprocess import POLIIGON_OT_asset_browser_reprocess
from .asset_browser_operator_sync_cancel import POLIIGON_OT_cancel_asset_browser_sync
from .asset_browser_operator_sync_client import POLIIGON_OT_sync_client
from .asset_browser_operator_update import POLIIGON_OT_update_asset_browser
classes = (
POLIIGON_OT_update_asset_browser,
POLIIGON_OT_cancel_asset_browser_sync,
POLIIGON_OT_asset_browser_import,
POLIIGON_OT_asset_browser_quick_menu,
POLIIGON_OT_asset_browser_reprocess,
POLIIGON_OT_sync_client
)
def register(addon_version: str):
for cls in classes:
bpy.utils.register_class(cls)
cls.init_context(addon_version)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -0,0 +1,129 @@
# #### 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 #####
"""Standalone blender startup script used to generate asset blend files.
General Asset Browser links:
Asset Catalogs: https://wiki.blender.org/wiki/Source/Architecture/Asset_System/Catalogs
Asset Operators: https://docs.blender.org/api/current/bpy.ops.asset.html
AssetMetaData: https://docs.blender.org/api/current/bpy.types.AssetMetaData.html#bpy.types.AssetMetaData
Catalogs and save: https://blender.stackexchange.com/questions/284833/get-asset-browser-catalogs-in-case-of-unsaved-changes
Operator overriding:
https://blender.stackexchange.com/questions/248274/a-comprehensive-list-of-operator-overrides
https://blender.stackexchange.com/questions/129989/override-context-for-operator-called-from-panel
https://blender.stackexchange.com/questions/182713/how-to-use-context-override-on-the-disable-and-keep-transform-operator
https://blender.stackexchange.com/questions/273474/how-to-override-context-to-launch-ops-commands-in-text-editor-3-2
https://blender.stackexchange.com/questions/875/proper-bpy-ops-context-setup-in-a-plugin
Asset browser related, not much use in here:
https://blender.stackexchange.com/questions/262284/how-do-i-access-the-list-of-selected-assets-from-an-event-in-python
https://blender.stackexchange.com/questions/261213/get-the-source-path-of-the-assets-in-asset-browser-using-python
"""
import argparse
import os
import sys
import bpy
from typing import Tuple
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from modules.poliigon_core.multilingual import _t # noqa: E402
from constants import ADDON_NAME # noqa: E402
DEBUG_CLIENT = False
def print_debug(*args, file=sys.stdout) -> None:
"""Use for printing in client script"""
if not DEBUG_CLIENT:
return
print(" C:", *args, file=file)
def command_line_args() -> Tuple[str, str]:
"""Parses command line args."""
path_catalog = None
path_categories = None
# Skip Blender's own command line args
argv = sys.argv
try:
idx_arg = argv.index("--") + 1
except ValueError:
idx_arg = None
if idx_arg is None or idx_arg >= len(argv):
return None, None
argv = argv[idx_arg:]
try:
parser = argparse.ArgumentParser()
parser.add_argument("-pcf", "--poliigon_cat_file",
help=_t("Path to catalog file"),
required=True)
parser.add_argument("-pc", "--poliigon_categories",
help=_t("Path to file with Poliigon categories"),
required=True)
args = parser.parse_args(argv)
except Exception as e:
print_debug(e)
if args.poliigon_cat_file:
path_catalog = args.poliigon_cat_file
else:
print_debug(
"Lacking path to Blender cat file in commandline arguments!")
return None, None
if args.poliigon_categories:
path_categories = args.poliigon_categories
else:
print_debug(
"Lacking path to Poliigon categories file in commandline "
"arguments!")
return None, None
return path_catalog, path_categories
def main():
print_debug("Hello Blender host, I am the client")
path_catalog, path_categories = command_line_args()
has_catalog = path_catalog is not None
has_categories = path_categories is not None
if not has_catalog or not has_categories:
print_debug("Missing catalog or categories path.")
return
bpy.ops.preferences.addon_enable(module=ADDON_NAME)
bpy.ops.poliigon.asset_browser_sync_client(
path_catalog=path_catalog, path_categories=path_categories)
print_debug("Subprocess exit")
if __name__ == "__main__":
main()
@@ -0,0 +1,112 @@
# #### 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 dataclasses import asdict, dataclass
from enum import IntEnum
import hashlib
import json
import sys
from typing import Dict, Optional
# FILE_H2C belongs to proc (proc.stdin)
FILE_C2H = sys.stderr
CMD_MARKER_START = "POLIIGON_CMD_START\n"
CMD_MARKER_END = "POLIIGON_CMD_END\n"
class SyncCmd(IntEnum):
"""Command codes"""
HELLO = 0 # Client -> Host
HELLO_OK = 1 # Host -> Client (ack)
ASSET = 2 # Host -> Client
ASSET_OK = 3 # Client -> Host (ack)
ASSET_ERROR = 4 # Client -> Host (ack)
EXIT = 5 # Host -> Client
EXIT_ACK = 6 # Client -> Host (ack)
CMD_DONE = 7 # internal
CMD_ERROR = 8 # both directions (ack)
STILL_THERE = 9 # Host -> Client
@dataclass
class SyncAssetBrowserCmd():
"""Command to be transmitted between host and client"""
code: SyncCmd
data: Optional[Dict] = None
params: Optional[Dict] = None
checksum: Optional[str] = ""
@classmethod
def from_json(cls, buf: str):
"""Alternate constructor, used after receiving a command."""
cmd_dict = json.loads(buf)
if "code" not in cmd_dict:
raise KeyError("code")
new = cls(**cmd_dict)
new.code = SyncCmd(new.code)
cmd_is_ok = new.check_checksum()
if not cmd_is_ok:
raise RuntimeError("Checksum error")
return new
def check_checksum(self) -> bool:
checksum_to_test = self.checksum
self.checksum = ""
cmd_dict = asdict(self)
json_str = json.dumps(cmd_dict, indent=4, default=vars) + "\n"
checksum_calculated = hashlib.md5(json_str.encode("utf-8")).hexdigest()
return checksum_to_test == checksum_calculated
def to_json(self) -> str:
cmd_dict = asdict(self)
json_str = json.dumps(cmd_dict, indent=4, default=vars) + "\n"
return json_str
def calc_checksum(self) -> None:
self.checksum = ""
json_str = self.to_json()
try:
self.checksum = hashlib.md5(json_str.encode("utf-8")).hexdigest()
except Exception as e:
print(f"MD5 error: {e}")
def prepare_send(self) -> str:
self.calc_checksum()
json_str = CMD_MARKER_START
json_str += self.to_json()
json_str += CMD_MARKER_END
return json_str
def send_to_process(self, proc) -> None: # use on host
try:
proc.stdin.write(self.prepare_send())
proc.stdin.flush()
except Exception:
# Deliberately silencing exceptions here.
# Any exceptions regarding unexpectedly closed handles are handled
# in respective threads instead.
pass
def send_to_stdio(self, file=FILE_C2H) -> None: # use on client
file.write(self.prepare_send())
file.flush()
@@ -0,0 +1,273 @@
# #### 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 Optional
import bpy
from ..modules.poliigon_core.multilingual import _t
from .asset_browser import (
get_num_selected_assets,
is_asset_browser,
is_only_poliigon_selected,
is_poliigon_library,
t_status_bar_update)
from ..constants import ASSET_ID_ALL
from ..toolbox import get_context
from .. import reporting
def build_asset_browser_progress(ui: bpy.types.Panel,
context: bpy.context,
layout: Optional[bpy.types.UILayout] = None,
show_label: bool = True,
show_cancel: bool = True,
show_second_line: bool = False) -> None:
if layout is None:
layout = ui.layout
if cTB.num_asset_browser_jobs == 0 and not cTB.blender_client_starting:
return
elif cTB.blender_client_starting:
col = layout.column()
row = col.row(align=True)
row.label(text=_t("Blender client starting up..."))
return
num_jobs_done = cTB.num_jobs_error + cTB.num_jobs_ok
progress = num_jobs_done / cTB.num_asset_browser_jobs
progress = max(0.01, progress)
done = num_jobs_done == cTB.num_asset_browser_jobs
layout.separator()
col = layout.column()
row = col.row(align=True)
if show_label and done:
row.label(text=_t("Asset Browser Synchronization : "))
elif show_label and not done:
row.label(text=_t("Asset Browser Synchronization : {0:.1%}").format(
progress))
if done:
if cTB.num_jobs_error:
text = _t("Finished {0} assets, {1} errors").format(
cTB.num_asset_browser_jobs, cTB.num_jobs_error)
row.label(text=text,
icon="ERROR")
else:
text = _t("Successfully finished {0} assets").format(
cTB.num_asset_browser_jobs)
row.label(text=text,
icon="CHECKMARK")
return
tooltip_progress = _t("Processing assets: {0} of {1} assets done").format(
num_jobs_done, cTB.num_asset_browser_jobs)
split = row.split(factor=progress, align=True)
op = split.operator(
"poliigon.poliigon_setting", text="", emboss=1, depress=1
)
op.mode = "none"
op.tooltip = tooltip_progress
op = split.operator(
"poliigon.poliigon_setting", text="", emboss=1, depress=0
)
op.mode = "none"
op.tooltip = tooltip_progress
if show_cancel:
op = row.operator(
"poliigon.cancel_asset_browser",
text="",
emboss=True,
depress=0,
icon="X"
)
if cTB.asset_browser_jobs_cancelled:
row.enabled = False
if show_second_line:
text = _t("Processed {0} of {1} assets.").format(
num_jobs_done, cTB.num_asset_browser_jobs)
col.label(text=text)
class POLIIGON_PT_sidebar_left(bpy.types.Panel):
bl_label = "Poliigon"
bl_space_type = "FILE_BROWSER"
bl_region_type = "TOOLS"
bl_options = {"HIDE_HEADER"}
view_screen_tracked = False
@classmethod
def poll(self, context):
if not cTB.is_logged_in():
return False
if not is_asset_browser(context):
return False
if not is_poliigon_library(context, incl_all_libs=False):
return False
return True
@reporting.handle_draw()
def draw(self, context):
cTB._api._mp_relevant = True
if not self.view_screen_tracked:
# TODO(patrick): value not retained, re-triggering on future draws
self.view_screen_tracked = True
cTB.track_screen("blend_browser_lib")
layout = self.layout
box = layout.box()
col = box.column(align=True)
name_is_set = cTB.prefs.asset_browser_library_name != ""
directory_is_set = cTB.get_library_path(primary=True) not in [None, ""]
sync_options_enabled = name_is_set and directory_is_set
if cTB.num_asset_browser_jobs == 0 and not cTB.lock_client_start.locked():
col.label(text="Poliigon assets:")
row_manual_sync = col.row(align=1)
op_manual_sync = row_manual_sync.operator(
"poliigon.update_asset_browser",
text=_t("Synchronize Local Assets"),
emboss=True,
icon="FILE_REFRESH",
)
op_manual_sync.asset_id = ASSET_ID_ALL
row_manual_sync.enabled = sync_options_enabled
else:
col.label(text=_t("Poliigon Asset Browser Synchronization"))
build_asset_browser_progress(self,
context,
col,
show_label=False,
show_second_line=True)
class POLIIGON_PT_sidebar_right(bpy.types.Panel):
bl_label = _t("Poliigon in Asset Browser")
bl_space_type = "FILE_BROWSER"
bl_region_type = "TOOL_PROPS" # right side panel
bl_options = {"HEADER_LAYOUT_EXPAND"}
view_screen_tracked = False
@classmethod
def poll(self, context):
if not cTB.is_logged_in():
return False
if not is_asset_browser(context):
return False
if not is_poliigon_library(context):
return False
if not is_only_poliigon_selected(context):
return False
return True
@reporting.handle_draw()
def draw(self, context):
if not is_poliigon_library(context):
return
if not self.view_screen_tracked:
cTB.track_screen("blend_browser_import")
self.view_screen_tracked = True
num_selected = get_num_selected_assets(context)
if num_selected == 1:
label_import = _t("Import Asset (TODO)")
label_reprocess = _t("Re-process Asset")
elif num_selected > 1:
label_import = _t("Import {0} Assets (TODO)").format(num_selected)
label_reprocess = _t("Re-process {0} Assets").format(num_selected)
else:
label_import = _t("No Asset Selected")
label_reprocess = _t("Re-process Asset")
layout = self.layout
col = layout.column()
row = col.row()
row.operator("poliigon.asset_browser_reprocess",
text=label_reprocess,
icon="FILE_REFRESH")
col.separator()
if not (cTB._env.env_name and "dev" in cTB._env.env_name.lower()):
return
# TODO(Andreas): Import button currently in dev environment, only
row = col.row(align=True)
row.operator(
"poliigon.asset_browser_import",
text=label_import,
emboss=True,
)
row.operator(
"poliigon.asset_browser_quick_menu",
text="",
icon="TRIA_DOWN",
)
classes_prod = (
POLIIGON_PT_sidebar_left,
)
classes_dev = (
POLIIGON_PT_sidebar_left,
POLIIGON_PT_sidebar_right
)
classes = None
cTB = None
def register(addon_version: str):
global classes
global cTB
cTB = get_context(addon_version)
if cTB._env.env_name and "dev" in cTB._env.env_name.lower():
classes = classes_dev
else:
classes = classes_prod
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.STATUSBAR_HT_header.prepend(build_asset_browser_progress)
def unregister():
if bpy.app.timers.is_registered(t_status_bar_update):
bpy.app.timers.unregister(t_status_bar_update)
bpy.types.STATUSBAR_HT_header.remove(build_asset_browser_progress)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)