save startup blend for animation tab & whatnot
This commit is contained in:
2026-04-08 12:10:18 -06:00
parent 57a652524a
commit 692e200ffe
180 changed files with 12336 additions and 3431 deletions
+221 -32
View File
@@ -19,8 +19,8 @@
bl_info = {
"name": "BlenderKit Online Asset Library",
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik",
"version": (3, 18, 1, 251219), # X.Y.Z.yymmdd
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik, Michal Hons",
"version": (3, 19, 1, 260402), # X.Y.Z.yymmdd
"blender": (3, 0, 0),
"location": "View3D > Properties > BlenderKit",
"description": "Boost your workflow with drag&drop assets from the community driven library.",
@@ -28,7 +28,7 @@ bl_info = {
"tracker_url": "https://github.com/BlenderKit/blenderkit/issues",
"category": "3D View",
}
VERSION = (3, 18, 1, 251219)
VERSION = (3, 19, 1, 260402)
import logging
import random
@@ -96,6 +96,7 @@ if "bpy" in locals():
ui = reload(ui)
ui_bgl = reload(ui_bgl)
ui_panels = reload(ui_panels)
keymap_utils = reload(keymap_utils)
upload = reload(upload)
upload_bg = reload(upload_bg)
utils = reload(utils)
@@ -152,6 +153,7 @@ else:
from . import ui
from . import ui_bgl
from . import ui_panels
from . import keymap_utils
from . import upload
from . import upload_bg
from . import utils
@@ -273,7 +275,7 @@ if bpy.app.version >= (4, 5, 0):
EXTRA_PATH_OPTIONS = {"options": {"PATH_SUPPORTS_BLEND_RELATIVE"}}
def udate_down_up(self, context):
def update_down_up(self, context):
"""Perform a search if results are empty."""
props = bpy.context.window_manager.blenderkitUI
if search.get_search_results() is None and props.down_up == "SEARCH":
@@ -308,6 +310,19 @@ def asset_type_callback(self, context):
if bpy.app.version >= (4, 2, 0):
items.append(("ADDON", "Add-ons", "Find add-ons", "PLUGIN", 7))
addon = bpy.context.preferences.addons.get(__package__)
if addon is not None:
preferences = addon.preferences
if preferences.experimental_features and preferences.author_tab:
items.append(
(
"AUTHOR",
"Authors",
"Find authors",
pcoll["asset_type_author"].icon_id,
8,
),
)
else:
items = [
("MODEL", "Model", "Upload a model", "OBJECT_DATAMODE", 0),
@@ -330,6 +345,8 @@ def asset_type_callback(self, context):
if bpy.app.version >= (4, 2, 0):
items.append(("ADDON", "Add-on", "Upload an addon", "PLUGIN", 7))
# Author is search-only, no upload entry needed
return items
@@ -337,6 +354,10 @@ def run_drag_drop_update(self, context):
if self.drag_init_button:
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.dragging:
self.drag_init_button = False
return
bpy.ops.view3d.close_popup_button("INVOKE_DEFAULT")
bpy.ops.view3d.asset_drag_drop(
"INVOKE_DEFAULT",
@@ -356,7 +377,7 @@ class BlenderKitUIProps(PropertyGroup):
),
description="BlenderKit",
default="SEARCH",
update=udate_down_up,
update=update_down_up,
)
asset_type: EnumProperty(
name=" ",
@@ -505,11 +526,11 @@ class BlenderKitUIProps(PropertyGroup):
ui_scale = 1
thumb_size_def = 96
thumb_size_def = 128
margin_def = 0
thumb_size: IntProperty(
name="Thumbnail Size", default=thumb_size_def, min=-1, max=256
name="Thumbnail Size", default=thumb_size_def, min=48, max=256
)
margin: IntProperty(name="Margin", default=margin_def, min=-1, max=256)
@@ -552,7 +573,6 @@ class BlenderKitUIProps(PropertyGroup):
description="Click or drag into scene for download.\nUse mouse wheel during drag to rotate the asset. Cancel the drag by pressing 'Esc'.",
update=run_drag_drop_update,
)
drag_length: IntProperty(name="Drag length", default=0)
draw_drag_image: BoolProperty(name="Draw Drag Image", default=False)
draw_snapped_bounds: BoolProperty(name="Draw Snapped Bounds", default=False)
@@ -622,6 +642,32 @@ class BlenderKitUIProps(PropertyGroup):
name="Upload HDR", type=bpy.types.Image, description="Pick an image to upload"
)
hdr_use_custom_thumbnail_tone: BoolProperty(
name="Use Custom Thumbnail Tone",
description="Use custom exposure and gamma for HDR thumbnail conversion",
default=False,
)
hdr_thumbnail_exposure: FloatProperty(
name="Thumbnail Exposure",
description="Exposure offset used only for HDR thumbnail conversion",
default=0.0,
min=-5.0,
max=5.0,
soft_min=-2.0,
soft_max=2.0,
)
hdr_thumbnail_gamma: FloatProperty(
name="Thumbnail Gamma",
description="Gamma used only for HDR thumbnail conversion",
default=1.0,
min=0.2,
max=3.0,
soft_min=0.7,
soft_max=1.6,
)
nodegroup_upload: PointerProperty(
name="Upload Tool",
type=bpy.types.GeometryNodeTree,
@@ -785,7 +831,7 @@ def update_free(self, context):
message="Any material uploaded to BlenderKit is free."
" However, it can still earn money for the author,"
" based on our fair share system. "
"Part of subscription is sent to artists based on usage by paying users.\n",
"Part of subscription is sent to authors based on usage by paying users.\n",
)
@@ -868,6 +914,21 @@ class BlenderKitCommonUploadProps(object):
description="License. Please read our help for choosing the right licenses",
)
# verification mainly to retrigger processing
verification_status: EnumProperty(
name="Verification status",
description="Verification status of the asset, set by moderators",
items=(
("UPLOADING", "Uploading", "uploading"),
("UPLOADED", "Uploaded", "uploaded"),
("VALIDATED", "Validated", "validated"),
("ON_HOLD", "On Hold", "on_hold"),
("REJECTED", "Rejected", "rejected"),
("DELETED", "Deleted", "deleted"),
),
default="UPLOADING",
)
is_private: EnumProperty(
name="Thumbnail Style",
items=(("PRIVATE", "Private", ""), ("PUBLIC", "Public", "")),
@@ -1187,13 +1248,17 @@ class BlenderKitAddonSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
description="Show only addons that are already installed in Blender",
default=False,
update=lambda self, context: (
search.refresh_search()
search.search_update(self, context)
if context.window_manager.blenderkitUI.asset_type == "ADDON"
else None
),
)
class BlenderKitAuthorSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
pass
class BlenderKitHDRUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
texture_resolution_max: IntProperty(
name="Texture Resolution Max",
@@ -1207,6 +1272,15 @@ class BlenderKitHDRUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
class BlenderKitBrushUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail: StringProperty(
name="Thumbnail",
description="Thumbnail path - at least 1024x1024 .jpg or .png",
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_brush_preview,
**EXTRA_PATH_OPTIONS,
)
mode: EnumProperty(
name="Mode",
items=(
@@ -1526,6 +1600,13 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
update=autothumb.update_upload_model_preview,
)
is_generating_wire_thumbnail: BoolProperty(
name="Generating Wire Thumbnail",
description="True when background process is running",
default=False,
update=autothumb.update_wire_thumbnail_preview,
)
has_autotags: BoolProperty(
name="Has Autotagging Done",
description="True when autotagging done",
@@ -1546,6 +1627,26 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
default=False,
)
wire_thumbnail: StringProperty(
name="Wireframe Thumbnail",
description="Wireframe thumbnail (JPG or PNG, preferred size is 1024x1024 or higher)",
subtype="FILE_PATH",
default="",
update=autothumb.update_wire_thumbnail_preview,
**EXTRA_PATH_OPTIONS,
)
wire_thumbnail_will_upload_on_website: BoolProperty(
name="I will upload wireframe thumbnail on website",
description="True if the wireframe thumbnail will upload on the website\n please read upload tutorial for more information",
default=False,
)
wire_thumbnail_generating_state: StringProperty(
name="Wire Thumbnail Generating State",
description="bg process reports for wireframe thumbnail generation",
default="Please add wireframe thumbnail (jpg or png, at least 1024x1024)",
)
class BlenderKitSceneUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
style: EnumProperty(
@@ -1923,6 +2024,14 @@ class BlenderKitHDRSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
)
def our_keymap_draw(self, context):
try:
keymap_utils.draw_keymap(self, context)
except Exception:
bk_logger.exception("Failed to draw keymap in preferences")
return
class BlenderKitSceneSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
search_style: EnumProperty(
name="Style",
@@ -2090,6 +2199,12 @@ class BlenderKitAddonPreferences(AddonPreferences):
default=True,
)
assetbar_follows_cursor: BoolProperty(
name="Assetbar follows active viewport",
description="Make the assetbar follow the cursor across the screen",
default=False,
)
global_dir: StringProperty(
name="Global Directory",
description="Global storage for your assets, will use subdirectories for the contents. Client will place its files in subdirectory 'client'",
@@ -2151,6 +2266,13 @@ class BlenderKitAddonPreferences(AddonPreferences):
update=update_unpack,
)
write_asset_metadata: BoolProperty(
name="Write Asset Metadata",
description="Write BlenderKit metadata into downloaded files so tags, description, and preview show in other scenes",
default=True,
update=utils.save_prefs,
)
# resolution download/import settings
resolution: EnumProperty(
name="Max resolution",
@@ -2317,6 +2439,13 @@ In this case you should also set path to your system CA bundle containing proxy'
update=utils.save_prefs,
)
thumbnail_disable_subdivision: BoolProperty(
name="Disable Subdivision for Thumbnails Rendering (For assets upload)",
description="By default this is off. Disable this for wireframe thumbnails to render faster",
default=False,
update=utils.save_prefs,
)
maximized_assetbar_rows: IntProperty(
name="Maximized Assetbar Rows",
description="Maximum rows of assetbar in the 3D view when expanded",
@@ -2334,8 +2463,8 @@ In this case you should also set path to your system CA bundle containing proxy'
thumb_size: IntProperty(
name="Assetbar Thumbnail Size",
default=96,
min=-1,
default=128,
min=48, # must newer be zero
max=256,
update=utils.save_prefs,
description="Size of thumbnails of the assetbar in 3D view",
@@ -2352,7 +2481,21 @@ In this case you should also set path to your system CA bundle containing proxy'
experimental_features: BoolProperty(
name="Enable experimental features",
description="Enable experimental features of BlenderKit. Note: There are no experimental features in this version.",
description="Enable experimental features of BlenderKit, such as the Authors tab",
default=False,
update=utils.save_prefs,
)
author_tab: BoolProperty(
name="Show Authors tab",
description="Show Authors tab in the assetbar. This tab allows you to see all assets of a specific author and is also used for showing your profile and assets",
default=False,
update=utils.save_prefs,
)
author_asset_type_picker: BoolProperty(
name="Author asset type picker",
description="Enable the Authors tab in the asset type picker. When disabled, clicking an author searches in the current tab",
default=False,
update=utils.save_prefs,
)
@@ -2458,22 +2601,45 @@ In this case you should also set path to your system CA bundle containing proxy'
options={"SKIP_SAVE"},
)
enable_wire_thumbnail_upload: BoolProperty(
name="Enable wire thumbnail upload",
description="If enabled, wireframe thumbnails will be uploaded.",
default=False,
# do not save prefs here, it's experimental
options={"SKIP_SAVE"},
)
def draw(self, context):
layout = self.layout
login_box = layout.box()
login_box.label(text="Login Options")
if self.api_key.strip() == "":
ui_panels.draw_login_buttons(layout)
layout.label(
ui_panels.draw_login_buttons(login_box)
login_box.label(
text="Sign up to bookmark your favorite assets. Get 200 MiB of private storage in Free Plan."
)
else:
layout.operator("wm.blenderkit_logout", text="Logout", icon="URL")
layout.prop(self, "api_key", text="Your API Key")
layout.prop(self, "keep_preferences")
community_row = layout.row()
login_box.operator("wm.blenderkit_logout", text="Logout", icon="URL")
login_box.prop(self, "api_key", text="Your API Key")
login_box.prop(self, "keep_preferences")
community_row = login_box.row()
community_row.prop(self, "experimental_features")
community_row.operator("wm.blenderkit_join_discord", icon="URL")
if utils.profile_is_validator():
layout.prop(self, "categories_fix")
validator_box = layout.box()
validator_box.label(text="Validator Settings")
validator_box.prop(self, "categories_fix")
# REPORT PATHS
report_settings = layout.box()
report_settings.label(text="Report a Bug")
report_settings.label(
text="Create an issue report with version information to help us resolve the issue faster.",
)
report_settings.operator(
"wm.blenderkit_report_bug", text="Submit Full Bug Report", icon="ERROR"
)
# FILE PATHS
locations_settings = layout.box()
@@ -2484,6 +2650,7 @@ In this case you should also set path to your system CA bundle containing proxy'
if self.directory_behaviour in ("BOTH", "LOCAL"):
locations_settings.prop(self, "project_subdir")
locations_settings.prop(self, "unpack_files")
locations_settings.prop(self, "write_asset_metadata")
# GUI SETTINGS
gui_settings = layout.box()
@@ -2498,6 +2665,7 @@ In this case you should also set path to your system CA bundle containing proxy'
gui_settings.prop(self, "show_VIEW3D_MT_blenderkit_model_properties")
gui_settings.prop(self, "tips_on_start")
gui_settings.prop(self, "announcements_on_start")
gui_settings.prop(self, "assetbar_follows_cursor")
gui_settings.prop(self, "use_clipboard_scan")
# NETWORKING SETTINGS
@@ -2516,16 +2684,11 @@ In this case you should also set path to your system CA bundle containing proxy'
# UPDATER SETTINGS
addon_updater_ops.update_settings_ui(self, context)
# EXPERIMENTAL SETTINGS
# only if experimental features enabled
if self.experimental_features:
experimental_settings = layout.box()
experimental_settings.alignment = "EXPAND"
experimental_settings.label(text="Experimental settings")
experimental_settings.prop(self, "ignore_env_for_thumbnails")
# RUNTIME INFO
globdir_op = layout.operator(
directory_box = layout.box()
directory_box.label(text="Directories and Paths")
globdir_op = directory_box.operator(
"wm.blenderkit_open_global_directory",
text=f"Global directory: {self.global_dir}",
icon="FILE_FOLDER",
@@ -2533,7 +2696,7 @@ In this case you should also set path to your system CA bundle containing proxy'
globdir_op.directory = self.global_dir
clientlog_path = client_lib.get_client_log_path()
clientlog_op = layout.operator(
clientlog_op = directory_box.operator(
"wm.blenderkit_open_client_log",
text=f"Client log: {clientlog_path}",
icon="FILE_FOLDER",
@@ -2541,7 +2704,7 @@ In this case you should also set path to your system CA bundle containing proxy'
clientlog_op.directory = clientlog_path
addondir = path.dirname(__file__)
addondir_op = layout.operator(
addondir_op = directory_box.operator(
"wm.blenderkit_open_addon_directory",
text=f"Installed at: {addondir}",
icon="FILE_FOLDER",
@@ -2549,13 +2712,27 @@ In this case you should also set path to your system CA bundle containing proxy'
addondir_op.directory = addondir
tempdir = paths.get_temp_dir()
tempdir_op = layout.operator(
tempdir_op = directory_box.operator(
"wm.blenderkit_open_temp_directory",
text=f"Temp directory: {tempdir}",
icon="FILE_FOLDER",
)
tempdir_op.directory = tempdir
# try to draw also custom keymaps
our_keymap_draw(self, context)
# EXPERIMENTAL SETTINGS
# only if experimental features enabled
if self.experimental_features:
experimental_settings = layout.box()
experimental_settings.alignment = "EXPAND"
experimental_settings.label(text="Experimental settings")
experimental_settings.prop(self, "author_tab")
experimental_settings.prop(self, "author_asset_type_picker")
experimental_settings.prop(self, "ignore_env_for_thumbnails")
# experimental_settings.prop(self, "enable_wire_thumbnail_upload")
# registration
classes = (
@@ -2574,6 +2751,7 @@ classes = (
BlenderKitGeoToolSearchProps,
BlenderKitNodeGroupUploadProps,
BlenderKitAddonSearchProps,
BlenderKitAuthorSearchProps,
)
@@ -2582,6 +2760,9 @@ def register():
global_vars.VERSION = VERSION
bpy.utils.register_class(BlenderKitAddonPreferences)
# Drop any downloads that might have been left running if the add-on was re-enabled mid-transfer.
download.cancel_running_downloads("addon register")
addon_updater_ops.register({"version": VERSION})
for cls in classes:
bpy.utils.register_class(cls)
@@ -2643,9 +2824,13 @@ def register():
bpy.types.WindowManager.blenderkit_addon = PointerProperty(
type=BlenderKitAddonSearchProps
)
bpy.types.WindowManager.blenderkit_author = PointerProperty(
type=BlenderKitAuthorSearchProps
)
if bpy.app.factory_startup is False:
user_preferences = bpy.context.preferences.addons[__package__].preferences
global_vars.PREFS = utils.get_preferences_as_dict()
paths.ensure_asset_library_path(user_preferences.global_dir)
client_lib.reorder_ports(user_preferences.client_port)
timer.update_trusted_CA_certs(user_preferences.trusted_ca_certs)
@@ -2694,6 +2879,8 @@ def register():
def unregister():
bk_logger.info("Unregistering BlenderKit add-on")
# Stop any in-flight downloads to avoid leaving stale UI state when disabling the add-on.
download.cancel_running_downloads("addon unregister")
timer.unregister_timers()
ui_panels.unregister_ui_panels()
ui.unregister_ui()
@@ -2726,6 +2913,8 @@ def unregister():
del bpy.types.WindowManager.blenderkit_brush
del bpy.types.WindowManager.blenderkit_mat
del bpy.types.WindowManager.blenderkit_nodegroup
del bpy.types.WindowManager.blenderkit_addon
del bpy.types.WindowManager.blenderkit_author
del bpy.types.Scene.blenderkit
del bpy.types.Object.blenderkit
@@ -1,12 +0,0 @@
include:
- "**/*.py"
exclude_dirs:
- "tests"
- ".venv"
- "__pycache__"
skips:
- "B404" # https://bandit.readthedocs.io/en/1.7.10/blacklists/blacklist_imports.html#b404-import-subprocess
- "B603" # https://bandit.readthedocs.io/en/1.7.10/plugins/b603_subprocess_without_shell_equals_true.html
- "B608" # https://bandit.readthedocs.io/en/1.7.10/plugins/b608_hardcoded_sql_expressions.html
@@ -29,6 +29,7 @@ import fnmatch
import json
import os
import platform
import logging
import shutil
import ssl
import threading
@@ -43,6 +44,10 @@ import addon_utils
# Blender imports, used in limited cases.
import bpy
from . import tasks_queue
bk_logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# The main class
@@ -134,15 +139,15 @@ class SingletonUpdater:
self._select_link = select_link_function
def print_trace(self):
"""Print handled exception details when use_print_traces is set"""
"""Logs handled exception details when use_print_traces is set"""
if self._use_print_traces:
traceback.print_exc()
bk_logger.error("%s", traceback.format_exc())
def print_verbose(self, msg):
"""Print out a verbose logging message if verbose is true."""
"""Logs verbose messages if verbose is true."""
if not self._verbose:
return
print("🔄 {}: ".format(self.addon) + msg)
bk_logger.info("%s", msg)
# -------------------------------------------------------------------------
# Getters and setters
@@ -177,7 +182,7 @@ class SingletonUpdater:
def auto_reload_post_update(self, value):
try:
self._auto_reload_post_update = bool(value)
except:
except Exception:
raise ValueError("auto_reload_post_update must be a boolean value")
@property
@@ -227,7 +232,7 @@ class SingletonUpdater:
elif type(tuple_values) is not tuple:
try:
tuple(tuple_values)
except:
except Exception:
raise ValueError("current_version must be a tuple of integers")
for i in tuple_values:
if type(i) is not int:
@@ -277,7 +282,7 @@ class SingletonUpdater:
def include_branch_auto_check(self, value):
try:
self._include_branch_auto_check = bool(value)
except:
except Exception:
raise ValueError("include_branch_autocheck must be a boolean")
@property
@@ -295,7 +300,7 @@ class SingletonUpdater:
)
else:
self._include_branch_list = value
except:
except Exception:
raise ValueError("include_branch_list should be a list of valid branches")
@property
@@ -306,7 +311,7 @@ class SingletonUpdater:
def include_branches(self, value):
try:
self._include_branches = bool(value)
except:
except Exception:
raise ValueError("include_branches must be a boolean value")
@property
@@ -329,7 +334,7 @@ class SingletonUpdater:
def manual_only(self, value):
try:
self._manual_only = bool(value)
except:
except Exception:
raise ValueError("manual_only must be a boolean value")
@property
@@ -377,7 +382,7 @@ class SingletonUpdater:
def repo(self, value):
try:
self._repo = str(value)
except:
except Exception:
raise ValueError("repo must be a string value")
@property
@@ -404,8 +409,8 @@ class SingletonUpdater:
elif value is not None and not os.path.exists(value):
try:
os.makedirs(value)
except:
self.print_verbose("Error trying to staging path")
except Exception:
self.print_verbose("Error trying to create staging path")
self.print_trace()
return
self._updater_path = value
@@ -453,7 +458,7 @@ class SingletonUpdater:
def use_releases(self, value):
try:
self._use_releases = bool(value)
except:
except Exception:
raise ValueError("use_releases must be a boolean value")
@property
@@ -464,7 +469,7 @@ class SingletonUpdater:
def user(self, value):
try:
self._user = str(value)
except:
except Exception:
raise ValueError("User must be a string value")
@property
@@ -476,7 +481,7 @@ class SingletonUpdater:
try:
self._verbose = bool(value)
self.print_verbose("Verbose is enabled")
except:
except Exception:
raise ValueError("Verbose must be a boolean value")
@property
@@ -487,7 +492,7 @@ class SingletonUpdater:
def use_print_traces(self, value):
try:
self._use_print_traces = bool(value)
except:
except Exception:
raise ValueError("use_print_traces must be a boolean value")
@property
@@ -687,7 +692,7 @@ class SingletonUpdater:
request = urllib.request.Request(url)
try:
context = ssl._create_unverified_context()
except:
except Exception:
# Some blender packaged python versions don't have this, largely
# useful for local network setups otherwise minimal impact.
context = None
@@ -712,24 +717,24 @@ class SingletonUpdater:
if str(e.code) == "403":
self._error = "HTTP error (access denied)"
self._error_msg = str(e.code) + " - server error response"
print(self._error, self._error_msg)
bk_logger.error("%s %s", self._error, self._error_msg)
else:
self._error = "HTTP error"
self._error_msg = str(e.code)
print(self._error, self._error_msg)
self.print_trace()
bk_logger.error("%s %s", self._error, self._error_msg)
# self.print_trace()
self._update_ready = None
except urllib.error.URLError as e:
reason = str(e.reason)
if "TLSV1_ALERT" in reason or "SSL" in reason.upper():
self._error = "Connection rejected, download manually"
self._error_msg = reason
print(self._error, self._error_msg)
bk_logger.error("%s %s", self._error, self._error_msg)
else:
self._error = "URL error, check internet connection"
self._error_msg = reason
print(self._error, self._error_msg)
self.print_trace()
bk_logger.error("%s %s", self._error, self._error_msg)
# self.print_trace()
self._update_ready = None
return None
else:
@@ -748,7 +753,7 @@ class SingletonUpdater:
self._error = "API response has invalid JSON format"
self._error_msg = str(e.reason)
self._update_ready = None
print(self._error, self._error_msg)
bk_logger.error("%s %s", self._error, self._error_msg)
self.print_trace()
return None
else:
@@ -766,13 +771,13 @@ class SingletonUpdater:
try:
shutil.rmtree(local)
os.makedirs(local)
except:
except Exception:
error = "failed to remove existing staging directory"
self.print_trace()
else:
try:
os.makedirs(local)
except:
except Exception:
error = "failed to create staging directory"
self.print_trace()
@@ -811,8 +816,8 @@ class SingletonUpdater:
except Exception as e:
self._error = "Error retrieving download, bad link?"
self._error_msg = "Error: {}".format(e)
print("Error retrieving download, bad link?")
print("Error: {}".format(e))
bk_logger.error("Error retrieving download, bad link?")
bk_logger.error("Error: %s", e)
self.print_trace()
return False
@@ -829,7 +834,7 @@ class SingletonUpdater:
if os.path.isdir(local):
try:
shutil.rmtree(local)
except:
except Exception:
self.print_verbose(
"Failed to removed previous backup folder, continuing"
)
@@ -840,7 +845,7 @@ class SingletonUpdater:
if os.path.isdir(tempdest):
try:
shutil.rmtree(tempdest)
except:
except Exception:
self.print_verbose("Failed to remove existing temp folder, continuing")
self.print_trace()
@@ -852,15 +857,15 @@ class SingletonUpdater:
tempdest,
ignore=shutil.ignore_patterns(*self._backup_ignore_patterns),
)
except:
print("Failed to create backup, still attempting update.")
except Exception:
self.print_verbose("Failed to create backup, still attempting update.")
self.print_trace()
return
else:
try:
shutil.copytree(self._addon_root, tempdest)
except:
print("Failed to create backup, still attempting update.")
except Exception:
self.print_verbose("Failed to create backup, still attempting update.")
self.print_trace()
return
shutil.move(tempdest, local)
@@ -906,23 +911,23 @@ class SingletonUpdater:
try:
shutil.rmtree(outdir)
self.print_verbose("Source folder cleared")
except:
except Exception:
self.print_verbose("Error occurred while clearing extract dir")
self.print_trace()
# Create parent directories if needed, would not be relevant unless
# installing addon into another location or via an addon manager.
try:
os.mkdir(outdir)
except Exception as err:
print("Error occurred while making extract dir:")
print(str(err))
except Exception:
self.print_verbose("Error occurred while making extract dir")
self.print_trace()
self._error = "Install failed"
self._error_msg = "Failed to make extract directory"
return -1
if not os.path.isdir(outdir):
print("Failed to create source directory")
bk_logger.error("Failed to create source directory")
self._error = "Install failed"
self._error_msg = "Failed to create extract directory"
return -1
@@ -972,7 +977,7 @@ class SingletonUpdater:
if not os.path.isdir(unpath):
self._error = "Install failed"
self._error_msg = "Extracted path does not exist"
print("Extracted path does not exist: ", unpath)
bk_logger.error("Extracted path does not exist: %s", unpath)
return -1
if self._subfolder_path:
@@ -991,9 +996,8 @@ class SingletonUpdater:
# Smarter check for additional sub folders for a single folder
# containing the __init__.py file.
if not os.path.isfile(os.path.join(unpath, "__init__.py")):
print("Not a valid addon found")
print("Paths:")
print(dirlist)
bk_logger.error("Not a valid addon found")
bk_logger.error("Paths: %s", dirlist)
self._error = "Install failed"
self._error_msg = "No __init__ file found in new source"
return -1
@@ -1052,8 +1056,10 @@ class SingletonUpdater:
self.print_verbose(
"Clean removing file {}".format(os.path.join(base, f))
)
except Exception as e:
print(f"Error removing file {os.path.join(base, f)}: {e}")
except Exception:
bk_logger.exception(
"Error removing file %s:", os.path.join(base, f)
)
for f in folders:
if os.path.join(base, f) is self._updater_path:
continue
@@ -1064,12 +1070,14 @@ class SingletonUpdater:
os.path.join(base, f)
)
)
except Exception as e:
print(f"Error removing folder {os.path.join(base, f)}: {e}")
except Exception:
bk_logger.exception(
"Error removing folder %s:", os.path.join(base, f)
)
except Exception as err:
except Exception:
error = "failed to create clean existing addon folder"
print(error, str(err))
self.print_verbose(error)
self.print_trace()
# Walk through the base addon folder for rules on pre-removing
@@ -1087,7 +1095,7 @@ class SingletonUpdater:
os.remove(fl)
self.print_verbose("Pre-removed file " + file)
except OSError:
print("Failed to pre-remove " + file)
self.print_verbose("Failed to pre-remove " + file)
self.print_trace()
# Walk through the temp addon sub folder for replacements
@@ -1134,13 +1142,13 @@ class SingletonUpdater:
# File did not previously exist, simply move it over.
os.rename(srcFile, dest_file)
self.print_verbose("New file " + os.path.basename(dest_file))
except Exception as e:
print(f"Error replacing file {file}: {e}")
except Exception:
bk_logger.exception("Error replacing file %s:", file)
# now remove the temp staging folder and downloaded zip
try:
shutil.rmtree(staging_path)
except:
except Exception:
error = (
"Error: Failed to remove existing staging directory, "
"consider manually removing "
@@ -1152,7 +1160,7 @@ class SingletonUpdater:
# if post_update false, skip this function
# else, unload/reload addon & trigger popup
if not self._auto_reload_post_update:
print("Restart blender to reload addon and complete update")
bk_logger.info("Restart blender to reload addon and complete update")
return
self.print_verbose("Reloading addon...")
@@ -1165,12 +1173,12 @@ class SingletonUpdater:
bpy.ops.wm.addon_disable(module=self._addon_package)
bpy.ops.wm.addon_refresh()
bpy.ops.wm.addon_enable(module=self._addon_package)
print("2.7 reload complete")
bk_logger.info("2.7 reload complete")
else: # 2.8
bpy.ops.preferences.addon_disable(module=self._addon_package)
bpy.ops.preferences.addon_refresh()
bpy.ops.preferences.addon_enable(module=self._addon_package)
print("2.8 reload complete")
bk_logger.info("2.8 reload complete")
# -------------------------------------------------------------------------
# Other non-api functions and setups
@@ -1190,10 +1198,10 @@ class SingletonUpdater:
while 1:
data = url_file.read(chunk)
if not data:
# print("done.")
# bk_logger.info("done.")
break
f.write(data)
# print("Read %s bytes" % len(data))
# bk_logger.info("Read %s bytes" % len(data))
f.close()
def version_tuple_from_text(self, text):
@@ -1249,7 +1257,9 @@ class SingletonUpdater:
self.print_verbose("Skipping async check, already started")
# already running the bg thread
elif self._update_ready is None:
print("{} updater: Running background check for update".format(self.addon))
bk_logger.info(
"%s updater: Running background check for update", self.addon
)
self.start_async_check_update(False, callback)
def check_for_update_now(self, callback=None):
@@ -1266,7 +1276,7 @@ class SingletonUpdater:
self.start_async_check_update(True, callback)
def check_for_update(self, now=False):
"""Check for update not in a syncrhonous manner.
"""Check for update not in a synchronous manner.
This function is not async, will always return in sequential fashion
but should have a parent which calls it in another thread.
@@ -1449,7 +1459,7 @@ class SingletonUpdater:
res = self.stage_repository(self._update_link)
if not res:
print("Error in staging repository: " + str(res))
bk_logger.error("Error in staging repository: %s", str(res))
if callback is not None:
callback(self._addon_package, self._error_msg)
return self._error_msg
@@ -1467,7 +1477,7 @@ class SingletonUpdater:
res = self.stage_repository(self._update_link)
if not res:
print("Error in staging repository: " + str(res))
bk_logger.error("Error in staging repository: %s", str(res))
if callback:
callback(self._addon_package, self._error_msg)
return self._error_msg
@@ -1525,9 +1535,11 @@ class SingletonUpdater:
os.rename(old_json_path, json_path)
except FileNotFoundError:
pass
except Exception as err:
print("Other OS error occurred while trying to rename old JSON")
print(err)
except Exception:
self.print_verbose(
"Other OS error occurred while trying to rename old JSON"
)
self.print_trace()
return json_path
@@ -1571,7 +1583,7 @@ class SingletonUpdater:
jpath = self.get_json_path()
if not os.path.isdir(os.path.dirname(jpath)):
print(
bk_logger.error(
"State error: Directory does not exist, cannot save json: ",
os.path.basename(jpath),
)
@@ -1580,8 +1592,9 @@ class SingletonUpdater:
with open(jpath, "w") as outf:
data_out = json.dumps(self._json, indent=4)
outf.write(data_out)
except:
print("Failed to open/save data to json: ", jpath)
self.print_verbose(f"Wrote updater JSON settings to file: {jpath}")
except Exception:
self.print_verbose(f"Failed to open/save data to json: {jpath}")
self.print_trace()
self.print_verbose("Wrote out updater JSON settings with content:")
self.print_verbose(str(self._json))
@@ -1630,8 +1643,7 @@ class SingletonUpdater:
try:
self.check_for_update(now=now)
except Exception as exception:
print("Checking for update error:")
print(exception)
self.print_verbose(f"Checking for update error: {exception}")
self.print_trace()
if not self._error:
self._update_ready = False
@@ -1644,8 +1656,11 @@ class SingletonUpdater:
self._check_thread = None
if callback:
self.print_verbose("Finished check update, doing callback")
callback(self._update_ready)
self.print_verbose(
"Finished check update, queueing callback on main thread"
)
# Run the callback on Blender's main thread to avoid bpy access from a background thread.
tasks_queue.add_task((callback, (self._update_ready,)), wait=0)
self.print_verbose("BG thread: Finished check update, no callback")
def stop_async_check_update(self):
@@ -24,11 +24,14 @@ Implements draw calls, popups, and operators that use the addon_updater.
import os
import traceback
import logging
import bpy
from bpy.app.handlers import persistent
from . import client_lib
from . import client_lib, utils
bk_logger = logging.getLogger(__name__)
# Safely import the updater.
@@ -37,9 +40,9 @@ from . import client_lib
try:
from .addon_updater import Updater as updater
except Exception as e:
print("ERROR INITIALIZING UPDATER")
print(str(e))
traceback.print_exc()
bk_logger.error("ERROR INITIALIZING UPDATER")
bk_logger.error(str(e))
bk_logger.error("%s", traceback.format_exc())
class SingletonUpdaterNone(object):
"""Fake, bare minimum fields and functions for the updater object."""
@@ -175,6 +178,10 @@ class AddonUpdaterInstallPopup(bpy.types.Operator):
return True
def invoke(self, context, event):
# Skip opening the popup when we already know there is nothing to show.
if not updater.invalid_updater and updater.update_ready is False:
updater.print_verbose("No update available; skipping popup")
return {"CANCELLED"}
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
@@ -229,9 +236,9 @@ class AddonUpdaterInstallPopup(bpy.types.Operator):
# Should return 0, if not something happened.
if updater.verbose:
if res == 0:
print("Updater returned successful")
bk_logger.info("Updater returned successful")
else:
print("Updater returned {}, error occurred".format(res))
bk_logger.info("Updater returned {}, error occurred".format(res))
elif updater.update_ready is None:
_ = updater.check_for_update(now=True)
@@ -322,9 +329,9 @@ class AddonUpdaterUpdateNow(bpy.types.Operator):
# Should return 0, if not something happened.
if updater.verbose:
if res == 0:
print("Updater returned successful")
bk_logger.info("Updater returned successful")
else:
print("Updater error response: {}".format(res))
bk_logger.info("Updater error response: {}".format(res))
except Exception as expt:
updater._error = "Error trying to run update"
updater._error_msg = str(expt)
@@ -450,7 +457,7 @@ class AddonUpdaterInstallManually(bpy.types.Operator):
layout.label(text="Updater error")
return
# Display error if a prior autoamted install failed.
# Display error if a prior automated install failed.
if self.error != "":
col = layout.column()
col.scale_y = 0.7
@@ -692,7 +699,7 @@ def updater_run_install_popup_handler(scene):
updater_run_install_popup_handler
)
except Exception as e:
print(e)
bk_logger.error("%s", e)
pass
if "ignore" in updater.json and updater.json["ignore"]:
@@ -833,8 +840,8 @@ def check_for_update_nonthreaded(self, context):
settings = get_user_preferences(bpy.context)
if not settings:
if updater.verbose:
print(
"Could not get {} preferences, update check skipped".format(__package__)
bk_logger.info(
"Could not get %s preferences, update check skipped", __package__
)
return
updater.set_check_interval(
@@ -1128,6 +1135,15 @@ def update_settings_ui(self, context, element=None):
else:
row.label(text="Last update check: Never")
version_row = box.row()
version_row.label(
text=f"BlenderKit v{utils.get_addon_version()} · Blender {bpy.app.version_string}",
icon="INFO",
)
version_row.operator(
"wm.blenderkit_copy_environment_info", text="Copy Info", icon="COPYDOWN"
)
def update_settings_ui_condensed(self, context, element=None):
"""Preferences - Condensed drawing within preferences.
@@ -1359,7 +1375,7 @@ def register(bl_info):
"""Registering the operators in this module"""
# Safer failure in case of issue loading module.
if updater.error:
print("Exiting updater registration, " + updater.error)
bk_logger.error("Exiting updater registration, %s", updater.error)
return
updater.clear_state() # Clear internal vars, avoids reloading oddities.
@@ -299,7 +299,7 @@ def append_material(file_name, matname=None, link=False, fake_user=True):
)
except Exception as e:
bk_logger.error(f"{e} - failed to open the asset file")
bk_logger.error("%s - failed to open the asset file", e)
# we have to find the new material , due to possible name changes
mat = None
for m in bpy.data.materials:
@@ -532,7 +532,7 @@ def append_particle_system(
total_max_threshold = 2000000
# emitting too many parent particles just kills blender now.
# this part tuned child count, we'll leave children to artists only.
# this part tuned child count, we'll leave children to authors only.
# if count > total_max_threshold:
# ratio = round(count / total_max_threshold)
#
@@ -621,14 +621,18 @@ def append_objects(
return_obs = []
to_hidden_collection = []
hidden_objects = []
appended_collection = None
main_object = None
# get first at least one parent for sure
# first get at least one parent for sure
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get():
if not ob.parent:
main_object = ob
ob.location = location
if ob.select_get() and not ob.parent:
main_object = ob
ob.location = location
if (
ob.hide_viewport or ob.hide_render
): # saved assets only retain hide render state
hidden_objects.append(ob)
# do once again to ensure hidden objects are hidden
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get():
@@ -654,7 +658,7 @@ def append_objects(
main_object.matrix_world.translation = location
# move objects that should be hidden to a sub collection
if len(to_hidden_collection) > 0 and appended_collection is not None:
if to_hidden_collection and appended_collection is not None:
hidden_collections = []
scene_collection = bpy.context.scene.collection # type: ignore[union-attr]
for ob in to_hidden_collection:
@@ -682,7 +686,7 @@ def append_objects(
if hide_collection in hidden_collections:
continue
# All other collections are moved to be children of the model collection
bk_logger.info(f"{hide_collection}, {appended_collection}")
bk_logger.info("%s, %s", hide_collection, appended_collection)
# If target collection is specified, move collections there instead
if collection and bpy.data.collections.get(collection):
utils.move_collection(
@@ -707,6 +711,12 @@ def append_objects(
if orig_active_collection:
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr]
if hidden_objects:
# only unique objects
hidden_objects = list(set(hidden_objects))
for ob in hidden_objects:
ob.hide_set(True)
utils.selection_set(sel)
# let collection also store info that it was created by BlenderKit, for purging reasons
@@ -753,16 +763,18 @@ def append_objects(
obj.select_set(True)
# we need to unhide object so make_local op can use those too.
if link == True:
if obj.hide_viewport:
if (
obj.hide_viewport or obj.hide_render
): # saved assets only retain hide render state
hidden_objects.append(obj)
obj.hide_viewport = False
obj.hide_set(False)
return_obs.append(obj)
# Only after all objects are in scene! Otherwise gets broken relationships
if link == True:
bpy.ops.object.make_local(type="SELECT_OBJECT")
for ob in hidden_objects:
ob.hide_viewport = True
ob.hide_set(True)
if kwargs.get("rotation") is not None:
main_object.rotation_euler = kwargs["rotation"] # type: ignore[union-attr]
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,7 @@ import logging
import math
import os
import random
from typing import Any, Optional, Set, Tuple, Union
import bpy
import mathutils
@@ -28,8 +29,6 @@ from bpy.props import IntProperty, StringProperty
from bpy_extras import view3d_utils
from mathutils import Vector
from typing import Any, Optional, Tuple, Set, Union
from . import (
bg_blender,
colors,
@@ -38,11 +37,12 @@ from . import (
image_utils,
paths,
reports,
search,
ui,
ui_bgl,
ui_panels,
utils,
search,
viewport_utils,
)
from .bl_ui_widgets.bl_ui_button import BL_UI_Button
from .bl_ui_widgets.bl_ui_drag_panel import BL_UI_Drag_Panel
@@ -56,11 +56,8 @@ handler_2d = None
handler_3d = None
DEAD_ZONE = 5 # pixels
"""Number of pixels mouse must move to start drag operation."""
DRAG_THRESHOLD = 10 # pixels
"""Number of pixels mouse must move to consider as a drag (vs click)."""
DEFAULT_DRAG_THRESHOLD = 30 # pixels
"""Pointer travel in pixels needed before we start rendering full drag hints."""
def is_draw_cb_available(self: bpy.types.Operator, context: bpy.types.Context) -> bool:
@@ -106,8 +103,8 @@ def draw_callback_dragging(
Returns:
None
"""
# Only draw 2D elements in the active region where the mouse is. Guard against destroyed operator.
# Only draw 2D elements in the active region where the mouse is. Guard against destroyed operator.
if not is_draw_cb_available(self, context):
return
@@ -356,6 +353,10 @@ def draw_callback_3d_dragging(
if not utils.guard_from_crash():
return
# ignore unless we are dragging
if not self.drag:
return
# Only draw 3D elements in VIEW_3D areas, not in outliner
if context.area.type != "VIEW_3D":
return
@@ -522,19 +523,6 @@ def draw_progress(
ui_bgl.draw_text(text, x, y + 8, 16, color)
def find_and_activate_instancers(
obj: bpy.types.Object,
) -> Optional[bpy.types.Object]:
for ob in bpy.context.visible_objects:
if (
ob.instance_type == "COLLECTION"
and ob.instance_collection
and obj.name in ob.instance_collection.objects
):
utils.activate(ob)
return ob
def mouse_raycast(
region: bpy.types.Region, rv3d: bpy.types.RegionView3D, mx: int, my: int
) -> Tuple[
@@ -741,11 +729,9 @@ def deep_ray_cast(ray_origin: Vector, vec: Vector) -> Tuple[
def object_in_particle_collection(o: bpy.types.Object) -> bool:
"""checks if an object is in a particle system as instance, to not snap to it and not to try to attach material."""
for p in bpy.data.particles:
if p.render_type == "COLLECTION":
if p.instance_collection:
for o1 in p.instance_collection.objects:
if o1 == o:
return True
if p.render_type == "COLLECTION" and p.instance_collection:
if o in p.instance_collection.objects:
return True
if p.render_type == "COLLECTION":
if p.instance_object == o:
return True
@@ -774,22 +760,6 @@ def get_node_tree(context: bpy.types.Context) -> bpy.types.NodeTree:
return context.scene.compositing_node_group
def assign_node_tree(
node_space: bpy.types.SpaceNodeEditor, node_tree: bpy.types.NodeTree
) -> None:
"""Blender version invariant way to assign a node tree to the current node editor."""
if bpy.app.version < (5, 0, 0):
node_space.node_tree = node_tree
return
# blender 5.0+
# recover the node_group from data and assign it
if hasattr(node_space, "node_group"):
node_space.node_group = bpy.data.node_groups[node_tree.name]
elif hasattr(node_space, "node_tree"):
node_space.node_tree = node_tree
class AssetDragOperator(bpy.types.Operator):
"""Drag & drop assets into scene. Operator being drawn when dragging asset."""
@@ -797,9 +767,9 @@ class AssetDragOperator(bpy.types.Operator):
bl_label = "BlenderKit asset drag drop"
asset_search_index: IntProperty(name="Active Index", default=0) # type: ignore
drag_length: IntProperty(name="Drag_length", default=0) # type: ignore
object_name = None
active_operator_id = None
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
@@ -813,32 +783,28 @@ class AssetDragOperator(bpy.types.Operator):
self.hovered_outliner_element: Union[bpy.types.Object, bpy.types.Collection] = (
None
)
self.active_window = None
self.active_area = None
self.active_region = None
self.orig_active_object = None
self.orig_selected_objects = None
self.orig_active_collection = None
self.downloader = None
# Mouse tracking variables
self.start_mouse_x = None
self.start_mouse_y = None
self.start_mouse_x = 0
self.start_mouse_y = 0
self.mouse_x = 0
self.mouse_y = 0
self.mouse_screen_x = 0
self.mouse_screen_y = 0
self.steps = 0
# Store the initial active region pointer
self.active_region_pointer = None
# Initialize outliner tracking variables
self.hovered_outliner_element = None
self.outliner_area = None
self.outliner_region = None
self.orig_selected_objects = None
self.orig_active_object = None
self.orig_active_collection = None
self.prev_area_type = None
# Initialize node editor tracking
@@ -858,6 +824,8 @@ class AssetDragOperator(bpy.types.Operator):
self.iname = ""
self.drag = False
self.steps = 0
self.closed_assetbar = False
def handlers_remove(self) -> None:
"""Remove all draw handlers."""
@@ -873,11 +841,8 @@ class AssetDragOperator(bpy.types.Operator):
self, nodegroup_type: str, editor_type: Optional[str] = None
) -> bool:
"""Check if a nodegroup of a specific type is compatible with the given editor type."""
# Direct matches
if nodegroup_type == editor_type:
return True
# Generic nodegroups can work in any editor
elif nodegroup_type is None:
# Direct matches, or invalid editor
if not nodegroup_type or nodegroup_type == editor_type:
return True
# Otherwise, not compatible
return False
@@ -958,7 +923,7 @@ class AssetDragOperator(bpy.types.Operator):
if obj.type == "MESH":
temp_mesh = object_eval.to_mesh()
mapping = create_material_mapping(obj, temp_mesh)
_mapping = create_material_mapping(obj, temp_mesh)
target_slot = temp_mesh.polygons[self.face_index].material_index
object_eval.to_mesh_clear()
else:
@@ -1073,7 +1038,7 @@ class AssetDragOperator(bpy.types.Operator):
target_collection = ""
# Check what type of element we're dropping on
element_type = type(self.hovered_outliner_element).__name__
_element_type = type(self.hovered_outliner_element).__name__
# If dropping on a collection, set target_collection parameter
if isinstance(self.hovered_outliner_element, bpy.types.Collection):
@@ -1480,10 +1445,10 @@ class AssetDragOperator(bpy.types.Operator):
for window in wins:
# first let's test if it's in this window, so we know we shall continue
window_x = window.x * self.resolution_factor
window_y = window.y * self.resolution_factor
window_width = window.width * self.resolution_factor
window_height = window.height * self.resolution_factor
window_x = window.x
window_y = window.y
window_width = window.width
window_height = window.height
if (
x < window_x
or x > window_x + window_width
@@ -1513,7 +1478,6 @@ class AssetDragOperator(bpy.types.Operator):
return None
context = bpy.context
scene = context.scene
view_layer = context.view_layer
selected_objects = context.selected_objects
active_object = context.active_object
@@ -1630,11 +1594,14 @@ class AssetDragOperator(bpy.types.Operator):
):
"""Get the active object under the mouse cursor during drag."""
region_data = None
for space in active_area.spaces:
if space.type == "VIEW_3D":
region_data = space.region_3d
# precise placement in ortho views, and quad view
region_data = viewport_utils.region_data_for_view(active_area, active_region)
if region_data is None:
for space in active_area.spaces:
if space.type == "VIEW_3D":
region_data = getattr(space, "region_3d", None)
if region_data is not None:
break
# Need to temporarily override context for raycasting
if bpy.app.version < (3, 2, 0): # B3.0, B3.1 - custom context override
@@ -1643,9 +1610,7 @@ class AssetDragOperator(bpy.types.Operator):
"screen": active_window.screen,
"area": active_area,
"region": active_region,
"region_data": active_area.spaces[
0
].region_3d, # Get region_data from space_data
"region_data": region_data,
"scene": context.scene,
"view_layer": context.view_layer,
}
@@ -1757,69 +1722,70 @@ class AssetDragOperator(bpy.types.Operator):
self.in_node_editor = False
self.node_editor_type = None
def _cleanup_drag(self, ui_props, cls, *, reopen_assetbar: bool = False) -> None:
"""Shared teardown: remove handlers, restore cursor, reset drag state."""
self.handlers_remove()
bpy.context.window.cursor_modal_restore()
ui_props.dragging = False
if self.closed_assetbar:
bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False)
if reopen_assetbar:
bpy.ops.view3d.blenderkit_asset_bar_widget(
"INVOKE_REGION_WIN", do_search=False
)
cls.active_operator_id = None
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]:
cls = type(self)
ui_props = bpy.context.window_manager.blenderkitUI
self.resolution_factor = (
bpy.context.preferences.system.pixel_size
/ bpy.context.preferences.view.ui_scale
)
self.mouse_screen_x = int(
context.window.x * self.resolution_factor + event.mouse_x
)
self.mouse_screen_y = int(
context.window.y * self.resolution_factor + event.mouse_y
)
self.mouse_screen_x = int(context.window.x + event.mouse_x)
self.mouse_screen_y = int(context.window.y + event.mouse_y)
# Find the active region under the mouse cursor using actual screen coordinates
self.active_window, self.active_area, self.active_region = (
self.find_active_region(self.mouse_screen_x, self.mouse_screen_y)
found_window, found_area, found_region = self.find_active_region(
self.mouse_screen_x, self.mouse_screen_y
)
if (
found_region is not None
and found_area is not None
and found_window is not None
):
self.active_window, self.active_area, self.active_region = (
found_window,
found_area,
found_region,
)
# --- CURSOR VISIBILITY FIX ---
if self.active_region is None or self.active_area is None:
bpy.context.window.cursor_modal_set("STOP")
# bpy.context.window.cursor_modal_set("STOP")
bpy.context.window.cursor_modal_restore()
return {"PASS_THROUGH"}
elif self.drag:
if self.drag:
bpy.context.window.cursor_modal_set("NONE")
# Convert screen coords (bottom-left) to region-local coords
# window.x/y and region.x/y are also in bottom-left coordinate system
self.mouse_x = int(
self.mouse_screen_x
- self.active_window.x * self.resolution_factor
- self.active_region.x
self.mouse_screen_x - self.active_window.x - self.active_region.x
)
self.mouse_y = int(
self.mouse_screen_y
- self.active_window.y * self.resolution_factor
- self.active_region.y
self.mouse_screen_y - self.active_window.y - self.active_region.y
)
if self.start_mouse_x is None or self.start_mouse_y is None:
self.start_mouse_x = self.mouse_x
self.start_mouse_y = self.mouse_y
# --- REDRAW ALL WINDOWS/AREAS FOR MULTI-WINDOW DRAG ---
# redraw all windows to update cursor and other elements
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
area.tag_redraw()
current_area_type = self.active_area.type if self.active_area else None
current_area_type = self.active_area.type
# Check if we're transitioning out of the outliner
if (
self.prev_area_type
and self.prev_area_type == "OUTLINER"
and current_area_type != "OUTLINER"
):
# If we're leaving the outliner, restore the original selection
if self.prev_area_type == "OUTLINER" and current_area_type != "OUTLINER":
self.restore_original_selection()
# shift pressed
if event.shift:
self.shift_pressed = True
else:
self.shift_pressed = False
self.shift_pressed = event.shift
# Track if we're in a node editor
self._handle_node_editor_type(current_area_type, self.active_area)
@@ -1832,12 +1798,8 @@ class AssetDragOperator(bpy.types.Operator):
# Store the active region pointer for drawing 2D elements only in this region
self.active_region_pointer = self.active_region.as_pointer()
# Make sure all 3D views get redrawn
for area in context.screen.areas:
area.tag_redraw()
# Handle outliner interaction
if self.active_area.type == "OUTLINER":
if current_area_type == "OUTLINER":
self.hovered_outliner_element = self.find_outliner_element_under_mouse()
self.outliner_window = self.active_window
self.outliner_area = self.active_area
@@ -1853,48 +1815,46 @@ class AssetDragOperator(bpy.types.Operator):
self.active_region_pointer = context.region.as_pointer()
# are we dragging already?
delta_x = abs(self.start_mouse_x - self.mouse_screen_x)
delta_y = abs(self.start_mouse_y - self.mouse_screen_y)
if not self.drag and (
abs(self.start_mouse_x - self.mouse_x) > DRAG_THRESHOLD
or abs(self.start_mouse_y - self.mouse_y) > DRAG_THRESHOLD
delta_x > DEFAULT_DRAG_THRESHOLD or delta_y > DEFAULT_DRAG_THRESHOLD
):
self.drag = True
if self.drag and ui_props.assetbar_on:
# turn off asset bar here, shout start again after finishing drag drop.
if self.drag and ui_props.assetbar_on and not self.closed_assetbar:
# turn off asset bar here; reopen after placement when we actually dragged
ui_props.turn_off = True
if (
event.type == "ESC"
or not ui.mouse_in_region(context.region, self.mouse_x, self.mouse_y)
) and (not self.drag or self.steps < DEAD_ZONE):
# this case is for canceling from inside popup card when there's an escape attempt to close the window
return {"PASS_THROUGH"}
self.closed_assetbar = True
if event.type in {"RIGHTMOUSE", "ESC"}:
# Restore original selection if we changed it
self.restore_original_selection()
self.handlers_remove()
bpy.context.window.cursor_modal_restore()
ui_props.dragging = False
bpy.ops.view3d.blenderkit_asset_bar_widget(
"INVOKE_REGION_WIN", do_search=False
)
self._cleanup_drag(ui_props, cls, reopen_assetbar=True)
return {"CANCELLED"}
self.steps += 1
if (
event.type == "ESC"
or not ui.mouse_in_region(context.region, self.mouse_x, self.mouse_y)
) and (not self.drag or self.steps < 5):
# this case is for canceling from inside popup card when there's an escape attempt to close the window
return {"PASS_THROUGH"}
sprops = bpy.context.window_manager.blenderkit_models
if event.type == "WHEELUPMOUSE":
sprops.offset_rotation_amount += sprops.offset_rotation_step
elif event.type == "WHEELDOWNMOUSE":
sprops.offset_rotation_amount -= sprops.offset_rotation_step
if (
event.type == "MOUSEMOVE"
or event.type == "WHEELUPMOUSE"
or event.type == "WHEELDOWNMOUSE"
):
if event.type in {
"MOUSEMOVE",
"INBETWEEN_MOUSEMOVE",
"WHEELUPMOUSE",
"WHEELDOWNMOUSE",
}:
# sometimes active area or region can be None, so we need to check for that
if self.active_area is None or self.active_region is None:
return {"RUNNING_MODAL"}
@@ -1904,11 +1864,7 @@ class AssetDragOperator(bpy.types.Operator):
self.has_hit = False
# Only perform raycasting in 3D view areas
if (
self.active_region
and self.active_area
and self.active_area.type == "VIEW_3D"
):
if current_area_type == "VIEW_3D":
# prefetch the drag active object info
self.drag_raycast_3d_view(
context,
@@ -1921,21 +1877,15 @@ class AssetDragOperator(bpy.types.Operator):
if self.asset_data["assetType"] in ["model", "printable"]:
self.snapped_bbox_min = Vector(self.asset_data["bbox_min"])
self.snapped_bbox_max = Vector(self.asset_data["bbox_max"])
elif self.active_area.type != "VIEW_3D":
elif current_area_type != "VIEW_3D":
# In outliner, don't do raycasting, but keep has_hit to avoid errors
self.has_hit = False
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
self.mouse_release(context) # Pass context here
self.handlers_remove()
bpy.context.window.cursor_modal_restore()
bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False)
ui_props.dragging = False
self.mouse_release(context)
self._cleanup_drag(ui_props, cls)
return {"FINISHED"}
self.steps += 1
# pass event to assetbar so it can close itself
if ui_props.assetbar_on and ui_props.turn_off:
return {"PASS_THROUGH"}
@@ -1946,9 +1896,29 @@ class AssetDragOperator(bpy.types.Operator):
# Before registering callbacks, check for canceling situations: login and localdir popups, sculpt popup/switch
sr = search.get_search_results()
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.dragging:
return {"CANCELLED"}
cls = type(self)
if cls.active_operator_id is not None and cls.active_operator_id != id(self):
return {"CANCELLED"}
# Acquire drag lock immediately so concurrent invoke paths cannot race while this invoke initializes.
ui_props.dragging = True
cls.active_operator_id = id(self)
self.closed_assetbar = False
# Use the asset_search_index parameter passed to the operator, not the global ui_props.active_index
# This is critical for multi-window support where active_index is shared across windows
self.asset_data = dict(sr[self.asset_search_index])
# Initialize drag-start coordinates immediately in invoke. If mouse-move
# events are sparse (or arrive late), we still compute threshold against
# the true click/press origin instead of first modal tick.
self.mouse_screen_x = int(context.window.x + event.mouse_x)
self.mouse_screen_y = int(context.window.y + event.mouse_y)
self.start_mouse_x = self.mouse_screen_x
self.start_mouse_y = self.mouse_screen_y
# Author assets should not be dragged, cancel immediately
if self.asset_data.get("assetType") == "author":
return {"CANCELLED"}
# add-ons
if self.asset_data.get("assetType") == "addon" and not self.asset_data.get(
"canDownload"
@@ -1959,6 +1929,8 @@ class AssetDragOperator(bpy.types.Operator):
bpy.ops.wm.blenderkit_url_dialog(
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
)
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"}
if not self.asset_data.get("canDownload"):
@@ -1969,6 +1941,8 @@ class AssetDragOperator(bpy.types.Operator):
bpy.ops.wm.blenderkit_url_dialog(
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
)
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"}
prefs = bpy.context.preferences.addons[__package__].preferences
@@ -1982,26 +1956,31 @@ class AssetDragOperator(bpy.types.Operator):
bpy.ops.wm.blenderkit_url_dialog(
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
)
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"}
if self.asset_data.get("assetType") == "brush":
if not (context.sculpt_object or context.image_paint_object):
# either switch to sculpt mode and layout automatically or show a popup message
if context.active_object and context.active_object.type == "MESH":
bpy.ops.object.mode_set(mode="SCULPT")
self.mouse_release(context) # does the main job with assets
if self.asset_data.get("assetType") == "brush" and not (
context.sculpt_object or context.image_paint_object
):
# either switch to sculpt mode and layout automatically or show a popup message
if context.active_object and context.active_object.type == "MESH":
bpy.ops.object.mode_set(mode="SCULPT")
self.mouse_release(context) # does the main job with assets
if bpy.data.workspaces.get("Sculpting") is not None:
bpy.context.window.workspace = bpy.data.workspaces["Sculpting"]
reports.add_report(
"Automatically switched to sculpt mode to use brushes."
)
else:
message = "Select a mesh and switch to sculpt or image paint modes to use the brushes."
bpy.ops.wm.blenderkit_popup_dialog(
"INVOKE_REGION_WIN", message=message, width=500
)
return {"CANCELLED"}
if bpy.data.workspaces.get("Sculpting") is not None:
bpy.context.window.workspace = bpy.data.workspaces["Sculpting"]
reports.add_report(
"Automatically switched to sculpt mode to use brushes."
)
else:
message = "Select a mesh and switch to sculpt or image paint modes to use the brushes."
bpy.ops.wm.blenderkit_popup_dialog(
"INVOKE_REGION_WIN", message=message, width=500
)
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"}
# the arguments we pass the the callback
args = (self, context)
@@ -2044,48 +2023,25 @@ class AssetDragOperator(bpy.types.Operator):
# but if RNA Struct fails we are not longer able to remove it, so we log an error and store None
self._handlers_universal[space_type] = handler
except (AttributeError, TypeError) as e:
bk_logger.error(f"Could not register handler for {space_type}: {e}")
bk_logger.error("Could not register handler for %s: %s", space_type, e)
self._handlers_universal[space_type] = None
self.mouse_x = 0
self.mouse_y = 0
self.mouse_screen_x = 0
self.mouse_screen_y = 0
self.steps = 0
self.mouse_screen_x = self.start_mouse_x
self.mouse_screen_y = self.start_mouse_y
# Store the initial active region pointer
self.active_region_pointer = context.region.as_pointer()
# Initialize outliner tracking variables
self.hovered_outliner_element = None
self.outliner_area = None
self.outliner_region = None
self.orig_selected_objects = None
self.orig_active_object = None
self.orig_active_collection = None
self.prev_area_type = context.area.type # Track previous area type
# Initialize node editor tracking
self.in_node_editor = False
self.node_editor_type = None
self.shift_pressed = False
# Initialize has_hit to False, and set other 3D properties
# We'll only use these in 3D views, not in outliner
self.has_hit = False
self.snapped_location = (0, 0, 0)
self.snapped_normal = (0, 0, 1)
self.snapped_rotation = (0, 0, 0)
self.face_index = 0
self.matrix = None
self.iname = f".{self.asset_data['thumbnail_small']}"
self.iname = (self.iname[:63]) if len(self.iname) > 63 else self.iname
bpy.context.window.cursor_modal_set("NONE")
bpy.context.window.cursor_modal_restore()
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.dragging = True
self.drag = False
self.steps = 0
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
@@ -2245,7 +2201,7 @@ class DownloadGizmoOperator(BL_UI_OT_draw_operator):
self.button_close.set_image_size((button_size, button_size))
self.button_close.set_image_position((0, 0))
directory = paths.get_temp_dir("%s_search" % self.asset_data["assetType"])
directory = paths.get_temp_dir(f"{self.asset_data['assetType']}_search")
thumbnail_path = os.path.join(directory, self.asset_data["thumbnail_small"])
self.image.set_image(thumbnail_path)
@@ -2296,7 +2252,10 @@ class DownloadGizmoOperator(BL_UI_OT_draw_operator):
bk_logger.debug("unregistering class %s", cls)
instances_copy = cls.instances.copy()
for instance in instances_copy:
bk_logger.debug("- class instance %s", instance)
try:
bk_logger.debug("- class instance %s", instance)
except ReferenceError:
bk_logger.debug("- class instance <deleted>")
try:
instance.unregister_handlers(instance.context)
except Exception as e:
@@ -2407,37 +2366,6 @@ def create_material_mapping(obj, temp_mesh):
return mapping
def add_set_material_node(tree):
"""Add a Set Material node at the end of the node tree"""
# Find output node
output_node = None
for node in tree.nodes:
if node.type == "GROUP_OUTPUT":
output_node = node
break
if output_node:
# Create Set Material node
set_mat_node = tree.nodes.new("GeometryNodeSetMaterial")
# Position it before output
set_mat_node.location = (output_node.location.x - 200, output_node.location.y)
# Connect nodes
last_geometry_socket = None
for source in output_node.inputs:
if source.type == "GEOMETRY":
if source.is_linked:
last_geometry_socket = source.links[0].from_socket
break
if last_geometry_socket:
tree.links.new(last_geometry_socket, set_mat_node.inputs["Geometry"])
tree.links.new(set_mat_node.outputs["Geometry"], output_node.inputs[0])
return set_mat_node
return None
classes = (
AssetDragOperator,
DownloadGizmoOperator,
@@ -30,6 +30,13 @@ RENDER_OBTYPES = ["MESH", "CURVE", "SURFACE", "METABALL", "TEXT"]
_BLE_5_PLUS = bpy.app.version >= (5, 0, 0)
def _image_tile_count(image) -> int:
"""Return the number of tiles for a UDIM image, 1 for regular images."""
if getattr(image, "source", "") == "TILED" and hasattr(image, "tiles"):
return max(1, len(image.tiles))
return 1
def check_material(props, mat):
e = bpy.context.scene.render.engine
shaders = []
@@ -62,7 +69,10 @@ def check_material(props, mat):
if n.image not in textures:
textures.append(n.image)
props.texture_count += 1
total_pixels += n.image.size[0] * n.image.size[1]
# Multiply per-tile pixel area by tile count so that
# UDIM images (source='TILED') report the correct total.
n_tiles = _image_tile_count(n.image)
total_pixels += n_tiles * n.image.size[0] * n.image.size[1]
maxres = max(n.image.size[0], n.image.size[1])
props.texture_resolution_max = max(
@@ -138,7 +148,10 @@ def check_render_engine(props, obs):
textures.append(n.image)
props.texture_count += 1
total_pixels += n.image.size[0] * n.image.size[1]
# Multiply per-tile pixel area by tile count so that
# UDIM images (source='TILED') report the correct total.
n_tiles = _image_tile_count(n.image)
total_pixels += n_tiles * n.image.size[0] * n.image.size[1]
maxres = max(n.image.size[0], n.image.size[1])
props.texture_resolution_max = max(
@@ -344,7 +357,7 @@ def check_meshprops(props, obs):
props.manifold = manifold
def countObs(props, obs):
def count_objects(props, obs):
ob_types = {}
count = len(obs)
for ob in obs:
@@ -425,7 +438,21 @@ def get_autotags():
check_anim(props, obs)
check_meshprops(props, obs)
check_modifiers(props, obs)
countObs(props, obs)
count_objects(props, obs)
elif ui.asset_type == "SCENE":
scene = bpy.context.scene
props = scene.blenderkit
if props.name == "":
props.name = scene.name
dim, bbox_min, bbox_max = utils.get_scene_dimensions(scene)
props.dimensions = dim
props.bbox_min = bbox_min
props.bbox_max = bbox_max
scene_objects = list(scene.objects)
check_meshprops(props, scene_objects)
count_objects(props, scene_objects)
elif ui.asset_type == "MATERIAL":
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore.
@@ -434,6 +461,7 @@ def get_autotags():
props.texture_resolution_max = 0
props.texture_resolution_min = 0
check_material(props, mat)
elif ui.asset_type == "HDR":
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore.
+218 -17
View File
@@ -32,6 +32,7 @@ from bpy.props import (
FloatProperty,
IntProperty,
FloatVectorProperty,
StringProperty,
)
from . import bg_blender, global_vars, paths, tasks_queue, utils, upload, search
@@ -81,16 +82,31 @@ def get_texture_ui(tpath, iname):
return tex
def check_thumbnail(props, imgpath):
def check_thumbnail(
props,
imgpath,
*,
texture_name="upload_preview",
flag_attr="has_thumbnail",
state_attr="thumbnail_generating_state",
):
"""Reload a thumbnail preview and update status attributes."""
def _set_prop(attr_name, value):
if attr_name and hasattr(props, attr_name):
setattr(props, attr_name, value)
# TODO implement check if the file exists, if size is correct etc. needs some care
if imgpath == "":
props.has_thumbnail = False
_set_prop(flag_attr, False)
return None
img = utils.get_hidden_image(imgpath, "upload_preview", force_reload=True)
img = utils.get_hidden_image(imgpath, texture_name, force_reload=True)
if img is not None: # and img.size[0] == img.size[1] and img.size[0] >= 512 and (
# img.file_format == 'JPEG' or img.file_format == 'PNG'):
props.has_thumbnail = True
props.thumbnail_generating_state = ""
_set_prop(flag_attr, True)
if hasattr(props, "THUMBNAIL_GENERATING_STATE"):
props.THUMBNAIL_GENERATING_STATE = ""
_set_prop(state_attr, "")
utils.get_hidden_texture(img.name)
# pcoll = icons.icon_collections["previews"]
@@ -98,7 +114,7 @@ def check_thumbnail(props, imgpath):
return img
else:
props.has_thumbnail = False
_set_prop(flag_attr, False)
output = ""
if (
img is None
@@ -115,7 +131,7 @@ def check_thumbnail(props, imgpath):
# output += 'image too small, should be at least 512x512\n'
# if img.file_format != 'JPEG' or img.file_format != 'PNG':
# output += 'image has to be a jpeg or png'
props.thumbnail_generating_state = output
_set_prop(state_attr, output)
def update_upload_model_preview(self, context):
@@ -126,6 +142,20 @@ def update_upload_model_preview(self, context):
check_thumbnail(props, imgpath)
def update_wire_thumbnail_preview(self, context):
ob = utils.get_active_model()
if ob is not None:
props = ob.blenderkit
imgpath = props.wire_thumbnail
check_thumbnail(
props,
imgpath,
texture_name=".upload_preview_wire",
flag_attr=None,
state_attr="wire_thumbnail_generating_state",
)
def update_upload_scene_preview(self, context):
s = bpy.context.scene
props = s.blenderkit
@@ -149,7 +179,7 @@ def update_upload_brush_preview(self, context):
brush = utils.get_active_brush()
if brush is not None:
props = brush.blenderkit
imgpath = bpy.path.abspath(brush.icon_filepath)
imgpath = props.thumbnail
check_thumbnail(props, imgpath)
@@ -182,9 +212,26 @@ def start_model_thumbnailer(
):
"""Start Blender in background and render the thumbnail."""
SCRIPT_NAME = "autothumb_model_bg.py"
thumbnail_upload_type = (
json_args.get("thumbnail_upload_type") if json_args else None
)
is_wire_upload = thumbnail_upload_type == "wire_thumbnail"
computing_attr = (
"is_generating_wire_thumbnail" if is_wire_upload else "is_generating_thumbnail"
)
state_attr = (
"wire_thumbnail_generating_state"
if is_wire_upload
else "thumbnail_generating_state"
)
def _set_prop(attr_name, value):
if props and hasattr(props, attr_name):
setattr(props, attr_name, value)
if props:
props.is_generating_thumbnail = True
props.thumbnail_generating_state = "Saving .blend file"
_set_prop(computing_attr, True)
_set_prop(state_attr, "Saving .blend file")
datafile = os.path.join(json_args["tempdir"], BLENDERKIT_EXPORT_DATA_FILE)
user_preferences = bpy.context.preferences.addons[__package__].preferences
@@ -194,6 +241,10 @@ def start_model_thumbnailer(
"cycles"
].preferences.compute_device_type
json_args["thumbnail_disable_subdivision"] = (
user_preferences.thumbnail_disable_subdivision
)
try:
with open(datafile, "w", encoding="utf-8") as s:
json.dump(json_args, s, ensure_ascii=False, indent=4)
@@ -228,9 +279,10 @@ def start_model_thumbnailer(
env=env,
)
bk_logger.info("Started Blender executing %s on file %s", SCRIPT_NAME, datafile)
eval_path_computing = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
eval_path = f"bpy.data.objects['{json_args['asset_name']}']"
eval_path_base = f"bpy.data.objects['{json_args['asset_name']}']"
eval_path = eval_path_base
eval_path_computing = f"{eval_path_base}.blenderkit.{computing_attr}"
eval_path_state = f"{eval_path_base}.blenderkit.{state_attr}"
name = f"{json_args['asset_name']} thumbnailer"
bg_blender.add_bg_process(
name=name,
@@ -241,7 +293,7 @@ def start_model_thumbnailer(
process=proc,
)
if props:
props.thumbnail_generating_state = "Started Blender instance"
_set_prop(state_attr, "Started Blender instance")
if wait:
while proc.poll() is None:
@@ -449,6 +501,132 @@ class GenerateThumbnailOperator(bpy.types.Operator):
return wm.invoke_props_dialog(self, width=400)
class GenerateWireframeThumbnailOperator(bpy.types.Operator):
"""Generate Cycles wireframe thumbnail for model assets"""
bl_idname = "object.blenderkit_generate_wireframe_thumbnail"
bl_label = "BlenderKit Wireframe Thumbnail Generator"
bl_options = {"REGISTER", "INTERNAL"}
@classmethod
def poll(cls, context):
return bpy.context.view_layer.objects.active is not None
def draw(self, context):
ui_props = bpy.context.window_manager.blenderkitUI
asset_type = ui_props.asset_type
ob = utils.get_active_model()
props = ob.blenderkit
layout = self.layout
layout.label(text="thumbnailer settings")
layout.prop(props, "thumbnail_background_lightness")
# for printable models
if asset_type == "PRINTABLE":
layout.prop(props, "thumbnail_material_color")
layout.prop(props, "thumbnail_angle")
layout.prop(props, "thumbnail_snap_to")
layout.prop(props, "thumbnail_samples")
layout.prop(props, "thumbnail_resolution")
layout.prop(props, "thumbnail_denoising")
preferences = bpy.context.preferences.addons[__package__].preferences
layout.prop(preferences, "thumbnail_use_gpu")
# TODO: wireframe
# layout.prop(preferences, "thumbnail_disable_subdivision")
def execute(self, context):
if not upload.wire_thumbnail_upload_enabled():
self.report({"ERROR"}, "Wireframe thumbnail uploads are disabled.")
return {"CANCELLED"}
asset = utils.get_active_model()
asset.blenderkit.is_generating_wire_thumbnail = True
asset.blenderkit.wire_thumbnail_generating_state = "starting blender instance"
tempdir = tempfile.mkdtemp()
ext = ".blend"
filepath = os.path.join(tempdir, "thumbnailer_wf_blenderkit" + ext)
path_can_be_relative = True
thumb_dir = os.path.dirname(bpy.data.filepath)
if thumb_dir == "":
thumb_dir = tempdir
path_can_be_relative = False
an_slug = paths.slugify(asset.name)
# add suffix to distinguish from regular thumbnail
an_slug += "_wf"
thumb_path = os.path.join(thumb_dir, an_slug)
if path_can_be_relative:
rel_thumb_path = f"//{an_slug}"
else:
rel_thumb_path = thumb_path
i = 0
while os.path.isfile(thumb_path + ".jpg"):
thumb_name = f"{an_slug}_{str(i).zfill(4)}"
thumb_path = os.path.join(thumb_dir, thumb_name)
if path_can_be_relative:
rel_thumb_path = f"//{thumb_name}"
i += 1
bkit = asset.blenderkit
bkit.wire_thumbnail = rel_thumb_path + ".jpg"
bkit.wire_thumbnail_generating_state = "Saving .blend file"
# if this isn't here, blender crashes.
if bpy.app.version >= (3, 0, 0):
bpy.context.preferences.filepaths.file_preview_type = "NONE"
# save a copy of actual scene but don't interfere with the users models
bpy.ops.wm.save_as_mainfile(filepath=filepath, compress=False, copy=True)
# get all included objects
obs = utils.get_hierarchy(asset)
obnames = []
for ob in obs:
obnames.append(ob.name)
# asset type can be model or printable
ui_props = bpy.context.window_manager.blenderkitUI
asset_type = ui_props.asset_type
args_dict = {
"type": asset_type,
"asset_name": asset.name,
"filepath": filepath,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
"thumbnail_render_type": "WIREFRAME",
"thumbnail_upload_type": "wire_thumbnail",
}
thumbnail_args = {
"type": asset_type,
"models": str(obnames),
"thumbnail_angle": bkit.thumbnail_angle,
"thumbnail_snap_to": bkit.thumbnail_snap_to,
"thumbnail_background_lightness": 0.2,
"thumbnail_material_color": (
bkit.thumbnail_material_color[0],
bkit.thumbnail_material_color[1],
bkit.thumbnail_material_color[2],
),
"thumbnail_resolution": bkit.thumbnail_resolution,
"thumbnail_samples": bkit.thumbnail_samples,
"thumbnail_denoising": bkit.thumbnail_denoising,
}
args_dict.update(thumbnail_args)
start_model_thumbnailer(
self, json_args=args_dict, props=asset.blenderkit, wait=False
)
return {"FINISHED"}
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=400)
class ReGenerateThumbnailOperator(bpy.types.Operator):
"""
Generate default thumbnail with Cycles renderer and upload it.
@@ -464,6 +642,12 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
name="Asset Index", description="asset index in search results", default=-1
)
asset_type: StringProperty( # type: ignore[valid-type]
name="Asset Type",
description="Asset type used for thumbnail generation",
default="",
)
render_locally: BoolProperty( # type: ignore[valid-type]
name="Render Locally",
description="Render thumbnail locally instead of using server-side rendering",
@@ -529,7 +713,12 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
layout.label(text="thumbnailer settings")
layout.prop(props, "thumbnail_background_lightness")
# for printable models
if self.asset_type == "PRINTABLE":
asset_type = (
getattr(self, "asset_type", "")
or getattr(self, "asset_data", {}).get("assetType", "")
or bpy.context.window_manager.blenderkitUI.asset_type
).upper()
if asset_type == "PRINTABLE":
layout.prop(props, "thumbnail_material_color")
layout.prop(props, "thumbnail_angle")
layout.prop(props, "thumbnail_snap_to")
@@ -545,6 +734,11 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
preferences = bpy.context.preferences.addons[__package__].preferences
# Ensure asset_type is set when execution is triggered directly.
ui_props = bpy.context.window_manager.blenderkitUI
if not getattr(self, "asset_type", ""):
self.asset_type = ui_props.asset_type
if not self.render_locally:
# Use server-side thumbnail regeneration
success = upload.mark_for_thumbnail(
@@ -575,8 +769,7 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
thumb_path = os.path.join(tempdir, an_slug)
# asset type can be model or printable
ui_props = bpy.context.window_manager.blenderkitUI
self.asset_type = ui_props.asset_type
self.asset_type = self.asset_type or ui_props.asset_type
args_dict = {
"type": self.asset_type,
"asset_name": self.asset_data["name"],
@@ -607,6 +800,12 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
history_step = search.get_active_history_step()
sr = history_step.get("search_results", [])
self.asset_data = sr[self.asset_index]
# Prepopulate asset_type so draw() can safely access it.
self.asset_type = (
self.asset_data.get("assetType", "")
if isinstance(self.asset_data, dict)
else ""
).upper() or bpy.context.window_manager.blenderkitUI.asset_type
return wm.invoke_props_dialog(self, width=400)
@@ -891,6 +1090,7 @@ class ReGenerateMaterialThumbnailOperator(bpy.types.Operator):
def register_thumbnailer():
bpy.utils.register_class(GenerateThumbnailOperator)
bpy.utils.register_class(ReGenerateThumbnailOperator)
bpy.utils.register_class(GenerateWireframeThumbnailOperator)
bpy.utils.register_class(GenerateMaterialThumbnailOperator)
bpy.utils.register_class(ReGenerateMaterialThumbnailOperator)
@@ -898,5 +1098,6 @@ def register_thumbnailer():
def unregister_thumbnailer():
bpy.utils.unregister_class(GenerateThumbnailOperator)
bpy.utils.unregister_class(ReGenerateThumbnailOperator)
bpy.utils.unregister_class(GenerateWireframeThumbnailOperator)
bpy.utils.unregister_class(GenerateMaterialThumbnailOperator)
bpy.utils.unregister_class(ReGenerateMaterialThumbnailOperator)
@@ -17,14 +17,14 @@
# ##### END GPL LICENSE BLOCK #####
# type: ignore
from __future__ import annotations
import json
import math
import os
import random
import colorsys
import sys
from traceback import print_exc
from typing import Any, Union
import bpy
@@ -36,47 +36,66 @@ def get_obnames(BLENDERKIT_EXPORT_DATA: str):
return obnames
def center_obs_for_thumbnail(obs):
s = bpy.context.scene
# obs = bpy.context.selected_objects
def center_objs_for_thumbnail(obs: list[Any]) -> None:
"""Center and scale objects for optimal thumbnail framing.
Steps:
1. Center objects in world space (handles parent-child hierarchy)
2. Adjust camera distance based on object bounds
3. Scale helper objects to fit the model in frame
Args:
obs: List of Blender objects to center and frame.
"""
scene = bpy.context.scene
parent = obs[0]
# Handle instanced collections (linked objects)
if parent.type == "EMPTY" and parent.instance_collection is not None:
obs = parent.instance_collection.objects[:]
# Get top-level parent
while parent.parent is not None:
parent = parent.parent
# reset parent rotation, so we see how it really snaps.
# Reset parent rotation for accurate snapping
parent.rotation_euler = (0, 0, 0)
parent.location = (0, 0, 0)
bpy.context.view_layer.update()
# Calculate bounding box in world space
minx, miny, minz, maxx, maxy, maxz = utils.get_bounds_worldspace(obs)
# Center object at world origin
cx = (maxx - minx) / 2 + minx
cy = (maxy - miny) / 2 + miny
for ob in s.collection.objects:
for ob in scene.collection.objects:
ob.select_set(False)
bpy.context.view_layer.objects.active = parent
# parent.location += mathutils.Vector((-cx, -cy, -minz))
parent.location = (-cx, -cy, 0)
camZ = s.camera.parent.parent
# camZ.location.z = (maxz - minz) / 2
camZ.location.z = (maxz) / 2
# Adjust camera position and scale based on object size
cam_z = scene.camera.parent.parent
cam_z.location.z = maxz / 2
# Calculate diagonal size of object for scaling
dx = maxx - minx
dy = maxy - miny
dz = maxz - minz
r = math.sqrt(dx * dx + dy * dy + dz * dz)
# Scale scene elements to fit object
scaler = bpy.context.view_layer.objects["scaler"]
scaler.scale = (r, r, r)
coef = 0.7
coef = 0.7 # Camera distance coefficient
r *= coef
camZ.scale = (r, r, r)
cam_z.scale = (r, r, r)
bpy.context.view_layer.update()
def render_thumbnails():
def render_thumbnails() -> None:
"""Render the current scene to a still image (no animation)."""
bpy.ops.render.render(write_still=True, animation=False)
@@ -112,17 +131,23 @@ def patch_imports(addon_module_name: str):
print(f"- Local repository {parts[1]} added")
def replace_materials(obs, material_name):
"""Replace all materials on objects with the specified material
def replace_materials(
obs: list[Any], material_name: str
) -> Union[bpy.types.Material, None]:
"""Replace all materials on the given objects with a wireframe material.
Args:
obs: List of objects to process
material_name: Name of the material to apply to all objects
obs: List of Blender objects to modify.
material_name: Name of the wireframe material to use.
"""
material = bpy.data.materials.get(material_name)
if not material:
# Create or get the wireframe material
if material_name in bpy.data.materials:
material = bpy.data.materials[material_name]
else:
bg_blender.progress(f"Material {material_name} not found")
return
# Assign the wireframe material to all objects
for ob in obs:
if ob.type == "MESH":
# Clear all material slots and add the specified material
@@ -131,6 +156,57 @@ def replace_materials(obs, material_name):
return material
def disable_modifier(obs: list[Any], modifier_type: str) -> None:
"""Disable a specific type of modifier on all given objects.
Args:
obs: List of Blender objects to modify.
modifier_type: Type of the modifier to disable (e.g., 'SUBSURF').
"""
for ob in obs:
if ob.type == "MESH":
for mod in ob.modifiers:
if mod.type == modifier_type:
mod.show_viewport = False
mod.show_render = False
# disable only first found
break
def _str_to_color(s: str) -> Union[tuple[float, float, float], None]:
"""Convert a color string to an RGB tuple.
Args:
s: Color string in the format "#RRGGBB" or "R,G,B".
Returns:
A tuple of (R, G, B) values as floats in the range [0.0, 1.0], or None.
"""
hex_size = 7 # e.g. "#RRGGBB"
rgb_size = 5 # e.g. "R,G,B"
rgb_count = 3
s = s.strip()
if s.startswith("#") and len(s) == hex_size:
r = int(s[1:3], 16) / 255.0
g = int(s[3:5], 16) / 255.0
b = int(s[5:7], 16) / 255.0
return (r, g, b)
if len(s) == rgb_size:
parts = s.split(",")
if len(parts) == rgb_count:
try:
r = float(parts[0].strip())
g = float(parts[1].strip())
b = float(parts[2].strip())
except ValueError:
pass
else:
return (r, g, b)
# Default to None
return None
if __name__ == "__main__":
try:
# args order must match the order in blenderkit/autothumb.py:get_thumbnailer_args()!
@@ -144,6 +220,7 @@ if __name__ == "__main__":
with open(BLENDERKIT_EXPORT_DATA, "r", encoding="utf-8") as s:
data = json.load(s)
thumbnail_use_gpu = data.get("thumbnail_use_gpu")
thumbnail_disable_subdivision = data.get("thumbnail_disable_subdivision", False)
if data.get("do_download"):
# if this isn't here, blender crashes.
@@ -195,7 +272,7 @@ if __name__ == "__main__":
}
bpy.context.scene.camera = bpy.data.objects[camdict[data["thumbnail_snap_to"]]]
center_obs_for_thumbnail(allobs)
center_objs_for_thumbnail(allobs)
bpy.context.scene.render.filepath = data["thumbnail_path"]
if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU"
@@ -214,8 +291,8 @@ if __name__ == "__main__":
"SIDE": 4,
"TOP": 5,
}
s = bpy.context.scene
s.frame_set(fdict[data["thumbnail_angle"]])
scene = bpy.context.scene
scene.frame_set(fdict[data["thumbnail_angle"]])
snapdict = {
"GROUND": "Ground",
@@ -262,12 +339,19 @@ if __name__ == "__main__":
1 - random_color[2],
1,
)
# disable subdivision for thumbnail rendering if needed
if thumbnail_disable_subdivision:
disable_modifier(allobs, "SUBSURF")
# replace material if we need to render wireframe thumbnail
if data.get("thumbnail_render_type") == "WIREFRAME":
replace_materials(allobs, "bkit wireframe")
bpy.data.materials["bkit background"].node_tree.nodes["Value"].outputs[
"Value"
].default_value = data["thumbnail_background_lightness"]
s.cycles.samples = data["thumbnail_samples"]
scene.cycles.samples = data["thumbnail_samples"]
bpy.context.view_layer.cycles.use_denoising = data["thumbnail_denoising"]
bpy.context.view_layer.update()
@@ -297,14 +381,17 @@ if __name__ == "__main__":
)
sys.exit(0)
# get sub type if we are not generating for main beauty thumbnail
filetype = "thumbnail"
if data.get("thumbnail_upload_type"):
filetype = data["thumbnail_upload_type"].lower()
bg_blender.progress("uploading thumbnail")
fpath = data["thumbnail_path"] + ".jpg"
ok = client_lib.complete_upload_file_blocking(
api_key=BLENDERKIT_EXPORT_API_KEY,
asset_id=data["asset_data"]["id"],
filepath=fpath,
filetype=f"thumbnail",
filetype=filetype,
fileindex=0,
)
if not ok:
@@ -25,7 +25,15 @@ import threading
import bpy
from bpy.props import EnumProperty
from . import utils
from . import utils, reports
def _safe_eval_target(eval_path: str, name: str):
"""Try resolving eval_path; fallback to bpy.data.objects by name; return None if missing."""
try:
return eval(eval_path)
except Exception:
return bpy.data.objects.get(name) if name else None
bk_logger = logging.getLogger(__name__)
@@ -62,8 +70,9 @@ class ThreadCom: # object passed to threads to read background process stdout i
def threadread(tcom: ThreadCom):
"""reads stdout of background process.
this threads basically waits for a stdout line to come in,
"""Reads stdout of background process.
This thread basically waits for a stdout line to come in,
fills the data, dies."""
found = False
while not found:
@@ -74,7 +83,12 @@ def threadread(tcom: ThreadCom):
# ignore empty lines
if inline.strip() == "":
continue
bk_logger.info(inline.strip())
# Background Blender already formats logs; strip a leading emoji/prefix to avoid double branding.
line = inline.strip()
line = re.sub(
r"^(?:[🐞ℹ️⚠️❌🔥]\s*)?blenderkit:\s*", "", line, flags=re.IGNORECASE
)
bk_logger.info(line)
progress = re.findall(r"progress\{(.*?)\}", inline)
if len(progress) > 0:
if type(progress[0]) == int or type(progress[0]) == float:
@@ -116,8 +130,10 @@ def progress(text, n=None):
sys.stdout.write(output)
sys.stdout.flush()
except Exception as e:
print("background progress reporting race condition")
print(e)
bk_logger.exception(
"background progress reporting race condition", exc_info=False
)
bk_logger.error(f"Error details: {e}")
# @bpy.app.handlers.persistent
@@ -140,10 +156,15 @@ def bg_update():
for p in remove_processes:
bk_logger.info(str(p[1].outtext))
estring = p[1].eval_path_computing + " = False"
try:
exec(estring)
except Exception as e:
bk_logger.error(f"Exception executing eval_path_computing: {e}")
target = _safe_eval_target(p[1].eval_path, p[1].name)
if target is not None:
try:
exec(estring)
except Exception as e:
bk_logger.exception(
"Exception executing eval_path_computing.", exc_info=False
)
bk_logger.error(f"Error details: {e}")
bg_processes.remove(p)
# Parse process output
@@ -162,7 +183,7 @@ def bg_update():
tcom.outtext = ""
text = tcom.lasttext.replace("'", "") # noqa: F841 needed in exec()
estring = tcom.eval_path_state + " = text"
# print(tcom.lasttext)
if "finished successfully" in tcom.lasttext:
bk_logger.info(str(tcom.lasttext))
bg_processes.remove(p)
@@ -174,10 +195,20 @@ def bg_update():
readthread.start()
p[0] = readthread
if estring:
try:
exec(estring)
except Exception as e:
print(f"Exception while reading from background process: {e}")
target = _safe_eval_target(tcom.eval_path, tcom.name)
if target is not None:
try:
exec(estring)
except Exception as e:
bk_logger.exception(
"Exception while reading from background process.",
exc_info=False,
)
bk_logger.error(f"Error details: {e}")
else:
bk_logger.debug(
"Skipping state update; target missing for %s", tcom.name
)
# if len(bg_processes) == 0:
# bpy.app.timers.unregister(bg_update)
@@ -235,6 +266,8 @@ class KillBgProcess(bpy.types.Operator):
props.uploading = False
if self.process_type == "THUMBNAILER":
props.is_generating_thumbnail = False
if hasattr(props, "is_generating_wire_thumbnail"):
props.is_generating_wire_thumbnail = False
# print('killing', self.process_source, self.process_type)
# then go kill the process. this wasn't working for unsetting props and that was the reason for changing to the method above.
@@ -244,36 +277,43 @@ class KillBgProcess(bpy.types.Operator):
tcom = p[1]
# print(tcom.process_type, self.process_type)
if tcom.process_type == self.process_type:
source = eval(tcom.eval_path)
source = _safe_eval_target(tcom.eval_path, tcom.name)
kill = False
# TODO HDR - add killing of process
if source.bl_rna.name == "Object" and self.process_source == "MODEL":
if source.name == bpy.context.active_object.name:
kill = True
if source.bl_rna.name == "Scene" and self.process_source == "SCENE":
if source.name == bpy.context.scene.name:
kill = True
if source.bl_rna.name == "Image" and self.process_source == "HDR":
ui_props = bpy.context.window_manager.blenderkitUI
if source.name == ui_props.hdr_upload_image.name:
kill = False
if source is not None:
if (
source.bl_rna.name == "Object"
and self.process_source == "MODEL"
):
if source.name == bpy.context.active_object.name:
kill = True
if source.bl_rna.name == "Scene" and self.process_source == "SCENE":
if source.name == bpy.context.scene.name:
kill = True
if source.bl_rna.name == "Image" and self.process_source == "HDR":
ui_props = bpy.context.window_manager.blenderkitUI
if source.name == ui_props.hdr_upload_image.name:
kill = False
if (
source.bl_rna.name == "Material"
and self.process_source == "MATERIAL"
):
if source.name == bpy.context.active_object.active_material.name:
kill = True
if source.bl_rna.name == "Brush" and self.process_source == "BRUSH":
brush = utils.get_active_brush()
if brush is not None and source.name == brush.name:
kill = True
if (
source.bl_rna.name == "Object"
and self.process_source == "PRINTABLE"
):
if source.name == bpy.context.active_object.name:
kill = True
if (
source.bl_rna.name == "Material"
and self.process_source == "MATERIAL"
):
if (
source.name
== bpy.context.active_object.active_material.name
):
kill = True
if source.bl_rna.name == "Brush" and self.process_source == "BRUSH":
brush = utils.get_active_brush()
if brush is not None and source.name == brush.name:
kill = True
if (
source.bl_rna.name == "Object"
and self.process_source == "PRINTABLE"
):
if source.name == bpy.context.active_object.name:
kill = True
if kill:
estring = tcom.eval_path_computing + " = False"
exec(estring)
@@ -60,7 +60,7 @@ def delete_unfinished_file(file_name):
try:
os.remove(file_name)
except Exception as e:
bk_logger.error(f"{e}")
bk_logger.error("Could not delete unfinished file %s: %s", file_name, e)
asset_dir = os.path.dirname(file_name)
if len(os.listdir(asset_dir)) == 0:
os.rmdir(asset_dir)
@@ -23,13 +23,24 @@ import random
import secrets
import string
import time
import uuid
from urllib.parse import quote as urlquote
from webbrowser import open_new_tab
import bpy
from bpy.props import BoolProperty
from . import client_lib, client_tasks, datas, global_vars, reports, tasks_queue, utils
from . import (
client_lib,
client_tasks,
datas,
global_vars,
reports,
search_price,
tasks_queue,
utils,
)
if bpy.app.version >= (4, 2, 0):
from . import override_extension_draw
@@ -104,6 +115,7 @@ def clean_login_data():
preferences.api_key = ""
preferences.api_key_timeout = 0
global_vars.BKIT_PROFILE = datas.MineProfile()
search_price.clear_price_cache()
# Cleanup also the api key in the extensions repository setting and clean the cache
if bpy.app.version >= (4, 2, 0):
override_extension_draw.ensure_repository(api_key="")
@@ -128,14 +140,19 @@ def login(signup: bool) -> None:
code_verifier, code_challenge = generate_pkce_pair()
state = secrets.token_urlsafe()
client_lib.send_oauth_verification_data(code_verifier, state)
authorize_url = f"/o/authorize?client_id={CLIENT_ID}&response_type=code&state={state}&redirect_uri={redirect_URI}&code_challenge={code_challenge}&code_challenge_method=S256"
system_id = get_system_id()
authorize_url = (
f"/o/authorize?client_id={CLIENT_ID}&response_type=code&state={state}"
f"&redirect_uri={redirect_URI}&code_challenge={code_challenge}"
f"&code_challenge_method=S256&system_id={system_id}"
)
if signup:
authorize_url = urlquote(authorize_url)
authorize_url = f"{global_vars.SERVER}/accounts/register/?next={authorize_url}"
else:
authorize_url = f"{global_vars.SERVER}{authorize_url}"
ok = open_new_tab(authorize_url)
bk_logger.info(f"Login page in browser opened ({ok})")
bk_logger.info("Login page in browser opened (%s)", ok)
def generate_pkce_pair() -> tuple[str, str]:
@@ -151,12 +168,17 @@ def generate_pkce_pair() -> tuple[str, str]:
return code_verifier, code_challenge
def get_system_id() -> str:
return f"{uuid.getnode():015d}"
def write_tokens(auth_token, refresh_token, oauth_response):
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.api_key_timeout = int(time.time() + oauth_response["expires_in"])
preferences.login_attempt = False
preferences.api_key_refresh = refresh_token
preferences.api_key = auth_token # triggers api_key update function
search_price.clear_price_cache()
# write token also to extensions repository setting and clear the cache
if bpy.app.version >= (4, 2, 0):
override_extension_draw.ensure_repository(api_key=auth_token)
@@ -20,7 +20,7 @@
bl_info = {
"name": "BlenderKit Online Asset Library",
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik",
"version": (3, 18, 0, 251121), # X.Y.Z.yymmdd
"version": (3, 18, 1, 251219), # X.Y.Z.yymmdd
"blender": (3, 0, 0),
"location": "View3D > Properties > BlenderKit",
"description": "Boost your workflow with drag&drop assets from the community driven library.",
@@ -28,7 +28,7 @@ bl_info = {
"tracker_url": "https://github.com/BlenderKit/blenderkit/issues",
"category": "3D View",
}
VERSION = (3, 18, 0, 251121)
VERSION = (3, 18, 1, 251219)
import logging
import random
@@ -242,7 +242,7 @@ engines = (
("CYCLES", "Cycles", "Blender Cycles"),
("EEVEE", "Eevee", "Blender eevee renderer"),
("EEEVE_NEXT", "Eevee Next", "Blender eevee renderer (new)"),
("OCTANE", "Octane", "Octane render enginge"),
("OCTANE", "Octane", "Octane render engine"),
("ARNOLD", "Arnold", "Arnold render engine"),
("V-RAY", "V-Ray", "V-Ray renderer"),
("UNREAL", "Unreal", "Unreal engine"),
@@ -267,6 +267,12 @@ mesh_poly_types = (
)
EXTRA_PATH_OPTIONS = {}
if bpy.app.version >= (4, 5, 0):
EXTRA_PATH_OPTIONS = {"options": {"PATH_SUPPORTS_BLEND_RELATIVE"}}
def udate_down_up(self, context):
"""Perform a search if results are empty."""
props = bpy.context.window_manager.blenderkitUI
@@ -461,12 +467,12 @@ class BlenderKitUIProps(PropertyGroup):
search_blender_version: BoolProperty(
name="Asset Blender Version",
description="Limit the assets by version of Blender (minimum, maximum) in which they were created. "
+ "Use maximum version limit to exclude incompatible assets from newer Blender versions than yours. Or set the minumum version to exclude assets created in quite old Blender versions",
+ "Use maximum version limit to exclude incompatible assets from newer Blender versions than yours. Or set the minimum version to exclude assets created in quite old Blender versions",
)
search_blender_version_min: StringProperty(
name="Minimal version (including, higher than or equal)",
default="0.0",
description="Limit the assets by minimum version of Blender in which they were created, including also the specified version and exluding all older versions from the search results. "
description="Limit the assets by minimum version of Blender in which they were created, including also the specified version and excluding all older versions from the search results. "
+ "Only assets created in HIGHER THAN OR EQUAL (>= min) minimum version will be shown. Use semantic versioning format: X.Y.Z.\n\n"
+ "E.g.: exclude all Blender 2 assets by specifying 3, 3.0, or 3.0.0. Assets created in 3.0 or higher will be shown",
update=search.search_update,
@@ -474,7 +480,7 @@ class BlenderKitUIProps(PropertyGroup):
search_blender_version_max: StringProperty(
name="Maximum version (excluding, lower than)",
default="5.99",
description="Limit the assets by maximum version of Blender in which they were created, exluding the specified version and all newer versions from the search results. "
description="Limit the assets by maximum version of Blender in which they were created, excluding the specified version and all newer versions from the search results. "
+ "Only assets created in LOWER THAN (< max) maximum version will be shown. Use semantic versioning format: X.Y.Z.\n\n"
+ "E.g.: exclude all Blender 4 assets by specifying 4, 4.0, or 4.0.0. Assets created in 3.6 and lower will be shown",
update=search.search_update,
@@ -580,7 +586,7 @@ class BlenderKitUIProps(PropertyGroup):
rating_ui_width: IntProperty(name="Rating UI Width", default=rating_ui_scale * 600)
rating_ui_height: IntProperty(
name="Rating UI Heightt", default=rating_ui_scale * 256
name="Rating UI Height", default=rating_ui_scale * 256
)
quality_stars_x: IntProperty(name="Rating UI Stars X", default=rating_ui_scale * 90)
@@ -1130,6 +1136,7 @@ class BlenderKitMaterialUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_material_preview,
**EXTRA_PATH_OPTIONS,
)
is_generating_thumbnail: BoolProperty(
@@ -1213,13 +1220,14 @@ class BlenderKitBrushUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
)
class BlenderKitNodeGroulUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
class BlenderKitNodeGroupUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail: StringProperty(
name="Thumbnail",
description="Thumbnail path - minimum 1024x1024 square .jpg\n"
"And make it beautiful!",
subtype="FILE_PATH",
default="",
**EXTRA_PATH_OPTIONS,
# update=autothumb.update_upload_model_preview,
)
# mode: EnumProperty(
@@ -1326,6 +1334,7 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_model_preview,
**EXTRA_PATH_OPTIONS,
)
thumbnail_background_lightness: FloatProperty(
@@ -1529,6 +1538,7 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
description="Photo of the 3D printed object (JPG or PNG, preferred size is 1024x1024 or higher)",
subtype="FILE_PATH",
default="",
**EXTRA_PATH_OPTIONS,
)
photo_thumbnail_will_upload_on_website: BoolProperty(
name="I will upload photo on website",
@@ -1603,6 +1613,7 @@ class BlenderKitSceneUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_scene_preview,
**EXTRA_PATH_OPTIONS,
)
use_design_year: BoolProperty(
@@ -1766,7 +1777,7 @@ class BlenderKitModelSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
update=search.search_update,
)
search_design_year: BoolProperty(
name="Sesigned in Year",
name="Designed in Year",
description="When the object was approximately designed. \n"
"Useful for search of historical or future objects",
default=False,
@@ -1966,7 +1977,7 @@ def fix_subdir(self, context):
ui_panels.ui_message(
title="Fixed to relative path",
message="This path should be always realative.\n"
message="This path should be always relative.\n"
" It's a directory BlenderKit creates where your .blend is \n "
"and uses it for storing assets.",
)
@@ -1992,7 +2003,7 @@ class BlenderKitAddonPreferences(AddonPreferences):
preferences_lock: BoolProperty(
name="Preferences Locked",
description="When this is on, preferences will not be saved. Used for programatical changes of preferences",
description="When this is on, preferences will not be saved. Used for programmatic changes of preferences",
default=False,
)
@@ -2120,6 +2131,14 @@ class BlenderKitAddonPreferences(AddonPreferences):
update=utils.save_prefs,
)
# USE OF CLIPBOARD SCAN
use_clipboard_scan: BoolProperty(
name="Use Clipboard Scan",
description="Use the info from BlenderKit website clipboard for visual search",
default=True,
update=utils.save_prefs,
)
unpack_files: BoolProperty(
name="Unpack Files",
description="Unpack assets after download \n "
@@ -2233,8 +2252,8 @@ class BlenderKitAddonPreferences(AddonPreferences):
proxy_address: StringProperty(
name="Custom proxy address",
description="""Set custom HTTP proxy for HTTPS requests of add-on. This setting preceeds any system wide proxy settings. If left empty custom proxy will not be set.
description="""Set custom HTTP proxy for HTTPS requests of add-on. This setting precedes any system wide proxy settings. If left empty custom proxy will not be set.
If you use simple HTTP proxy, set in format http://ip:port, or http://username:password@ip:port if your HTTP proxy requires authentication (make sure to escape special characters like #$%:^&*() etc. in username and password). You have to specify the address with http:// prefix.
HTTPS proxies are not supported! We wait for support in Python 3.11 and in aiohttp module. You can specify the HTTPS proxy with https:// prefix for hacking around and development purposes, but functionality cannot be guaranteed.
@@ -2430,12 +2449,21 @@ In this case you should also set path to your system CA bundle containing proxy'
default="[]",
)
# EXPERIMENTAL AND DEBUG FEATURES CAN GO BELOW
ignore_env_for_thumbnails: BoolProperty(
name="Ignore ENVIRONMENT variables for thumbnails",
description="If enabled, we will not modify the system environment variables for background thumbnail rendering.",
default=False,
# do not save prefs here, it's experimental
options={"SKIP_SAVE"},
)
def draw(self, context):
layout = self.layout
if self.api_key.strip() == "":
ui_panels.draw_login_buttons(layout)
layout.label(
text="Sign up to bookmark your favourite assets. Get 200 MiB of private storage in Free Plan."
text="Sign up to bookmark your favorite assets. Get 200 MiB of private storage in Free Plan."
)
else:
layout.operator("wm.blenderkit_logout", text="Logout", icon="URL")
@@ -2470,8 +2498,9 @@ In this case you should also set path to your system CA bundle containing proxy'
gui_settings.prop(self, "show_VIEW3D_MT_blenderkit_model_properties")
gui_settings.prop(self, "tips_on_start")
gui_settings.prop(self, "announcements_on_start")
gui_settings.prop(self, "use_clipboard_scan")
# NETWORKING SETINGS
# NETWORKING SETTINGS
network_settings = layout.box()
network_settings.alignment = "EXPAND"
network_settings.label(text="Networking settings")
@@ -2487,6 +2516,14 @@ In this case you should also set path to your system CA bundle containing proxy'
# UPDATER SETTINGS
addon_updater_ops.update_settings_ui(self, context)
# EXPERIMENTAL SETTINGS
# only if experimental features enabled
if self.experimental_features:
experimental_settings = layout.box()
experimental_settings.alignment = "EXPAND"
experimental_settings.label(text="Experimental settings")
experimental_settings.prop(self, "ignore_env_for_thumbnails")
# RUNTIME INFO
globdir_op = layout.operator(
"wm.blenderkit_open_global_directory",
@@ -2535,7 +2572,7 @@ classes = (
BlenderKitBrushSearchProps,
BlenderKitBrushUploadProps,
BlenderKitGeoToolSearchProps,
BlenderKitNodeGroulUploadProps,
BlenderKitNodeGroupUploadProps,
BlenderKitAddonSearchProps,
)
@@ -2598,10 +2635,10 @@ def register():
type=BlenderKitGeoToolSearchProps
)
bpy.types.NodeGroup.blenderkit = PointerProperty( # for uploads, not now...
type=BlenderKitNodeGroulUploadProps
type=BlenderKitNodeGroupUploadProps
)
bpy.types.NodeTree.blenderkit = PointerProperty( # for uploads, not now...
type=BlenderKitNodeGroulUploadProps
type=BlenderKitNodeGroupUploadProps
)
bpy.types.WindowManager.blenderkit_addon = PointerProperty(
type=BlenderKitAddonSearchProps
@@ -97,7 +97,13 @@ def make_annotations(cls):
if bl_props:
if "__annotations__" not in cls.__dict__:
setattr(cls, "__annotations__", {})
annotations = cls.__dict__["__annotations__"]
try:
annotations = cls.__dict__["__annotations__"]
except KeyError:
# Fedora 43 bug workaround #1823
annotations = getattr(cls, "__annotations__")
for k, v in bl_props.items():
annotations[k] = v
delattr(cls, k)
@@ -42,15 +42,19 @@ def find_layer_collection(layer_collection, collection_name):
def append_brush(file_name, brushname=None, link=False, fake_user=True):
"""append a brush"""
brushes_before = bpy.data.brushes[:]
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for m in data_from.brushes:
if m == brushname or brushname is None:
if brushname is None or m.strip() == brushname.strip():
data_to.brushes = [m]
brushname = m
brush = bpy.data.brushes[brushname]
for b in bpy.data.brushes:
if b not in brushes_before:
brush = b
break
brush.use_fake_user = fake_user
return brush
@@ -93,8 +97,7 @@ def append_nodegroup(
data_to,
):
for g in data_from.node_groups:
print(g)
if g == nodegroupname or nodegroupname is None:
if nodegroupname is None or g.strip() == nodegroupname.strip():
data_to.node_groups = [g]
nodegroupname = g
nodegroup = bpy.data.node_groups[nodegroupname]
@@ -281,7 +284,7 @@ def append_material(file_name, matname=None, link=False, fake_user=True):
):
found = False
for m in data_from.materials:
if m == matname or matname is None:
if matname is None or m.strip() == matname.strip():
data_to.materials = [m]
matname = m
found = True
@@ -319,7 +322,7 @@ def append_scene(file_name, scenename=None, link=False, fake_user=False):
data_to,
):
for s in data_from.scenes:
if s == scenename or scenename is None:
if scenename is None or s.strip() == scenename.strip():
data_to.scenes = [s]
scenename = s
scene = bpy.data.scenes[scenename]
@@ -448,7 +451,7 @@ def link_collection(
data_to,
):
for col in data_from.collections:
if col == kwargs["name"]:
if col.strip() == kwargs["name"].strip():
data_to.collections = [col]
rotation = (0, 0, 0)
@@ -21,7 +21,7 @@ import math
import os
import re
import time
from typing import Any, Dict
from typing import Any, Dict, Union
import bpy
from bpy.props import BoolProperty, StringProperty
@@ -286,6 +286,12 @@ def modal_inside(self, context, event):
if self.check_ui_resized(context) or self.check_new_search_results(context):
self.update_assetbar_sizes(context)
self.update_assetbar_layout(context)
# also update tooltip visibility
# if there's less results and active button is not visible, hide tooltip
# happened only when e.g. running new search from web browser (copying assetbaseid to clipboard)
# fixes issue #1766
if self.active_index >= len(search.get_search_results()):
self.hide_tooltip()
self.scroll_update(
always=True
) # one extra update for scroll for correct redraw, updates all buttons
@@ -395,6 +401,17 @@ def get_tooltip_data(asset_data):
# Add pricing information
price_text = ""
price_color = colors.WHITE
price_background = (0, 0, 0, 0)
def format_price(value):
if value is None:
return ""
value_str = str(value).strip()
if not value_str:
return ""
if value_str.startswith("$"):
return value_str
return f"${value_str}"
# Check if asset is free or paid (works for all asset types)
is_free = asset_data.get("isFree", True)
@@ -403,23 +420,38 @@ def get_tooltip_data(asset_data):
if asset_data.get("assetType") == "addon":
# Get pricing info from extensions cache.
# Pricing info is shown only for add-ons.
base_price = asset_data.get("basePrice")
base_price = format_price(asset_data.get("basePrice"))
user_price = format_price(asset_data.get("userPrice"))
is_for_sale = asset_data.get("isForSale")
if is_for_sale and not can_download and base_price:
price_text = f"${base_price}"
price_color = colors.PURPLE
if utils.profile_is_validator():
segments = []
if user_price:
segments.append(f"User {user_price}")
if base_price:
segments.append(f"Base {base_price}")
price_text = " | ".join(segments)
price_background = colors.PURPLE_PRICE
elif is_for_sale and not can_download and user_price and base_price:
price_text = f"{user_price} (was {base_price})"
price_background = colors.PURPLE_PRICE
elif is_for_sale and not can_download and base_price:
price_text = base_price
price_background = colors.PURPLE_PRICE
elif not is_free and not is_for_sale:
price_text = "Full Plan"
price_color = colors.PURPLE
elif (
is_for_sale and can_download
): # purchased, but not yet downloaded, so we can't show price
price_text = f"Purchased (${base_price})"
price_color = colors.PURPLE
price_background = colors.ORANGE_FULL
elif is_for_sale and can_download:
price_text = "Purchased"
price_background = colors.PURPLE_PRICE
else:
price_text = "Free"
price_color = colors.GREEN_FREE
price_background = colors.GREEN_PRICE
tooltip_data = {
"aname": aname,
@@ -427,12 +459,15 @@ def get_tooltip_data(asset_data):
"quality": quality,
"price_text": price_text,
"price_color": price_color,
"price_background": price_background,
}
asset_data["tooltip_data"] = tooltip_data
def set_thumb_check(
element: BL_UI_Button, asset: Dict[str, Any], thumb_type: str = "thumbnail_small"
element: Union[BL_UI_Button, BL_UI_Image],
asset: Dict[str, Any],
thumb_type: str = "thumbnail_small",
) -> None:
"""Set image in case it is loaded in search results. Checks global_vars.DATA["images available"].
- if image download failed, it will be set to 'thumbnail_not_available.jpg'
@@ -457,6 +492,8 @@ def set_thumb_check(
class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""BlenderKit Asset Bar Operator."""
bl_idname = "view3d.blenderkit_asset_bar_widget"
bl_label = "BlenderKit asset bar refresh"
bl_description = "BlenderKit asset bar refresh"
@@ -508,8 +545,23 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""Initialize the tooltip panel and its widgets."""
self.tooltip_widgets = []
self.tooltip_scale = 1.0
self.tooltip_height = self.tooltip_size
self.tooltip_width = self.tooltip_size
# Fallbacks in case update_tooltip_size was not called yet
self.tooltip_width = getattr(self, "tooltip_width", self.tooltip_size)
image_height = getattr(self, "tooltip_image_height", self.tooltip_size)
info_height = getattr(
self,
"tooltip_info_height",
max(
int(image_height * self.bottom_panel_fraction),
self.asset_name_text_size * 3,
),
)
self.tooltip_image_height = image_height
self.tooltip_info_height = info_height
self.tooltip_height = self.tooltip_image_height + self.tooltip_info_height
self.labels_start = self.tooltip_image_height
# total_size = tooltip# + 2 * self.margin
self.tooltip_panel = BL_UI_Drag_Panel(
0, 0, self.tooltip_width, self.tooltip_height
@@ -520,20 +572,16 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tooltip_image = BL_UI_Image(0, 0, 1, 1)
img_path = paths.get_addon_thumbnail_path("thumbnail_notready.jpg")
tooltip_image.set_image(img_path)
tooltip_image.set_image_size((self.tooltip_width, self.tooltip_height))
tooltip_image.set_image_size((self.tooltip_width, self.tooltip_image_height))
tooltip_image.set_image_position((0, 0))
tooltip_image.set_image_colorspace("")
self.tooltip_image = tooltip_image
self.tooltip_widgets.append(tooltip_image)
self.bottom_panel_fraction = 0.15
self.labels_start = self.tooltip_height * (1 - self.bottom_panel_fraction)
dark_panel = BL_UI_Widget(
0,
self.labels_start,
self.tooltip_width,
self.tooltip_height * self.bottom_panel_fraction,
self.tooltip_info_height,
)
dark_panel.bg_color = (0.0, 0.0, 0.0, 0.7)
self.tooltip_dark_panel = dark_panel
@@ -549,8 +597,9 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.asset_name = name_label
self.tooltip_widgets.append(name_label)
self.gravatar_size = int(
self.tooltip_height * self.bottom_panel_fraction - self.tooltip_margin
self.gravatar_size = max(
int(self.tooltip_info_height - 2 * self.tooltip_margin),
self.asset_name_text_size,
)
authors_name = self.new_text(
@@ -566,8 +615,8 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_widgets.append(authors_name)
gravatar_image = BL_UI_Image(
self.tooltip_width - self.gravatar_size,
self.tooltip_height - self.gravatar_size,
self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_height - self.gravatar_size - self.tooltip_margin,
1,
1,
)
@@ -575,8 +624,8 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
gravatar_image.set_image(img_path)
gravatar_image.set_image_size(
(
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size,
self.gravatar_size,
)
)
gravatar_image.set_image_position((0, 0))
@@ -617,7 +666,14 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
height=self.asset_name_text_size,
text_size=self.asset_name_text_size,
)
price_label.text_color = (1.0, 0.8, 0.2, 1.0) # Golden color for price
price_label.background = True
price_label.padding = (3, 4)
price_label.text_color = (
1.0,
0.8,
0.2,
1.0,
) # Golden color for price
self.tooltip_widgets.append(price_label)
self.price_label = price_label
@@ -728,14 +784,30 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""Calculate all important sizes for the tooltip"""
region = context.region
ui_props = bpy.context.window_manager.blenderkitUI
ui_scale = bpy.context.preferences.view.ui_scale
ui_scale = self.get_ui_scale()
base_panel_height = self.tooltip_base_size_pixels * (
1 + self.bottom_panel_fraction
)
if hasattr(self, "tooltip_panel"):
tooltip_y_offset = abs(region.height - self.tooltip_panel.y_screen)
tooltip_y_available_height = abs(
region.height - self.tooltip_panel.y_screen
)
# if tooltip is above, we need to reduce it's size if its y is out of region height
if self.tooltip_panel.y_screen <= 0:
tooltip_y_available_height = (
base_panel_height * ui_scale + self.tooltip_panel.y_screen
)
self.tooltip_panel.set_location(self.tooltip_panel.x, 0)
else:
tooltip_y_offset = abs(region.height - (self.bar_height + self.bar_y))
tooltip_y_available_height = abs(
region.height - (self.bar_height + self.bar_y)
)
self.tooltip_scale = min(
1.0, tooltip_y_offset / (self.tooltip_base_size_pixels * ui_scale)
1.0, tooltip_y_available_height / (base_panel_height * ui_scale)
)
self.asset_name_text_size = int(
0.039 * self.tooltip_base_size_pixels * ui_scale * self.tooltip_scale
@@ -750,14 +822,33 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
if ui_props.asset_type == "HDR":
self.tooltip_width = self.tooltip_size * 2
self.tooltip_height = self.tooltip_size
self.tooltip_image_height = self.tooltip_size
else:
self.tooltip_width = self.tooltip_size
self.tooltip_height = self.tooltip_size
self.tooltip_image_height = self.tooltip_size
self.gravatar_size = int(
self.tooltip_height * self.bottom_panel_fraction - self.tooltip_margin
self.tooltip_info_height = max(
int(self.tooltip_image_height * self.bottom_panel_fraction),
self.asset_name_text_size * 3,
)
self.labels_start = self.tooltip_image_height
self.tooltip_height = self.tooltip_image_height + self.tooltip_info_height
self.gravatar_size = max(
int(self.tooltip_info_height - 2 * self.tooltip_margin),
self.asset_name_text_size,
)
def get_ui_scale(self):
"""Get the UI scale"""
ui_scale = bpy.context.preferences.view.ui_scale
pixel_size = bpy.context.preferences.system.pixel_size
if pixel_size > 1:
# for a reason unknown,
# the pixel size is modified only on mac
# where pixel size is 2.0
ui_scale = pixel_size
return ui_scale
def update_assetbar_sizes(self, context):
"""Calculate all important sizes for the asset bar"""
@@ -766,8 +857,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
ui_props = bpy.context.window_manager.blenderkitUI
user_preferences = bpy.context.preferences.addons[__package__].preferences
ui_scale = bpy.context.preferences.view.ui_scale
ui_scale = self.get_ui_scale()
# assetbar scaling
self.button_margin = int(0 * ui_scale)
self.assetbar_margin = int(2 * ui_scale)
@@ -793,6 +883,10 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.bar_x = int(
tools_width + self.button_margin + ui_props.bar_x_offset * ui_scale
)
# self.bar_y = region.height - ui_props.bar_y_offset * ui_scale
self.bar_y = int(ui_props.bar_y_offset * ui_scale)
self.bar_end = int(ui_width + 180 * ui_scale + self.other_button_size)
self.bar_width = int(region.width - self.bar_x - self.bar_end)
@@ -810,6 +904,16 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
if search_results is not None and self.wcount > 0:
if user_preferences.assetbar_expanded:
max_rows = user_preferences.maximized_assetbar_rows
available_height = (
region.height
- self.bar_y
- 2 * self.assetbar_margin
- self.other_button_size
)
max_rows_by_height = math.floor(available_height / self.button_size)
max_rows = (
min(max_rows, max_rows_by_height) if max_rows_by_height > 0 else 1
)
else:
max_rows = 1
self.hcount = min(
@@ -821,8 +925,6 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.hcount = 1
self.bar_height = (self.button_size) * self.hcount + 2 * self.assetbar_margin
# self.bar_y = region.height - ui_props.bar_y_offset * ui_scale
self.bar_y = int(ui_props.bar_y_offset * ui_scale)
if ui_props.down_up == "UPLOAD":
self.reports_y = region.height - self.bar_y - 600
ui_props.reports_y = region.height - self.bar_y - 600
@@ -886,26 +988,28 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_panel.width = self.tooltip_width
self.tooltip_panel.height = self.tooltip_height
self.tooltip_image.width = self.tooltip_width
self.tooltip_image.height = self.tooltip_height
self.tooltip_image.height = self.tooltip_image_height
self.labels_start = self.tooltip_height * (1 - self.bottom_panel_fraction)
self.labels_start = self.tooltip_image_height
self.tooltip_image.set_image_size((self.tooltip_width, self.tooltip_height))
self.tooltip_image.set_image_size(
(self.tooltip_width, self.tooltip_image_height)
)
self.tooltip_image.set_location(0, 0)
self.gravatar_image.set_location(
self.tooltip_width - self.gravatar_size,
self.tooltip_height - self.gravatar_size,
self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_height - self.gravatar_size - self.tooltip_margin,
)
self.gravatar_image.set_image_size(
(
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size,
self.gravatar_size,
)
)
self.authors_name.set_location(
self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_width - self.gravatar_size - (self.tooltip_margin * 2),
self.tooltip_height - self.author_text_size - self.tooltip_margin,
)
self.authors_name.text_size = self.author_text_size
@@ -922,9 +1026,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
0,
self.labels_start,
)
self.tooltip_dark_panel.height = (
self.tooltip_height * self.bottom_panel_fraction
)
self.tooltip_dark_panel.height = self.tooltip_info_height
self.tooltip_dark_panel.width = self.tooltip_width
self.quality_label.set_location(
@@ -942,6 +1044,15 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
(self.asset_name_text_size, self.asset_name_text_size)
)
# right after the asset name
self.price_label.set_location(
self.tooltip_margin,
self.labels_start + (self.tooltip_margin * 3) + self.asset_name.height,
)
self.price_label.width = self.tooltip_width - 2 * self.tooltip_margin
self.price_label.height = self.asset_name_text_size
self.price_label.text_size = self.asset_name_text_size
def update_layout(self, context, event):
"""update UI sizes after their recalculation"""
self.update_assetbar_layout(context)
@@ -1044,6 +1155,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.button_bg_color = (0.2, 0.2, 0.2, 1.0)
self.button_hover_color = (0.8, 0.8, 0.8, 1.0)
self.button_selected_color = (0.5, 0.5, 0.5, 1.0)
self.button_selected_color_dim = (0.3, 0.3, 0.3, 1.0)
self.buttons = []
self.asset_buttons = []
@@ -1072,7 +1184,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.other_button_size, # Same height as tab buttons
)
# dark blue
self.tab_area_bg.bg_color = (0.2, 0.25, 0.4, 1.0)
self.tab_area_bg.bg_color = colors.TOP_BAR_BLUE
# Add widgets to panel - add tab background first so it's behind everything
self.widgets_panel.append(self.tab_area_bg)
@@ -1162,8 +1274,11 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Add tab navigation elements
button_size = self.other_button_size
margin = int(button_size * 0.05)
space = int(button_size * 0.4)
tab_icon_size = int(button_size * 0.7) # Size for the asset type icon
tab_width = button_size * 4 # Wider tabs to accommodate icon
tab_width = (
button_size * 4 + tab_icon_size
) # Widen the tabs to accommodate type icon
# Back/Forward history buttons
self.history_back_button = BL_UI_Button(
@@ -1199,10 +1314,14 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tabs = global_vars.TABS["tabs"]
tab_x_start = margin * 4 + button_size * 3 # Starting x position of first tab
tabs_end_x = 0
for i, tab in enumerate(tabs):
is_active = i == global_vars.TABS["active_tab"]
# Calculate positions
tab_x = tab_x_start + i * (
tab_width + button_size + margin
tab_width + button_size + margin + space
) # Space for tab and close button
# Tab button
@@ -1212,13 +1331,15 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tab_width, # Width of tab
button_size,
)
tab_button.bg_color = self.button_bg_color
if i == global_vars.TABS["active_tab"]:
tab_button.bg_color = self.button_selected_color
tab_button.hover_bg_color = self.button_hover_color
tab_button.text = tab["name"]
tab_button.text_size = button_size * 0.5
tab_button.text_color = self.text_color
tab_button.bg_color = self.button_bg_color
if is_active:
tab_button.bg_color = self.button_selected_color
tab_button.tab_index = i # Store tab index
tab_button.set_mouse_down(self.switch_tab) # Add click handler
self.tab_buttons.append(tab_button)
@@ -1226,7 +1347,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Set asset type icon as tab button image
tab_button.set_image_size((tab_icon_size, tab_icon_size))
tab_button.set_image_position(
(margin, (button_size - tab_icon_size) / 2)
(margin * 2, (button_size - tab_icon_size) / 2)
) # Center vertically
# Only create close button if there's more than one tab
@@ -1243,22 +1364,24 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
close_tab.text = "×" # Set text after creation
close_tab.text_size = button_size * 0.8
close_tab.text_color = self.text_color
if is_active:
close_tab.bg_color = self.button_selected_color_dim
close_tab.tab_index = i # Store tab index
# if there's only one tab, the button closes asset bar instead of closing tab
if len(tabs) > 1:
close_tab.set_mouse_down(self.remove_tab) # Add click handler
else:
close_tab.set_mouse_down(self.cancel_press)
self.close_tab_buttons.append(close_tab)
tabs_end_x = close_x + button_size
# New tab button - position after all tabs and close buttons
if len(tabs) > 0:
last_tab_index = len(tabs) - 1
last_tab_x = tab_x_start + last_tab_index * (
tab_width + button_size + margin
)
new_tab_x = (
last_tab_x + tab_width + button_size + margin * 2
space + tabs_end_x + margin * 2
) # After last tab and its close button
else:
new_tab_x = tab_x_start # If no tabs, start at the beginning
@@ -1302,8 +1425,6 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
active_tab["history_index"] < len(active_tab["history"]) - 1
)
# self.update_buttons()
def set_element_images(self):
"""set ui elements images, has to be done after init of UI."""
# img_fp = paths.get_addon_thumbnail_path("vs_rejected.png")
@@ -1345,7 +1466,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
icon_path = paths.get_addon_thumbnail_path(
f"asset_type_{asset_type}.png"
)
if not os.path.exists(icon_path):
if not paths.icon_path_exists(icon_path):
icon_path = paths.get_addon_thumbnail_path(
"asset_type_model.png"
)
@@ -1444,7 +1565,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""Initialize the asset bar operator."""
self.tooltip_base_size_pixels = 512
self.tooltip_scale = 1.0
self.bottom_panel_fraction = 0.15
self.bottom_panel_fraction = 0.18
self.needs_tooltip_update = False
self.update_ui_size(bpy.context)
@@ -1679,9 +1800,13 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
price_color = asset_data["tooltip_data"].get(
"price_color", (1.0, 0.8, 0.2, 1.0)
)
price_background = asset_data["tooltip_data"].get(
"price_background", (0.2, 0.2, 0.2, 0.0)
)
self.price_label.text = price_text
self.price_label.text_color = price_color
self.price_label.visible = bool(price_text)
self.price_label.bg_color = price_background
# preview comments for validators
self.update_comments_for_validators(asset_data)
@@ -1721,7 +1846,22 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
- properties_width
),
)
tooltip_y = int(widget.y_screen + widget.height)
# Calculate space above and below the button
ui_scale = self.get_ui_scale()
full_tooltip_height = self.tooltip_panel.height
space_above = widget.y_screen
space_below = bpy.context.region.height - (widget.y_screen + widget.height)
# If space below is insufficient (would make tooltip < 70% size), position above
if (
space_below < full_tooltip_height
and space_below < full_tooltip_height * 0.7
and space_below < space_above
):
tooltip_y = int(widget.y_screen - full_tooltip_height)
else:
tooltip_y = int(widget.y_screen + widget.height)
# need to set image here because of context issues.
img_path = paths.get_addon_thumbnail_path("star_grey.png")
self.quality_star.set_image(img_path)
@@ -1730,7 +1870,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_panel.set_location(tooltip_x, tooltip_y)
self.update_tooltip_size(bpy.context)
self.update_tooltip_layout(bpy.context)
self.tooltip_panel.set_location(tooltip_x, tooltip_y)
self.tooltip_panel.set_location(self.tooltip_panel.x, self.tooltip_panel.y)
self.tooltip_panel.layout_widgets()
# show bookmark button - always on mouse enter
if widget.bookmark_button:
@@ -2317,6 +2457,10 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
return # Already on this tab and history step
# make original tab original background color
self.tab_buttons[global_vars.TABS["active_tab"]].bg_color = self.button_bg_color
# make also tab close button original background color
self.close_tab_buttons[global_vars.TABS["active_tab"]].bg_color = (
self.button_bg_color
)
global_vars.TABS["active_tab"] = tab_index
global_vars.TABS["tabs"][tab_index]["history_index"] = history_index
@@ -2354,14 +2498,24 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Update history button visibility
active_tab = global_vars.TABS["tabs"][tab_index]
self.history_back_button.visible = active_tab["history_index"] > 0
self.history_forward_button.visible = (
active_tab["history_index"] < len(active_tab["history"]) - 1
)
# make active tab a bit darker
if len(self.tab_buttons) > tab_index:
self.tab_buttons[tab_index].bg_color = self.button_selected_color
# update tab colors
for tab_button in self.tab_buttons:
c_tab_index = tab_button.tab_index
if c_tab_index == tab_index:
tab_button.bg_color = self.button_selected_color
self.close_tab_buttons[tab_index].bg_color = (
self.button_selected_color_dim
)
else:
tab_button.bg_color = self.button_bg_color
self.close_tab_buttons[c_tab_index].bg_color = self.button_bg_color
# update filters
search.update_filters()
@@ -1467,8 +1467,18 @@ class AssetDragOperator(bpy.types.Operator):
Tuple[None, None, None],
]:
"""Find the window, region and area under the mouse cursor."""
# Iterate windows backwards, so we go from the top-most window to the bottommost window
for window in reversed(bpy.context.window_manager.windows):
wins = bpy.context.window_manager.windows[:]
# reverse the list, seemed to work well at least on windows.
wins.reverse()
context_win = bpy.context.window
# let's prioritize the context window
if context_win is not None:
wins.remove(context_win)
wins.insert(0, context_win)
for window in wins:
# first let's test if it's in this window, so we know we shall continue
window_x = window.x * self.resolution_factor
window_y = window.y * self.resolution_factor
@@ -27,6 +27,8 @@ from . import utils
RENDER_OBTYPES = ["MESH", "CURVE", "SURFACE", "METABALL", "TEXT"]
_BLE_5_PLUS = bpy.app.version >= (5, 0, 0)
def check_material(props, mat):
e = bpy.context.scene.render.engine
@@ -217,17 +219,39 @@ def check_rig(props, obs):
props.rig = True
def has_keyframes(obj):
"""Checks if object has animation data with keyframes.
This function only checks for keyframes,
may return false negatives for objects animated with constraints, drivers, etc.
"""
if obj.animation_data is None:
return False
a = obj.animation_data.action
if a is None:
return False
# should work from at least Blender4.2+
if _BLE_5_PLUS:
# combined fcurves ranges
# check if start and end frames are different
if a.curve_frame_range[0] != a.curve_frame_range[1]:
return True
else:
# older Blender versions
for c in a.fcurves:
if len(c.keyframe_points) > 1:
return True
return False
def check_anim(props, obs):
animated = False
for ob in obs:
if ob.animation_data is not None:
a = ob.animation_data.action
if a is not None:
for c in a.fcurves:
if len(c.keyframe_points) > 1:
animated = True
# c.keyframe_points.remove(c.keyframe_points[0])
if has_keyframes(ob):
animated = True
break
if animated:
props.animated = True
@@ -209,8 +209,17 @@ def start_model_thumbnailer(
blender_user_scripts_dir = (
Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ)
# both must be enabled
if (
user_preferences.experimental_features
and user_preferences.ignore_env_for_thumbnails
):
env = None
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
@@ -218,7 +227,7 @@ def start_model_thumbnailer(
creationflags=utils.get_process_flags(),
env=env,
)
bk_logger.info(f"Started Blender executing {SCRIPT_NAME} on file {datafile}")
bk_logger.info("Started Blender executing %s on file %s", SCRIPT_NAME, datafile)
eval_path_computing = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
eval_path = f"bpy.data.objects['{json_args['asset_name']}']"
@@ -284,8 +293,16 @@ def start_material_thumbnailer(
blender_user_scripts_dir = (
Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ)
if (
user_preferences.experimental_features
and user_preferences.ignore_env_for_thumbnails
):
env = None
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
@@ -293,7 +310,7 @@ def start_material_thumbnailer(
creationflags=utils.get_process_flags(),
env=env,
)
bk_logger.info(f"Started Blender executing {SCRIPT_NAME} on file {datafile}")
bk_logger.info("Started Blender executing %s on file %s", SCRIPT_NAME, datafile)
eval_path_computing = f"bpy.data.materials['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.materials['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
@@ -164,11 +164,20 @@ if __name__ == "__main__":
ob.data.texspace_size.x = 1 # / tscale
ob.data.texspace_size.y = 1 # / tscale
ob.data.texspace_size.z = 1 # / tscale
if data["adaptive_subdivision"] == True:
ob.cycles.use_adaptive_subdivision = True
# this option was moved in Blender 5.0 from cycles directly to modifier
if bpy.app.version >= (5, 0, 0):
for mod in ob.modifiers:
if mod.type == "SUBSURF":
if data["adaptive_subdivision"] == True:
mod.use_adaptive_subdivision = True
else:
mod.use_adaptive_subdivision = False
else:
ob.cycles.use_adaptive_subdivision = False
if data["adaptive_subdivision"] == True:
ob.cycles.use_adaptive_subdivision = True
else:
ob.cycles.use_adaptive_subdivision = False
ts = data["texture_size_meters"]
if data["thumbnail_type"] in ["BALL", "BALL_COMPLEX", "CLOTH"]:
utils.automap(
@@ -179,7 +188,13 @@ if __name__ == "__main__":
)
bpy.context.view_layer.update()
s.cycles.volume_step_size = tscale * 0.1
# this option was removed in Blender 5.0
# but we have option to set biased volumes
if bpy.app.version >= (5, 0, 0):
# usually small speedup with little quality loss
s.cycles.volume_biased = True
else:
s.cycles.volume_step_size = tscale * 0.1
if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU"
@@ -71,6 +71,9 @@ def threadread(tcom: ThreadCom):
return # process terminated
inline = tcom.proc.stdout.readline()
inline = inline.decode("utf-8")
# ignore empty lines
if inline.strip() == "":
continue
bk_logger.info(inline.strip())
progress = re.findall(r"progress\{(.*?)\}", inline)
if len(progress) > 0:
@@ -139,8 +139,8 @@ def login(signup: bool) -> None:
def generate_pkce_pair() -> tuple[str, str]:
"""Generate PKCE pair - a code verifier and code challange.
The challange should be sent first to the server, the verifier is used in next steps to verify identity (handles Client).
"""Generate PKCE pair - a code verifier and code challenge.
The challenge should be sent first to the server, the verifier is used in next steps to verify identity (handles Client).
"""
rand = random.SystemRandom()
code_verifier = "".join(rand.choices(string.ascii_letters + string.digits, k=128))
@@ -162,8 +162,6 @@ def write_tokens(auth_token, refresh_token, oauth_response):
override_extension_draw.ensure_repository(api_key=auth_token)
override_extension_draw.clear_repo_cache()
#
def ensure_token_refresh() -> bool:
"""Check if API token needs refresh, call refresh and return True if so.
@@ -1,17 +1,17 @@
{
"last_check": "2026-01-12 10:24:10.400844",
"backup_date": "December-1-2025",
"last_check": "2026-04-02 12:41:20.491300",
"backup_date": "January-12-2026",
"update_ready": true,
"ignore": false,
"just_restored": false,
"just_updated": false,
"version_text": {
"link": "https://github.com/BlenderKit/BlenderKit/releases/download/v3.18.1.251219/blenderkit-v3.18.1.251219.zip",
"link": "https://github.com/BlenderKit/BlenderKit/releases/download/v3.19.1.260402/blenderkit-v3.19.1.260402.zip",
"version": [
3,
18,
19,
1,
251219
260402
]
}
}
@@ -1,5 +1,10 @@
import blf
import bpy
import gpu
from typing import Tuple, Union
from gpu_extras.batch import batch_for_shader
from .bl_ui_widget import BL_UI_Widget
@@ -17,6 +22,9 @@ class BL_UI_Label(BL_UI_Widget):
self.multiline = False
self.row_height = 20
self.padding: Union[Tuple[float, float], float] = 0
self.background = False
@property
def text_color(self):
return self._text_color
@@ -61,6 +69,30 @@ class BL_UI_Label(BL_UI_Widget):
blf.size(font_id, self._text_size, 72)
else:
blf.size(font_id, self._text_size)
lines = self._text.split("\n") if self.multiline else [self._text]
if not lines:
return
default_line_height = self.row_height if self.multiline else self._text_size
line_metrics = []
max_line_width = 0.0
total_height = 0.0
for line in lines:
width, height = blf.dimensions(font_id, line)
if height == 0:
height = default_line_height
line_height = (
self.row_height if self.multiline else max(height, self._text_size)
)
if line_height == 0:
line_height = default_line_height
line_metrics.append((line, width, line_height))
max_line_width = max(max_line_width, width)
total_height += line_height
if not line_metrics:
return
textpos_y = area_height - self.y_screen - self.height
@@ -76,16 +108,55 @@ class BL_UI_Label(BL_UI_Widget):
if self._valign == "CENTER":
y -= height // 2
# bottom could be here but there's no reason for it
first_line_height = line_metrics[0][2]
if self.background and (max_line_width > 0 or total_height > 0):
pad_x, pad_y = self._padding_tuple()
text_top = y + first_line_height
text_bottom = text_top - total_height
left = x - pad_x
right = x + max_line_width + pad_x
top = text_top + pad_y
bottom = text_bottom - pad_y
self._draw_background_rect(left, right, bottom, top)
current_y = y
if not self.multiline:
blf.position(font_id, x, y, 0)
blf.position(font_id, x, current_y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, self._text)
else:
lines = self._text.split("\n")
for line in lines:
blf.position(font_id, x, y, 0)
for line, _, line_height in line_metrics:
blf.position(font_id, x, current_y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, line)
y -= self.row_height
current_y -= line_height
def _padding_tuple(self) -> Tuple[float, float]:
pad = self.padding
if isinstance(pad, (list, tuple)):
if len(pad) == 0:
return (0.0, 0.0)
if len(pad) == 1:
value = float(pad[0])
return (value, value)
return (float(pad[0]), float(pad[1]))
value = float(pad)
return (value, value)
def _draw_background_rect(self, left, right, bottom, top):
vertices = (
(left, top),
(left, bottom),
(right, bottom),
(right, top),
)
indices = ((0, 1, 2), (0, 2, 3))
gpu.state.blend_set("ALPHA")
self.shader.bind()
self.shader.uniform_float("color", self._bg_color)
batch = batch_for_shader(
self.shader, "TRIS", {"pos": vertices}, indices=indices
)
batch.draw(self.shader)
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "blenderkit"
type = "add-on"
version = "3.18.0-251121" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
version = "3.18.1-251219" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
name = "BlenderKit Online Asset Library"
tagline = "Drag & drop of assets from the community driven library"
@@ -91,7 +91,7 @@ def ensure_minimal_data(data: Optional[dict] = None) -> dict:
return data
def ensure_minimal_data_class(data_class):
def ensure_minimal_data_class(data_class: datas.SearchData) -> datas.SearchData:
"""Ensure that the data send to the BlenderKit-Client contains:
- app_id is the process ID of the Blender instance, so BlenderKit-client can return reports to the correct instance.
- api_key is the authentication token for the BlenderKit server, so BlenderKit-Client can authenticate the user.
@@ -19,16 +19,35 @@
Module colors defines color palette for BlenderKit UI.
"""
# UI Colors
TOP_BAR_BLUE = (0.2, 0.25, 0.4, 1.0)
"""TOP_BAR_BLUE Color for BlenderKit UI top bar."""
WHITE = (1, 1, 1, 0.9)
TEXT = (0.9, 0.9, 0.9, 0.6)
GREEN = (0.9, 1, 0.9, 0.6)
RED = (1, 0.5, 0.5, 0.8)
BLUE = (0.8, 0.8, 1, 0.8)
TEXT = (0.9, 0.9, 0.9, 0.9)
"""TEXT Color for BlenderKit UI text."""
PURPLE = (0.8, 0.4, 1.0, 1.0) # Full Plan purple
GREEN_FREE = (0.4, 0.8, 0.4, 1.0) # Green for free addons
"""Color for validator reports."""
TEXT_DIM = (0.8, 0.8, 0.8, 0.9)
GREEN = (0.9, 1, 0.9, 0.6)
"""GREEN Color for validator reports."""
RED = (1, 0.5, 0.5, 0.8)
"""RED Color for validator reports."""
BLUE = (0.8, 0.8, 1, 0.8)
"""BLUE Color for validator reports."""
GREEN_PRICE = (0.42, 0.49, 0.19, 1.0)
"""Emerald Green to be used on "discounted" add-ons."""
PURPLE_PRICE = (0.59, 0.05, 0.82, 1.0)
"""Lavender Purple to be used on "for sale" add-ons."""
ORANGE_FULL = (0.702, 0.349, 0.208, 1.0)
"""Burnt Orange associated with full plan assets and add-ons."""
GRAY = (0.7, 0.7, 0.7, 0.6)
"""Default color for debug reports."""
@@ -109,8 +109,10 @@ def get_addon_installation_status(asset_data):
if not is_enabled:
extension_module_name = f"bl_ext.www_blenderkit_com.{extension_id}"
is_enabled = extension_module_name in enabled_addons
bk_logger.info(
f"Checking extension format: {extension_module_name} -> enabled: {is_enabled}"
bk_logger.debug(
"Checking extension format: %s -> enabled: %s",
extension_module_name,
is_enabled,
)
# Also try other possible repository name formats
@@ -210,10 +212,15 @@ def get_addon_installation_status(asset_data):
if "blenderkit" in addon.lower() or addon.endswith(extension_id)
]
if blenderkit_addons:
bk_logger.info(f"Found BlenderKit-related enabled addons: {blenderkit_addons}")
bk_logger.debug(
"Found BlenderKit-related enabled addons: %s", blenderkit_addons
)
bk_logger.info(
f"Addon status check for '{extension_id}': installed={is_installed}, enabled={is_enabled}"
bk_logger.debug(
"Addon status check for '%s': installed=%s, enabled=%s",
extension_id,
is_installed,
is_enabled,
)
return {
@@ -877,8 +884,13 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
if asset_blender_version < (4, 3, 0) and bpy.app.version >= (4, 3, 0):
brush.asset_clear()
brush.asset_mark()
brush.icon_filepath = asset_thumb_path
if bpy.app.version <= (4, 5, 0):
brush.icon_filepath = asset_thumb_path
else:
# load asset thumbnail into brush if it's not already present
if brush.preview is None:
with bpy.context.temp_override(id=brush):
bpy.ops.ed.lib_id_load_custom_preview(filepath=asset_thumb_path)
# set the brush active
if bpy.context.view_layer.objects.active.mode == "SCULPT":
if bpy.app.version < (4, 3, 0):
@@ -897,7 +909,6 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
relative_asset_identifier=f"Brush{os.sep}{brush.name}"
)
# TODO add grease pencil brushes!
# bpy.context.tool_settings.image_paint.brush = brush
asset_main = brush
@@ -16,7 +16,7 @@
#
# ##### END GPL LICENSE BLOCK #####
from logging import INFO, WARN
from logging import DEBUG, INFO, WARN
from os import environ
from subprocess import Popen
from typing import Any, Optional
@@ -59,6 +59,11 @@ BKIT_AUTHORS: dict[int, datas.UserProfile] = {}
"""All loaded profiles of other users. Current user is also present in stripped down version. Key is the UserProfile.id."""
LOGGING_LEVEL_BLENDERKIT = INFO
# read special DEBUG env var to set logging level to DEBUG
if environ.get("BLENDERKIT_DEBUG", "0") == "1":
LOGGING_LEVEL_BLENDERKIT = DEBUG
LOGGING_LEVEL_IMPORTED = WARN
PREFS = {}
@@ -27,8 +27,10 @@ import bpy
icon_collections = {}
icons_read = {
"fp.png": "free",
"flp.png": "full",
"free_plan.png": "free",
"full_plan.png": "full",
"promo_sale_symbol.png": "promo_sale_symbol",
"sale_purple.png": "for_sale",
"trophy.png": "trophy",
"dumbbell.png": "dumbbell",
"cc0.png": "cc0",
@@ -24,6 +24,7 @@ import shutil
import subprocess
import sys
import tempfile
from functools import lru_cache
import bpy
@@ -39,6 +40,9 @@ BLENDERKIT_REPORT_URL = f"{global_vars.SERVER}/usage_report"
BLENDERKIT_USER_ASSETS_URL = f"{global_vars.SERVER}/my-assets"
BLENDERKIT_MANUAL_URL = "https://youtu.be/0P8ZjfbUjeA"
BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/upload/"
BLENDERKIT_PRINTABLE_UPLOAD_INSTRUCTIONS_URL = (
f"{global_vars.SERVER}/docs/upload-printables/"
)
BLENDERKIT_MATERIAL_UPLOAD_INSTRUCTIONS_URL = (
f"{global_vars.SERVER}/docs/uploading-material/"
)
@@ -463,6 +467,8 @@ def get_addon_file(subpath=""):
script_path = os.path.dirname(os.path.realpath(__file__))
# cache this for minor performance boost
@lru_cache(maxsize=128)
def get_addon_thumbnail_path(name):
global script_path
# fpath = os.path.join(p, subpath)
@@ -474,6 +480,13 @@ def get_addon_thumbnail_path(name):
return os.path.join(script_path, subpath)
# cache this for minor performance boost
@lru_cache(maxsize=128)
def icon_path_exists(path: str) -> bool:
"""Cached version of os.path.exists"""
return os.path.exists(path)
def get_config_dir_path() -> str:
"""Get the path to the config directory in global_dir."""
global_dir = bpy.context.preferences.addons[__package__].preferences.global_dir # type: ignore
@@ -203,7 +203,7 @@ class SetBookmark(bpy.types.Operator):
"""Add or remove bookmarking of the asset.\nShortcut: hover over asset in the asset bar and press 'B'."""
bl_idname = "wm.blenderkit_bookmark_asset"
bl_label = "BlenderKit bookmark assest"
bl_label = "BlenderKit bookmark assets"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
asset_id: StringProperty( # type: ignore[valid-type]
@@ -233,28 +233,29 @@ class SetBookmark(bpy.types.Operator):
ratings_utils.store_rating_local(
self.asset_id, rating_type="bookmarks", value=bookmark_value
)
client_lib.send_rating(self.asset_id, "bookmarks", bookmark_value)
client_lib.send_rating(self.asset_id, "bookmarks", str(bookmark_value))
return {"FINISHED"}
def rating_menu_draw(self, context):
layout = self.layout
## NOT USED ANYMORE
# def rating_menu_draw(self, context):
# layout = self.layout
ui_props = context.window_manager.blenderkitUI
sr = search.get_search_results()
# ui_props = context.window_manager.blenderkitUI
# sr = search.get_search_results()
asset_search_index = ui_props.active_index
if asset_search_index > -1:
asset_data = dict(sr["results"][asset_search_index])
# asset_search_index = ui_props.active_index
# if asset_search_index > -1:
# asset_data = dict(sr["results"][asset_search_index])
col = layout.column()
layout.label(text="Admin rating Tools:")
col.operator_context = "INVOKE_DEFAULT"
# col = layout.column()
# layout.label(text="Admin rating Tools:")
# col.operator_context = "INVOKE_DEFAULT"
op = col.operator("wm.blenderkit_menu_rating_upload", text="Add Rating")
op.asset_id = asset_data["id"]
op.asset_name = asset_data["name"]
op.asset_type = asset_data["assetType"]
# op = col.operator("wm.blenderkit_menu_rating_upload", text="Add Rating")
# op.asset_id = asset_data["id"]
# op.asset_name = asset_data["name"]
# op.asset_type = asset_data["assetType"]
# Coordinates (each one is a triangle).
@@ -20,6 +20,7 @@ import copy
import json
import logging
import math
from functools import lru_cache
import os
import re
import unicodedata
@@ -43,6 +44,7 @@ from . import (
image_utils,
paths,
reports,
search_price,
resolutions,
tasks_queue,
utils,
@@ -53,6 +55,51 @@ bk_logger = logging.getLogger(__name__)
search_tasks = {}
def _inject_user_price_data(assets: list[dict]) -> None:
"""Augment search results with per-user pricing info when available."""
if not assets:
bk_logger.debug("User price lookup skipped: empty assets list.")
return
version_uuids: list[str] = [ass["id"] for ass in assets]
if not version_uuids:
bk_logger.debug("User price lookup skipped: empty version UUIDs list.")
return
try:
price_response = search_price.query_user_price(
version_uuids=version_uuids,
page_size=len(version_uuids),
)
except Exception as exc:
bk_logger.warning("Failed to fetch user prices: %s", exc)
return
if not price_response:
bk_logger.debug(
"User price lookup skipped: %s",
price_response,
)
return
price_by_uuid: dict[str, dict] = {}
for entry in price_response:
version_uuid = entry.get("versionUuid") # maybe assetUuid ?
if not version_uuid:
continue
price_by_uuid[version_uuid] = entry
if not price_by_uuid:
return
for asset in assets:
version_uuid = asset["id"]
price_info = price_by_uuid.get(version_uuid)
if not price_info:
continue
asset["userPrice"] = price_info["discountedPrice"]
def update_ad(ad):
if not ad.get("assetBaseId"):
try:
@@ -136,22 +183,23 @@ def check_clipboard():
"""
global last_clipboard
try: # could be problematic on Linux
current_clipboard = bpy.context.window_manager.clipboard
current_clipboard = str(bpy.context.window_manager.clipboard)
except Exception as e:
bk_logger.warning(f"Failed to get clipboard: {e}")
return
if current_clipboard == last_clipboard:
return
last_clipboard = current_clipboard
asset_type_index = last_clipboard.find("asset_type:")
asset_type_index = current_clipboard.find("asset_type:")
if asset_type_index == -1:
return
if not last_clipboard.startswith("asset_base_id:"):
if not current_clipboard.startswith("asset_base_id:"):
return
last_clipboard = current_clipboard
asset_type_string = current_clipboard[asset_type_index:].lower()
if asset_type_string.find("model") > -1:
target_asset_type = "MODEL"
@@ -169,6 +217,10 @@ def check_clipboard():
target_asset_type = "NODEGROUP"
elif asset_type_string.find("addon") > -1 or asset_type_string.find("add-on") > -1:
target_asset_type = "ADDON"
else:
bk_logger.debug("Clipboard does not contain valid asset type.")
return
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.asset_type != target_asset_type:
ui_props.asset_type = target_asset_type # switch asset type before placing keywords, so it does not search under wrong asset type
@@ -341,7 +393,7 @@ def handle_search_task(task: client_tasks.Task) -> bool:
return True
# don't do anything while dragging - this could switch asset during drag, and make results list length different,
# causing a lot of throuble.
# causing a lot of trouble.
if bpy.context.window_manager.blenderkitUI.dragging: # type: ignore[attr-defined]
return False
@@ -403,6 +455,10 @@ def handle_search_task(task: client_tasks.Task) -> bool:
asset for asset in result_field if asset.get("downloaded", 0) > 0
]
# TODO: if ever needed, implement for other future types
if result_field:
_inject_user_price_data(result_field)
# Store results in history step
history_step["search_results"] = result_field
history_step["search_results_orig"] = task.result
@@ -710,7 +766,7 @@ def query_to_url(
scene_uuid: str = "",
page_size: int = 15,
) -> str:
"""Build a new search request by parsing query dictionaty into appropriate URL.
"""Build a new search request by parsing query dictionary into appropriate URL.
Also modifies query and adds some stuff in there which is very misleading anti-pattern.
TODO: just convert to URL here and move the sorting and adding of params to separate function.
https://www.blenderkit.com/api/v1/search/
@@ -1012,6 +1068,7 @@ def filter_addon_search_results(search_results, filter_installed_only=False):
def add_search_process(
query, get_next: bool, page_size: int, next_url: str, history_id: str
):
"""Initialize search task and add it to the task queue."""
global search_tasks
addon_version = utils.get_addon_version()
blender_version = utils.get_blender_version()
@@ -1232,7 +1289,7 @@ def search(get_next=False, query=None, author_id=""):
def clean_filters():
"""Cleanup filters in case search needs to be reset, typicaly when asset id is copy pasted."""
"""Cleanup filters in case search needs to be reset, typically when asset id is copy pasted."""
sprops = utils.get_search_props()
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.property_unset("own_only")
@@ -1551,6 +1608,13 @@ class SearchOperator(Operator):
default="Runs search and displays the asset bar at the same time"
)
force_clear: BoolProperty( # type: ignore[valid-type]
name="Force clear keywords, before programmatic search",
description="Force clear keywords before search",
default=True,
options={"SKIP_SAVE"},
)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@@ -1564,16 +1628,25 @@ class SearchOperator(Operator):
if self.esc:
bpy.ops.view3d.close_popup_button("INVOKE_DEFAULT")
ui_props = bpy.context.window_manager.blenderkitUI
search_keywords = str(ui_props.search_keywords)
if self.keywords != "":
search_keywords = self.keywords
# remove all search keywords if force_clear is set
if self.force_clear:
# self.force_clear = False # reset the force clear
search_keywords = ""
if self.author_id != "":
bk_logger.info(f"Author ID: {self.author_id}")
# if there is already an author id in the search keywords, remove it first, the author_id can be any so
# use regex to find it
ui_props.search_keywords = re.sub(
r"\+author_id:\d+", "", ui_props.search_keywords
)
ui_props.search_keywords += f"+author_id:{self.author_id}"
if self.keywords != "":
ui_props.search_keywords = self.keywords
search_keywords = re.sub(r"\+author_id:\d+", "", search_keywords)
search_keywords += f"+author_id:{self.author_id}"
ui_props.search_keywords = search_keywords
search(get_next=self.get_next)
@@ -0,0 +1,57 @@
from typing import Iterable, List, Optional, Tuple
from . import client_lib, paths, utils
def _normalize_version_uuid_list(values: Optional[Iterable[str]]) -> List[str]:
if values is None:
return []
normalized: List[str] = []
for value in values:
if not value:
continue
as_str = str(value)
if as_str not in normalized:
normalized.append(as_str)
return normalized
def query_user_price(
version_uuids: list[str] = [],
page_size: int = 15,
timeout: Tuple[float, float] = (1, 30),
) -> dict:
"""Return results for price lookup of multiple asset versions.
The server endpoint now expects a POST body with `version_uuids`, so we keep
the helper focused on returning the correct URL alongside the JSON payload
that should be sent in the request.
"""
if isinstance(version_uuids, str):
version_uuids = [version_uuids]
version_uuid_list = _normalize_version_uuid_list(version_uuids)
if page_size > 0:
version_uuid_list = version_uuid_list[:page_size]
payload: dict = {"version_uuids": version_uuid_list}
url = f"{paths.BLENDERKIT_API}/cart/request-price-bulk/"
if not payload["version_uuids"]:
raise ValueError("No version UUIDs provided for price lookup.")
headers = utils.get_simple_headers()
headers.setdefault("Content-Type", "application/json")
response = client_lib.blocking_request(
url,
"POST",
headers,
json_data=payload,
timeout=timeout,
)
search_results = response.json()
return search_results
@@ -120,12 +120,14 @@ def handle_failed_reports(exception: Exception) -> float:
@bpy.app.handlers.persistent
def client_communication_timer():
"""Recieve all responses from Client and run according followup commands.
"""Receive all responses from Client and run according followup commands.
This function is the only one responsible for keeping the Client up and running.
"""
global pending_tasks
bk_logger.debug("Getting tasks from Client")
search.check_clipboard()
bk_logger.log(5, "Getting tasks from Client")
user_preferences = bpy.context.preferences.addons[__package__].preferences
if user_preferences.use_clipboard_scan:
search.check_clipboard()
results = list()
try:
results = client_lib.get_reports(os.getpid())
@@ -141,7 +143,7 @@ def client_communication_timer():
wm = bpy.context.window_manager
wm.blenderkitUI.logo_status = "logo"
bk_logger.debug("Handling tasks")
bk_logger.log(5, "Handling tasks")
results_converted_tasks = []
# convert to task type
@@ -166,8 +168,8 @@ def client_communication_timer():
for task in results_converted_tasks:
handle_task(task)
bk_logger.debug("Task handling finished")
delay = bpy.context.preferences.addons[__package__].preferences.client_polling
bk_logger.log(5, "Task handling finished")
delay = user_preferences.client_polling
if len(download.download_tasks) > 0:
return min(0.2, delay)
return delay
@@ -1,50 +0,0 @@
.updater_install_popup
.updater_check_now
.updater_update_now
.updater_update_target
.updater_install_manually
.updater_update_successful
.updater_restore_backup
.updater_ignore
.end_background_check
view3d.asset_drag_drop
object.blenderkit_auto_tags
object.blenderkit_generate_thumbnail
object.blenderkit_regenerate_thumbnail
object.blenderkit_generate_material_thumbnail
object.blenderkit_regenerate_material_thumbnail
object.kill_bg_process
wm.blenderkit_login
wm.blenderkit_logout
wm.blenderkit_login_cancel
scene.blenderkit_addon_manager
scene.blenderkit_addon_choice
scene.blenderkit_download_kill
scene.blenderkit_download
wm.blenderkit_bookmark_asset
wm.blenderkit_mark_notification_read
wm.blenderkit_mark_notifications_read_all
wm.blenderkit_open_notification_target
wm.blenderkit_upvote_comment
wm.blenderkit_is_private_comment
wm.blenderkit_post_comment
wm.logo_status
wm.show_notifications
wm.blenderkit_join_discord
wm.blenderkit_welcome
wm.blenderkit_open_system_directory
wm.blenderkit_asset_popup
view3d.blenderkit_set_comment_reply_id
view3d.blenderkit_set_category_origin
view3d.blenderkit_clear_search_keywords
view3d.close_popup_button
wm.blenderkit_popup_dialog
wm.blenderkit_url_dialog
wm.blenderkit_login_dialog
wm.blenderkit_nodegroup_drop_dialog
object.blenderkit_particles_drop
object.blenderkit_data_trasnfer
wm.modal_timer_operator
view3d.run_assetbar_start_modal
view3d.run_assetbar_fix_context
wm.blenderkit_fast_metadata
@@ -83,7 +83,7 @@ def draw_upload_common(layout, props, asset_type, context):
url = "" # paths.BLENDERKIT_NODEGROUP_UPLOAD_INSTRUCTIONS_URL
if asset_type == "PRINTABLE":
url = (
paths.BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL
paths.BLENDERKIT_PRINTABLE_UPLOAD_INSTRUCTIONS_URL
) # Reuse model instructions since prints are similar
if asset_type == "ADDON":
asset_type_text = asset_type
@@ -1721,6 +1721,10 @@ class VIEW3D_PT_blenderkit_import_settings(Panel):
layout.prop(preferences, "resolution")
# layout.prop(props, 'unpack_files')
# general settings
# show toggle for clipboard scan
layout.prop(preferences, "use_clipboard_scan")
def deferred_set_name(props, expected_obj_name):
"""Deferred timer to set empty name of uploaded asset to active Object's name.
@@ -2725,15 +2729,26 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
is_free = self.asset_data.get("isFree")
# Get pricing info from extensions cache
user_price = self.asset_data.get("userPrice")
base_price = self.asset_data.get("basePrice")
is_for_sale = self.asset_data.get("isForSale")
if self.asset_data["isPrivate"]:
text = "Private"
self.draw_property(box, "Access", text, icon="LOCKED")
elif is_for_sale and not can_download and user_price and base_price:
text = f"${user_price} (Not purchased)"
icon = pcoll["for_sale"]
self.draw_property(
box,
"Price",
text,
icon_value=icon.icon_id,
tooltip="This addon is for sale but you haven't purchased it yet.\nPrice shown is your price / base price",
)
elif is_for_sale and not can_download and base_price:
text = f"${base_price} (Not purchased)"
icon = pcoll["full"]
icon = pcoll["for_sale"]
self.draw_property(
box,
"Price",
@@ -2742,8 +2757,8 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
tooltip="This addon is for sale but you haven't purchased it yet",
)
elif is_for_sale and can_download and base_price:
text = f"${base_price} (Purchased)"
icon = pcoll["full"]
text = f"Purchased"
icon = pcoll["for_sale"]
self.draw_property(
box,
"Price",
@@ -2752,7 +2767,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
tooltip="You have purchased this addon",
)
elif not is_free and not is_for_sale:
text = "Full plan required"
text = "Full plan"
icon = pcoll["full"]
self.draw_property(
box,
@@ -2991,7 +3006,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
)
op.tooltip = "Search all assets by this author.\nShortcut: Hover over the asset in the asset bar and press 'A'." # type: ignore[attr-defined]
op.esc = True # type: ignore[attr-defined]
op.keywords = "" # type: ignore[attr-defined]
op.keywords = "" # type: ignore[attr-defined] # must not be empty otherwise search will use previous keywords
op.author_id = str(author_id) # type: ignore[attr-defined]
button_row = button_row.row(align=True)
@@ -3222,7 +3237,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
# name_row.label(text='>')
name_row.label(text=aname)
push_op_left(name_row, strength=3)
push_op_left(name_row, strength=1)
op = name_row.operator("view3d.close_popup_button", text="", icon="CANCEL")
def draw_comment_response(self, context, layout, comment_id):
@@ -3568,6 +3583,18 @@ class SetCategoryOperatorLastInPopupCard(SetCategoryOperatorOrigin):
bl_idname = "view3d.blenderkit_set_category_in_popup_card_last"
class ToggleClipboardScan(bpy.types.Operator):
"""Toggle whether asset links are set from clipboard when copied."""
bl_idname = "wm.blenderkit_toggle_clipboard_scan"
bl_label = "Toggle Clipboard Scan"
def execute(self, context):
user_preferences = bpy.context.preferences.addons[__package__].preferences
user_preferences.use_clipboard_scan = not user_preferences.use_clipboard_scan
return {"FINISHED"}
class ClearSearchKeywords(bpy.types.Operator):
"""Clear search keywords"""
@@ -339,6 +339,25 @@ def get_active_asset_by_type(asset_type="model"):
return None
def get_equivalent_datablock(asset_type, name):
"""Get the datablock that blocks us from renaming the asset, and rename it to something a bit else."""
if asset_type == "MATERIAL":
return bpy.data.materials.get(name)
elif asset_type == "OBJECT":
return bpy.data.objects.get(name)
elif asset_type == "SCENE":
return bpy.data.scenes.get(name)
elif asset_type == "HDR":
return bpy.data.images.get(name)
elif asset_type == "BRUSH":
return bpy.data.brushes.get(name)
elif asset_type == "NODEGROUP":
return bpy.data.node_groups.get(name)
elif asset_type == "ADDON":
return bpy.data.addons.get(name)
return None
def get_active_asset():
scene = bpy.context.scene
ui_props = bpy.context.window_manager.blenderkitUI
@@ -970,12 +989,17 @@ def get_dimensions(obs):
return dim, bbmin, bbmax
def get_headers(api_key: str = "") -> dict[str, str]:
def get_simple_headers() -> dict[str, str]:
headers = {
"accept": "application/json",
"Platform-Version": platform.platform(),
"addon-version": f"{global_vars.VERSION[0]}.{global_vars.VERSION[1]}.{global_vars.VERSION[2]}.{global_vars.VERSION[3]}",
}
return headers
def get_headers(api_key: str = "") -> dict[str, str]:
headers = get_simple_headers()
if api_key != "":
headers["Authorization"] = f"Bearer {api_key}"
@@ -1129,6 +1153,18 @@ def name_update(props, context=None):
asset = get_active_asset()
if asset.name != fname: # Here we actually rename assets datablocks
asset.name = fname # change name of active object to upload Name
# we need to set the name back for proper appending later
if asset.name != fname and re.search(r"\.\d+$", asset.name) is not None:
# - because assets end up with .001, .002, etc. names sometimes.
# first, let's get the datablock that blocks us from renaming the asset, and rename it to something a bit else:
# we need to ge the equivalent datablock ,
# then we can swap those names around.
datablock = get_equivalent_datablock(ui_props.asset_type, fname)
if datablock is not None:
datablock.name = fname + "_temprename"
replace_name = asset.name
asset.name = fname
datablock.name = replace_name
def fmt_dimensions(p):
@@ -1,6 +1,6 @@
{
"last_check": "2026-03-16 11:19:55.639825",
"backup_date": "January-12-2026",
"last_check": "2026-04-02 12:41:20.491300",
"backup_date": "April-2-2026",
"update_ready": false,
"ignore": false,
"just_restored": false,
@@ -1,14 +1,12 @@
import os
import logging
from typing import Optional
import blf
import bpy
import gpu
from .. import image_utils, ui_bgl
from .bl_ui_widget import BL_UI_Widget
from .bl_ui_image import BL_UI_Image
from .bl_ui_widget import BL_UI_Widget, region_redraw
bk_logger = logging.getLogger(__name__)
@@ -18,6 +16,8 @@ class BL_UI_Button(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self.background = True
self.background_padding = (0, 0)
self._text_color = (1.0, 1.0, 1.0, 1.0)
self._hover_bg_color = (0.5, 0.5, 0.5, 1.0)
self._select_bg_color = (0.7, 0.7, 0.7, 1.0)
@@ -30,6 +30,8 @@ class BL_UI_Button(BL_UI_Widget):
self.__image = None
self.__image_size = (24, 24)
self.__image_position = (4, 2)
self.__image_padding = 0.0
self.image_corner_radius = None
@property
def text_color(self):
@@ -38,7 +40,7 @@ class BL_UI_Button(BL_UI_Widget):
@text_color.setter
def text_color(self, value):
if value != self._text_color:
bpy.context.region.tag_redraw()
region_redraw()
self._text_color = value
@property
@@ -48,7 +50,7 @@ class BL_UI_Button(BL_UI_Widget):
@text.setter
def text(self, value):
if value != self._text:
bpy.context.region.tag_redraw()
region_redraw()
self._text = value
@property
@@ -58,7 +60,7 @@ class BL_UI_Button(BL_UI_Widget):
@text_size.setter
def text_size(self, value):
if value != self._text_size:
bpy.context.region.tag_redraw()
region_redraw()
self._text_size = value
@property
@@ -68,7 +70,7 @@ class BL_UI_Button(BL_UI_Widget):
@hover_bg_color.setter
def hover_bg_color(self, value):
if value != self._hover_bg_color:
bpy.context.region.tag_redraw()
region_redraw()
self._hover_bg_color = value
@property
@@ -78,7 +80,7 @@ class BL_UI_Button(BL_UI_Widget):
@select_bg_color.setter
def select_bg_color(self, value):
if value != self._select_bg_color:
bpy.context.region.tag_redraw()
region_redraw()
self._select_bg_color = value
def set_image_size(self, image_size):
@@ -93,13 +95,16 @@ class BL_UI_Button(BL_UI_Widget):
self.__image
self.__image.filepath
# self.__image.pixels
except Exception as e:
except Exception:
self.__image = None
def set_image_colorspace(self, colorspace: str = ""):
image_utils.set_colorspace(self.__image, colorspace)
def set_image(self, rel_filepath):
if rel_filepath is None:
self.__image = None
return
# first try to access the image, for cases where it can get removed
self.check_image_exists()
try:
@@ -117,6 +122,17 @@ class BL_UI_Button(BL_UI_Widget):
return None
return self.__image.filepath
@property
def image_padding(self):
return self.__image_padding
@image_padding.setter
def image_padding(self, padding: float):
self.__image_padding = padding
def get_image_padding(self):
return self.__image_padding
def update(self, x, y):
super().update(x, y)
self._textpos = [x, y]
@@ -127,19 +143,30 @@ class BL_UI_Button(BL_UI_Widget):
area_height = self.get_area_height()
gpu.state.blend_set("ALPHA")
fill_color = self._resolve_panel_color()
self.shader.bind()
self.set_colors()
self.batch_panel.draw(self.shader)
if self.use_rounded_background:
rect_y = area_height - self.y_screen - self.height
self.draw_background_rect(
self.x_screen,
rect_y,
self.width,
self.height,
fill_color,
force=True,
fill_color_override=fill_color,
)
else:
self.shader.bind()
self.shader.uniform_float("color", fill_color)
self.batch_panel.draw(self.shader)
self.draw_image()
# Draw text
self.draw_text(area_height)
def set_colors(self):
def _resolve_panel_color(self):
color = self._bg_color
# pressed
@@ -150,7 +177,7 @@ class BL_UI_Button(BL_UI_Widget):
elif self.__state == 2:
color = self._hover_bg_color
self.shader.uniform_float("color", color)
return color
def draw_text(self, area_height):
font_id = 1
@@ -165,9 +192,19 @@ class BL_UI_Button(BL_UI_Widget):
size = blf.dimensions(font_id, self._text)
# When an image is present, center text in the remaining space after the image
image_offset = 0
if self.__image is not None:
image_offset = self.__image_position[0] + self.__image_size[0]
textpos_y = area_height - self._textpos[1] - (self.height + size[1]) / 2.0
blf.position(
font_id, self._textpos[0] + (self.width - size[0]) / 2.0, textpos_y + 1, 0
font_id,
self._textpos[0]
+ image_offset
+ (self.width - image_offset - size[0]) / 2.0,
textpos_y + 1,
0,
)
r, g, b, a = self._text_color
@@ -180,15 +217,17 @@ class BL_UI_Button(BL_UI_Widget):
y_screen_flip = self.get_area_height() - self.y_screen
off_x, off_y = self.__image_position
sx, sy = self.__image_size
pad = self.__image_padding
ui_bgl.draw_image_runtime(
self.x_screen + off_x,
y_screen_flip - off_y - sy,
sx,
sy,
self.x_screen + off_x + pad,
y_screen_flip - off_y - sy + pad,
sx - 2 * pad,
sy - 2 * pad,
self.__image,
1.0,
crop=(0, 0, 1, 1),
batch=None,
corner_radius=self.image_corner_radius,
)
return True
return False
@@ -203,9 +242,7 @@ class BL_UI_Button(BL_UI_Widget):
self.mouse_down_func(self)
except Exception:
bk_logger.exception("BL_UI_BUTTON mouse_down() error:")
return True
return False
def set_mouse_down_right(self, mouse_down_right_func):
@@ -213,10 +250,11 @@ class BL_UI_Button(BL_UI_Widget):
def mouse_down_right(self, x, y):
if self.is_in_rect(x, y):
try:
self.mouse_down_right_func(self)
except Exception:
bk_logger.exception("BL_UI_BUTTON mouse_down_right() error:")
if hasattr(self, "mouse_down_right_func"):
try:
self.mouse_down_right_func(self)
except Exception:
bk_logger.exception("BL_UI_BUTTON mouse_down_right() error:")
return True
@@ -20,6 +20,15 @@ class BL_UI_Drag_Panel(BL_UI_Widget):
self.widgets = widgets
self.layout_widgets()
def remove_widgets(self):
self.widgets = []
self.update(self.x_screen, self.y_screen)
def remove_widget(self, widget):
if widget in self.widgets:
self.widgets.remove(widget)
self.layout_widgets()
def layout_widgets(self):
for widget in self.widgets:
widget.update(self.x_screen + widget.x, self.y_screen + widget.y)
@@ -4,9 +4,34 @@ from typing import Optional
import bpy
from bpy.types import Operator
from .. import ui_bgl
from .bl_ui_widget import region_redraw
bk_logger = logging.getLogger(__name__)
def get_safely(obj, attr_name, default=None):
"""Get attribute from object while tolerating freed data."""
try:
return getattr(obj, attr_name, default)
except ReferenceError:
return default
except Exception:
return default
def restart_asset_bar():
# ignore failures if already gone
from asset_bar_op import BlenderKitAssetBarOperator
try:
bpy.utils.unregister_class(BlenderKitAssetBarOperator)
except Exception:
pass
bpy.utils.register_class(BlenderKitAssetBarOperator)
bpy.ops.view3d.blenderkit_asset_bar_widget("INVOKE_DEFAULT")
class BL_UI_OT_draw_operator(Operator):
bl_idname = "object.bl_ui_ot_draw_operator"
bl_label = "bl ui widgets operator"
@@ -36,10 +61,6 @@ class BL_UI_OT_draw_operator(Operator):
def invoke(self, context, event):
self.on_invoke(context, event)
args = (self, context)
self.register_handlers(args, context, timer_interval=self._timer_interval)
context.window_manager.modal_handler_add(self)
# first set pointers to keep track if the area is still available
@@ -47,7 +68,10 @@ class BL_UI_OT_draw_operator(Operator):
self.active_area_pointer = context.area.as_pointer()
self.active_region_pointer = context.region.as_pointer()
context.region.tag_redraw()
args = (self, context)
self.register_handlers(args, context, timer_interval=self._timer_interval)
region_redraw(context)
return {"RUNNING_MODAL"}
def register_handlers(self, args, context, timer_interval=0.1):
@@ -80,7 +104,7 @@ class BL_UI_OT_draw_operator(Operator):
return {"FINISHED"}
if context.area:
context.region.tag_redraw()
region_redraw(context)
if self.handle_widget_events(event):
return {"RUNNING_MODAL"}
@@ -93,8 +117,7 @@ class BL_UI_OT_draw_operator(Operator):
def finish(self):
self.unregister_handlers(bpy.context)
# it is possible that the area has been closed, so we check if it is still available
if bpy.context.region is not None:
bpy.context.region.tag_redraw()
region_redraw()
self.on_finish(bpy.context)
# Draw handler to paint onto the screen
@@ -114,7 +137,27 @@ def draw_callback_px_separated(self, op, context):
# hide during animation playback, to improve performance
if context.screen.is_animation_playing:
return
if context.area.as_pointer() == self.active_area_pointer:
area_pointer = (
context.area.as_pointer() if getattr(context, "area", None) else None
)
# get area, check if RNA failed
active_pointer = get_safely(self, "active_area_pointer", None)
if area_pointer is None or area_pointer != active_pointer:
return
active_region_pointer = get_safely(self, "active_region_pointer", None)
if active_region_pointer is not None:
region_pointer = (
context.region.as_pointer()
if getattr(context, "region", None)
else None
)
if region_pointer is None or region_pointer != active_region_pointer:
return
region = getattr(context, "region", None)
with ui_bgl.overlay_matrix_guard(region):
for widget in self.widgets:
widget.draw()
except Exception:
@@ -18,11 +18,21 @@ class BL_UI_Image(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self.bg_color = (1.0, 1.0, 1.0, 1.0)
self.__state = 0
self.__image = None
self.__image_size = (24, 24)
self.__image_position = (4, 2)
self.__image_padding: float = 0.0
@property
def image_padding(self):
return self.__image_padding
@image_padding.setter
def image_padding(self, value: float):
self.__image_padding = value
def set_image_size(self, image_size):
self.__image_size = image_size
@@ -67,25 +77,68 @@ class BL_UI_Image(BL_UI_Widget):
return
gpu.state.blend_set("ALPHA")
self.shader.bind()
self.batch_panel.draw(self.shader)
if self.draw_image():
return
self.draw_image()
if self.use_rounded_background:
area_height = self.get_area_height()
rect_y = area_height - self.y_screen - self.height
self.draw_background_rect(
self.x_screen,
rect_y,
self.width,
self.height,
self._bg_color,
force=True,
fill_color_override=self._bg_color,
)
return
self.shader.bind()
self.shader.uniform_float("color", self._bg_color)
self.batch_panel.draw(self.shader)
def draw_image(self):
if self.__image is not None:
y_screen_flip = self.get_area_height() - self.y_screen
off_x, off_y = self.__image_position
sx, sy = self.__image_size
pad = self.image_padding
img_x = self.x_screen + off_x + pad
img_y = y_screen_flip - off_y - sy + pad
img_w = sx - 2 * pad
img_h = sy - 2 * pad
if self.use_rounded_background:
fill_color = self.bg_color or (1.0, 1.0, 1.0, 1.0)
self.draw_background_rect(
img_x,
img_y,
img_w,
img_h,
fill_color,
force=True,
fill_color_override=fill_color,
)
corner_radius = (
self.background_corner_radius
if self.has_background_corner_radius_override()
else None
)
ui_bgl.draw_image_runtime(
self.x_screen + off_x,
y_screen_flip - off_y - sy,
sx,
sy,
img_x,
img_y,
img_w,
img_h,
self.__image,
1.0,
crop=(0, 0, 1, 1),
batch=None,
corner_radius=corner_radius,
corner_segments=12,
)
return True
@@ -1,17 +1,19 @@
import blf
import bpy
import gpu
from typing import Tuple, Union
from gpu_extras.batch import batch_for_shader
from .bl_ui_widget import BL_UI_Widget
from .bl_ui_widget import BL_UI_Widget, region_redraw, set_font_size
class BL_UI_Label(BL_UI_Widget):
"""A simple text label widget."""
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self._set_background_corner_radius_default((4.0,))
self._text_color = (1.0, 1.0, 1.0, 1.0)
self._text = "Label"
@@ -32,7 +34,7 @@ class BL_UI_Label(BL_UI_Widget):
@text_color.setter
def text_color(self, value):
if value != self._text_color:
bpy.context.region.tag_redraw()
region_redraw()
self._text_color = value
@property
@@ -42,13 +44,173 @@ class BL_UI_Label(BL_UI_Widget):
@text.setter
def text(self, value):
if value != self._text:
bpy.context.region.tag_redraw()
region_redraw()
self._text = value
@property
def text_size(self):
return self._text_size
@text_size.setter
def text_size(self, value):
if value != self._text_size:
region_redraw()
self._text_size = value
def is_in_rect(self, x, y):
return False
def draw(self):
if not self._is_visible or not self._text:
return
area_height = self.get_area_height()
font_id = 1
set_font_size(font_id, self._text_size)
textpos_y = area_height - self.y_screen - self.height
x = self.x_screen
y = textpos_y
block_width, block_height = blf.dimensions(font_id, self._text)
if self._halign == "RIGHT":
x -= block_width
elif self._halign == "CENTER":
x -= block_width // 2
if self._halign != "LEFT" and self._valign == "CENTER":
y -= block_height // 2
lines = self._text.split("\n") if self.multiline else [self._text]
entries = []
cursor_y = y
for index, line in enumerate(lines):
if index > 0:
cursor_y -= self.row_height
width, height = blf.dimensions(font_id, line)
if self.multiline and height == 0:
height = self.row_height
elif height == 0:
height = self._text_size
entries.append(
{
"text": line,
"x": x,
"y": cursor_y,
"width": width,
"height": height,
}
)
if not entries:
return
min_x = min(item["x"] for item in entries)
max_x = max(item["x"] + item["width"] for item in entries)
min_y = min(item["y"] for item in entries)
max_y = max(item["y"] + item["height"] for item in entries)
content_width = max(0.0, max_x - min_x)
content_height = max(0.0, max_y - min_y)
self.draw_background_rect(
min_x,
min_y,
content_width,
content_height,
self._bg_color,
)
r, g, b, a = self._text_color
for item in entries:
if not item["text"]:
continue
blf.position(font_id, item["x"], item["y"], 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, item["text"])
if content_width > 0:
strike_y = min_y + content_height * 0.5
self.draw_strikethrough(
min_x,
max_x,
strike_y,
self._text_color,
)
class BL_UI_DuoLabel(BL_UI_Widget):
"""A label with two text fields, A and B."""
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self._set_background_corner_radius_default((4.0,))
self._text_a_color = (1.0, 1.0, 1.0, 1.0)
self._text_a = "Label"
self._text_b_color = (1.0, 1.0, 1.0, 1.0)
self._text_b = ""
self._text_size = 16
self._halign = "LEFT"
self._valign = "TOP"
# multiline
self.multiline = False
self.row_height = 20
self.strikethrough_a = False
self.strikethrough_b = False
self.segment_backgrounds = False
self.segment_background_padding = None
self.segment_background_color_a = None
self.segment_background_color_b = None
self.segment_background_gap = 0.0
self.segment_spacing = 4.0
self.segment_background_extra_top = 0.0
self.segment_background_extra_bottom = 1.5
@property
def text_a_color(self):
return self._text_a_color
@text_a_color.setter
def text_a_color(self, value):
if value != self._text_a_color:
bpy.context.region.tag_redraw()
self._text_a_color = value
@property
def text_a(self):
return self._text_a
@text_a.setter
def text_a(self, value):
if value != self._text_a:
bpy.context.region.tag_redraw()
self._text_a = value
@property
def text_b_color(self):
return self._text_b_color
@text_b_color.setter
def text_b_color(self, value):
if value != self._text_b_color:
bpy.context.region.tag_redraw()
self._text_b_color = value
@property
def text_b(self):
return self._text_b
@text_b.setter
def text_b(self, value):
if value != self._text_b:
bpy.context.region.tag_redraw()
self._text_b = value
@property
def text_size(self):
return self._text_size
@text_size.setter
def text_size(self, value):
if value != self._text_size:
@@ -65,98 +227,266 @@ class BL_UI_Label(BL_UI_Widget):
area_height = self.get_area_height()
font_id = 1
if bpy.app.version < (4, 0, 0):
blf.size(font_id, self._text_size, 72)
else:
blf.size(font_id, self._text_size)
lines = self._text.split("\n") if self.multiline else [self._text]
if not lines:
return
default_line_height = self.row_height if self.multiline else self._text_size
line_metrics = []
max_line_width = 0.0
total_height = 0.0
for line in lines:
width, height = blf.dimensions(font_id, line)
if height == 0:
height = default_line_height
line_height = (
self.row_height if self.multiline else max(height, self._text_size)
)
if line_height == 0:
line_height = default_line_height
line_metrics.append((line, width, line_height))
max_line_width = max(max_line_width, width)
total_height += line_height
if not line_metrics:
return
set_font_size(font_id, self._text_size)
textpos_y = area_height - self.y_screen - self.height
r, g, b, a = self._text_color
x = self.x_screen
y = textpos_y
if self._halign != "LEFT":
width, height = blf.dimensions(font_id, self._text)
if self._halign == "RIGHT":
x -= width
elif self._halign == "CENTER":
x -= width // 2
cursor_x = self.x_screen
spacing = max(0.0, float(self.segment_spacing))
blocks = [
(
self._text_a,
self._text_a_color,
self.strikethrough_a,
self.segment_background_color_a,
),
(
self._text_b,
self._text_b_color,
self.strikethrough_b,
self.segment_background_color_b,
),
]
segments = []
for text, color, strike_flag, background_color in blocks:
if not text:
continue
set_font_size(font_id, self._text_size)
width, height = blf.dimensions(font_id, text)
scaled_size = self._text_size
scaled = False
if self.width > 0:
if self._halign == "LEFT":
available_width = max(1, self.x_screen + self.width - cursor_x)
else:
available_width = self.width
if width > available_width and width > 0:
scale = available_width / width
scaled_size = max(8, int(self._text_size * scale))
if scaled_size < self._text_size:
set_font_size(font_id, scaled_size)
width, height = blf.dimensions(font_id, text)
scaled = True
x = cursor_x if self._halign == "LEFT" else self.x_screen
y = textpos_y
if self._halign != "LEFT":
if self._halign == "RIGHT":
x -= width
elif self._halign == "CENTER":
x -= width // 2
if self._valign == "CENTER":
y -= height // 2
# bottom could be here but there's no reason for it
first_line_height = line_metrics[0][2]
if not self.multiline:
lines = [
{
"text": text,
"x": x,
"y": y,
"width": width,
"height": height or self._text_size,
"font_size": scaled_size,
}
]
else:
lines = []
current_y = y
split_lines = text.split("\n")
for index, line in enumerate(split_lines):
if index > 0:
current_y -= self.row_height
line_width, line_height = blf.dimensions(font_id, line)
if line_height == 0:
line_height = self.row_height
lines.append(
{
"text": line,
"x": x,
"y": current_y,
"width": line_width,
"height": line_height,
"font_size": scaled_size,
}
)
width = max((line["width"] for line in lines), default=width)
height = max(len(lines) * self.row_height, height)
if self.background and (max_line_width > 0 or total_height > 0):
pad_x, pad_y = self._padding_tuple()
text_top = y + first_line_height
text_bottom = text_top - total_height
left = x - pad_x
right = x + max_line_width + pad_x
top = text_top + pad_y
bottom = text_bottom - pad_y
self._draw_background_rect(left, right, bottom, top)
if lines:
seg_min_x = min(line["x"] for line in lines)
seg_max_x = max(line["x"] + line["width"] for line in lines)
seg_min_y = min(line["y"] for line in lines)
seg_max_y = max(line["y"] + line["height"] for line in lines)
bounds = {
"min_x": seg_min_x,
"max_x": seg_max_x,
"min_y": seg_min_y,
"max_y": seg_max_y,
}
else:
bounds = None
current_y = y
if not self.multiline:
blf.position(font_id, x, current_y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, self._text)
else:
for line, _, line_height in line_metrics:
blf.position(font_id, x, current_y, 0)
segments.append(
{
"lines": lines,
"color": color,
"strikethrough": strike_flag,
"bounds": bounds,
"background_color": background_color,
}
)
if self._halign == "LEFT" and bounds:
cursor_x = bounds["max_x"] + spacing
if scaled:
set_font_size(font_id, self._text_size)
if not segments:
return
all_lines = [line for segment in segments for line in segment["lines"]]
if not all_lines:
return
min_x = min(line["x"] for line in all_lines)
max_x = max(line["x"] + line["width"] for line in all_lines)
min_y = min(line["y"] for line in all_lines)
max_y = max(line["y"] + line["height"] for line in all_lines)
content_width = max(0.0, max_x - min_x)
content_height = max(0.0, max_y - min_y)
if self.segment_backgrounds:
pad_source = (
self.segment_background_padding
if self.segment_background_padding is not None
else self.background_padding
)
if isinstance(pad_source, (list, tuple)):
base_pad_x = float(pad_source[0])
base_pad_y = (
float(pad_source[1])
if len(pad_source) > 1
else float(pad_source[0])
)
else:
base_pad_x = base_pad_y = float(pad_source)
bounded_segments = [seg for seg in segments if seg.get("bounds")]
total_bounded = len(bounded_segments)
desired_gap = max(0.0, float(self.segment_background_gap))
spacing = max(0.0, float(self.segment_spacing))
interior_pad = max(0.0, (spacing - desired_gap) * 0.5)
extra_top = max(0.0, float(self.segment_background_extra_top))
extra_bottom = max(0.0, float(self.segment_background_extra_bottom))
def coerce_corner_radii(value):
if isinstance(value, (tuple, list)):
values = list(value)
else:
values = [value]
if not values:
values = [0.0]
if len(values) == 1:
values = values * 4
elif len(values) == 2:
values = [values[0], values[1], values[1], values[0]]
elif len(values) < 4:
values = values + [values[-1]] * (4 - len(values))
return tuple(values[:4])
base_corner_radii = coerce_corner_radii(self.background_corner_radius)
for idx, segment in enumerate(bounded_segments):
bounds = segment.get("bounds")
if not bounds:
continue
seg_width = max(0.0, bounds["max_x"] - bounds["min_x"])
seg_height = max(0.0, bounds["max_y"] - bounds["min_y"])
if seg_width <= 0 or seg_height <= 0:
continue
pad_left = base_pad_x if idx == 0 else interior_pad
pad_right = base_pad_x if idx == total_bounded - 1 else interior_pad
if total_bounded > 1:
left_edge = idx == 0
right_edge = idx == total_bounded - 1
corner_override = (
base_corner_radii[0] if left_edge else 0.0,
base_corner_radii[1] if right_edge else 0.0,
base_corner_radii[2] if right_edge else 0.0,
base_corner_radii[3] if left_edge else 0.0,
)
else:
corner_override = None
padding_override = (
pad_left,
pad_right,
base_pad_y + extra_bottom,
base_pad_y + extra_top,
)
self.draw_background_rect(
bounds["min_x"],
bounds["min_y"],
seg_width,
seg_height,
segment.get("background_color") or segment["color"],
force=True,
padding_override=padding_override,
corner_radius_override=corner_override,
)
background_drawn = False
if not self.segment_backgrounds:
base_color = segments[0]["color"] if segments else self._text_a_color
self.draw_background_rect(
min_x,
min_y,
content_width,
content_height,
base_color,
)
background_drawn = True
for segment in segments:
r, g, b, a = segment["color"]
for line in segment["lines"]:
if not line["text"]:
continue
set_font_size(font_id, line.get("font_size", self._text_size))
blf.position(font_id, line["x"], line["y"], 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, line)
current_y -= line_height
blf.draw(font_id, line["text"])
set_font_size(font_id, self._text_size)
def _padding_tuple(self) -> Tuple[float, float]:
pad = self.padding
if isinstance(pad, (list, tuple)):
if len(pad) == 0:
return (0.0, 0.0)
if len(pad) == 1:
value = float(pad[0])
return (value, value)
return (float(pad[0]), float(pad[1]))
value = float(pad)
return (value, value)
for segment in segments:
if not segment.get("strikethrough"):
continue
bounds = segment.get("bounds")
if not bounds:
continue
segment_min_x = bounds["min_x"]
segment_max_x = bounds["max_x"]
if segment_max_x <= segment_min_x:
continue
strike_y = bounds["min_y"] + (bounds["max_y"] - bounds["min_y"]) * 0.5
self.draw_strikethrough(
segment_min_x,
segment_max_x,
strike_y,
segment["color"],
force=True,
)
def _draw_background_rect(self, left, right, bottom, top):
vertices = (
(left, top),
(left, bottom),
(right, bottom),
(right, top),
)
indices = ((0, 1, 2), (0, 2, 3))
gpu.state.blend_set("ALPHA")
self.shader.bind()
self.shader.uniform_float("color", self._bg_color)
batch = batch_for_shader(
self.shader, "TRIS", {"pos": vertices}, indices=indices
)
batch.draw(self.shader)
if content_width > 0 and background_drawn:
strike_color = segments[0]["color"]
strike_y = min_y + content_height * 0.5
self.draw_strikethrough(
min_x,
max_x,
strike_y,
strike_color,
)
@@ -1,7 +1,53 @@
import blf
import bpy
import gpu
from gpu_extras.batch import batch_for_shader
from .. import ui_bgl
from typing import Union
def clamp(value, min_value=0.0, max_value=1.0):
return max(min_value, min(max_value, value))
def set_font_size(font_id, size):
if bpy.app.version < (4, 0, 0):
blf.size(font_id, size, 72)
else:
blf.size(font_id, size)
def tint_color(color, tint_amount):
if tint_amount == 0.0:
return color
r, g, b, a = color
if tint_amount > 0.0:
r += (1.0 - r) * tint_amount
g += (1.0 - g) * tint_amount
b += (1.0 - b) * tint_amount
else:
r *= 1.0 + tint_amount
g *= 1.0 + tint_amount
b *= 1.0 + tint_amount
return (clamp(r), clamp(g), clamp(b), a)
def resolve_fill_color(preferred_color, fallback_color):
color = preferred_color or fallback_color or (1.0, 1.0, 1.0, 1.0)
r, g, b, a = color
return (clamp(r), clamp(g), clamp(b), clamp(a))
def region_redraw(ctx: bpy.types.Context = None):
if ctx is not None:
context = ctx
else:
context = bpy.context
if context.region is not None:
context.region.tag_redraw()
class BL_UI_Widget:
def __init__(self, x, y, width, height):
@@ -19,15 +65,141 @@ class BL_UI_Widget:
self._mouse_down_right = False
self._is_visible = True
self._is_active = True # if the widget needs to be disabled
# decorative helpers (opt-in per widget)
self._background_enabled = False
self.use_rounded_background = False
self.background_padding: tuple[int, int] = (0, 0)
# Radius can be '50%' for pill shape, each corner individually
self._background_corner_radius: Union[
tuple[Union[str, float], ...],
str,
float,
] = (0.0,)
self._background_corner_radius_custom = False
self.background_border = False
self.background_border_color = None
self.background_border_tint = 0.2
self.background_border_thickness = 1.0
self.strikethrough = False
self.strikethrough_thickness = 1.25
if bpy.app.version < (4, 0, 0):
self.shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR")
else:
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
@property
def background_corner_radius(self):
return self._background_corner_radius
@background_corner_radius.setter
def background_corner_radius(self, value):
self._background_corner_radius = value
self._background_corner_radius_custom = True
@property
def background(self):
return self._background_enabled
@background.setter
def background(self, value):
enabled = bool(value)
self._background_enabled = enabled
if not enabled:
self.use_rounded_background = False
elif enabled and not self.use_rounded_background:
self.use_rounded_background = True
def _set_background_corner_radius_default(self, value):
self._background_corner_radius = value
self._background_corner_radius_custom = False
def has_background_corner_radius_override(self):
return self._background_corner_radius_custom
def resolve_background_fill(self, fallback_color, preferred_color=None):
base_color = fallback_color if preferred_color is None else preferred_color
return resolve_fill_color(
base_color,
fallback_color,
)
def resolve_background_border(self, fill_color):
if not self.background_border:
return None
if self.background_border_color:
return self.background_border_color
return tint_color(fill_color, self.background_border_tint)
def draw_background_rect(
self,
min_x,
min_y,
width,
height,
fallback_color,
*,
force=False,
padding_override=None,
corner_radius_override=None,
fill_color_override=None,
):
if (not self.use_rounded_background and not force) or width <= 0 or height <= 0:
return
if padding_override is None:
pad_x = self.background_padding[0]
pad_y = self.background_padding[1]
pad_left = pad_right = pad_x
pad_bottom = pad_top = pad_y
else:
if len(padding_override) == 4:
pad_left, pad_right, pad_bottom, pad_top = padding_override
elif len(padding_override) == 2:
pad_left = pad_right = padding_override[0]
pad_bottom = pad_top = padding_override[1]
else:
pad_left = pad_right = padding_override[0]
pad_bottom = pad_top = padding_override[1]
rect_x = min_x - pad_left
rect_y = min_y - pad_bottom
rect_width = width + pad_left + pad_right
rect_height = height + pad_top + pad_bottom
fill_color = self.resolve_background_fill(
fallback_color,
preferred_color=fill_color_override,
)
border_color = self.resolve_background_border(fill_color)
corner_radius = (
corner_radius_override
if corner_radius_override is not None
else self.background_corner_radius
)
ui_bgl.draw_rounded_rect_with_border(
rect_x,
rect_y,
rect_width,
rect_height,
radius=corner_radius,
fill_color=fill_color,
border_color=border_color,
border_thickness=self.background_border_thickness,
)
def draw_strikethrough(self, min_x, max_x, y, color, *, force=False):
if (not self.strikethrough and not force) or max_x <= min_x:
return
ui_bgl.draw_line2d(
min_x,
y,
max_x,
y,
self.strikethrough_thickness,
color,
)
def set_location(self, x, y):
# if self.x != x or self.y != y or self.x_screen != x or self.y_screen != y:
# bpy.context.region.tag_redraw()
# region_redraw()
self.x = x
self.y = y
self.x_screen = x
@@ -41,8 +213,15 @@ class BL_UI_Widget:
@bg_color.setter
def bg_color(self, value):
self._bg_color = value
if bpy.context.region is not None:
bpy.context.region.tag_redraw()
region_redraw()
@property
def background_color(self):
return self._bg_color
@background_color.setter
def background_color(self, value):
self.bg_color = value
@property
def visible(self):
@@ -51,17 +230,17 @@ class BL_UI_Widget:
@visible.setter
def visible(self, value):
if value != self._is_visible:
bpy.context.region.tag_redraw()
region_redraw()
self._is_visible = value
@property
def active(self):
return self._is_active
@visible.setter
@active.setter
def active(self, value):
if value != self._is_active:
bpy.context.region.tag_redraw()
region_redraw()
self._is_active = value
@property
@@ -78,6 +257,20 @@ class BL_UI_Widget:
gpu.state.blend_set("ALPHA")
if self.use_rounded_background:
area_height = self.get_area_height()
rect_y = area_height - self.y_screen - self.height
self.draw_background_rect(
self.x_screen,
rect_y,
self.width,
self.height,
self._bg_color,
force=True,
fill_color_override=self._bg_color,
)
return
self.shader.bind()
self.shader.uniform_float("color", self._bg_color)
@@ -107,7 +300,7 @@ class BL_UI_Widget:
self.batch_panel = batch_for_shader(
self.shader, "TRIS", {"pos": vertices}, indices=indices
)
bpy.context.region.tag_redraw()
region_redraw()
def handle_event(self, event):
"""
@@ -121,28 +314,27 @@ class BL_UI_Widget:
if not self._is_active:
return False
x = event.mouse_region_x
y = event.mouse_region_y
x, y = self._to_widget_region_coords(event)
if event.type == "LEFTMOUSE":
if event.value == "PRESS":
self._mouse_down = True
bpy.context.region.tag_redraw()
region_redraw()
return self.mouse_down(x, y)
else:
self._mouse_down = False
bpy.context.region.tag_redraw()
region_redraw()
self.mouse_up(x, y)
return False
elif event.type == "RIGHTMOUSE":
if event.value == "PRESS":
self._mouse_down_right = True
bpy.context.region.tag_redraw()
region_redraw()
return self.mouse_down_right(x, y)
else:
self._mouse_down_right = False
bpy.context.region.tag_redraw()
region_redraw()
self.mouse_up(x, y)
elif event.type == "MOUSEMOVE":
@@ -154,13 +346,13 @@ class BL_UI_Widget:
self.__inrect = True
self.mouse_enter(event, x, y)
# we tag redraw since the hover colors are picked in the draw function
bpy.context.region.tag_redraw()
region_redraw()
# we are leaving the rect
elif self.__inrect and not inrect:
self.__inrect = False
self.mouse_exit(event, x, y)
bpy.context.region.tag_redraw()
region_redraw()
# return always false to enable mouse exit events on other buttons.(would sometimes not hide the tooltip)
return False # self.__inrect
@@ -174,6 +366,26 @@ class BL_UI_Widget:
return False
def _to_widget_region_coords(self, event):
region = None
ctx = self.context
if isinstance(ctx, dict):
region = ctx.get("region")
elif hasattr(ctx, "region"):
region = getattr(ctx, "region")
if (
region is not None
and hasattr(event, "mouse_x")
and hasattr(event, "mouse_y")
):
try:
return event.mouse_x - region.x, event.mouse_y - region.y
except AttributeError:
pass
return getattr(event, "mouse_region_x", 0), getattr(event, "mouse_region_y", 0)
def get_input_keys(self):
return []
@@ -2,11 +2,11 @@ schema_version = "1.0.0"
id = "blenderkit"
type = "add-on"
version = "3.18.1-251219" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
version = "3.19.1-260402" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
name = "BlenderKit Online Asset Library"
tagline = "Drag & drop of assets from the community driven library"
maintainer = "Vilém Duha <admin@blenderkit.com>"
maintainer = "Vilem Duha <admin@blenderkit.com>, Petr Dlouhy, A. Gajdosik, Michal Hons"
website = "https://blenderkit.com/"
tags = ["3D View"]
@@ -125,6 +125,7 @@ def handle_categories_task(task: client_tasks.Task):
"NODEGROUP": ["nodegroup"],
"PRINTABLE": ["printable"],
"ADDON": ["addon"],
"AUTHOR": ["author"],
}
if task.status == "finished":
@@ -135,20 +136,20 @@ def handle_categories_task(task: client_tasks.Task):
) # TODO: do this in Client, just saving the file so next time it is updated even without internet
return
bk_logger.warning(f"Could not load categories: {task.message}")
bk_logger.warning("Could not load categories: %s", task.message)
if not os.path.exists(categories_filepath):
source_path = paths.get_addon_file(subpath="data" + os.sep + "categories.json")
try:
shutil.copy(source_path, categories_filepath)
except Exception as e:
bk_logger.warning(f"Could not copy categories file: {e}")
bk_logger.warning("Could not copy categories file: %s", e)
return
try:
with open(categories_filepath, "r", encoding="utf-8") as catfile:
global_vars.DATA["bkit_categories"] = json.load(catfile)
except Exception as e:
bk_logger.warning(f"Could not read categories file: {e}")
bk_logger.warning("Could not read categories file: %s", e)
# def get_upload_asset_type(self):
@@ -16,6 +16,8 @@
#
# ##### END GPL LICENSE BLOCK #####
from __future__ import annotations
import dataclasses
import logging
import os
@@ -23,7 +25,7 @@ import platform
import shutil
import subprocess
from os import path
from typing import Optional
from typing import Optional, Union
from http.client import responses as http_responses
@@ -36,6 +38,8 @@ from . import datas, global_vars, reports, utils
bk_logger = logging.getLogger(__name__)
NO_PROXIES = {"http": "", "https": ""}
TIMEOUT = (0.1, 1)
# Shorter timeout for the frequent report polling that runs on Blender's main thread.
POLL_TIMEOUT = (0.05, 0.25)
def get_address() -> str:
@@ -127,7 +131,7 @@ def reorder_ports(port: str = ""):
)
def get_reports(app_id: str):
def get_reports(app_id: int):
"""Get reports for all tasks of app_id Blender instance at once.
If few last calls failed, then try to get reports also from other than default ports.
"""
@@ -152,7 +156,7 @@ def get_reports(app_id: str):
reorder_ports(port)
return report
except Exception as e:
bk_logger.info(f"Failed to get BlenderKit-Client reports: {e}")
bk_logger.info("Failed to get BlenderKit-Client reports: %s", e)
last_exception = e
if last_exception is not None:
raise last_exception
@@ -163,7 +167,7 @@ def request_report(url: str, data: dict) -> dict:
If something goes south, this function raises requests.HTTPError or requests.JSONDecodeError.
"""
with requests.Session() as session:
resp = session.get(url, json=data, timeout=TIMEOUT, proxies=NO_PROXIES)
resp = session.get(url, json=data, timeout=POLL_TIMEOUT, proxies=NO_PROXIES)
if resp.status_code != 200:
# not using resp.raise_for_status() for better message
raise requests.HTTPError(
@@ -176,7 +180,7 @@ def request_report(url: str, data: dict) -> dict:
# SEARCH
def asset_search(search_data: datas.SearchData):
"""Search for specified asset."""
bk_logger.info(f"Starting search request: {search_data.urlquery}")
bk_logger.info("Starting search request: %s", search_data.urlquery)
search_data = ensure_minimal_data_class(search_data)
with requests.Session() as session:
@@ -184,7 +188,7 @@ def asset_search(search_data: datas.SearchData):
resp = session.post(
url, json=datas.asdict(search_data), timeout=TIMEOUT, proxies=NO_PROXIES
)
bk_logger.debug("Got search response")
bk_logger.log(5, "Got search response")
return resp.json()
@@ -219,7 +223,7 @@ def asset_upload(upload_data, export_data, upload_set):
data = ensure_minimal_data(data)
with requests.Session() as session:
url = get_base_url() + "/blender/asset_upload"
bk_logger.debug(f"making a request to: {url}")
bk_logger.debug("making a request to: %s", url)
resp = session.post(url, json=data, timeout=TIMEOUT, proxies=NO_PROXIES)
return resp
@@ -355,12 +359,15 @@ def get_rating(asset_id: str):
)
def send_rating(asset_id: str, rating_type: str, rating_value: str):
def send_rating(asset_id: str, rating_type: str, rating_value: Union[str, int]):
data = {
"asset_id": asset_id,
"rating_type": rating_type,
"rating_value": rating_value,
}
if rating_type == "bookmarks":
data["rating_value"] = int(rating_value) # type: ignore
data = ensure_minimal_data(data)
with requests.Session() as session:
return session.post(
@@ -674,7 +681,7 @@ def start_blenderkit_client():
reports.add_report(msg, type="ERROR")
raise (e)
bk_logger.info(f"BlenderKit-Client {client_version} starting on {get_address()}")
bk_logger.info("BlenderKit-Client %s starting on %s", client_version, get_address())
def decide_client_binary_name() -> str:
@@ -760,11 +767,11 @@ def ensure_client_binary_installed():
return
preinstalled_client_path = get_preinstalled_client_path()
bk_logger.info(f"Copying BlenderKit-Client binary {preinstalled_client_path}")
bk_logger.info("Copying BlenderKit-Client binary %s", preinstalled_client_path)
os.makedirs(path.dirname(client_binary_path), exist_ok=True)
shutil.copy(preinstalled_client_path, client_binary_path)
os.chmod(client_binary_path, 0o711)
bk_logger.info(f"BlenderKit-Client binary copied to {client_binary_path}")
bk_logger.info("BlenderKit-Client binary copied to %s", client_binary_path)
def get_addon_dir():
+7 -1
View File
@@ -40,8 +40,11 @@ RED = (1, 0.5, 0.5, 0.8)
BLUE = (0.8, 0.8, 1, 0.8)
"""BLUE Color for validator reports."""
ACTIVE_BLUE = (0.7, 0.8, 1.0, 1.0)
"""ACTIVE_BLUE Color for active elements in the UI, mainly filters."""
GREEN_PRICE = (0.42, 0.49, 0.19, 1.0)
"""Emerald Green to be used on "discounted" add-ons."""
"""Emerald Green to be used on "free" add-ons."""
PURPLE_PRICE = (0.59, 0.05, 0.82, 1.0)
"""Lavender Purple to be used on "for sale" add-ons."""
@@ -52,6 +55,9 @@ ORANGE_FULL = (0.702, 0.349, 0.208, 1.0)
GRAY = (0.7, 0.7, 0.7, 0.6)
"""Default color for debug reports."""
YELLOW = (1.0, 1.0, 0.5, 0.8)
"""Yellow color for warning reports."""
# pure colors
PURE_WHITE = (1, 1, 1, 1)
PURE_BLACK = (0, 0, 0, 1)
@@ -26,9 +26,9 @@ bk_logger = logging.getLogger(__name__)
### COMMENTS
def handle_get_comments_task(task: client_tasks.Task):
"""Handle incomming task which downloads comments on asset."""
"""Handle incoming task which downloads comments on asset."""
if task.status == "error":
return bk_logger.warning(f"failed to get comments: {task.message}")
return bk_logger.warning("failed to get comments: %s", task.message)
if task.status == "finished":
comments = task.result["results"]
store_comments_local(task.data["asset_id"], comments)
@@ -38,25 +38,25 @@ def handle_get_comments_task(task: client_tasks.Task):
def handle_create_comment_task(task: client_tasks.Task):
"""Handle incoming task for creating a new comment."""
if task.status == "finished":
return bk_logger.debug(f"Creating comment finished - {task.message}")
return bk_logger.debug("Creating comment finished - %s", task.message)
if task.status == "error":
return bk_logger.warning(f"Creating comment failed - {task.message}")
return bk_logger.warning("Creating comment failed - %s", task.message)
def handle_feedback_comment_task(task: client_tasks.Task):
"""Handle incomming task for update of feedback on comment."""
"""Handle incoming task for update of feedback on comment."""
if task.status == "finished": # action not needed
return bk_logger.debug(f"Comment feedback finished - {task.message}")
return bk_logger.debug("Comment feedback finished - %s", task.message)
if task.status == "error":
return bk_logger.warning(f"Comment feedback failed - {task.message}")
return bk_logger.warning("Comment feedback failed - %s", task.message)
def handle_mark_comment_private_task(task: client_tasks.Task):
"""Handle incomming task for marking the comment as private/public."""
"""Handle incoming task for marking the comment as private/public."""
if task.status == "finished": # action not needed
return bk_logger.debug(f"Marking comment visibility finished - {task.message}")
return bk_logger.debug("Marking comment visibility finished - %s", task.message)
if task.status == "error":
return bk_logger.warning(f"Marking comment visibility failed - {task.message}")
return bk_logger.warning("Marking comment visibility failed - %s", task.message)
def store_comments_local(asset_id, comments):
@@ -69,12 +69,12 @@ def get_comments_local(asset_id):
### NOTIFICATIONS
def handle_notifications_task(task: client_tasks.Task):
"""Handle incomming task with notifications data."""
"""Handle incoming task with notifications data."""
if task.status == "finished":
global_vars.DATA["bkit notifications"] = task.result
return
if task.status == "error":
return bk_logger.warning(f"Could not load notifications: {task.message}")
return bk_logger.warning("Could not load notifications: %s", task.message)
def check_notifications_read():
+3 -1
View File
@@ -25,6 +25,7 @@ class Prefs:
global_dir: str
project_subdir: str
unpack_files: bool
write_asset_metadata: bool
show_on_start: bool
thumb_size: int
maximized_assetbar_rows: int
@@ -32,6 +33,7 @@ class Prefs:
search_in_header: bool
tips_on_start: bool
announcements_on_start: bool
assetbar_follows_cursor: bool
client_port: str
ip_version: str
ssl_context: str
@@ -115,7 +117,7 @@ class MineProfile:
This is private information about current user's profile. Fields can be also None.
Because API can just return null just for fun (https://github.com/BlenderKit/BlenderKit/issues/1545#event-17220997340).
None/null is not 0 or "" however, so we keep the None to distinguish both states.
As result the Nones has to be catched later in code, types are just hints in here!
As result the Nones has to be captured later in code, types are just hints in here!
"""
aboutMe: str = ""
@@ -58,8 +58,8 @@ class BlenderKitDisclaimerOperator(BL_UI_OT_draw_operator):
)
fadeout_time: IntProperty( # type: ignore[valid-type]
name="Fadout time",
description="after how many seconds do fadout",
name="Fadeout time",
description="after how many seconds do fadeout",
default=5,
min=1,
max=50,
@@ -224,18 +224,21 @@ class BlenderKitDisclaimerOperator(BL_UI_OT_draw_operator):
@classmethod
def unregister(cls):
bk_logger.debug(f"unregistering class {cls}")
bk_logger.debug("unregistering class %s", cls)
instances_copy = cls.instances.copy()
for instance in instances_copy:
bk_logger.debug(f"- class instance {instance}")
try:
bk_logger.debug("- class instance %s", instance)
except ReferenceError:
bk_logger.debug("- class instance <deleted>")
try:
instance.unregister_handlers(instance.context)
except Exception as e:
bk_logger.debug(f"-- error unregister_handlers(): {e}")
bk_logger.debug("-- error unregister_handlers(): %s", e)
try:
instance.on_finish(instance.context)
except Exception as e:
bk_logger.debug(f"-- error calling on_finish() {e}")
bk_logger.debug("-- error calling on_finish() %s", e)
if bpy.context.region is not None:
bpy.context.region.tag_redraw()
@@ -290,7 +293,7 @@ def handle_disclaimer_task(task: client_tasks.Task):
return
if task.status == "error":
bk_logger.warning(f"Could not load disclaimer: {task.message}")
bk_logger.warning("Could not load disclaimer: %s", task.message)
return show_random_tip()
+194 -50
View File
@@ -16,16 +16,16 @@
#
# ##### END GPL LICENSE BLOCK #####
from __future__ import annotations
import addon_utils
import copy
import json
import logging
import os
import shutil
import tempfile
import time
import urllib.request
from typing import Optional
from . import (
append_link,
@@ -56,6 +56,12 @@ from bpy.props import (
bk_logger = logging.getLogger(__name__)
STALE_DOWNLOAD_TIMEOUT = (
20.0 # seconds without progress before we treat a download as stalled
)
download_tasks = {}
def get_blenderkit_repository():
"""Find the BlenderKit extensions repository index.
@@ -123,7 +129,7 @@ def get_addon_installation_status(asset_data):
) and addon_module.startswith("bl_ext."):
is_enabled = True
bk_logger.info(
f"Found enabled addon with extension format: {addon_module}"
"Found enabled addon with extension format: %s", addon_module
)
break
@@ -141,11 +147,12 @@ def get_addon_installation_status(asset_data):
) and addon_module.__name__.startswith("bl_ext."):
is_installed = True
bk_logger.info(
f"Found installed addon with extension format: {addon_module.__name__}"
"Found installed addon with extension format: %s",
addon_module.__name__,
)
break
except Exception as e:
bk_logger.warning(f"Error checking addon_utils.modules(): {e}")
bk_logger.warning("Error checking addon_utils.modules(): %s", e)
# If found through addon_utils, we know it's installed
# But we need to double-check enabled status using the correct module name
@@ -160,10 +167,10 @@ def get_addon_installation_status(asset_data):
# Check if this specific module name is enabled
is_enabled = addon_module.__name__ in enabled_addons
if is_enabled:
bk_logger.info(f"Found enabled addon: {addon_module.__name__}")
bk_logger.info("Found enabled addon: %s", addon_module.__name__)
break
except Exception as e:
bk_logger.warning(f"Error double-checking enabled status: {e}")
bk_logger.warning("Error double-checking enabled status: %s", e)
# Method 3: If not found through traditional addon system, check extensions system
if not is_installed:
@@ -173,7 +180,7 @@ def get_addon_installation_status(asset_data):
"blenderkit_extensions_repo_cache", {}
)
for cache_key, pkg_data in bk_ext_cache.items():
for _cache_key, pkg_data in bk_ext_cache.items():
if isinstance(pkg_data, dict) and pkg_data.get("id") == extension_id:
# Check if it's actually installed in the extension system
is_installed = pkg_data.get("installed", False)
@@ -182,12 +189,11 @@ def get_addon_installation_status(asset_data):
is_enabled = pkg_data.get("enabled", False)
break
except Exception as e:
bk_logger.warning(f"Error checking extension cache: {e}")
bk_logger.warning("Error checking extension cache: %s", e)
# Method 4: Check through Blender's extension repositories directly
if not is_installed:
try:
# Look for BlenderKit repository and check its packages
for repo in bpy.context.preferences.extensions.repos:
if not repo.enabled:
@@ -203,7 +209,7 @@ def get_addon_installation_status(asset_data):
# For now, we'll rely on the previous methods
break
except Exception as e:
bk_logger.warning(f"Error checking extension repositories: {e}")
bk_logger.warning("Error checking extension repositories: %s", e)
# Debug: Show some enabled addons for reference
blenderkit_addons = [
@@ -252,7 +258,7 @@ def install_addon_from_local_file(asset_data, file_path, enable_on_install=True)
reports.add_report(error_msg, type="ERROR")
raise Exception(error_msg)
bk_logger.info(f"Installing addon '{addon_name}' from local file: {file_path}")
bk_logger.info("Installing addon '%s' from local file: %s", addon_name, file_path)
status = get_addon_installation_status(asset_data)
if status["installed"]:
@@ -288,7 +294,19 @@ def install_addon_from_local_file(asset_data, file_path, enable_on_install=True)
)
download_tasks = {}
def _reset_progress_for_asset_ids(asset_ids):
"""Reset UI progress bars for the given asset ids."""
if not asset_ids:
return
search_results = search.get_search_results()
if search_results is None:
return
for result in search_results:
if result.get("id") in asset_ids:
result["downloaded"] = 0
INT32_MIN = -2_147_483_648
INT32_MAX = 2_147_483_647
@@ -348,7 +366,7 @@ def check_unused():
for l in bpy.data.libraries:
if l not in used_libs and l.getn("asset_data"):
bk_logger.info(f"attempt to remove this library: {l.filepath}")
bk_logger.info("attempt to remove this library: %s", l.filepath)
# have to unlink all groups, since the file is a 'user' even if the groups aren't used at all...
for user_id in l.users_id:
if type(user_id) == bpy.types.Collection:
@@ -364,7 +382,7 @@ def get_temp_enabled_addons():
temp_addons_json = prefs.temp_enabled_addons
return json.loads(temp_addons_json)
except Exception as e:
bk_logger.warning(f"Error reading temporary addons from preferences: {e}")
bk_logger.warning("Error reading temporary addons from preferences: %s", e)
return []
@@ -374,9 +392,9 @@ def set_temp_enabled_addons(addon_list):
try:
prefs = bpy.context.preferences.addons[__package__].preferences
prefs.temp_enabled_addons = json.dumps(addon_list)
bk_logger.info(f"Saved {len(addon_list)} temporary addons to preferences")
bk_logger.info("Saved %d temporary addons to preferences", len(addon_list))
except Exception as e:
bk_logger.error(f"Error saving temporary addons to preferences: {e}")
bk_logger.error("Error saving temporary addons to preferences: %s", e)
def add_temp_enabled_addon(pkg_id):
@@ -385,7 +403,7 @@ def add_temp_enabled_addon(pkg_id):
if pkg_id not in temp_enabled:
temp_enabled.append(pkg_id)
set_temp_enabled_addons(temp_enabled)
bk_logger.info(f"Added {pkg_id} to temporary addons list")
bk_logger.info("Added %s to temporary addons list", pkg_id)
def cleanup_temp_enabled_addons():
@@ -398,24 +416,24 @@ def cleanup_temp_enabled_addons():
bk_logger.info("No temporarily enabled addons to clean up")
return
bk_logger.info(f"Cleaning up {len(temp_enabled)} temporarily enabled addons")
bk_logger.info("Cleaning up %d temporarily enabled addons", len(temp_enabled))
# Disable all temporarily enabled addons using preferences API
for pkg_id in temp_enabled:
try:
full_module_name = f"bl_ext.www_blenderkit_com.{pkg_id}"
bpy.ops.preferences.addon_disable(module=full_module_name)
bk_logger.info(f"Disabled temporarily enabled addon: {pkg_id}")
bk_logger.info("Disabled temporarily enabled addon: %s", pkg_id)
except Exception as e:
bk_logger.warning(
f"Failed to disable temporarily enabled addon {pkg_id}: {e}"
"Failed to disable temporarily enabled addon %s: %s", pkg_id, e
)
# Clear the list in preferences
set_temp_enabled_addons([])
bk_logger.info("Temporary addon cleanup completed")
except Exception as e:
bk_logger.error(f"Error during temporary addon cleanup: {e}")
bk_logger.error("Error during temporary addon cleanup: %s", e)
@persistent
@@ -466,7 +484,7 @@ def refresh_addon_search_results_status():
asset_data["enabled"] = False
except Exception as e:
bk_logger.warning(f"Error refreshing addon search results status: {e}")
bk_logger.warning("Error refreshing addon search results status: %s", e)
@persistent
@@ -617,7 +635,7 @@ def _sanitize_for_idprops(value):
return value
def udpate_asset_data_in_dicts(asset_data):
def update_asset_data_in_dicts(asset_data):
"""
updates asset data in all relevant dictionaries, after a threaded download task \
- where the urls were retrieved, and now they can be reused
@@ -705,6 +723,8 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
user_preferences = bpy.context.preferences.addons[__package__].preferences
user_preferences.download_counter += 1
asset_main = None
if asset_data["assetType"] == "scene":
sprops = wm.blenderkit_scene
@@ -758,7 +778,7 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
# also, if it was successful, no other operations are needed , basically all asset data is already ready from the original asset
if new_obs:
# update here assets rated/used because there might be new download urls?
udpate_asset_data_in_dicts(asset_data)
update_asset_data_in_dicts(asset_data)
bpy.ops.ed.undo_push(
"INVOKE_REGION_WIN",
message="add %s to scene" % asset_data["name"],
@@ -772,7 +792,7 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
if downloaders:
for downloader in downloaders:
# this cares for adding particle systems directly to target mesh, but I had to block it now,
# because of the sluggishnes of it. Possibly re-enable when it's possible to do this faster?
# because of the sluggishness of it. Possibly re-enable when it's possible to do this faster?
if (
"particle_plants" in asset_data["tags"]
and kwargs["target_object"] != ""
@@ -854,6 +874,7 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
lib["asset_data"] = asset_data
elif asset_data["assetType"] == "brush":
brush = None
inscene = False
for b in bpy.data.brushes:
if b.blenderkit.id == asset_data["id"]:
@@ -910,9 +931,11 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
)
# TODO add grease pencil brushes!
# bpy.context.tool_settings.image_paint.brush = brush
asset_main = brush
if brush is not None:
asset_main = brush
elif asset_data["assetType"] == "material":
material = None
inscene = False
sprops = wm.blenderkit_mat
@@ -927,12 +950,14 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
file_names[-1], matname=asset_data["name"], link=link, fake_user=False
)
target_object = bpy.data.objects[kwargs["target_object"]]
assign_material(target_object, material, kwargs["material_target_slot"])
if material is not None:
target_object = bpy.data.objects[kwargs["target_object"]]
assign_material(target_object, material, kwargs["material_target_slot"])
asset_main = material
asset_main = material
elif asset_data["assetType"] == "nodegroup":
nodegroup = None
inscene = False
sprops = wm.blenderkit_nodegroup
for g in bpy.data.node_groups:
@@ -979,11 +1004,12 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
model_location=kwargs.get("model_location", (0, 0, 0)),
model_rotation=kwargs.get("model_rotation", (0, 0, 0)),
)
bk_logger.info(f"appended nodegroup: {nodegroup}")
asset_main = nodegroup
if nodegroup is not None:
bk_logger.info("appended nodegroup: %s", nodegroup)
asset_main = nodegroup
asset_data["resolution"] = kwargs["resolution"]
udpate_asset_data_in_dicts(asset_data)
update_asset_data_in_dicts(asset_data)
if asset_main is not None:
update_asset_metadata(asset_main, asset_data)
@@ -1000,6 +1026,36 @@ def update_asset_metadata(asset_main, asset_data):
asset_main.blenderkit.id = asset_data["id"]
asset_main.blenderkit.description = asset_data["description"]
asset_main.blenderkit.tags = utils.list2string(asset_data["tags"])
asset_main.blenderkit.is_private = (
"PRIVATE" if asset_data["isPrivate"] else "PUBLIC"
)
asset_main.blenderkit.verification_status = asset_data.get(
"verificationStatus", "UPLOADING"
).upper()
# manufacturer
if asset_data.get("dictParameters"):
dp = asset_data["dictParameters"]
if dp.get("manufacturer"):
asset_main.blenderkit.manufacturer = dp["manufacturer"]
else:
asset_main.blenderkit.manufacturer = ""
if dp.get("designer"):
asset_main.blenderkit.designer = dp["designer"]
else:
asset_main.blenderkit.designer = ""
if dp.get("designCollection"):
asset_main.blenderkit.design_collection = dp["designCollection"]
else:
asset_main.blenderkit.design_collection = ""
if dp.get("designVariant"):
asset_main.blenderkit.design_variant = dp["designVariant"]
else:
asset_main.blenderkit.design_variant = ""
if dp.get("designYear"):
asset_main.blenderkit.use_design_year = True
asset_main.blenderkit.design_year = int(float(dp["designYear"]))
# BUG #554: categories needs update, but are not in asset_data
sanitized = _sanitize_for_idprops(asset_data)
# TODO consider reducing stored fields for filesize.
@@ -1022,11 +1078,11 @@ def replace_resolution_linked(file_paths, asset_data):
bk_logger.debug("try to re-link library")
if not os.path.isfile(file_paths[-1]):
bk_logger.debug("library file doesnt exist")
bk_logger.debug("library file doesn't exist")
break
l.filepath = os.path.join(os.path.dirname(l.filepath), file_name)
l.name = file_name
udpate_asset_data_in_dicts(asset_data)
update_asset_data_in_dicts(asset_data)
def replace_resolution_appended(file_paths, asset_data, resolution):
@@ -1060,7 +1116,7 @@ def replace_resolution_appended(file_paths, asset_data, resolution):
for pf in i.packed_files:
pf.filepath = fp
i.reload()
udpate_asset_data_in_dicts(asset_data)
update_asset_data_in_dicts(asset_data)
# TODO: keep this until we check resolution replacement and other features from this one are supported in daemon.
@@ -1179,6 +1235,13 @@ def handle_download_task(task: client_tasks.Task):
"""
global download_tasks
# If the task was already pruned/cancelled, ignore late reports from the client.
if task.task_id not in download_tasks:
bk_logger.debug(
"Ignoring late download task %s (no longer tracked)", task.task_id
)
return
if task.status == "finished":
# we still write progress since sometimes the progress bars wouldn't end on 100%
download_write_progress(task.task_id, task)
@@ -1188,7 +1251,7 @@ def handle_download_task(task: client_tasks.Task):
download_tasks.pop(task.task_id)
return
except Exception as e:
bk_logger.exception(f"Asset appending/linking has failed")
bk_logger.exception("Asset appending/linking has failed: %s", e)
task.message = f"Append failed: {e}"
task.status = "error"
@@ -1205,15 +1268,92 @@ def clear_downloads():
download_tasks.clear()
def cancel_running_downloads(reason: str = ""):
"""Cancel all running downloads for this Blender process and reset local UI state."""
global download_tasks
if not download_tasks:
return
task_ids = list(download_tasks.keys())
asset_ids = set()
for task in download_tasks.values():
if not isinstance(task, dict):
continue
asset_id = task.get("asset_data", {}).get("id")
if asset_id:
asset_ids.add(asset_id)
suffix = f" ({reason})" if reason else ""
bk_logger.info("Cancelling %d running downloads%s", len(task_ids), suffix)
for task_id in task_ids:
try:
client_lib.cancel_download(task_id)
except Exception as e:
bk_logger.warning("Failed to cancel download %s: %s", task_id, e)
clear_downloads()
_reset_progress_for_asset_ids(asset_ids)
def prune_stalled_downloads(
max_idle_seconds: float = STALE_DOWNLOAD_TIMEOUT, now: Optional[float] = None
) -> None:
"""Cancel downloads that have not reported progress for too long."""
if not download_tasks:
return
now = now if now is not None else time.monotonic()
stalled_task_ids = []
stalled_asset_ids = set()
for task_id, task in list(download_tasks.items()):
last_report_time = task.get("last_report_time") or task.get("started_at")
if last_report_time is None:
task["last_report_time"] = now
continue
if now - last_report_time < max_idle_seconds:
continue
stalled_task_ids.append(task_id)
asset_info = task.get("asset_data", {})
asset_id = asset_info.get("id")
asset_name = asset_info.get("name", "Asset")
reports.add_report(
f"Download for {asset_name} stalled and was cancelled. Please try again.",
type="ERROR",
)
if asset_id:
stalled_asset_ids.add(asset_id)
if not stalled_task_ids:
return
for task_id in stalled_task_ids:
try:
client_lib.cancel_download(task_id)
except Exception as e:
bk_logger.warning("Failed to cancel stalled download %s: %s", task_id, e)
download_tasks.pop(task_id, None)
_reset_progress_for_asset_ids(stalled_asset_ids)
def download_write_progress(task_id, task):
"""writes progress from client_lib reports to addon tasks list"""
global download_tasks
task_addon = download_tasks.get(task.task_id)
if task_addon is None:
bk_logger.warning(f"couldn't write download progress to {task.progress}")
return
return # task was likely cancelled/stalled and removed; ignore late progress
task_addon["progress"] = task.progress
task_addon["text"] = task.message
task_addon["last_report_time"] = time.monotonic()
# go through search results to write progress to display progress bars
sr = search.get_search_results()
@@ -1350,6 +1490,7 @@ def download(asset_data, **kwargs):
if "unpack_files" in kwargs: # for add-on download
prefs["unpack_files"] = kwargs["unpack_files"]
now = time.monotonic()
data = {
"asset_data": asset_data,
"PREFS": prefs,
@@ -1362,6 +1503,9 @@ def download(asset_data, **kwargs):
data["download_dirs"] = paths.get_download_dirs(asset_data["assetType"])
if "downloaders" in kwargs:
data["downloaders"] = kwargs["downloaders"]
data.setdefault("downloaders", [])
data["started_at"] = now
data["last_report_time"] = now
response = client_lib.asset_download(data)
download_tasks[response["task_id"]] = data
@@ -1379,7 +1523,7 @@ def check_downloading(asset_data, **kwargs) -> bool:
p_asset_data = task["asset_data"]
if p_asset_data["id"] == asset_data["id"]:
at = asset_data["assetType"]
if at in ("model", "material"):
if at in ("model", "material", "printable", "scene"):
downloader = {
"location": kwargs["model_location"],
"rotation": kwargs["model_rotation"],
@@ -1449,7 +1593,7 @@ def try_finished_append(asset_data, **kwargs):
try:
os.remove(file_path)
except Exception as e1:
bk_logger.error(f"removing file {file_path} failed: {e1}")
bk_logger.error("removing file %s failed: %s", file_path, e1)
raise e
# Update downloaded status in search results
@@ -1619,9 +1763,9 @@ def start_download(asset_data, **kwargs) -> bool:
try_finished_append(asset_data, **kwargs)
return False
except Exception as e:
bk_logger.info(f"Failed to append asset: {e}, continuing with download")
bk_logger.info("Failed to append asset: %s, continuing with download", e)
if asset_data["assetType"] in ("model", "material"):
if asset_data["assetType"] in ("model", "material", "printable", "scene"):
downloader = {
"location": kwargs["model_location"],
"rotation": kwargs["model_rotation"],
@@ -1965,7 +2109,7 @@ class BlenderkitAddonChoiceOperator(bpy.types.Operator):
self.report({"INFO"}, f"Successfully enabled '{addon_name}'")
refresh_addon_search_results_status()
except Exception as e:
bk_logger.error(f"Failed to enable addon: {e}")
bk_logger.error("Failed to enable addon: %s", e)
reports.add_report(
f"Failed to enable '{addon_name}': {e}", type="ERROR"
)
@@ -1985,7 +2129,7 @@ class BlenderkitAddonChoiceOperator(bpy.types.Operator):
self.report({"INFO"}, f"Successfully disabled '{addon_name}'")
refresh_addon_search_results_status()
except Exception as e:
bk_logger.error(f"Failed to disable addon: {e}")
bk_logger.error("Failed to disable addon: %s", e)
reports.add_report(
f"Failed to disable '{addon_name}': {e}", type="ERROR"
)
@@ -2000,7 +2144,7 @@ class BlenderkitAddonChoiceOperator(bpy.types.Operator):
f"Temporary enable operation failed - returned: {result}"
)
except Exception as e:
bk_logger.error(f"Failed to temp enable addon: {e}")
bk_logger.error("Failed to temp enable addon: %s", e)
reports.add_report(
f"Failed to enable '{addon_name}': {e}", type="ERROR"
)
@@ -2016,7 +2160,7 @@ class BlenderkitAddonChoiceOperator(bpy.types.Operator):
refresh_addon_search_results_status()
except Exception as e:
bk_logger.error(f"Addon operation failed for '{addon_name}': {e}")
bk_logger.error("Addon operation failed for '%s': %s", addon_name, e)
error_msg = f"Failed to {selected_action.lower().replace('_', ' ')} '{addon_name}': {e}"
reports.add_report(error_msg, type="ERROR")
self.report({"ERROR"}, error_msg)
@@ -2309,7 +2453,7 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
return {"FINISHED"}
# replace resolution needs to replace all instances of the resolution in the scene
# and deleting originals has to be thus done after the downlaod
# and deleting originals has to be thus done after the download
kwargs = {
"cast_parent": self.cast_parent,
@@ -2333,7 +2477,7 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
return {"FINISHED"}
def draw(self, context):
# this timer is there to not let double clicks thorugh the popups down to the asset bar.
# this timer is there to not let double clicks through the popups down to the asset bar.
ui_panels.last_time_overlay_panel_active = time.time()
layout = self.layout
if self.invoke_resolution:
@@ -21,10 +21,13 @@ from os import environ
from subprocess import Popen
from typing import Any, Optional
from . import datas
try:
from . import datas
except ImportError:
# for release CI action
import datas # type: ignore
CLIENT_VERSION = "v1.7.0"
CLIENT_VERSION = "v1.8.3"
CLIENT_ACCESSIBLE = False
"""Is Client accessible? Can add-on access it and call stuff which uses it?"""
CLIENT_RUNNING = False
@@ -48,6 +51,7 @@ TABS: dict[str, Any] = {
"name": "Default", # Tab name
"history": [], # List of history steps
"history_index": -1, # Current position in history
"active_filters": [], # Per-tab active filter chips
}
],
}
@@ -58,6 +58,7 @@ icons_read = {
"logo_x.png": "logo_twitter",
"logo_youtube.png": "logo_youtube",
"asset_type_printable.png": "asset_type_printable",
"asset_type_author.png": "asset_type_author",
}
# fill the icon_collections["previews"] with icons of numbers for complexity rating
+115 -61
View File
@@ -16,11 +16,11 @@
#
# ##### END GPL LICENSE BLOCK #####
import os
import time
from functools import lru_cache
import logging
from dataclasses import dataclass
from functools import lru_cache
import os
import time
import bpy
@@ -180,13 +180,97 @@ def analyze_image_is_true_hdr(image):
imageHeight = size[1]
tempBuffer = numpy.empty(imageWidth * imageHeight * 4, dtype=numpy.float32)
image.pixels.foreach_get(tempBuffer)
image.blenderkit.true_hdr = numpy.amax(tempBuffer) > 1.05
image.blenderkit.true_hdr = bool(numpy.amax(tempBuffer) > 1.05)
def _save_hdr_thumbnail_image(
hdr_image,
output_path: str,
max_thumbnail_size: int,
use_custom_tone: bool,
exposure: float,
gamma: float,
):
import numpy
image_width, image_height = hdr_image.size
ratio = image_width / image_height
thumbnail_width = min(image_width, max_thumbnail_size)
thumbnail_height = min(image_height, int(max_thumbnail_size / ratio))
# Read once so we can both detect HDR and safely create a scaled temp image.
pixel_count = image_width * image_height
pixel_buffer = numpy.empty(pixel_count * 4, dtype=numpy.float32)
hdr_image.pixels.foreach_get(pixel_buffer)
hdr_image.blenderkit.true_hdr = bool(numpy.amax(pixel_buffer) > 1.05)
source_image = hdr_image
temp_image = None
if thumbnail_width < image_width:
temp_name = f"{hdr_image.name}_thumb_tmp"
temp_image = bpy.data.images.new(
temp_name,
width=image_width,
height=image_height,
alpha=False,
float_buffer=True,
)
temp_image.pixels.foreach_set(pixel_buffer)
temp_image.scale(thumbnail_width, thumbnail_height)
source_image = temp_image
try:
scene = bpy.context.scene
view_settings = scene.view_settings
orig_exposure = view_settings.exposure
orig_gamma = view_settings.gamma
try:
if use_custom_tone:
view_settings.exposure = exposure
view_settings.gamma = gamma
img_save_as(
source_image,
filepath=output_path,
view_transform="Standard",
)
finally:
view_settings.exposure = orig_exposure
view_settings.gamma = orig_gamma
finally:
if temp_image is not None:
bpy.data.images.remove(temp_image)
def generate_hdr_thumbnail_preview(
hdr_image,
use_custom_tone: bool,
exposure: float,
gamma: float,
max_preview_size: int = 256,
) -> str:
from . import paths
safe_name = "".join(
c if c.isalnum() or c in ("-", "_", ".") else "_" for c in hdr_image.name
)
preview_dir = paths.get_temp_dir(subdir="hdr_thumbnail_preview")
preview_path = os.path.join(preview_dir, f"{safe_name}_preview.jpg")
_save_hdr_thumbnail_image(
hdr_image=hdr_image,
output_path=preview_path,
max_thumbnail_size=max_preview_size,
use_custom_tone=use_custom_tone,
exposure=exposure,
gamma=gamma,
)
return preview_path
def generate_hdr_thumbnail():
import numpy
scene = bpy.context.scene
ui_props = bpy.context.window_manager.blenderkitUI
hdr_image = (
ui_props.hdr_upload_image
@@ -194,36 +278,15 @@ def generate_hdr_thumbnail():
base, ext = os.path.splitext(hdr_image.filepath)
thumb_path = base + ".jpg"
thumb_name = os.path.basename(thumb_path)
max_thumbnail_size = 2048
size = hdr_image.size
ratio = size[0] / size[1]
imageWidth = size[0]
imageHeight = size[1]
thumbnailWidth = min(size[0], max_thumbnail_size)
thumbnailHeight = min(size[1], int(max_thumbnail_size / ratio))
tempBuffer = numpy.empty(imageWidth * imageHeight * 4, dtype=numpy.float32)
inew = bpy.data.images.new(
thumb_name, imageWidth, imageHeight, alpha=False, float_buffer=False
_save_hdr_thumbnail_image(
hdr_image=hdr_image,
output_path=thumb_path,
max_thumbnail_size=2048,
use_custom_tone=ui_props.hdr_use_custom_thumbnail_tone,
exposure=ui_props.hdr_thumbnail_exposure,
gamma=ui_props.hdr_thumbnail_gamma,
)
hdr_image.pixels.foreach_get(tempBuffer)
hdr_image.blenderkit.true_hdr = numpy.amax(tempBuffer) > 1.05
inew.filepath = thumb_path
set_colorspace(inew, "Linear")
inew.pixels.foreach_set(tempBuffer)
bpy.context.view_layer.update()
if thumbnailWidth < imageWidth:
inew.scale(thumbnailWidth, thumbnailHeight)
img_save_as(inew, filepath=inew.filepath)
def find_color_mode(image):
if not isinstance(image, bpy.types.Image):
@@ -257,8 +320,7 @@ def can_erase_alpha(na):
alpha = na[3::4]
alpha_sum = alpha.sum()
if alpha_sum == alpha.size:
print("image can have alpha erased")
# print(alpha_sum, alpha.size)
bk_logger.info("image can have alpha erased")
return alpha_sum == alpha.size
@@ -269,9 +331,8 @@ def is_image_black(na):
rgbsum = r.sum() + g.sum() + b.sum()
# print('rgb sum', rgbsum, r.sum(), g.sum(), b.sum())
if rgbsum == 0:
print("image can have alpha channel dropped")
bk_logger.info("image can have alpha channel dropped")
return rgbsum == 0
@@ -284,7 +345,7 @@ def is_image_bw(na):
gb_equal = g == b
rgbequal = rg_equal.all() and gb_equal.all()
if rgbequal:
print("image is black and white, can have channels reduced")
bk_logger.info("image is black and white, can have channels reduced")
return rgbequal
@@ -326,7 +387,6 @@ def numpytoimage(a, iname, width=0, height=0, channels=3):
i = None
for image in bpy.data.images:
# print(image.name[:len(iname)],iname, image.size[0],a.shape[0],image.size[1],a.shape[1])
if (
image.name[: len(iname)] == iname
and image.size[0] == width
@@ -352,7 +412,7 @@ def numpytoimage(a, iname, width=0, height=0, channels=3):
# a = a.repeat(channels)
# a[3::4] = 1
i.pixels.foreach_set(a) # this gives big speedup!
print("\ntime " + str(time.time() - t))
bk_logger.info("\ntime " + str(time.time() - t))
return i
@@ -363,7 +423,6 @@ def imagetonumpy_flat(i):
width = i.size[0]
height = i.size[1]
# print(i.channels)
size = width * height * i.channels
na = numpy.empty(size, numpy.float32)
@@ -374,7 +433,6 @@ def imagetonumpy_flat(i):
# na = na.reshape(height, width, i.channels)
# na = na.swapaxnes(0, 1)
# print('\ntime of image to numpy ' + str(time.time() - t))
return na
@@ -385,7 +443,6 @@ def imagetonumpy(i):
width = i.size[0]
height = i.size[1]
# print(i.channels)
size = width * height * i.channels
na = np.empty(size, np.float32)
@@ -396,7 +453,6 @@ def imagetonumpy(i):
na = na.reshape(height, width, i.channels)
na = na.swapaxes(0, 1)
# print('\ntime of image to numpy ' + str(time.time() - t))
return na
@@ -424,9 +480,9 @@ def get_rgb_mean(i):
gmean = g.mean()
bmean = b.mean()
rmedian = numpy.median(r)
gmedian = numpy.median(g)
bmedian = numpy.median(b)
# rmedian = numpy.median(r)
# gmedian = numpy.median(g)
# bmedian = numpy.median(b)
# return(rmedian,gmedian, bmedian)
return (rmean, gmean, bmean)
@@ -514,15 +570,13 @@ def check_nmap_ogl_vs_dx(i, mask=None, generated_test_images=False):
ogl_std = ogl.std()
dx_std = dx.std()
# print(mean_ogl, mean_dx)
# print(max_ogl, max_dx)
print(ogl_std, dx_std)
print(i.name)
bk_logger.info("OpenGL std: %s, DirectX std: %s", ogl_std, dx_std)
bk_logger.info("Image name: %s", i.name)
# if abs(mean_ogl) > abs(mean_dx):
if abs(ogl_std) > abs(dx_std):
print("this is probably a DirectX texture")
bk_logger.info("this is probably a DirectX texture")
else:
print("this is probably an OpenGL texture")
bk_logger.info("this is probably an OpenGL texture")
if generated_test_images:
# red_x_comparison_img = red_x_comparison_img.swapaxes(0,1)
@@ -584,9 +638,9 @@ def make_possible_reductions_on_image(
# setup image depth, 8 or 16 bit.
# this should normally divide depth with number of channels, but blender always states that number of channels is 4, even if there are only 3
print(teximage.name)
print(teximage.depth)
print(teximage.channels)
bk_logger.info("Image name: %s", teximage.name)
bk_logger.info("Image depth: %s", teximage.depth)
bk_logger.info("Image channels: %s", teximage.channels)
bpy.context.scene.display_settings.display_device = "None"
@@ -594,16 +648,16 @@ def make_possible_reductions_on_image(
ims.color_mode = find_color_mode(teximage)
# image_depth = str(max(min(int(teximage.depth / 3), 16), 8))
print("resulting depth set to:", image_depth)
bk_logger.info("resulting depth set to: %s", image_depth)
fp = input_filepath
if do_reductions:
na = imagetonumpy_flat(teximage)
if can_erase_alpha(na):
print(teximage.file_format)
bk_logger.info("Image file format: %s", teximage.file_format)
if teximage.file_format == "PNG":
print("changing type of image to JPG")
bk_logger.info("Changing type of image to JPG")
base, ext = os.path.splitext(fp)
teximage["original_extension"] = ext
@@ -0,0 +1,265 @@
# ##### 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 __future__ import annotations
import logging
from dataclasses import dataclass, field
import bpy
import rna_keymap_ui
bk_logger = logging.getLogger(__name__)
@dataclass
class KeyMapItemDef:
"""Description of a keymap item we want to register."""
idname: str
type: str
value: str
shift: bool = False
ctrl: bool = False
alt: bool = False
oskey: bool = False
key_modifier: str = "NONE"
properties: dict[str, object] = field(default_factory=dict)
@dataclass
class KeyMapDef:
"""Description of a keymap with its items."""
name: str
space_type: str
region_type: str = "WINDOW"
items: list[KeyMapItemDef] = field(default_factory=list)
# Default bindings we ship. Users can edit these in Preferences.
DEFAULT_KEYMAP_ITEMS: list[KeyMapItemDef] = [
KeyMapItemDef(
idname="view3d.run_assetbar_fix_context",
type="SEMI_COLON",
value="PRESS",
properties={"keep_running": False, "do_search": False},
),
KeyMapItemDef(
idname="wm.blenderkit_menu_rating_upload",
type="R",
value="PRESS",
),
]
DEFAULT_KEYMAPS: list[KeyMapDef] = [
# Register into the standard "Window" keymap so Blender shows it in the main tree.
KeyMapDef(
name="Window", # must be windows otherwise blender will not show it in the default keymap
space_type="EMPTY",
region_type="WINDOW",
items=DEFAULT_KEYMAP_ITEMS,
),
]
# Store only the keymap items we create so we can clean them up without touching user overrides.
_registered_keymaps: list[
tuple[bpy.types.KeyConfig, bpy.types.KeyMap, bpy.types.KeyMapItem]
] = []
def _keymap_has_item(km: bpy.types.KeyMap, idname: str) -> bpy.types.KeyMapItem | None:
for item in km.keymap_items:
if item.idname == idname:
return item
return None
def _find_in_keyconfig(
keyconfig: bpy.types.KeyConfig, idname: str
) -> bpy.types.KeyMapItem | None:
for km in keyconfig.keymaps:
kmi = _keymap_has_item(km, idname)
if kmi:
return kmi
return None
def register_keymaps(custom_keymaps: list[KeyMapDef] | None = None) -> None:
"""Register keymaps for the add-on.
Args:
custom_keymaps: Optional iterable of KeyMapDef if callers want to override defaults.
"""
wm = bpy.context.window_manager
if not wm:
bk_logger.warning("Unable to register keymaps: no window manager available")
return
kc_addon = wm.keyconfigs.addon
kc_user = wm.keyconfigs.user
if not kc_addon:
bk_logger.warning("Unable to register keymaps: no add-on keyconfig available")
return
bk_logger.debug("Registering keymaps for BlenderKit add-on")
keymaps = list(custom_keymaps) if custom_keymaps is not None else DEFAULT_KEYMAPS
for km_def in keymaps:
# If the user already has a custom binding in their keyconfig, don't recreate it.
if kc_user and _find_in_keyconfig(kc_user, km_def.items[0].idname):
bk_logger.debug(
f"User keyconfig already has binding for {km_def.items[0].idname}; leaving user override intact"
)
continue
km = kc_addon.keymaps.find(
km_def.name, space_type=km_def.space_type, region_type=km_def.region_type
)
if km is None:
bk_logger.debug(
f"Keymap {km_def.name} not found in {kc_addon.name}, creating new one"
)
km = kc_addon.keymaps.new(
name=km_def.name,
space_type=km_def.space_type,
region_type=km_def.region_type,
)
for item_def in km_def.items:
if _keymap_has_item(km, item_def.idname):
bk_logger.debug(
f"Keymap {km_def.name} in {kc_addon.name} already has item {item_def.idname}, skipping"
)
continue
bk_logger.debug(
f"Adding keymap item {item_def.idname} to keymap {km_def.name} in {kc_addon.name}"
)
kmi = km.keymap_items.new(
idname=item_def.idname,
type=item_def.type,
value=item_def.value,
shift=item_def.shift,
ctrl=item_def.ctrl,
alt=item_def.alt,
oskey=item_def.oskey,
key_modifier=item_def.key_modifier,
)
for prop_name, prop_value in item_def.properties.items():
bk_logger.debug(
f"Setting property {prop_name}={prop_value} on keymap item {item_def.idname} in {kc_addon.name}"
)
setattr(kmi.properties, prop_name, prop_value)
_registered_keymaps.append((kc_addon, km, kmi))
def unregister_keymaps() -> None:
if not _registered_keymaps:
return
for kc, km, kmi in _registered_keymaps:
try:
km.keymap_items.remove(kmi)
except RuntimeError:
# Already removed by user; ignore.
pass
_registered_keymaps.clear()
def get_keymap_item(idname: str) -> bpy.types.KeyMapItem | None:
"""Return the current keymap item for the given operator.
Prefers the user's key configuration (where edits are stored) and falls back to the
add-on keyconfig defaults.
"""
wm = bpy.context.window_manager
if not wm:
return None
for cfg in (wm.keyconfigs.user, wm.keyconfigs.addon):
if cfg:
kmi = _find_in_keyconfig(cfg, idname)
if kmi:
return kmi
return None
def format_keymap_item(kmi: bpy.types.KeyMapItem) -> str:
"""Return a human readable shortcut label for a KeyMapItem."""
parts = []
if kmi.ctrl:
parts.append("Ctrl")
if kmi.alt:
parts.append("Alt")
if kmi.shift:
parts.append("Shift")
if kmi.oskey:
parts.append("Cmd")
if kmi.key_modifier and kmi.key_modifier != "NONE":
parts.append(kmi.key_modifier.replace("_", " ").title())
parts.append(kmi.type.replace("_", " ").title())
return "+".join(parts)
def get_shortcut_label(idname: str, fallback: str = "") -> str:
"""Return a formatted shortcut string for the operator if available."""
kmi = get_keymap_item(idname)
if not kmi:
return fallback
return format_keymap_item(kmi)
def _find_km_and_kmi(
keyconfig: bpy.types.KeyConfig, idname: str
) -> tuple[bpy.types.KeyMap, bpy.types.KeyMapItem] | None:
if not keyconfig:
return None
for km in keyconfig.keymaps:
kmi = _keymap_has_item(km, idname)
if kmi:
return km, kmi
return None
def draw_keymap(self, context):
layout = self.layout
wm = context.window_manager
kc_addon = wm.keyconfigs.addon
kc_user = wm.keyconfigs.user
if not kc_addon:
return
box = layout.box()
box.label(text="BlenderKit Keymaps")
for item_def in DEFAULT_KEYMAP_ITEMS:
# Prefer user override if available, otherwise show addon default.
entry = _find_km_and_kmi(kc_user, item_def.idname) if kc_user else None
source_kc = kc_user if entry else kc_addon
km, kmi = (
entry
if entry
else _find_km_and_kmi(kc_addon, item_def.idname) or (None, None)
)
if not km or not kmi:
continue
rna_keymap_ui.draw_kmi([], source_kc, km, kmi, box, 0)
+3 -3
View File
@@ -32,11 +32,11 @@ class BlenderKitFormatter(logging.Formatter):
"""
EMOJIS = {
logging.DEBUG: "🐞",
logging.DEBUG: "🐞 ",
logging.INFO: "",
logging.WARNING: "⚠️ ",
logging.ERROR: "",
logging.CRITICAL: "🔥",
logging.ERROR: " ",
logging.CRITICAL: "🔥 ",
}
def format(self, record):
@@ -9,10 +9,12 @@ The original method is then called from the new method, with the same arguments,
import json
import os
import time
import re
import logging
from . import icons
from . import icons, version_compare
import blf
import bpy
import bl_pkg.bl_extension_ui as exui
from bpy.props import IntProperty, StringProperty
@@ -23,6 +25,57 @@ EXTENSIONS_API_URL = "https://www.blenderkit.com/api/v1/extensions/"
bk_logger = logging.getLogger(__name__)
# Per-draw-cycle caching for ensure_repo_cache() to avoid repeated
# filesystem stat calls when drawing hundreds of extension items.
_repo_cache_last_check: float = 0.0
_repo_cache_last_result: bool = False
_REPO_CACHE_CHECK_INTERVAL: float = 3.0 # seconds between filesystem checks
# Cached repository reference to avoid iterating repos per item.
_cached_repository = None
_cached_repository_time: float = 0.0
_REPO_LOOKUP_INTERVAL: float = 5.0 # seconds between repo lookups
# Cache for price padding strings to avoid repeated blf.dimensions() calls.
_price_padding_cache: dict = {}
def get_perfect_price_padding(price_str: str, target_length: int = 70) -> str:
"""Generate a padding string to align price text nicely in the UI."""
cache_key = (price_str, target_length)
cached = _price_padding_cache.get(cache_key)
if cached is not None:
return cached
spaces = [
(19, "\u2003"), # em space = U+2003 > 19 units
(2, "\u200a"), # hair space = U+200A > 2 units
(3, "\u2009"), # 6 em space = U+2009 > 3 units
(5, "\u2005"), # 4 em space = U+2005 > 5 units
(6, "\u2004"), # 3 em space = U+2004 > 6 units
]
out = ""
size = blf.dimensions(0, price_str)
w_size = size[0]
final_size = target_length - w_size
if final_size <= 0:
_price_padding_cache[cache_key] = out
return out
while w_size < target_length:
for spc_len, spc_char in spaces:
if w_size + spc_len <= target_length:
out += spc_char
w_size += spc_len
break
else:
break # No suitable space found, exit loop
# double check if we are exporting only white spaces to prevent issues
if re.fullmatch(r"\s*", out) is None:
_price_padding_cache[cache_key] = ""
return ""
_price_padding_cache[cache_key] = out
return out
# --- New Modal Operator ---
class BK_OT_buy_extension_and_watch(Operator):
@@ -278,23 +331,47 @@ def extension_draw_item_blenderkit(
if pkg_block is not None:
row_right.label(text="Blocked ")
elif is_installed:
if is_outdated:
# double check if we are truly outdated
# local repo can have a newer version than remote if user installed a dev version
# so we compare versions here again
local_older = version_compare.compare_versions(
item_local.version, item_remote.version
)
if local_older < 0:
props = row_right.operator("extensions.package_install", text="Update")
props.repo_index = repo_index
props.pkg_id = pkg_id
props.enable_on_install = is_enabled
elif local_older == 0:
row_right.label(text="Up to date ")
else:
props = row_right.operator(
"extensions.package_install",
text=f"Downgrade {item_remote.version}",
)
props.repo_index = repo_index
props.pkg_id = pkg_id
props.enable_on_install = is_enabled
else:
### BlenderKit specific code
# blenderkit logo icon
pcoll = icons.icon_collections["main"]
icon_value = pcoll["logo"].icon_id
# row.label(text="", icon_value=icon_value)
# only enable install for those for whom it's available
if bk_cache_pkg is not None:
can_download_value = bk_cache_pkg.get("can_download")
is_for_sale_flag = bk_cache_pkg.get("is_for_sale") is True
is_free_flag = bk_cache_pkg.get("is_free") is True
# special case for blenderkit addon itself
if pkg_id == "blenderkit":
can_download_value = True
# Free , purchased and subscribed add-ons, probably also private add-ons
if bk_cache_pkg.get("can_download") is True:
if can_download_value is True:
# if the addon is also for sale, it means the user purchased it and we write "install purchased"
if bk_cache_pkg.get("is_for_sale") is True:
if is_for_sale_flag:
props = row_right.operator(
"extensions.package_install",
text="Install purchased",
@@ -309,14 +386,13 @@ def extension_draw_item_blenderkit(
props.repo_index = repo_index
props.pkg_id = pkg_id
# Full plan addons
elif not bk_cache_pkg.get("is_free") and not bk_cache_pkg.get(
"is_for_sale"
# Free addon but limited to full plan
elif (can_download_value == "Rejected in this plan") or (
not is_free_flag and not is_for_sale_flag
):
# open website to subscribe
props = row_right.operator(
"wm.url_open",
text="Subscribe to Full Plan",
text="Requires Full Plan",
icon_value=icon_value,
)
props.url = "https://www.blenderkit.com/plans/pricing/"
@@ -324,9 +400,17 @@ def extension_draw_item_blenderkit(
# Paid addons get a buy button and lead to their website link
else:
# Use the new modal operator
base_price_value = bk_cache_pkg.get("user_price")
if base_price_value in {None, "", "None"}:
base_price_value = bk_cache_pkg.get("base_price")
if base_price_value in {None, "", "None"}:
buy_label = "Buy online"
else:
pad_str = get_perfect_price_padding(base_price_value)
buy_label = f"Buy online {pad_str}${base_price_value}"
props = row_right.operator(
BK_OT_buy_extension_and_watch.bl_idname, # Use bl_idname
text=f"Buy online ${bk_cache_pkg.get('base_price')}",
text=buy_label,
icon_value=icon_value,
)
props.url = bk_cache_pkg.get("website", "") # Pass URL
@@ -528,12 +612,46 @@ def get_repository_by_url(url: str):
return None
def get_blenderkit_repository_cached():
"""Get BlenderKit repository with time-based caching to avoid iterating repos per item."""
global _cached_repository, _cached_repository_time
now = time.time()
if (
now - _cached_repository_time < _REPO_LOOKUP_INTERVAL
and _cached_repository is not None
):
# Verify the cached reference is still valid
try:
_ = _cached_repository.remote_url
return _cached_repository
except ReferenceError:
pass
_cached_repository = get_repository_by_url(EXTENSIONS_API_URL)
_cached_repository_time = now
return _cached_repository
def clear_repo_cache():
"""Clear the repository cache."""
global _repo_cache_last_check, _repo_cache_last_result
wm = bpy.context.window_manager
cache_key = "blenderkit_extensions_repo_cache"
if cache_key in wm:
del wm[cache_key]
# Reset throttle so next ensure_repo_cache() does a fresh check
_repo_cache_last_check = 0.0
_repo_cache_last_result = False
def _sanitize_pkg_for_cache(pkg):
"""Keep values stored as string to prevent C overflow."""
sanitized = {}
for k, v in pkg.items():
if isinstance(v, bool):
sanitized[k] = v
else:
sanitized[k] = str(v)
return sanitized
def ensure_repo_cache():
@@ -541,13 +659,28 @@ def ensure_repo_cache():
Reads the .json file blender stores in \extensions\www_blenderkit_com\.blender_ext
and parses it to a dict from json, we can use it then for drawing purposes and have the extra data BlenderKit api provides.
Checks the modification time of the cache file and reloads it if necessary.
Uses a time-based throttle so filesystem checks happen at most once per
_REPO_CACHE_CHECK_INTERVAL seconds, avoiding repeated stat calls when this
function is called per-item during extension list drawing.
"""
global _repo_cache_last_check, _repo_cache_last_result
now = time.time()
if now - _repo_cache_last_check < _REPO_CACHE_CHECK_INTERVAL:
# Consume the reload signal so only the first caller in the throttle window
# sees True — prevents the redraw timer from being registered on every draw tick.
result = _repo_cache_last_result
_repo_cache_last_result = False
return result
_repo_cache_last_check = now
reloaded_flag = False # Track if we actually reloaded
wm = bpy.context.window_manager
cache_key = "blenderkit_extensions_repo_cache"
mtime_key = "blenderkit_extensions_repo_cache_mtime"
blenderkit_repository = get_repository_by_url(EXTENSIONS_API_URL)
blenderkit_repository = get_blenderkit_repository_cached()
if blenderkit_repository is None:
# If repo doesn't exist, clear cache if it exists in window manager
if cache_key in wm:
@@ -566,7 +699,9 @@ def ensure_repo_cache():
current_mtime = None
try:
if os.path.exists(cache_file):
current_mtime = os.path.getmtime(cache_file)
# Use int to avoid float precision loss when stored in Blender IDProperty
# (IDProperties use single-precision floats, os.path.getmtime() returns double)
current_mtime = int(os.path.getmtime(cache_file))
except OSError as e: # Handle potential race condition or permission issue
bk_logger.exception("Could not get modification time for %s.", cache_file)
# Clear cache if we can't verify its freshness? Safer approach.
@@ -621,15 +756,13 @@ def ensure_repo_cache():
for pkg in data.get(
"data", []
): # Handle case where 'data' key might be missing
if (
isinstance(pkg, dict) and "id" in pkg
): # Ensure pkg is a dict and 'id' key exists
new_cache[pkg["id"][:32]] = pkg
else:
if not (isinstance(pkg, dict) and "id" in pkg):
bk_logger.info("Skipping invalid package entry in cache: %s.", pkg)
continue
new_cache[pkg["id"][:32]] = _sanitize_pkg_for_cache(pkg)
wm[cache_key] = new_cache
wm[mtime_key] = current_mtime # Update mtime only on successful load
wm[mtime_key] = current_mtime # Stored as int to survive IDProperty round-trip
reloaded_flag = True # Mark that we reloaded successfully
@@ -652,9 +785,46 @@ def ensure_repo_cache():
if mtime_key in wm:
del wm[mtime_key]
_repo_cache_last_result = reloaded_flag
return reloaded_flag # Return whether cache was actually reloaded
def update_cache_with_asset_prices(assets):
"""Copy addon pricing info from search assets into the extensions cache."""
if not assets:
return
wm = bpy.context.window_manager
cache_key = "blenderkit_extensions_repo_cache"
if cache_key not in wm:
wm[cache_key] = {}
cache = wm[cache_key]
for asset in assets:
if not isinstance(asset, dict):
continue
if asset.get("assetType") != "addon":
continue
dict_params = asset.get("dictParameters") or {}
extension_id = dict_params.get("extensionId") or asset.get("extensionId")
if not extension_id:
continue
cache_key_entry = extension_id[:32]
cache_entry = cache.get(cache_key_entry)
if cache_entry is None:
cache_entry = {}
cache[cache_key_entry] = cache_entry
base_price = asset.get("basePrice")
if base_price not in {None, "", "None"}:
cache_entry["base_price"] = str(base_price)
user_price = asset.get("userPrice")
if user_price not in {None, "", "None"}:
cache_entry["user_price"] = str(user_price)
def ensure_repo_order():
"""Ensure order of repositories in Blender's preferences."""
# get the blenderkit repository
+99 -5
View File
@@ -16,6 +16,8 @@
#
# ##### END GPL LICENSE BLOCK #####
from __future__ import annotations
import getpass
import logging
import os
@@ -38,6 +40,7 @@ BLENDERKIT_OAUTH_LANDING_URL = f"{global_vars.SERVER}/oauth-landing"
BLENDERKIT_PLANS_URL = f"{global_vars.SERVER}/plans/pricing"
BLENDERKIT_REPORT_URL = f"{global_vars.SERVER}/usage_report"
BLENDERKIT_USER_ASSETS_URL = f"{global_vars.SERVER}/my-assets"
BLENDERKIT_ASSETS_EDIT_URL = f"{global_vars.SERVER}/asset-edit"
BLENDERKIT_MANUAL_URL = "https://youtu.be/0P8ZjfbUjeA"
BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/upload/"
BLENDERKIT_PRINTABLE_UPLOAD_INSTRUCTIONS_URL = (
@@ -54,6 +57,18 @@ BLENDERKIT_LOGIN_URL = f"{global_vars.SERVER}/accounts/login"
BLENDERKIT_SIGNUP_URL = f"{global_vars.SERVER}/accounts/register"
WINDOWS_PATH_LIMIT = 250
ASSET_LIBRARY_NAME = "BlenderKit"
def _normalize_path(path_value: str | None) -> str:
"""Return an absolute, normalized path for comparisons; blank string if invalid."""
if not path_value:
return ""
try:
resolved = bpy.path.abspath(path_value)
except Exception:
resolved = path_value
return os.path.normpath(os.path.abspath(resolved))
def cleanup_old_directories():
@@ -63,7 +78,7 @@ def cleanup_old_directories():
try:
shutil.rmtree(orig_temp)
except Exception as e:
bk_logger.error(f"could not delete old temp directory: {e}")
bk_logger.error("could not delete old temp directory: %s", e)
def find_in_local(text=""):
@@ -134,11 +149,11 @@ def get_temp_dir(subdir=None):
cleanup_old_directories()
except Exception as e:
reports.add_report("Cache directory not found. Resetting Cache directory path.")
bk_logger.warning(f"due to exception: {e}")
bk_logger.warning("due to exception: %s", e)
p = default_global_dict()
if p == user_preferences.global_dir:
message = "Global dir was already default, plese set a global directory in addon preferences to a dir where you have write permissions."
message = "Global dir was already default, please set a global directory in addon preferences to a dir where you have write permissions."
reports.add_report(message)
return None
user_preferences.global_dir = p
@@ -158,6 +173,7 @@ def get_download_dirs(asset_type):
"nodegroup": "nodegroups",
"printable": "printables",
"addon": "addons",
"author": "authors",
}
dirs = []
@@ -196,6 +212,84 @@ def get_download_dirs(asset_type):
return dirs
def ensure_asset_library_path(
global_dir: str | None = None, previous_global_dir: str | None = None
):
"""Ensure Blender's asset library list contains the BlenderKit library path.
- Creates the library entry when missing.
- Updates an existing entry if the global directory changes.
- Reuses a library that already points to the target path even if the name differs.
"""
if bpy.app.background:
return
filepaths = getattr(bpy.context.preferences, "filepaths", None)
asset_libraries = getattr(filepaths, "asset_libraries", None) if filepaths else None
if asset_libraries is None:
return
target_path = _normalize_path(
global_dir
or bpy.context.preferences.addons[__package__].preferences.global_dir # type: ignore
)
if not target_path:
return
os.makedirs(target_path, exist_ok=True)
previous_path = _normalize_path(previous_global_dir) if previous_global_dir else ""
if previous_path:
for lib in asset_libraries:
if _normalize_path(lib.path) == previous_path:
lib.path = target_path
existing = (
asset_libraries.get(ASSET_LIBRARY_NAME)
if hasattr(asset_libraries, "get")
else None
)
if existing is not None:
if _normalize_path(existing.path) != target_path:
existing.path = target_path
return existing
for lib in asset_libraries:
if _normalize_path(lib.path) == target_path:
try:
lib.name = ASSET_LIBRARY_NAME
except Exception:
pass
return lib
if hasattr(asset_libraries, "new"):
asset_libraries.new(name=ASSET_LIBRARY_NAME, directory=target_path)
else:
try:
bpy.ops.preferences.asset_library_add(directory=target_path)
# Operator names the library after the directory basename, rename it.
for lib in asset_libraries:
if (
_normalize_path(lib.path) == target_path
and lib.name != ASSET_LIBRARY_NAME
):
lib.name = ASSET_LIBRARY_NAME
break
except Exception as e:
logging.getLogger(__name__).warning(
"Failed to add asset library: %s. "
"Please add it manually in Preferences > File Paths",
e,
)
return None
return (
asset_libraries.get(ASSET_LIBRARY_NAME)
if hasattr(asset_libraries, "get")
else None
)
def slugify(input: str) -> str:
"""
Slugify converts a string to a URL-friendly slug.
@@ -423,10 +517,10 @@ def delete_asset_debug(asset_data):
continue
try:
shutil.rmtree(asset_dir)
bk_logger.info(f"deleted {asset_dir}")
bk_logger.info("deleted asset dir: %s", asset_dir)
except Exception as err:
e = sys.exc_info()[0]
bk_logger.error(f"{e} - {err}")
bk_logger.error("%s - %s", e, err)
def get_clean_filepath():
-576
View File
@@ -1,576 +0,0 @@
# This file is @generated by PDM.
# It is not intended for manual editing.
[metadata]
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:e66407ebe96aea59816d07e7d141f2564da883f1073e87bb85c544e60644b85a"
[[metadata.targets]]
requires_python = ">=3.10,<3.13"
[[package]]
name = "bandit"
version = "1.8.6"
requires_python = ">=3.9"
summary = "Security oriented static analyser for python code."
groups = ["dev"]
dependencies = [
"PyYAML>=5.3.1",
"colorama>=0.3.9; platform_system == \"Windows\"",
"rich",
"stevedore>=1.20.0",
]
files = [
{file = "bandit-1.8.6-py3-none-any.whl", hash = "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0"},
{file = "bandit-1.8.6.tar.gz", hash = "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b"},
]
[[package]]
name = "black"
version = "25.9.0"
requires_python = ">=3.9"
summary = "The uncompromising code formatter."
groups = ["dev"]
dependencies = [
"click>=8.0.0",
"mypy-extensions>=0.4.3",
"packaging>=22.0",
"pathspec>=0.9.0",
"platformdirs>=2",
"pytokens>=0.1.10",
"tomli>=1.1.0; python_version < \"3.11\"",
"typing-extensions>=4.0.1; python_version < \"3.11\"",
]
files = [
{file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"},
{file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"},
{file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"},
{file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"},
{file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"},
{file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"},
{file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"},
{file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"},
{file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"},
{file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"},
{file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"},
{file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"},
{file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"},
{file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"},
]
[[package]]
name = "certifi"
version = "2024.8.30"
requires_python = ">=3.6"
summary = "Python package for providing Mozilla's CA Bundle."
groups = ["default"]
files = [
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.0"
requires_python = ">=3.7.0"
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
groups = ["default"]
files = [
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"},
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"},
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"},
{file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"},
{file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"},
{file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"},
{file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"},
{file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"},
{file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"},
{file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"},
{file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"},
{file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"},
{file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"},
]
[[package]]
name = "click"
version = "8.3.0"
requires_python = ">=3.10"
summary = "Composable command line interface toolkit"
groups = ["dev"]
dependencies = [
"colorama; platform_system == \"Windows\"",
]
files = [
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
]
[[package]]
name = "colorama"
version = "0.4.6"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Cross-platform colored terminal text."
groups = ["dev"]
marker = "platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "docstring-parser-fork"
version = "0.0.14"
requires_python = ">=3.8"
summary = "Parse Python docstrings in reST, Google and Numpydoc format"
groups = ["dev"]
files = [
{file = "docstring_parser_fork-0.0.14-py3-none-any.whl", hash = "sha256:4c544f234ef2cc2749a3df32b70c437d77888b1099143a1ad5454452c574b9af"},
{file = "docstring_parser_fork-0.0.14.tar.gz", hash = "sha256:a2743a63d8d36c09650594f7b4ab5b2758fee8629dcf794d1b221b23179baa5c"},
]
[[package]]
name = "idna"
version = "3.10"
requires_python = ">=3.6"
summary = "Internationalized Domain Names in Applications (IDNA)"
groups = ["default"]
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
[[package]]
name = "isort"
version = "7.0.0"
requires_python = ">=3.10.0"
summary = "A Python utility / library to sort Python imports."
groups = ["dev"]
files = [
{file = "isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1"},
{file = "isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187"},
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
requires_python = ">=3.10"
summary = "Python port of markdown-it. Markdown parsing, done right!"
groups = ["dev"]
dependencies = [
"mdurl~=0.1",
]
files = [
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
]
[[package]]
name = "mdurl"
version = "0.1.2"
requires_python = ">=3.7"
summary = "Markdown URL utilities"
groups = ["dev"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "mypy"
version = "1.13.0"
requires_python = ">=3.8"
summary = "Optional static typing for Python"
groups = ["dev"]
dependencies = [
"mypy-extensions>=1.0.0",
"tomli>=1.1.0; python_version < \"3.11\"",
"typing-extensions>=4.6.0",
]
files = [
{file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"},
{file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"},
{file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"},
{file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"},
{file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"},
{file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"},
{file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"},
{file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"},
{file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"},
{file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"},
{file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"},
{file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"},
{file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"},
{file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"},
{file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"},
{file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"},
{file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"},
{file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"},
{file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"},
{file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"},
{file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"},
{file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"},
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
requires_python = ">=3.8"
summary = "Type system extensions for programs checked with the mypy type checker."
groups = ["dev"]
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "numpy"
version = "1.26.4"
requires_python = ">=3.9"
summary = "Fundamental package for array computing in Python"
groups = ["default"]
files = [
{file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"},
{file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"},
{file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"},
{file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"},
{file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"},
{file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"},
{file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"},
{file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"},
{file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"},
{file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"},
{file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"},
{file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"},
{file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"},
{file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"},
{file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"},
{file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"},
{file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"},
{file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"},
{file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"},
{file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"},
{file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"},
{file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"},
{file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"},
{file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"},
{file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"},
]
[[package]]
name = "packaging"
version = "25.0"
requires_python = ">=3.8"
summary = "Core utilities for Python packages"
groups = ["dev"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "pathspec"
version = "0.12.1"
requires_python = ">=3.8"
summary = "Utility library for gitignore style pattern matching of file paths."
groups = ["dev"]
files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
name = "platformdirs"
version = "4.5.0"
requires_python = ">=3.10"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["dev"]
files = [
{file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"},
{file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"},
]
[[package]]
name = "pydoclint"
version = "0.7.3"
requires_python = ">=3.9"
summary = "A Python docstring linter that checks arguments, returns, yields, and raises sections"
groups = ["dev"]
dependencies = [
"click>=8.1.0",
"docstring-parser-fork>=0.0.12",
"tomli>=2.0.1; python_version < \"3.11\"",
]
files = [
{file = "pydoclint-0.7.3-py3-none-any.whl", hash = "sha256:a656b0e863565644670ded19a4b506450364e4f1f5e8ff7705d6ba8bb5a82982"},
{file = "pydoclint-0.7.3.tar.gz", hash = "sha256:3351d5eeb19f8831d992714f71f5ea1175af649503d39b9da0071445a4002138"},
]
[[package]]
name = "pygments"
version = "2.19.2"
requires_python = ">=3.8"
summary = "Pygments is a syntax highlighting package written in Python."
groups = ["dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[[package]]
name = "pytokens"
version = "0.2.0"
requires_python = ">=3.8"
summary = "A Fast, spec compliant Python 3.13+ tokenizer that runs on older Pythons."
groups = ["dev"]
files = [
{file = "pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8"},
{file = "pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43"},
]
[[package]]
name = "pyyaml"
version = "6.0.3"
requires_python = ">=3.8"
summary = "YAML parser and emitter for Python"
groups = ["dev"]
files = [
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "requests"
version = "2.32.3"
requires_python = ">=3.8"
summary = "Python HTTP for Humans."
groups = ["default"]
dependencies = [
"certifi>=2017.4.17",
"charset-normalizer<4,>=2",
"idna<4,>=2.5",
"urllib3<3,>=1.21.1",
]
files = [
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
]
[[package]]
name = "rich"
version = "14.2.0"
requires_python = ">=3.8.0"
summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
groups = ["dev"]
dependencies = [
"markdown-it-py>=2.2.0",
"pygments<3.0.0,>=2.13.0",
]
files = [
{file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
{file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
]
[[package]]
name = "ruff"
version = "0.14.1"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
groups = ["dev"]
files = [
{file = "ruff-0.14.1-py3-none-linux_armv6l.whl", hash = "sha256:083bfc1f30f4a391ae09c6f4f99d83074416b471775b59288956f5bc18e82f8b"},
{file = "ruff-0.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f6fa757cd717f791009f7669fefb09121cc5f7d9bd0ef211371fad68c2b8b224"},
{file = "ruff-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6191903d39ac156921398e9c86b7354d15e3c93772e7dbf26c9fcae59ceccd5"},
{file = "ruff-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed04f0e04f7a4587244e5c9d7df50e6b5bf2705d75059f409a6421c593a35896"},
{file = "ruff-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9e6cf6cd4acae0febbce29497accd3632fe2025c0c583c8b87e8dbdeae5f61"},
{file = "ruff-0.14.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fa2458527794ecdfbe45f654e42c61f2503a230545a91af839653a0a93dbc6"},
{file = "ruff-0.14.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:39f1c392244e338b21d42ab29b8a6392a722c5090032eb49bb4d6defcdb34345"},
{file = "ruff-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7382fa12a26cce1f95070ce450946bec357727aaa428983036362579eadcc5cf"},
{file = "ruff-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd0bf2be3ae8521e1093a487c4aa3b455882f139787770698530d28ed3fbb37c"},
{file = "ruff-0.14.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabcaa9ccf8089fb4fdb78d17cc0e28241520f50f4c2e88cb6261ed083d85151"},
{file = "ruff-0.14.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:747d583400f6125ec11a4c14d1c8474bf75d8b419ad22a111a537ec1a952d192"},
{file = "ruff-0.14.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5a6e74c0efd78515a1d13acbfe6c90f0f5bd822aa56b4a6d43a9ffb2ae6e56cd"},
{file = "ruff-0.14.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0ea6a864d2fb41a4b6d5b456ed164302a0d96f4daac630aeba829abfb059d020"},
{file = "ruff-0.14.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0826b8764f94229604fa255918d1cc45e583e38c21c203248b0bfc9a0e930be5"},
{file = "ruff-0.14.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cbc52160465913a1a3f424c81c62ac8096b6a491468e7d872cb9444a860bc33d"},
{file = "ruff-0.14.1-py3-none-win32.whl", hash = "sha256:e037ea374aaaff4103240ae79168c0945ae3d5ae8db190603de3b4012bd1def6"},
{file = "ruff-0.14.1-py3-none-win_amd64.whl", hash = "sha256:59d599cdff9c7f925a017f6f2c256c908b094e55967f93f2821b1439928746a1"},
{file = "ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44"},
{file = "ruff-0.14.1.tar.gz", hash = "sha256:1dd86253060c4772867c61791588627320abcb6ed1577a90ef432ee319729b69"},
]
[[package]]
name = "stevedore"
version = "5.5.0"
requires_python = ">=3.9"
summary = "Manage dynamic plugins for Python applications"
groups = ["dev"]
files = [
{file = "stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf"},
{file = "stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73"},
]
[[package]]
name = "tomli"
version = "2.3.0"
requires_python = ">=3.8"
summary = "A lil' TOML parser"
groups = ["dev"]
marker = "python_version < \"3.11\""
files = [
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"},
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"},
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"},
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"},
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"},
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"},
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"},
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"},
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"},
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"},
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"},
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"},
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"},
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"},
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"},
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"},
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"},
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"},
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"},
]
[[package]]
name = "types-requests"
version = "2.32.0.20241016"
requires_python = ">=3.8"
summary = "Typing stubs for requests"
groups = ["default"]
dependencies = [
"urllib3>=2",
]
files = [
{file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"},
{file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"},
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
requires_python = ">=3.9"
summary = "Backported and Experimental Type Hints for Python 3.9+"
groups = ["dev"]
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "urllib3"
version = "2.2.3"
requires_python = ">=3.8"
summary = "HTTP library with thread-safe connection pooling, file post, and more."
groups = ["default"]
files = [
{file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
{file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
]
@@ -42,9 +42,9 @@ def write_preferences_to_JSON(preferences: dict):
try:
with open(preferences_path, "w", encoding="utf-8") as s:
json.dump(preferences, s, ensure_ascii=False, indent=4)
bk_logger.info(f"Saved preferences to {preferences_path}")
bk_logger.info("Saved preferences to %s", preferences_path)
except Exception as e:
bk_logger.warning(f"Failed to save preferences: {e}")
bk_logger.warning("Failed to save preferences: %s", e)
def load_preferences_from_JSON():
@@ -100,6 +100,9 @@ def load_preferences_from_JSON():
user_preferences.unpack_files = prefs.get(
"unpack_files", user_preferences.unpack_files
)
user_preferences.write_asset_metadata = prefs.get(
"write_asset_metadata", user_preferences.write_asset_metadata
)
# GUI
user_preferences.show_on_start = prefs.get(
@@ -121,6 +124,9 @@ def load_preferences_from_JSON():
user_preferences.announcements_on_start = prefs.get(
"announcements_on_start", user_preferences.announcements_on_start
)
user_preferences.assetbar_follows_cursor = prefs.get(
"assetbar_follows_cursor", user_preferences.assetbar_follows_cursor
)
# NETWORK
user_preferences.client_port = prefs.get(
@@ -159,7 +165,7 @@ def load_preferences_from_JSON():
# IMPORT SETTINGS
user_preferences.resolution = prefs.get("resolution", user_preferences.resolution)
bk_logger.info(f"Successfully loaded preferences from {preferences_path}")
bk_logger.info("Successfully loaded preferences from %s", preferences_path)
user_preferences.preferences_lock = False
return prefs
@@ -177,7 +183,7 @@ def property_keep_preferences_updated(user_preferences, context):
try:
os.remove(preferences_path)
bk_logger.info(f"Deleted preferences file {preferences_path}")
bk_logger.info("Deleted preferences file %s", preferences_path)
except Exception as e:
bk_logger.error(f"Failed to delete preferences file {preferences_path}: {e}")
bk_logger.error("Failed to delete preferences file %s: %s", preferences_path, e)
utils.save_prefs(user_preferences, context)
@@ -1,105 +0,0 @@
[project]
requires-python = ">=3.10,<3.13" # in order to support numpy<2.0.0
dependencies = [
"requests>=2.18.4",
"types-requests>=2.31.0.5",
"numpy>=1.21.0,<2.0.0",
]
# these will not be included in build distributions
[dependency-groups]
dev = [
## dev dependencies
"black==25.9.0",
"isort==7.0.0",
"mypy==1.13.0",
## I would like to enable these in the future
"bandit>=1.8.2", # code is currently invalid for bandit
"ruff>=0.14.1", # code is currently invalid for ruff
"pydoclint>=0.7.3", # code is currently invalid for pydoclint
]
[tool.isort]
multi_line_output = 3
lines_after_imports = 2
skip = ["lib", "out", ".venv"]
profile = "black"
[tool.black]
target-version = ['py310']
include = '\.pyi?$'
exclude = '''
/(\.git|lib|out)/
'''
[tool.ruff]
target-version = "py310"
line-length = 120
include = ["pyproject.toml",
"**/*.py",
"*.py"
]
exclude = [
"lib",
"out",
"addon_updater.py",
"addon_updater_ops.py",
"_debug/**",
"_bck/**",
"*.tmp",
"__pycache__/**",
".venv/**",
"*.pyi",
"sentry_sdk",
]
ignore = [
"E501", # Line too long
]
[tool.ruff.format]
line-ending = "auto"
[tool.ruff.lint]
select = ["F", "E", "W", "C90", "I", "N", "D", "UP", "S1", "S2", "S3", "S5", "BLE", "FBT",
"B", "A", "C4", "COM", "DTZ", "T10", "FA", "ISC", "ICN", "LOG", "G", "INP", "PIE", "T20", "Q", "RSE", "RET", "SLF",
"SLOT", "SIM", "TID",
"TC001", "TC004", "TC005", "TC010",
"INT", "ARG", "FIX", "ERA", "PL", "TRY", "FLY", "PERF", "RUF"
]
ignore = [
"D105", # https://docs.astral.sh/ruff/rules/undocumented-magic-method/
"D107", # https://docs.astral.sh/ruff/rules/undocumented-public-init/
"PLC0415", # https://docs.astral.sh/ruff/rules/import-outside-top-level/
"RET504", # https://docs.astral.sh/ruff/rules/unnecessary-assign/
]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.mypy]
exclude = ['test_*', 'out', 'lib']
disallow_untyped_globals = false # remove this in the future
[[tool.mypy.overrides]]
module = [
"bpy",
"bpy.*",
"bpy_extras",
"mathutils",
"addonutils",
"blf",
"gpu",
"gpu_extras.*"
]
ignore_missing_imports = true # Ignore missing type hints for bpy
[tool.pydoclint]
style = "google"
arg-type-hints-in-docstring = false
check-return-types = false
ignore-private-args = true
ignore-underscore-args = true
exclude = "\\.venv|\\sentry_sdk"
+14 -7
View File
@@ -36,7 +36,6 @@ from . import (
utils,
)
bk_logger = logging.getLogger(__name__)
@@ -170,7 +169,7 @@ class FastRateMenu(Operator, ratings_utils.RatingProperties):
else:
if bpy.context.view_layer.objects.active is not None:
ob = utils.get_active_model()
ad = ob.get("asset_data")
ad = utils.get_asset_data_from_ob(ob)
if ad:
self.asset_data = ad
self.asset_id = self.asset_data["id"]
@@ -184,8 +183,9 @@ class FastRateMenu(Operator, ratings_utils.RatingProperties):
self.img = ui.get_large_thumbnail_image(self.asset_data)
utils.img_to_preview(self.img, copy_original=True)
ratings_utils.ensure_rating(self.asset_id)
self.prefill_ratings()
if self.asset_type != "author":
ratings_utils.ensure_rating(self.asset_id)
self.prefill_ratings()
# Update last popup activity time to prevent shortcut interference
from . import ui_panels
@@ -223,6 +223,12 @@ class SetBookmark(bpy.types.Operator):
return True
def execute(self, context):
# Authors cannot be bookmarked
sr = search.get_search_results()
for r in sr:
if r.get("id") == self.asset_id and r.get("assetType") == "author":
return {"CANCELLED"}
rating = ratings_utils.get_rating_local(self.asset_id)
if rating is None:
rating = datas.AssetRating()
@@ -233,7 +239,7 @@ class SetBookmark(bpy.types.Operator):
ratings_utils.store_rating_local(
self.asset_id, rating_type="bookmarks", value=bookmark_value
)
client_lib.send_rating(self.asset_id, "bookmarks", str(bookmark_value))
client_lib.send_rating(self.asset_id, "bookmarks", bookmark_value)
return {"FINISHED"}
@@ -323,7 +329,7 @@ class RatingStarWidget(Gizmo):
def should_be_rated(ob) -> bool:
ad = ob.get("asset_data")
ad = utils.get_asset_data_from_ob(ob)
if ad is None:
return False
rating = ratings_utils.get_rating_local(ad["id"])
@@ -354,7 +360,8 @@ class RatingStarWidgetGroup(GizmoGroup):
ob = utils.get_active_model()
gz = self.gizmos.new(RatingStarWidget.bl_idname)
props = gz.target_set_operator("wm.blenderkit_menu_rating_upload")
props.asset_id = ob["asset_data"]["assetBaseId"]
ad = utils.get_asset_data_from_ob(ob)
props.asset_id = ad["assetBaseId"] if ad else ""
gz.color = 0.5, 0.5, 0.0
gz.alpha = 0.5
@@ -46,11 +46,11 @@ bk_logger = logging.getLogger(__name__)
def handle_get_rating_task(task: client_tasks.Task):
"""Handle incomming get_rating task by saving the results into global_vars."""
"""Handle incoming get_rating task by saving the results into global_vars."""
if task.status == "created":
return
if task.status == "error":
return bk_logger.warning(f"{task.task_type} task failed: {task.message}")
return bk_logger.warning("%s task failed: %s", task.task_type, task.message)
asset_id = task.data["asset_id"]
ratings = task.result["results"]
@@ -64,23 +64,23 @@ def handle_get_rating_task(task: client_tasks.Task):
def handle_get_ratings_task(task: client_tasks.Task):
"""Handle incomming get_ratings task. This is a special task used only by validators which fetches the ratings
"""Handle incoming get_ratings task. This is a special task used only by validators which fetches the ratings
in big batch right after the search results come into the Client. This is used only to signal problems in the
Goroutine which fetches the ratings. The individual ratings are then sent as normal 'get_rating' tasks.
"""
if task.status == "error": # only reason this task type exists right now
return bk_logger.warning(f"{task.task_type} task failed: {task.message}")
return bk_logger.warning("%s task failed: %s", task.task_type, task.message)
def handle_get_bookmarks_task(task: client_tasks.Task):
"""Handle incomming get_bookmarks task by saving the results into global_vars.
"""Handle incoming get_bookmarks task by saving the results into global_vars.
This is different from standard ratings - the results come from elastic search API
instead of ratings API.
"""
if task.status == "created":
return
if task.status == "error":
bk_logger.warning(f"Could not load bookmarks: {task.message}")
bk_logger.warning("Could not load bookmarks: %s", task.message)
return
for asset in task.result["results"]:
@@ -134,7 +134,7 @@ def ensure_rating(asset_id: str):
if rating is None:
client_lib.get_rating(asset_id)
return
if not rating.quality_fetched or rating.working_hours_fetched:
if not rating.quality_fetched or not rating.working_hours_fetched:
client_lib.get_rating(asset_id)
@@ -419,7 +419,7 @@ class RatingProperties(PropertyGroup):
whs = "0"
self.rating_work_hours_ui = whs
except Exception as e:
bk_logger.warning(f"exception setting rating_work_hours_ui: {e}")
bk_logger.warning("exception setting rating_work_hours_ui: %s", e)
self.rating_work_hours = round(rating.working_hours, 2)
self.rating_work_hours_lock = False
@@ -36,12 +36,12 @@ reports = []
def add_report(
text: str = "",
timeout: float = -1,
type: Literal["INFO", "ERROR", "VALIDATOR"] = "INFO",
type: Literal["INFO", "ERROR", "VALIDATOR", "WARNING"] = "INFO",
details: str = "",
) -> None:
"""Add text report to GUI. Function checks for same reports and make them longer by the timeout.
Also log the text and details into the console with levels: ERROR=RED, INFO=GREEN, VALIDATOR=BLUE.
When timeout is not specified, default 15s will be used for ERROR, 5s for INFO/VALIDATOR.
Also log the text and details into the console with levels: ERROR=RED, INFO=GREEN, VALIDATOR=BLUE, WARNING=YELLOW.
When timeout is not specified, default 15s will be used for ERROR, 5s for INFO/VALIDATOR/WARNING.
"""
global reports
text = text.strip()
@@ -72,6 +72,9 @@ def add_report(
elif type == "VALIDATOR":
bk_logger.info(full_message, stacklevel=2)
color = colors.BLUE
elif type == "WARNING":
bk_logger.warning(full_message, stacklevel=2)
color = colors.YELLOW
# check for same reports and just make them longer by the timeout.
for old_report in reports:
@@ -113,7 +116,7 @@ class Report:
try:
reports.remove(self)
except Exception as e:
bk_logger.warning(f"exception in fading: {e}")
bk_logger.warning("exception in fading: %s", e)
def draw(self, x, y):
if (
@@ -17,16 +17,12 @@
# ##### END GPL LICENSE BLOCK #####
import json
import logging
import os
import sys
import time
import bpy
from . import paths, utils
bk_logger = logging.getLogger(__name__)
+647 -48
View File
@@ -20,7 +20,6 @@ import copy
import json
import logging
import math
from functools import lru_cache
import os
import re
import unicodedata
@@ -35,12 +34,14 @@ from bpy.types import Operator
from . import (
asset_bar_op,
categories,
client_lib,
client_tasks,
comments_utils,
datas,
download,
global_vars,
icons,
image_utils,
paths,
reports,
@@ -50,11 +51,340 @@ from . import (
utils,
)
if bpy.app.version >= (4, 2, 0):
from . import override_extension_draw
else:
override_extension_draw = None
bk_logger = logging.getLogger(__name__)
MAX_PAGE_SIZE = 80
"""Maximum number of assets to fetch per search page."""
search_tasks = {}
# ------------------------------------------------------------------
# Active filter helpers (per tab)
# ------------------------------------------------------------------
def _ensure_tab_filters(tab: dict) -> list[dict]:
if "active_filters" not in tab:
tab["active_filters"] = []
if tab["active_filters"] is None:
tab["active_filters"] = []
return tab["active_filters"]
def get_active_filters(tab: Optional[dict] = None) -> list[dict]:
tab = tab or get_active_tab()
return copy.deepcopy(_ensure_tab_filters(tab))
PANEL_FILTER_TERMS: set[str] = {
"style",
"geometry_nodes",
"design_year",
"polycount",
"texture_resolution",
"file_size",
"condition",
"animated",
"free_only",
"bookmarks",
"quality_limit",
"license",
"blender_version",
"order",
"category",
}
def _collect_panel_filters() -> list[dict]:
"""Translate UI filter state into active filter chip descriptors."""
ui_props = bpy.context.window_manager.blenderkitUI
sprops = utils.get_search_props()
preferences = bpy.context.preferences.addons[__package__].preferences
panel_filters: list[dict] = []
if ui_props.free_only:
panel_filters.append(
{"term": "free_only", "value": "true", "label": "Free first"}
)
if ui_props.search_bookmarks:
panel_filters.append(
{"term": "bookmarks", "value": "true", "label": "Bookmarks"}
)
if getattr(sprops, "search_style", "ANY") != "ANY":
panel_filters.append(
{
"term": "style",
"value": sprops.search_style,
"label": sprops.search_style.capitalize(),
}
)
if getattr(sprops, "search_condition", "UNSPECIFIED") != "UNSPECIFIED":
panel_filters.append(
{
"term": "condition",
"value": sprops.search_condition,
"label": sprops.search_condition.capitalize(),
}
)
if getattr(sprops, "search_design_year", False):
label = f"{sprops.search_design_year_min}-{sprops.search_design_year_max}"
panel_filters.append(
{
"term": "design_year",
"value": label,
"label": label,
}
)
if getattr(sprops, "search_polycount", False):
label = f"Poly {sprops.search_polycount_min}-{sprops.search_polycount_max}"
panel_filters.append(
{
"term": "polycount",
"value": label,
"label": label,
}
)
if getattr(sprops, "search_texture_resolution", False):
label = f"TexRes {sprops.search_texture_resolution_min}-{sprops.search_texture_resolution_max}"
panel_filters.append(
{
"term": "texture_resolution",
"value": label,
"label": label,
}
)
if getattr(sprops, "search_file_size", False):
label = f"File {sprops.search_file_size_min}-{sprops.search_file_size_max}MB"
panel_filters.append(
{
"term": "file_size",
"value": label,
"label": label,
}
)
if getattr(sprops, "search_animated", False):
panel_filters.append({"term": "animated", "value": "true", "label": "Animated"})
if getattr(sprops, "search_geometry_nodes", False):
panel_filters.append(
{
"term": "geometry_nodes",
"value": "true",
"label": "GeoNodes",
}
)
if ui_props.quality_limit > 0:
panel_filters.append(
{
"term": "quality_limit",
"value": str(ui_props.quality_limit),
"label": f"Q≥{ui_props.quality_limit}",
}
)
if ui_props.search_license != "ANY":
panel_filters.append(
{
"term": "license",
"value": ui_props.search_license,
"label": ui_props.search_license,
}
)
if ui_props.search_blender_version:
label = f"Blender {ui_props.search_blender_version_min}-{ui_props.search_blender_version_max}"
panel_filters.append(
{
"term": "blender_version",
"value": label,
"label": label,
}
)
if ui_props.search_order_by != "default":
panel_filters.append(
{
"term": "order",
"value": ui_props.search_order_by,
"label": ui_props.search_order_by,
}
)
# NSFW is intentionally left out; it already changes server query and badge state.
category = getattr(sprops, "search_category", "")
if category and category != ui_props.asset_type.lower():
bkit_categories = global_vars.DATA.get("bkit_categories") or []
name_path = categories.get_category_name_path(bkit_categories, category)
label = name_path[-1] if name_path else category.split("/")[-1]
panel_filters.append({"term": "category", "value": category, "label": label})
return panel_filters
def _sync_panel_filters_into_active(tab: dict):
"""Merge panel-derived filters with existing ad-hoc filters (e.g., manufacturer)."""
current = _ensure_tab_filters(tab)
preserved = [f for f in current if f.get("term") not in PANEL_FILTER_TERMS]
tab["active_filters"] = preserved + _collect_panel_filters()
def set_active_filter(
term: str,
value: str,
label: Optional[str] = None,
origin: Optional[str] = None,
):
tab = get_active_tab()
filters = _ensure_tab_filters(tab)
# drop existing entry for the same term to keep one value per term for now
filters = [f for f in filters if f.get("term") != term]
filters.append(
{"term": term, "value": value, "label": label or value, "origin": origin}
)
tab["active_filters"] = filters
def remove_active_filter(term: str, value: Optional[str] = None):
tab = get_active_tab()
filters = _ensure_tab_filters(tab)
if term in PANEL_FILTER_TERMS:
_clear_panel_filter(term)
if value is None:
filters = [f for f in filters if f.get("term") != term]
else:
filters = [
f
for f in filters
if not (f.get("term") == term and f.get("value") == value)
]
tab["active_filters"] = filters
def set_active_filters_for_tab(tab: dict, filters: list[dict]):
tab["active_filters"] = copy.deepcopy(filters) if filters else []
def search_by_author_id(author_id: str, author_name: str = ""):
"""Set author filter, clean keywords of author name parts, and run search.
This is the single entry point for all "search by author" actions:
asset bar click, keyboard shortcut, popup card button, etc.
"""
author_id = str(author_id)
if not author_name or author_name == author_id:
author = global_vars.BKIT_AUTHORS.get(int(author_id))
if author:
full = f"{author.firstName} {author.lastName}".strip()
if full:
author_name = full
if not author_name:
author_name = author_id
ui_props = bpy.context.window_manager.blenderkitUI
keywords = ui_props.search_keywords
if keywords and author_name:
kw_parts = keywords.split()
if len(kw_parts) <= 1:
# Single word — user was clearly searching for this author, clear it
keywords = ""
else:
name_parts = author_name.lower().split()
keywords = " ".join(w for w in kw_parts if w.lower() not in name_parts)
# Strip any legacy +author_id: from keywords
keywords = re.sub(r"\+author_id:\d+", "", keywords).strip()
ui_props.search_keywords = keywords
sprops = utils.get_search_props()
if utils.profile_is_validator():
sprops.search_verification_status = "ALL"
set_active_filter(
term="author_id",
value=author_id,
label=author_name,
origin="data",
)
update_filters()
create_history_step(get_active_tab())
search()
def _clear_panel_filter(term: str):
"""Reset underlying filter props when a panel-derived chip is removed."""
ui_props = bpy.context.window_manager.blenderkitUI
sprops = utils.get_search_props()
if term == "style" and hasattr(sprops, "search_style"):
sprops.search_style = "ANY"
elif term == "condition" and hasattr(sprops, "search_condition"):
sprops.search_condition = "UNSPECIFIED"
elif term == "design_year" and hasattr(sprops, "search_design_year"):
sprops.search_design_year = False
elif term == "polycount" and hasattr(sprops, "search_polycount"):
sprops.search_polycount = False
elif term == "texture_resolution" and hasattr(sprops, "search_texture_resolution"):
sprops.search_texture_resolution = False
elif term == "file_size" and hasattr(sprops, "search_file_size"):
sprops.search_file_size = False
elif term == "animated" and hasattr(sprops, "search_animated"):
sprops.search_animated = False
elif term == "geometry_nodes" and hasattr(sprops, "search_geometry_nodes"):
sprops.search_geometry_nodes = False
elif term == "free_only":
ui_props.free_only = False
elif term == "bookmarks":
ui_props.search_bookmarks = False
elif term == "quality_limit":
ui_props.quality_limit = 0
elif term == "license":
ui_props.search_license = "ANY"
elif term == "blender_version":
ui_props.search_blender_version = False
elif term == "order":
ui_props.search_order_by = "default"
elif term == "category" and hasattr(sprops, "search_category"):
sprops.search_category = ""
# Reset the category browse path back to the root for this asset type
asset_type = ui_props.asset_type
active_browse = global_vars.DATA.get("active_category_browse")
if active_browse is not None and asset_type in active_browse:
active_browse[asset_type] = [asset_type.lower()]
def get_active_filter_keywords(tab: Optional[dict] = None) -> list[str]:
tab = tab or get_active_tab()
filters = _ensure_tab_filters(tab)
tokens = []
for f in filters:
term = f.get("term")
value = f.get("value")
# Panel-derived filters (style, free_only, order, etc.) are represented
# directly in query parameters and should not emit keyword tokens.
if term in PANEL_FILTER_TERMS:
continue
if term and value:
tokens.append(f"+{term}:{value}")
return tokens
def _inject_user_price_data(assets: list[dict]) -> None:
"""Augment search results with per-user pricing info when available."""
if not assets:
@@ -67,6 +397,7 @@ def _inject_user_price_data(assets: list[dict]) -> None:
return
try:
# returns entry per versionUuid
price_response = search_price.query_user_price(
version_uuids=version_uuids,
page_size=len(version_uuids),
@@ -84,17 +415,17 @@ def _inject_user_price_data(assets: list[dict]) -> None:
price_by_uuid: dict[str, dict] = {}
for entry in price_response:
version_uuid = entry.get("versionUuid") # maybe assetUuid ?
if not version_uuid:
base_uuid = entry.get("versionUuid")
if not base_uuid:
continue
price_by_uuid[version_uuid] = entry
price_by_uuid[base_uuid] = entry
if not price_by_uuid:
return
for asset in assets:
version_uuid = asset["id"]
price_info = price_by_uuid.get(version_uuid)
base_uuid = asset["id"]
price_info = price_by_uuid.get(base_uuid)
if not price_info:
continue
asset["userPrice"] = price_info["discountedPrice"]
@@ -185,7 +516,7 @@ def check_clipboard():
try: # could be problematic on Linux
current_clipboard = str(bpy.context.window_manager.clipboard)
except Exception as e:
bk_logger.warning(f"Failed to get clipboard: {e}")
bk_logger.warning("Failed to get clipboard: %s", e)
return
if current_clipboard == last_clipboard:
@@ -217,6 +548,8 @@ def check_clipboard():
target_asset_type = "NODEGROUP"
elif asset_type_string.find("addon") > -1 or asset_type_string.find("add-on") > -1:
target_asset_type = "ADDON"
elif asset_type_string.find("artist") > -1 or asset_type_string.find("author") > -1:
target_asset_type = "AUTHOR"
else:
bk_logger.debug("Clipboard does not contain valid asset type.")
return
@@ -229,6 +562,73 @@ def check_clipboard():
ui_props.search_keywords = current_clipboard[:asset_type_index].rstrip()
def parse_author_result(r) -> dict:
"""Parse an author-type search result into asset_data with safe defaults.
Author results have full author data in the ``author`` sub-object (same
structure as regular assets). We use it to populate ``BKIT_AUTHORS`` and
fetch the gravatar, then synthesize the remaining fields that downstream
code expects but the server doesn't provide for authors.
"""
author_id = r.get("id", r.get("author", {}).get("id", 0))
display_name = r.get("displayName", r.get("name", ""))
asset_data = {
"thumbnail": "",
"thumbnail_small": "",
"downloaded": 0,
"available_resolutions": [],
"max_resolution": 0,
"filesSize": 0,
"dictParameters": {},
"files": [],
"verificationStatus": "validated",
"canDownload": False,
"isFree": True,
"score": 0,
"ratingsCount": {},
"ratingsAverage": {},
"assetBaseId": str(author_id),
"displayName": display_name,
}
# Process full author profile data (fetches gravatar, populates BKIT_AUTHORS)
# NOTE: generate_author_profile sends id to the GO client which expects int,
# so we must NOT convert id to string before calling it (same as parse_result).
adata = r.get("author")
if adata and isinstance(adata, dict) and len(adata) > 1:
# Full author data available — parse it like regular assets do
adata = dict(adata) # copy so pop() doesn't mutate the original
social_networks = datas.parse_social_networks(
adata.pop("socialNetworks", None) or []
)
author = datas.UserProfile(**adata, socialNetworks=social_networks)
generate_author_profile(author)
r["author"]["id"] = str(r["author"]["id"])
else:
# Minimal author data — just ensure id is a string
if "author" not in r:
r["author"] = {"id": str(author_id)}
else:
r["author"]["id"] = str(r["author"]["id"])
# Apply server data, then re-apply safe defaults for any fields that ended
# up as None or empty (Go serializes nil maps/slices as null → Python None,
# and some string fields come back as "" instead of a valid value).
safe_defaults = {
"dictParameters": {},
"files": [],
"ratingsCount": {},
"ratingsAverage": {},
"verificationStatus": "validated",
}
asset_data.update(r)
for key, default in safe_defaults.items():
if not asset_data.get(key):
asset_data[key] = default
return asset_data
# TODO: type annotate and check this crazy function!
# Are we sure it behaves correctly on network issues, malfunctioning search etc?
def parse_result(r) -> dict:
@@ -248,6 +648,9 @@ def parse_result(r) -> dict:
utils.p("asset with no files-size")
asset_type = r["assetType"]
if asset_type == "author":
return parse_author_result(r)
adata = r["author"]
social_networks = datas.parse_social_networks(adata.pop("socialNetworks", []))
author = datas.UserProfile(**adata, socialNetworks=social_networks)
@@ -302,7 +705,7 @@ def parse_result(r) -> dict:
r["max_resolution"] = max(r["available_resolutions"])
# tooltip = generate_tooltip(r)
# for some reason, the id was still int on some occurances. investigate this.
# for some reason, the id was still int on some occurrences. investigate this.
r["author"]["id"] = str(r["author"]["id"])
# some helper props, but generally shouldn't be renaming/duplifiying original properties,
@@ -373,7 +776,7 @@ def cleanup_search_results():
def handle_search_task_error(task: client_tasks.Task) -> None:
"""Handle incomming search task error."""
"""Handle incoming search task error."""
# First find the history step that the task belongs to
for history_step in get_history_steps().values():
if task.task_id in history_step.get("search_tasks", {}).keys():
@@ -434,6 +837,8 @@ def handle_search_task(task: client_tasks.Task) -> bool:
result_field.append(asset_data)
if not utils.profile_is_validator():
continue
if asset_data.get("assetType") == "author":
continue
# VALIDATORS
# fetch all comments if user is validator to preview them faster
# these comments are also shown as part of the tooltip oh mouse hover in asset bar.
@@ -441,6 +846,12 @@ def handle_search_task(task: client_tasks.Task) -> bool:
if comments is None:
client_lib.get_comments(asset_data["assetBaseId"])
# Separate author results from regular assets, put authors first
author_results = [r for r in result_field if r.get("assetType") == "author"]
asset_results = [r for r in result_field if r.get("assetType") != "author"]
result_field = author_results + asset_results
# Apply addon-specific status checking and filtering if needed
if ui_props.asset_type == "ADDON":
# Always process addon search results to store installation status
@@ -450,14 +861,18 @@ def handle_search_task(task: client_tasks.Task) -> bool:
addon_props = bpy.context.window_manager.blenderkit_addon
if addon_props.search_installed:
# Filter to only show installed addons
# Filter to only show installed addons, but preserve author results
result_field = [
asset for asset in result_field if asset.get("downloaded", 0) > 0
asset
for asset in result_field
if asset.get("assetType") == "author" or asset.get("downloaded", 0) > 0
]
# TODO: if ever needed, implement for other future types
if result_field:
_inject_user_price_data(result_field)
if override_extension_draw is not None:
override_extension_draw.update_cache_with_asset_prices(result_field)
# Store results in history step
history_step["search_results"] = result_field
@@ -512,6 +927,11 @@ def handle_thumbnail_download_task(task: client_tasks.Task) -> None:
if task.data["thumbnail_type"] == "full":
asset_bar_op.asset_bar_operator.update_tooltip_image(task.data["assetBaseId"])
return
if task.data["thumbnail_type"] in {"photo_full", "wire_full"}:
asset_bar_op.asset_bar_operator.needs_tooltip_update = True
return
def load_preview(asset):
@@ -623,6 +1043,7 @@ def write_block_from_value(tooltip, value, pretext="", width=2000): # for longe
if not value:
return tooltip
intext = value
if type(value) == list:
intext = list_to_str(value)
elif type(value) == float:
@@ -682,11 +1103,14 @@ def generate_author_textblock(first_name: str, last_name: str, about_me: str):
def handle_fetch_gravatar_task(task: client_tasks.Task):
"""Handle incomming fetch_gravatar_task which contains path to author's image on the disk."""
"""Handle incoming fetch_gravatar_task which contains path to author's image on the disk."""
if task.status == "finished":
author_id = int(task.data["id"])
gravatar_path = task.result["gravatar_path"]
global_vars.BKIT_AUTHORS[author_id].gravatarImg = gravatar_path
# Notify asset bar to refresh author thumbnails
if asset_bar_op.asset_bar_operator is not None:
asset_bar_op.asset_bar_operator.update_image(str(author_id))
def generate_author_profile(author_data: datas.UserProfile):
@@ -708,12 +1132,12 @@ def generate_author_profile(author_data: datas.UserProfile):
def handle_get_user_profile(task: client_tasks.Task):
"""Handle incomming get_user_profile task which contains data about current logged-in user."""
"""Handle incoming get_user_profile task which contains data about current logged-in user."""
if task.status not in ["finished", "error"]:
return
if task.status == "error":
bk_logger.warning(f"Could not load user profile: {task.message}")
bk_logger.warning("Could not load user profile: %s", task.message)
return
user_data = task.result.get("user")
@@ -782,7 +1206,13 @@ def query_to_url(
for q in query:
if q in ["query", "free_first", "search_order_by"]:
continue
requeststring += f"+{q}:{urllib.parse.quote_plus(str(query[q]))}"
value = str(query[q])
if q == "asset_type" and value != "author":
has_keywords = query.get("query") not in ("", None)
has_author_filter = query.get("author_id") not in ("", None)
if has_keywords and not has_author_filter:
value += ",author"
requeststring += f"+{q}:{urllib.parse.quote_plus(value)}"
# add dict_parameters to make results smaller
@@ -864,9 +1294,15 @@ def build_query_common(query: dict, props, ui_props) -> dict:
"""
query = copy.deepcopy(query)
query_common = {}
if ui_props.search_keywords != "":
keywords = ui_props.search_keywords.replace("&", "%26")
query_common["query"] = keywords
base_keywords = ui_props.search_keywords.strip()
filter_tokens = get_active_filter_keywords()
combined_parts = []
if base_keywords:
combined_parts.append(base_keywords.replace("&", "%26"))
combined_parts.extend(filter_tokens)
combined_keywords = " ".join(part for part in combined_parts if part)
if combined_keywords:
query_common["query"] = combined_keywords
if props.search_verification_status != "ALL" and utils.profile_is_validator():
query_common["verification_status"] = props.search_verification_status.lower()
@@ -1009,6 +1445,26 @@ def build_query_addon(props, ui_props) -> dict:
return build_query_common(query, props, ui_props)
def build_query_author(props, ui_props) -> dict:
"""Pure function to construct search query dict for authors."""
query = {"asset_type": "author"}
query = build_query_common(query, props, ui_props)
# +author_id:XXX doesn't match author profile documents in elasticsearch
# (that field only exists on asset documents). Replace it with the
# author's name so the API can do a text-search for the profile instead.
q = query.get("query", "")
match = re.search(r"\+author_id:(\d+)", q)
if match:
aid = int(match.group(1))
author = global_vars.BKIT_AUTHORS.get(aid)
name = author.fullName if author else ""
q = re.sub(r"\+author_id:\d+\s*", "", q).strip()
if name:
q = f"{name} {q}".strip() if q else name
query["query"] = q or None
return query
def filter_addon_search_results(search_results, filter_installed_only=False):
"""
Filter addon search results based on local installation status.
@@ -1026,10 +1482,8 @@ def filter_addon_search_results(search_results, filter_installed_only=False):
filtered_results = []
for asset in search_results:
if asset.get("assetType") != "addon":
# Skip non-addon assets (shouldn't happen in addon search but safety check)
if not filter_installed_only:
filtered_results.append(asset)
if not filter_installed_only:
filtered_results.append(asset)
continue
# Check installation and enablement status for addon
@@ -1054,7 +1508,9 @@ def filter_addon_search_results(search_results, filter_installed_only=False):
except Exception as e:
# If we can't determine status, mark as not installed/enabled
bk_logger.warning(
f"Could not determine installation status for addon {asset.get('name', 'Unknown')}: {e}"
"Could not determine installation status for addon %s : %s",
asset.get("name", "Unknown"),
e,
)
asset["downloaded"] = 0
asset["enabled"] = False
@@ -1135,7 +1591,7 @@ def get_search_simple(
page_index = 2
page_count = math.ceil(search_results["count"] / page_size)
while search_results.get("next") and len(results) < max_results:
bk_logger.info(f"getting page {page_index} , total pages {page_count}")
bk_logger.info("getting page %d , total pages %d", page_index, page_count)
response = client_lib.blocking_request(search_results["next"], "GET", headers)
search_results = response.json()
results.extend(search_results["results"])
@@ -1146,7 +1602,7 @@ def get_search_simple(
with open(filepath, "w", encoding="utf-8") as s:
json.dump(results, s, ensure_ascii=False, indent=4)
bk_logger.info(f"retrieved {len(results)} assets from elastic search")
bk_logger.info("retrieved %d assets from elastic search", len(results))
return results
@@ -1249,6 +1705,12 @@ def search(get_next=False, query=None, author_id=""):
ui_props=bpy.context.window_manager.blenderkitUI,
)
if ui_props.asset_type == "AUTHOR":
query = build_query_author(
props=bpy.context.window_manager.blenderkit_author,
ui_props=bpy.context.window_manager.blenderkitUI,
)
# crop long searches
if query.get("query"):
if len(query["query"]) > 50:
@@ -1278,8 +1740,9 @@ def search(get_next=False, query=None, author_id=""):
active_history_step["is_searching"] = True
page_size = min(40, ui_props.wcount * user_preferences.maximized_assetbar_rows + 5)
page_size = min(
MAX_PAGE_SIZE, ui_props.wcount * user_preferences.maximized_assetbar_rows + 5
)
next_url = ""
if get_next and active_history_step.get("search_results_orig"):
next_url = active_history_step["search_results_orig"].get("next", "")
@@ -1292,6 +1755,7 @@ def clean_filters():
"""Cleanup filters in case search needs to be reset, typically when asset id is copy pasted."""
sprops = utils.get_search_props()
ui_props = bpy.context.window_manager.blenderkitUI
active_tab = get_active_tab()
ui_props.property_unset("own_only")
sprops.property_unset("search_texture_resolution")
sprops.property_unset("search_file_size")
@@ -1326,6 +1790,7 @@ def update_filters():
sprops = utils.get_search_props()
ui_props = bpy.context.window_manager.blenderkitUI
active_tab = get_active_tab()
if ui_props.search_bookmarks and not utils.user_logged_in():
ui_props.search_bookmarks = False
@@ -1341,6 +1806,8 @@ def update_filters():
)
return False
_sync_panel_filters_into_active(active_tab)
fcommon = (
ui_props.own_only
or sprops.search_texture_resolution
@@ -1352,6 +1819,7 @@ def update_filters():
or ui_props.search_license != "ANY"
or ui_props.search_blender_version
or ui_props.search_order_by != "default"
or len(get_active_filters()) > 0
# NSFW filter is signaled in a special way and should not affect the filter icon
)
@@ -1377,6 +1845,8 @@ def update_filters():
sprops.use_filters = fcommon
elif ui_props.asset_type == "ADDON":
sprops.use_filters = fcommon
elif ui_props.asset_type == "AUTHOR":
sprops.use_filters = fcommon
return True
@@ -1428,6 +1898,8 @@ def detect_asset_type_from_keywords(keywords: str) -> tuple[str, str]:
"addon": "ADDON",
"add-on": "ADDON",
"extension": "ADDON",
"artist": "AUTHOR",
"author": "AUTHOR",
}
# Convert to lowercase for matching
@@ -1449,6 +1921,20 @@ def search_update(self, context):
# when search is locked, don't trigger search update
ui_props = bpy.context.window_manager.blenderkitUI
# Drop data-driven filters (e.g. manufacturer chips) when switching asset type.
# Seed the tracker on first run to avoid wiping filters during the initial update.
last_asset_type = getattr(ui_props, "_last_asset_type", None)
if last_asset_type is None:
ui_props._last_asset_type = ui_props.asset_type
elif last_asset_type != ui_props.asset_type:
tab = get_active_tab()
filters = _ensure_tab_filters(tab)
# Keep only panel-defined filters; drop all ad-hoc/data-derived ones
tab["active_filters"] = [
f for f in filters if f.get("term") in PANEL_FILTER_TERMS
]
ui_props._last_asset_type = ui_props.asset_type
if ui_props.search_lock:
return
@@ -1507,6 +1993,11 @@ def search_update(self, context):
target_asset_type = "PRINTABLE"
elif asset_type_string.find("addon") > -1:
target_asset_type = "ADDON"
elif (
asset_type_string.find("artist") > -1
or asset_type_string.find("author") > -1
):
target_asset_type = "AUTHOR"
if ui_props.asset_type != target_asset_type:
ui_props.search_keywords = ""
@@ -1541,9 +2032,9 @@ def strip_accents(s):
def refresh_search():
"""Refresh search results. Useful after login/logout."""
props = utils.get_search_props()
if props is not None:
props.report = ""
sprops = utils.get_search_props()
ui_props = bpy.context.window_manager.blenderkitUI
active_tab = get_active_tab()
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.assetbar_on:
@@ -1631,20 +2122,18 @@ class SearchOperator(Operator):
search_keywords = str(ui_props.search_keywords)
if self.keywords != "":
search_keywords = self.keywords
# remove all search keywords if force_clear is set
if self.force_clear:
# self.force_clear = False # reset the force clear
search_keywords = ""
if self.keywords != "":
search_keywords = self.keywords
if self.author_id != "":
bk_logger.info(f"Author ID: {self.author_id}")
# if there is already an author id in the search keywords, remove it first, the author_id can be any so
# use regex to find it
search_keywords = re.sub(r"\+author_id:\d+", "", search_keywords)
search_keywords += f"+author_id:{self.author_id}"
ui_props.search_keywords = search_keywords
search_by_author_id(self.author_id)
return {"FINISHED"}
ui_props.search_keywords = search_keywords
@@ -1704,7 +2193,115 @@ def get_search_similar_keywords(asset_data: dict) -> str:
return keywords
classes = [SearchOperator, UrlOperator, TooltipLabelOperator]
class AuthorAssetTypeSearch(Operator):
"""Switch to a specific asset type tab and search by author"""
bl_idname = "view3d.blenderkit_author_asset_type_search"
bl_label = "Search Author Assets"
bl_options = {"REGISTER", "INTERNAL"}
author_id: StringProperty(name="Author ID", default="", options={"SKIP_SAVE"})
author_name: StringProperty(name="Author Name", default="", options={"SKIP_SAVE"})
asset_type: StringProperty(
name="Asset Type", default="MODEL", options={"SKIP_SAVE"}
)
def execute(self, context):
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.search_lock = True
ui_props.asset_type = self.asset_type
ui_props.search_keywords = ""
ui_props.search_lock = False
search_by_author_id(self.author_id, self.author_name)
return {"FINISHED"}
class AuthorAssetTypePopup(Operator):
"""Choose which asset type to browse for this author"""
bl_idname = "view3d.blenderkit_author_asset_type_popup"
bl_label = "Find Author's Assets"
bl_options = {"REGISTER", "INTERNAL"}
author_id: StringProperty(name="Author ID", default="", options={"SKIP_SAVE"})
author_name: StringProperty(name="Author Name", default="", options={"SKIP_SAVE"})
# Set by caller before invoke — per-type asset counts from the author result
_asset_type_counts: dict = {}
def execute(self, context):
return {"FINISHED"}
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_popup(self, width=200)
def draw(self, context):
layout = self.layout
layout.label(text=self.author_name or "Author")
layout.separator()
counts = self._asset_type_counts
pcoll = icons.icon_collections["main"]
asset_types = [
("MODEL", "Models", "OBJECT_DATAMODE", "model"),
("MATERIAL", "Materials", "MATERIAL", "material"),
("SCENE", "Scenes", "SCENE_DATA", "scene"),
("HDR", "HDRs", "WORLD", "hdr"),
("BRUSH", "Brushes", "BRUSH_DATA", "brush"),
("NODEGROUP", "Node Groups", "NODETREE", "nodegroup"),
]
for at_id, at_label, at_icon, at_key in asset_types:
count = counts.get(at_key, 0)
if counts and count == 0:
continue
label = f"{at_label} ({count})" if count else at_label
op = layout.operator(
"view3d.blenderkit_author_asset_type_search",
text=label,
icon=at_icon,
)
op.author_id = self.author_id
op.author_name = self.author_name
op.asset_type = at_id
# Printable
printable_count = counts.get("printable", 0)
if not counts or printable_count > 0:
label = (
f"Printables ({printable_count})" if printable_count else "Printables"
)
op = layout.operator(
"view3d.blenderkit_author_asset_type_search",
text=label,
icon_value=pcoll["asset_type_printable"].icon_id,
)
op.author_id = self.author_id
op.author_name = self.author_name
op.asset_type = "PRINTABLE"
# Add-ons (Blender 4.2+)
addon_count = counts.get("addon", 0)
if bpy.app.version >= (4, 2, 0) and (not counts or addon_count > 0):
label = f"Add-ons ({addon_count})" if addon_count else "Add-ons"
op = layout.operator(
"view3d.blenderkit_author_asset_type_search",
text=label,
icon="PLUGIN",
)
op.author_id = self.author_id
op.author_name = self.author_name
op.asset_type = "ADDON"
classes = [
SearchOperator,
UrlOperator,
TooltipLabelOperator,
AuthorAssetTypeSearch,
AuthorAssetTypePopup,
]
def register_search():
@@ -1744,6 +2341,7 @@ def unregister_search():
def get_ui_state():
"""Get the current UI state."""
ui_props = bpy.context.window_manager.blenderkitUI
active_tab = get_active_tab()
ui_state = {
"ui_props": {
@@ -1759,6 +2357,7 @@ def get_ui_state():
"search_blender_version_max": ui_props.search_blender_version_max,
},
"search_props": {},
"active_filters": get_active_filters(active_tab),
}
# we need to add all props manually since they are a mess now and some should not be stored.
@@ -1823,6 +2422,8 @@ def get_ui_state():
store_props = store_model_props
elif asset_type == "ADDON":
store_props = [] # Addons don't need to store specific props
elif asset_type == "AUTHOR":
store_props = [] # Authors don't need to store specific props
search_props = utils.get_search_props()
@@ -1849,15 +2450,13 @@ def update_tab_name(active_tab):
# Update tab name based on search or category
search_keywords = ui_state.get("ui_props", {}).get("search_keywords", "").strip()
# if there's author_id let's get the author's name from db of authors
# we need to get the number after +author_id:
author_id = re.search(r"\+author_id:(\d+)", search_keywords)
# Check active filters for author_id
author_name = None
if author_id is not None:
author_id = author_id.group(1)
author = global_vars.BKIT_AUTHORS.get(int(author_id))
if author:
author_name = author.fullName
active_filters = ui_state.get("active_filters", [])
for flt in active_filters:
if flt.get("term") == "author_id":
author_name = flt.get("label")
break
search_category = (
ui_state.get("search_props", {}).get("search_category", "").strip()
@@ -1893,7 +2492,7 @@ def update_tab_name(active_tab):
if asset_bar.area and asset_bar.area.region:
asset_bar.area.tag_redraw()
except Exception as e:
bk_logger.debug(f"Could not update tab name in UI: {e}")
bk_logger.debug("Could not update tab name in UI: %s", e)
return history_step
@@ -1,7 +1,40 @@
"""Search price lookup helper."""
from __future__ import annotations
import time
import logging
from typing import Iterable, List, Optional, Tuple
from . import client_lib, paths, utils
bk_logger = logging.getLogger(__name__)
# Simple in-memory cache to avoid repeated price lookups per version UUID.
# TTL keeps cache fresh-ish while drastically reducing chatter on paged searches.
_PRICE_CACHE: dict[str, tuple[float, dict]] = {}
_PRICE_CACHE_TTL_SEC = 300.0 # 5 minutes is plenty for session-level reuse
def _cache_get(uuid: str) -> Optional[dict]:
entry = _PRICE_CACHE.get(uuid)
if not entry:
return None
ts, payload = entry
if time.perf_counter() - ts > _PRICE_CACHE_TTL_SEC:
_PRICE_CACHE.pop(uuid, None)
return None
return payload
def _cache_put(uuid: str, payload: dict) -> None:
_PRICE_CACHE[uuid] = (time.perf_counter(), payload)
def clear_price_cache() -> None:
"""Clear cached price responses (e.g. on login/logout/user switch)."""
_PRICE_CACHE.clear()
def _normalize_version_uuid_list(values: Optional[Iterable[str]]) -> List[str]:
if values is None:
@@ -21,7 +54,7 @@ def query_user_price(
version_uuids: list[str] = [],
page_size: int = 15,
timeout: Tuple[float, float] = (1, 30),
) -> dict:
) -> list[dict]:
"""Return results for price lookup of multiple asset versions.
The server endpoint now expects a POST body with `version_uuids`, so we keep
@@ -36,22 +69,44 @@ def query_user_price(
if page_size > 0:
version_uuid_list = version_uuid_list[:page_size]
payload: dict = {"version_uuids": version_uuid_list}
url = f"{paths.BLENDERKIT_API}/cart/request-price-bulk/"
if not payload["version_uuids"]:
if not version_uuid_list:
raise ValueError("No version UUIDs provided for price lookup.")
headers = utils.get_simple_headers()
headers.setdefault("Content-Type", "application/json")
# Pull cached entries first.
fresh_uuids: list[str] = []
cached_results: list[dict] = []
for vu in version_uuid_list:
cached = _cache_get(vu)
if cached is None:
fresh_uuids.append(vu)
else:
cached_results.append(cached)
response = client_lib.blocking_request(
url,
"POST",
headers,
json_data=payload,
timeout=timeout,
)
search_results = response.json()
return search_results
fetched_results: list[dict] = []
if fresh_uuids:
payload: dict = {"version_uuids": fresh_uuids}
url = f"{paths.BLENDERKIT_API}/cart/request-price-bulk/"
headers = utils.get_simple_headers()
headers.setdefault("Content-Type", "application/json")
response = client_lib.blocking_request(
url,
"POST",
headers,
json_data=payload,
timeout=timeout,
)
fetched_results = response.json() or []
bk_logger.debug("Fetched price for %d version UUIDs", len(fetched_results))
for entry in fetched_results:
version_uuid = entry.get("versionUuid")
if not version_uuid:
continue
_cache_put(str(version_uuid), entry)
# Merge cached + fetched for the caller; order doesn't matter.
merged = []
merged.extend(cached_results)
merged.extend(fetched_results)
return merged
@@ -50,7 +50,7 @@ class task_object:
self,
command="",
arguments=(),
wait=0,
wait: float = 0,
only_last=False,
fake_context=False,
fake_context_area="VIEW_3D",
@@ -65,7 +65,7 @@ class task_object:
def add_task(
task: Tuple,
wait=0,
wait: float = 0,
only_last=False,
fake_context=False,
fake_context_area="VIEW_3D",
@@ -122,8 +122,8 @@ def queue_worker():
task.wait -= time_step
back_to_queue.append(task)
else:
bk_logger.debug(
"task queue task:" + str(task.command) + str(task.arguments)
bk_logger.log(
5, "task queue task: %s %s", task.command, task.arguments[10:]
)
try:
if task.fake_context:
@@ -137,12 +137,11 @@ def queue_worker():
task.command(*task.arguments)
else:
task.command(*task.arguments)
except Exception as e:
bk_logger.error(
"task queue failed task:"
+ str(task.command)
+ str(task.arguments)
+ str(e)
except Exception:
bk_logger.exception(
"task queue failed task: %s %s %s",
task.command,
task.arguments[10:],
)
# bk_logger.exception('Got exception on main handler')
# raise
+9 -6
View File
@@ -19,6 +19,7 @@
import logging
import os
import queue
import time
import requests
import bpy
@@ -49,7 +50,7 @@ bk_logger = logging.getLogger(__name__)
reports_queue: queue.Queue = queue.Queue()
pending_tasks = (
list()
) # pending tasks are tasks that were not parsed correclty and should be tried to be parsed later.
) # pending tasks are tasks that were not parsed correctly and should be tried to be parsed later.
def handle_failed_reports(exception: Exception) -> float:
@@ -133,6 +134,7 @@ def client_communication_timer():
results = client_lib.get_reports(os.getpid())
global_vars.CLIENT_FAILED_REPORTS = 0
except Exception as e:
download.prune_stalled_downloads(now=time.monotonic())
return handle_failed_reports(e)
if global_vars.CLIENT_ACCESSIBLE is False:
@@ -168,6 +170,7 @@ def client_communication_timer():
for task in results_converted_tasks:
handle_task(task)
download.prune_stalled_downloads(now=time.monotonic())
bk_logger.log(5, "Task handling finished")
delay = user_preferences.client_polling
if len(download.download_tasks) > 0:
@@ -201,8 +204,8 @@ def save_prefs_cancel_all_tasks_and_restart_client(user_preferences, context):
try:
cancel_all_tasks(user_preferences, context)
client_lib.shutdown_client()
except Exception as e:
bk_logger.warning(str(e))
except Exception:
bk_logger.exception("Error shutting down client")
client_lib.reorder_ports(
user_preferences.client_port
@@ -233,7 +236,7 @@ def cancel_all_tasks(self, context):
"""Cancel all tasks."""
global pending_tasks
pending_tasks.clear()
download.clear_downloads()
download.cancel_running_downloads("cancel all tasks")
search.clear_searches()
# TODO: should add uploads
@@ -257,7 +260,7 @@ def task_error_overdrive(task: client_tasks.Task) -> None:
def handle_task(task: client_tasks.Task):
"""Handle incomming task information. Sort tasks by type and call apropriate functions."""
"""Handle incoming task information. Sort tasks by type and call appropriate functions."""
if task.status == "error":
task_error_overdrive(task)
@@ -349,7 +352,7 @@ def handle_task(task: client_tasks.Task):
# HANDLE MESSAGE FROM CLIENT
if (
task.task_type == "message_from_daemon" # TODO: depracate message_from_daemon
task.task_type == "message_from_daemon" # TODO: deprecate message_from_daemon
or task.task_type == "message_from_client"
):
level = task.result.get("level", "INFO").upper()
+42 -59
View File
@@ -23,7 +23,7 @@ from typing import Any
import bpy
from bpy.props import BoolProperty, FloatVectorProperty, IntProperty, StringProperty
from . import colors, global_vars, paths, search, ui_bgl, utils
from . import colors, global_vars, keymap_utils, paths, search, ui_bgl, utils
draw_time = 0
@@ -103,41 +103,63 @@ def get_large_thumbnail_image(asset_data):
return img
def get_full_photo_thumbnail(asset_data):
"""Get full photo thumbnail from asset data. This is different from the large thumbnail
as the photo_thumbnails are not available on the asset data root, but inside the files[].
We need to get the data from files[] where assetType=='photo_thumbnail'."""
# Find the photo thumbnail file
photo_file = None
def get_full_thumbnail_variant(asset_data, variant: str):
"""Get full thumbnail variant from asset data.
Args:
asset_data: The asset data dictionary.
variant (str): The variant type to retrieve ('photo' or 'wire').
Returns:
The Blender image object for the requested variant, or None if not found.
"""
# Find the file corresponding to the requested variant
file_data = None
for file in asset_data.get("files", []):
if file.get("fileType") == "photo_thumbnail":
photo_file = file
if file.get("fileType") == f"{variant.lower()}_thumbnail":
file_data = file
break
if photo_file is None:
bk_logger.warning("No photo thumbnail file found in asset data")
if file_data is None:
bk_logger.log(1, f"No {variant} thumbnail file found in asset data")
return None
photo_url = photo_file.get("thumbnailMiddleUrl")
if photo_url is None:
bk_logger.warning("No thumbnail URL found in photo file")
file_url = file_data.get("thumbnailMiddleUrl")
if file_url is None:
bk_logger.warning(f"No thumbnail URL found in {variant} file")
return None
# Get the directory and construct the path
ui_props = bpy.context.window_manager.blenderkitUI
directory = paths.get_temp_dir(f"{ui_props.asset_type.lower()}_search")
photo_name = os.path.basename(photo_url)
tpath = os.path.join(directory, photo_name)
file_name = os.path.basename(file_url)
tpath = os.path.join(directory, file_name)
# Load the image into Blender
if os.path.exists(tpath):
img = utils.get_hidden_image(tpath, photo_name, colorspace="")
img = utils.get_hidden_image(tpath, file_name, colorspace="")
bk_logger.debug(f"{variant} thumbnail loaded from path: {tpath}")
return img
bk_logger.info(f"Photo thumbnail file not found at path: {tpath}")
bk_logger.info("Thumbnail file not found at path: %s", tpath)
return None
def get_full_photo_thumbnail(asset_data):
"""Get full photo thumbnail from asset data. This is different from the large thumbnail
as the photo_thumbnails are not available on the asset data root, but inside the files[].
We need to get the data from files[] where assetType=='photo_thumbnail'."""
# Find the photo thumbnail file
thumb = get_full_thumbnail_variant(asset_data, "photo")
return thumb
def get_full_wire_thumbnail(asset_data):
"""Get full wireframe thumbnail from asset data."""
# Find the photo thumbnail file
thumb = get_full_thumbnail_variant(asset_data, "wire")
return thumb
def is_rating_possible() -> tuple[bool, bool, Any, Any]:
# TODO remove this, but first check and reuse the code for new rating system...
ao = bpy.context.active_object
@@ -472,9 +494,6 @@ classes = (
ParticlesDropDialog,
)
# store keymap items here to access after registration
addon_keymapitems = []
# @persistent
def pre_load(context):
@@ -490,32 +509,7 @@ def register_ui():
for c in classes:
bpy.utils.register_class(c)
wm = bpy.context.window_manager
# spaces solved by registering shortcut to Window. Couldn't register object mode before somehow.
if not wm.keyconfigs.addon:
return
km = wm.keyconfigs.addon.keymaps.new(name="Window", space_type="EMPTY")
# asset bar shortcut
kmi = km.keymap_items.new(
"view3d.run_assetbar_fix_context",
"SEMI_COLON",
"PRESS",
ctrl=False,
shift=False,
)
kmi.properties.keep_running = False
kmi.properties.do_search = False
addon_keymapitems.append(kmi)
# fast rating shortcut
wm = bpy.context.window_manager
km = wm.keyconfigs.addon.keymaps["Window"]
kmi = km.keymap_items.new(
"wm.blenderkit_menu_rating_upload", "R", "PRESS", ctrl=False, shift=False
)
addon_keymapitems.append(kmi)
# kmi = km.keymap_items.new(upload.FastMetadata.bl_idname, 'F', 'PRESS', ctrl=True, shift=False)
# addon_keymapitems.append(kmi)
keymap_utils.register_keymaps()
def unregister_ui():
@@ -524,15 +518,4 @@ def unregister_ui():
for c in classes:
bpy.utils.unregister_class(c)
wm = bpy.context.window_manager
if not wm.keyconfigs.addon:
return
km = wm.keyconfigs.addon.keymaps.get("Window")
if km:
for kmi in addon_keymapitems:
try:
km.keymap_items.remove(kmi)
except:
pass
del addon_keymapitems[:]
keymap_utils.unregister_keymaps()
+454 -61
View File
@@ -18,7 +18,12 @@
import os
import logging
from collections.abc import Mapping
from contextlib import contextmanager
from typing import Optional, Tuple, Union
import math
from mathutils import Matrix
import blf
import bpy
@@ -29,7 +34,6 @@ from gpu_extras.batch import batch_for_shader
from .image_utils import IMG
bk_logger = logging.getLogger(__name__)
cached_images = {}
@@ -38,6 +42,83 @@ cached_gpu_textures = {}
_cached_image_shader: Optional[gpu.types.GPUShader] = None
SEGMENTS_DEFAULT = 4
def _resolve_region_dimensions(region) -> Tuple[float, float]:
width = getattr(region, "width", None) if region else None
height = getattr(region, "height", None) if region else None
if width is None or width <= 0 or height is None or height <= 0:
ctx_region = getattr(bpy.context, "region", None)
if ctx_region is not None:
width = width or getattr(ctx_region, "width", None)
height = height or getattr(ctx_region, "height", None)
width = float(width or 1.0)
height = float(height or 1.0)
return width, height
@contextmanager
def overlay_matrix_guard(region=None, *args, **kwargs):
"""Ensure viewport overlays draw in screen space regardless of other handlers."""
pushed = False
try:
try:
gpu.matrix.push()
pushed = True
gpu.matrix.load_identity()
width, height = _resolve_region_dimensions(region)
_set_overlay_projection(width, height)
except Exception: # noqa: BLE001
bk_logger.exception("Failed to prepare overlay matrix state")
yield
finally:
if pushed:
try:
gpu.matrix.pop()
except Exception: # noqa: BLE001
bk_logger.exception("Failed to restore overlay matrix state")
def _ortho_projection_matrix(width: float, height: float, *, near=-100.0, far=100.0):
"""Return a pixel-aligned orthographic projection matrix."""
if width <= 0.0:
width = 1.0
if height <= 0.0:
height = 1.0
if far == near:
far = near + 0.001
sx = 2.0 / width
sy = 2.0 / height
sz = -2.0 / (far - near)
tx = -1.0
ty = -1.0
tz = -(far + near) / (far - near)
return Matrix(
(
(sx, 0.0, 0.0, tx),
(0.0, sy, 0.0, ty),
(0.0, 0.0, sz, tz),
(0.0, 0.0, 0.0, 1.0),
)
)
def _set_overlay_projection(width: float, height: float):
"""Set the 2D projection matrix used by BlenderKit overlays."""
try:
projection = _ortho_projection_matrix(width, height)
gpu.matrix.load_projection_matrix(projection)
except Exception: # noqa: BLE001
bk_logger.exception("overlay_matrix_guard: Failed to load projection matrix")
VERTEX_SHADER_LEGACY = """
uniform mat4 ModelViewProjectionMatrix;
@@ -169,6 +250,34 @@ def create_image_shader():
return shader
def get_ui_scale() -> float:
"""Get the UI scale
Returns:
The UI scale factor as a float.
"""
return bpy.context.preferences.system.dpi / 96.0
# sources:
# "sys" values are from system,
# and for current monitor on which the blender is running
sys_ps = bpy.context.preferences.system.pixel_size
# dpi scaling 72 =-> 1.0, 126 -> 1.75 (dpi -> sys_ui_scale)
# 96 / dpi gives correct scale factor
sys_dpi = bpy.context.preferences.system.dpi
# global scaler
sys_sc = bpy.context.preferences.system.ui_scale
# local ui scaler (can be set by user per blender window)
view_sc = bpy.context.preferences.view.ui_scale
def _get_flat_shader_2d():
if app.version < (4, 0, 0):
shader_name = "2D_UNIFORM_COLOR"
else:
shader_name = "UNIFORM_COLOR"
return gpu.shader.from_builtin(shader_name)
def draw_rect(x, y, width, height, color):
"""Used for drawing 2D rectangle backgrounds."""
xmax = x + width
@@ -181,10 +290,7 @@ def draw_rect(x, y, width, height, color):
)
indices = ((0, 1, 2), (2, 3, 0))
if app.version < (4, 0, 0):
shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR")
else:
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader = _get_flat_shader_2d()
batch = batch_for_shader(shader, "TRIS", {"pos": points}, indices=indices)
gpu.state.blend_set("ALPHA")
@@ -205,10 +311,7 @@ def draw_rect_outline(x, y, width, height, color, line_width=1.0):
)
indices = ((0, 1), (1, 2), (2, 3), (3, 0))
if app.version < (4, 0, 0):
shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR")
else:
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader = _get_flat_shader_2d()
batch = batch_for_shader(shader, "LINES", {"pos": coords}, indices=indices)
gpu.state.blend_set("ALPHA")
@@ -223,19 +326,286 @@ def draw_line2d(x1, y1, x2, y2, width, color):
coords = ((x1, y1), (x2, y2))
indices = ((0, 1),)
if app.version < (4, 0, 0):
shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR")
elif app.version < (4, 5, 0):
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
else:
shader_info = create_shader_info()
shader = gpu.shader.create_from_info(shader_info)
shader = _get_flat_shader_2d()
batch = batch_for_shader(shader, "LINES", {"pos": coords}, indices=indices)
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(max(1.0, width))
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
gpu.state.line_width_set(1.0)
def _parse_radius_value(
value,
*,
max_radius: float,
min_dimension: float,
ui_scale: float = 1.0,
) -> float:
"""Return a clamped radius in pixels.
Accepts raw pixel values, strings with percentages (e.g. "50%"),
mapping types containing ``percent``/``pct``/``ratio`` or ``px`` keys,
and falls back to treating anything else as raw pixels.
"""
def clamp_radius(radius: float) -> float:
return max(0.0, min(radius, max_radius))
effective_scale = ui_scale if ui_scale > 0.0 else 1.0
def scale_px_value(px_value: float) -> float:
return clamp_radius(px_value * effective_scale)
if isinstance(value, str):
text = value.strip()
if text.endswith("%"):
number = text[:-1].strip()
try:
pct = float(number) / 100.0
except ValueError:
return 0.0
radius_px = pct * min_dimension
return clamp_radius(radius_px)
# plain numeric string interpreted as pixels
try:
value = float(text)
except ValueError:
return 0.0
return scale_px_value(value)
if isinstance(value, Mapping):
if "percent" in value:
try:
pct = float(value["percent"]) / 100.0
except (TypeError, ValueError):
pct = 0.0
radius_px = pct * min_dimension
return clamp_radius(radius_px)
if "pct" in value:
try:
pct = float(value["pct"]) / 100.0
except (TypeError, ValueError):
pct = 0.0
radius_px = pct * min_dimension
return clamp_radius(radius_px)
if "ratio" in value:
try:
ratio = float(value["ratio"])
except (TypeError, ValueError):
ratio = 0.0
radius_px = ratio * min_dimension
return clamp_radius(radius_px)
if "px" in value:
try:
px_value = float(value["px"])
except (TypeError, ValueError):
px_value = 0.0
return scale_px_value(px_value)
try:
numeric_value = float(value) # type: ignore
except (TypeError, ValueError):
numeric_value = 0.0
return scale_px_value(numeric_value)
def _rounded_rect_outline(
x: float,
y: float,
width: float,
height: float,
radius: Union[tuple[Union[str, float], ...], str, float] = (0.0,),
segments: int = SEGMENTS_DEFAULT,
):
if width <= 0 or height <= 0:
return []
min_dimension = min(width, height)
max_radius = max(0.0, min_dimension / 2.0)
if isinstance(radius, (tuple, list)):
raw_radii = list(radius)
else:
raw_radii = [radius]
if not raw_radii:
raw_radii = [0.0]
ui_scale = get_ui_scale()
parsed_radii = [
_parse_radius_value(
value,
max_radius=max_radius,
min_dimension=min_dimension,
ui_scale=ui_scale,
)
for value in raw_radii
]
while len(parsed_radii) < 4:
parsed_radii.append(parsed_radii[-1])
radii = parsed_radii[:4]
r_tl, r_tr, r_br, r_bl = radii
if all(r == 0.0 for r in radii):
outline = [
(x, y),
(x + width, y),
(x + width, y + height),
(x, y + height),
]
outline.append(outline[0])
return outline
steps = max(1, int(segments))
outline = []
steps = max(1, int(segments))
outline = []
def emit_corner(cx, cy, start_angle, end_angle, radius_value, fallback_point):
if radius_value <= 0.0:
outline.append(fallback_point)
return
for step in range(steps + 1):
t = step / steps
angle = start_angle + (end_angle - start_angle) * t
outline.append(
(
cx + math.cos(angle) * radius_value,
cy + math.sin(angle) * radius_value,
)
)
emit_corner(
x + r_tl,
y + height - r_tl,
math.pi,
math.pi / 2.0,
r_tl,
(x, y + height),
)
emit_corner(
x + width - r_tr,
y + height - r_tr,
math.pi / 2.0,
0.0,
r_tr,
(x + width, y + height),
)
emit_corner(
x + width - r_br,
y + r_br,
0.0,
-math.pi / 2.0,
r_br,
(x + width, y),
)
emit_corner(
x + r_bl,
y + r_bl,
-math.pi / 2.0,
-math.pi,
r_bl,
(x, y),
)
if outline and outline[0] != outline[-1]:
outline.append(outline[0])
return outline
def _rounded_rect_mesh(
x: float,
y: float,
width: float,
height: float,
radius: Union[tuple[Union[str, float], ...], str, float],
crop: Tuple[float, float, float, float],
segments: int = SEGMENTS_DEFAULT,
):
if width <= 0.0 or height <= 0.0:
return None
outline = _rounded_rect_outline(
x,
y,
width,
height,
radius,
segments=segments,
)
if not outline:
return None
loop = outline[:-1] if len(outline) > 1 and outline[0] == outline[-1] else outline
if len(loop) < 3:
return None
crop_u0, crop_v0, crop_u1, crop_v1 = crop
u_span = crop_u1 - crop_u0
v_span = crop_v1 - crop_v0
if u_span == 0.0:
u_span = 1.0
if v_span == 0.0:
v_span = 1.0
coords = list(loop)
try:
inv_width = 1.0 / width
except ZeroDivisionError:
inv_width = 0.0
try:
inv_height = 1.0 / height
except ZeroDivisionError:
inv_height = 0.0
uvs = []
for vx, vy in coords:
rel_x = (vx - x) * inv_width
rel_y = (vy - y) * inv_height
u = crop_u0 + rel_x * u_span
v = crop_v0 + rel_y * v_span
uvs.append((u, v))
indices = [(0, idx, idx + 1) for idx in range(1, len(coords) - 1)]
return coords, uvs, indices
def draw_rounded_rect_with_border(
x: float,
y: float,
width: float,
height: float,
radius: Union[tuple[Union[str, float], ...], str, float] = (0.0,),
fill_color: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0),
border_color: Optional[Tuple[float, float, float, float]] = None,
border_thickness: float = 1.0,
):
if width <= 0 or height <= 0:
return
outline = _rounded_rect_outline(x, y, width, height, radius)
if not outline:
return
loop = outline[:-1] if len(outline) > 1 and outline[0] == outline[-1] else outline
if len(loop) < 3:
return
shader = _get_flat_shader_2d()
indices = [(0, idx, idx + 1) for idx in range(1, len(loop) - 1)]
batch = batch_for_shader(shader, "TRIS", {"pos": loop}, indices=indices)
gpu.state.blend_set("ALPHA")
shader.bind()
shader.uniform_float("color", fill_color)
batch.draw(shader)
if border_color and border_thickness > 0:
gpu.state.line_width_set(border_thickness)
if outline[0] == outline[-1]:
line_points = outline
else:
line_points = outline + [outline[0]]
line_batch = batch_for_shader(shader, "LINE_STRIP", {"pos": line_points})
shader.uniform_float("color", border_color)
line_batch.draw(shader)
gpu.state.line_width_set(1.0)
def draw_strikethrough_line(x_start, x_end, y, color, thickness):
if x_end <= x_start:
return
draw_line2d(x_start, y, x_end, y, thickness, color)
def create_shader_info():
@@ -325,80 +695,101 @@ def draw_image_runtime(
transparency: Optional[float] = 1.0,
crop: Tuple[float, float, float, float] = (0, 0, 1, 1),
batch: Optional[gpu.types.GPUBatch] = None,
corner_radius: Optional[Union[tuple[Union[str, float], ...], str, float]] = None,
corner_segments: int = SEGMENTS_DEFAULT,
) -> Optional[gpu.types.GPUBatch]:
"""Draws an image at given location with given size.
Supports optional rounded corner clipping by supplying ``corner_radius``.
Returns:
The batch object if successful, or None if the image is invalid.
"""
if not image.name or not image.filepath:
if width <= 0.0 or height <= 0.0 or not image.name or not image.filepath:
return None
image_shader = create_image_shader()
rounded_segments = max(1, int(corner_segments))
cache_key = (
image.filepath,
float(x),
float(y),
float(width),
float(height),
tuple(float(component) for component in crop),
repr(corner_radius) if corner_radius is not None else None,
rounded_segments,
)
texture = None
ci = cached_images.get(image.filepath + "GPU_TEXTURE")
if ci is not None:
if (
ci["x"] == x
and ci["y"] == y
and ci["width"] == width
and ci["height"] == height
):
if batch is None:
ci = cached_images.get(cache_key)
if ci is not None:
batch = ci["batch"]
image_shader = ci["image_shader"]
texture = ci["texture"]
if not batch:
coords = [(x, y), (x + width, y), (x, y + height), (x + width, y + height)]
uvs = [
(crop[0], crop[1]),
(crop[2], crop[1]),
(crop[0], crop[3]),
(crop[2], crop[3]),
]
indices = [(0, 1, 2), (2, 1, 3)]
if batch is None:
coords = None
uvs = None
indices = None
if corner_radius is not None:
mesh_data = _rounded_rect_mesh(
x,
y,
width,
height,
corner_radius,
crop,
rounded_segments,
)
if mesh_data:
coords, uvs, indices = mesh_data
if coords is None or uvs is None or indices is None:
coords = [(x, y), (x + width, y), (x, y + height), (x + width, y + height)]
uvs = [
(crop[0], crop[1]),
(crop[2], crop[1]),
(crop[0], crop[3]),
(crop[2], crop[3]),
]
indices = [(0, 1, 2), (2, 1, 3)]
batch = batch_for_shader(
image_shader, "TRIS", {"pos": coords, "texCoord": uvs}, indices=indices
)
texture = path_to_gpu_texture(image.filepath)
# tell shader to use the image that is bound to image unit 0
cached_images[image.filepath + "GPU_TEXTURE"] = {
"x": x,
"y": y,
"width": width,
"height": height,
cached_images[cache_key] = {
"batch": batch,
"image_shader": image_shader,
"texture": texture,
}
if batch is None:
if batch is None or image_shader is None:
return None
if image_shader and texture:
color_space_mode = _resolve_color_space_mode()
if texture is None:
texture = path_to_gpu_texture(image.filepath)
gpu.state.blend_set("ALPHA")
if texture is None:
return None
image_shader.bind()
image_shader.uniform_sampler("image", texture)
color_space_mode = _resolve_color_space_mode()
# may not be available in simple shader
try:
# set floats
image_shader.uniform_float("transparency", transparency)
gpu.state.blend_set("ALPHA")
# set color space mode
image_shader.uniform_int("color_space_mode", color_space_mode)
batch.draw(image_shader)
except Exception:
pass
image_shader.bind()
image_shader.uniform_sampler("image", texture)
# may not be available in simple shader
try:
# set floats
image_shader.uniform_float("transparency", transparency)
# set color space mode
image_shader.uniform_int("color_space_mode", color_space_mode)
batch.draw(image_shader)
except Exception:
pass
return batch
@@ -427,7 +818,9 @@ def path_to_gpu_texture(path: str) -> Optional[gpu.types.GPUTexture]:
return tex
def get_text_size(font_id=0, text="", text_size=16, dpi=72):
def get_text_size(
font_id: int = 0, text: str = "", text_size: float = 16, dpi: int = 72
):
if app.version < (4, 0, 0):
blf.size(font_id, text_size, dpi)
else:
File diff suppressed because it is too large Load Diff
@@ -16,29 +16,69 @@
#
# ##### END GPL LICENSE BLOCK #####
from __future__ import annotations
import json
import os
import sys
import traceback
import urllib.request
import uuid
import logging
import bpy
bk_logger = logging.getLogger(__name__)
def get_texture_filepath(tex_dir_path, image, resolution="blend"):
if len(image.packed_files) > 0:
_INT32_MIN = -2_147_483_648
_INT32_MAX = 2_147_483_647
def _sanitize_for_idprops(value):
"""Recursively sanitize a value so it can be stored as a Blender IDProperty.
Large integers that would overflow int32 are converted to strings.
"""
if isinstance(value, int):
if value < _INT32_MIN or value > _INT32_MAX:
return str(value)
return value
if isinstance(value, dict):
return {k: _sanitize_for_idprops(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_sanitize_for_idprops(v) for v in value]
return value
_ASSET_TYPE_DIRS = {
"models",
"materials",
"hdrs",
"scenes",
"brushes",
"textures",
"nodegroups",
"printables",
"addons",
}
def get_texture_filepath(tex_dir_path, image, resolution="blend", source_path=""):
if source_path:
path = source_path
elif len(image.packed_files) > 0:
path = image.packed_files[0].filepath
else:
path = image.filepath
# backslashes needs to be replaced because bpy.path.basename(path)
# does not work on Mac for Windows paths
path = path or ""
path = path.replace("\\", "/")
image_file_name = bpy.path.basename(path)
if image_file_name == "":
image_file_name = image.name.split(".")[0]
# check if there is allready an image with same name and thus also assigned path
# (can happen easily with genearted tex sets and more materials)
# check if there is already an image with same name and thus also assigned path
# (can happen easily with generated tex sets and more materials)
file_path_original = os.path.join(tex_dir_path, image_file_name)
file_path_final = file_path_original
@@ -72,51 +112,523 @@ def get_resolution_from_file_path(file_path):
return "blend"
def unpack_asset(data):
print("🗃️ unpacking asset")
asset_data = data["asset_data"]
resolution = get_resolution_from_file_path(bpy.data.filepath)
def _resolve_author_name(asset_data: dict) -> str:
author = asset_data.get("author") or {}
full_name = author.get("fullName") or ""
if full_name:
return full_name
first = author.get("firstName") or ""
last = author.get("lastName") or ""
return f"{first} {last}".strip()
# TODO - passing resolution inside asset data might not be the best solution
tex_dir_path = paths.get_texture_directory(asset_data, resolution=resolution)
tex_dir_abs = bpy.path.abspath(tex_dir_path)
if not os.path.exists(tex_dir_abs):
def _resolve_thumbnail_url(asset_data: dict) -> str:
for key in ("thumbnailMiddleUrl", "thumbnailSmallUrl", "thumbnailLargeUrl"):
url = asset_data.get(key)
if url:
return str(url)
for file in asset_data.get("files", []):
if file.get("fileType") not in (
"thumbnail",
"photo_thumbnail",
"wire_thumbnail",
):
continue
for key in (
"thumbnailMiddleUrl",
"thumbnailSmallUrl",
"fileThumbnailLarge",
"fileThumbnail",
):
url = file.get(key)
if url:
return str(url)
return ""
def _library_dir_from_fpath(blend_path: str) -> str:
"""Derive library root from a blend file path by stripping the asset_type folder.
Expected structure: <library>/<asset_type>/<asset_id>/<file>.blend
Returns the portion up to <library>. Falls back to two levels above the blend file directory.
"""
if not blend_path:
return ""
norm_path = os.path.abspath(blend_path)
parts = norm_path.split(os.sep)
for idx, part in enumerate(parts):
if part.lower() in _ASSET_TYPE_DIRS and idx > 0:
return os.sep.join(parts[:idx])
# Fallback: go two levels up from the blend file directory
dir_path = os.path.dirname(norm_path)
return os.path.abspath(os.path.join(dir_path, os.pardir, os.pardir))
def _download_thumbnail(url: str) -> str:
"""Download the thumbnail image from the given URL and save it to the same directory as the current .blend file.
Returns the file path of the downloaded thumbnail, or an empty string if the download failed.
"""
if not url:
return ""
base_name = "preview.png"
target_dir = os.path.dirname(bpy.data.filepath)
if not target_dir:
return ""
target_path = os.path.join(target_dir, base_name)
if os.path.exists(target_path):
return target_path
try:
req = urllib.request.Request(url)
req.add_header("User-Agent", "BlenderKit")
req.add_header("Accept", "image/*")
with urllib.request.urlopen(req, timeout=15) as response:
with open(target_path, "wb") as handle:
handle.write(response.read())
return target_path
except Exception:
return ""
def _sanitize_preview_image(preview_path: str) -> str:
"""Some thumbnail images have issues libEx support.
This function tries to sanitize the image by re-saving it as PNG from the blender.
"""
if not preview_path or not os.path.exists(preview_path):
return ""
base_dir = os.path.dirname(preview_path)
base_name = os.path.splitext(os.path.basename(preview_path))[0]
sanitized_path = os.path.join(base_dir, f"{base_name}_clean.png")
if os.path.exists(sanitized_path):
return sanitized_path
img = None
try:
img = bpy.data.images.load(preview_path, check_existing=False)
img.filepath_raw = sanitized_path
img.file_format = "PNG"
img.save()
return sanitized_path
except Exception:
return ""
finally:
if img is not None:
try:
bpy.data.images.remove(img)
except Exception:
pass
def _op_poll(op_callable, data_block) -> bool:
"""Check if the operator can run in the context of the given data block."""
try:
if hasattr(bpy.context, "temp_override"):
with bpy.context.temp_override(id=data_block):
return op_callable.poll()
override = bpy.context.copy()
override["id"] = data_block
return op_callable.poll(override)
except Exception:
return False
def _op_call(op_callable, data_block, **kwargs):
"""Call the operator in the context of the given data block."""
if hasattr(bpy.context, "temp_override"):
with bpy.context.temp_override(id=data_block):
return op_callable(**kwargs)
override = bpy.context.copy()
override["id"] = data_block
return op_callable(override, **kwargs)
def _apply_asset_preview(data_block, asset_data: dict) -> None:
"""Apply asset preview image to the asset data block.
It first tries to download the thumbnail from the URL provided in asset data.
If that fails, it falls back to generating a preview within Blender."""
if data_block is None:
return
print("🖼️ applying asset preview")
url = _resolve_thumbnail_url(asset_data)
preview_path = _download_thumbnail(url) if url else ""
if preview_path:
clean_path = _sanitize_preview_image(preview_path)
if clean_path:
preview_path = clean_path
try:
os.mkdir(tex_dir_abs)
loaded = False
if _op_poll(bpy.ops.ed.lib_id_load_custom_preview, data_block):
result = _op_call(
bpy.ops.ed.lib_id_load_custom_preview,
data_block,
filepath=preview_path,
)
loaded = "FINISHED" in result
if loaded:
print(" Thumbnail preview applied successfully.")
return
except Exception as e:
print(
"Failed to load thumbnail preview, falling back to generating preview: "
f"{e}"
)
try:
if _op_poll(bpy.ops.ed.lib_id_generate_preview, data_block):
_op_call(bpy.ops.ed.lib_id_generate_preview, data_block)
print(" Generated preview applied successfully.")
except Exception:
print("Failed to generate preview, asset will have no preview")
return
def _write_metadata(data_block, asset_data: dict) -> None:
"""Write asset metadata to the asset data block.
This includes tags, author, and description."""
if data_block is None:
return
print("📝 writing asset metadata")
tags = data_block.asset_data.tags
for t in tags:
tags.remove(t)
tags = data_block.asset_data.tags
for t in asset_data.get("tags", []):
tags.new(str(t))
# assign more metadata in tags, so it is searchable in asset browser, and also visible in metadata panel
other_meta = {}
if asset_data.get("assetBaseId"):
other_meta["id"] = asset_data["assetBaseId"]
if asset_data.get("assetType"):
other_meta["asset_type"] = asset_data.get("assetType", "")
if asset_data.get("sourceAppVersion"):
other_meta["source_app_version"] = asset_data.get("sourceAppVersion", "")
# further custom meta from dictParameters
dict_parameters = asset_data.get("dictParameters", {})
if "category" in dict_parameters:
other_meta["category"] = dict_parameters["category"]
if "condition" in dict_parameters:
other_meta["condition"] = dict_parameters["condition"]
if "pbrType" in dict_parameters:
other_meta["pbr_type"] = dict_parameters["pbrType"]
if "materialStyle" in dict_parameters:
other_meta["material_style"] = dict_parameters["materialStyle"]
if "engine" in dict_parameters:
other_meta["engine"] = dict_parameters["engine"]
if "animated" in dict_parameters and dict_parameters["animated"]:
other_meta["animated"] = "yes"
if "simulation" in dict_parameters and dict_parameters["simulation"]:
other_meta["simulation"] = "yes"
# ad additional metadata to tags
for key, value in other_meta.items():
tags.new(f"{key}:{value}")
description = asset_data.get("description", "")
author_name = _resolve_author_name(asset_data)
data_block.asset_data.author = author_name
data_block.asset_data.description = description
if hasattr(data_block.asset_data, "copyright"):
data_block.asset_data.copyright = asset_data.get("copyright", "")
if hasattr(data_block.asset_data, "license"):
data_block.asset_data.license = asset_data.get("license", "")
def _sanitize_catalog_segment(segment: str) -> str:
cleaned = (segment or "").strip()
cleaned = cleaned.replace(":", "-").replace("/", "-").replace("\\", "-")
return cleaned or "Uncategorized"
def _resolve_category_segments(asset_data: dict) -> list[str]:
category_slug = asset_data.get("category") or asset_data.get(
"dictParameters", {}
).get("category")
if not category_slug:
return []
parts = [p for p in str(category_slug).split("-") if p]
return [_sanitize_catalog_segment(part) for part in parts]
def _resolve_catalog_path_parts(asset_data: dict) -> list[str]:
asset_type = (asset_data.get("assetType") or "").lower()
catalog_map = {
"model": "Models",
"material": "Materials",
"hdr": "HDRIs",
"hdri": "HDRIs",
"printable": "Printables",
"scene": "Scenes",
"brush": "Brushes",
"texture": "Textures",
"nodegroup": "Node Groups",
"addon": "Add-ons",
}
parts = []
top_level = catalog_map.get(asset_type)
if top_level:
parts.append(top_level)
parts.extend(_resolve_category_segments(asset_data))
bk_logger.debug(
"Resolved catalog path parts: %s for asset type '%s' and category '%s'",
parts,
asset_type,
asset_data.get("category"),
)
return parts
def _ensure_catalog_exists(
library_path: str, catalog_path: str, catalog_simple_name: str
) -> str:
"""Ensure that an asset catalog exists for the given path.
Returns the catalog ID if it exists or was created successfully, otherwise returns an empty string.
"""
# TODO use python exposed API, (currently only C API is available))
head = (
"# 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"
"VERSION 1\n"
)
# check if file exists in library, if not create it
# this is needed to assign asset to catalog, otherwise it will be assigned to "unc
cat_path = os.path.join(library_path, "blender_assets.cats.txt")
if not os.path.exists(cat_path):
try:
with open(cat_path, "w", encoding="utf-8") as f:
f.write(head)
except Exception as e:
traceback.print_exc()
return ""
# create file if it does not exists with uuid and name
# get all catalogs in the file, if there is one with same name, return its uuid
cats = {}
# read existing catalogs
with open(cat_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(":")
if len(parts) != 3:
continue
cat_uuid, cat_path_entry, _ = parts
cats[cat_path_entry] = cat_uuid
bpy.data.use_autopack = False
for image in bpy.data.images:
if image.name == "Render Result":
continue # skip rendered images
# use regex to found the library name
if catalog_path in cats:
return cats[catalog_path]
# suffix = paths.resolution_suffix(data['suffix'])
fp = get_texture_filepath(tex_dir_path, image, resolution=resolution)
print(f"🖼️ unpacking file: {image.name} - {image.filepath}, {fp}")
# create new catalog entry
new_uuid = str(uuid.uuid4())
cats[catalog_path] = new_uuid
for pf in image.packed_files:
pf.filepath = fp # bpy.path.abspath(fp)
image.filepath = fp # bpy.path.abspath(fp)
image.filepath_raw = fp # bpy.path.abspath(fp)
# image.save()
if len(image.packed_files) > 0:
# image.unpack(method='REMOVE')
image.unpack(method="WRITE_ORIGINAL")
# write new catalog entry to file
try:
with open(cat_path, "a", encoding="utf-8") as f:
f.write(f"{new_uuid}:{catalog_path}:{catalog_simple_name}\n")
return new_uuid
except Exception as e:
traceback.print_exc()
return ""
def _assign_asset_catalog(
data_block, asset_data: dict, blend_path: str | None = None
) -> None:
"""Assign the asset to a catalog based on its type and category hierarchy."""
if data_block is None or data_block.asset_data is None:
return
print("📁 assigning asset to catalog")
if not blend_path:
print("Asset catalog assignment skipped: blend path missing.")
return
library_dir = _library_dir_from_fpath(blend_path)
library_dir = os.path.abspath(bpy.path.abspath(library_dir))
print("Resolved library directory: '%s'" % library_dir)
if not os.path.exists(library_dir):
try:
os.makedirs(library_dir, exist_ok=True)
except Exception:
print(f"Asset catalog assignment skipped: cannot create '{library_dir}'.")
return
path_parts = _resolve_catalog_path_parts(asset_data)
print(f"Resolved catalog path parts: {path_parts}")
if not path_parts:
print(
"Asset catalog assignment skipped: could not resolve catalog path from asset data."
)
return
catalog_path = "/".join(path_parts)
catalog_simple_name = path_parts[-1]
print(
"Resolved catalog path: '%s' and simple name: '%s' for asset with type '%s'"
% (
catalog_path,
catalog_simple_name,
asset_data.get("assetType"),
)
)
catalog_id = _ensure_catalog_exists(library_dir, catalog_path, catalog_simple_name)
if not catalog_id:
print("Asset catalog assignment skipped: failed to create catalog entry.")
return
print(f"Assigning asset to catalog '{catalog_path}' with ID {catalog_id}")
asset_meta = data_block.asset_data
if hasattr(asset_meta, "catalog_id"):
try:
asset_meta.catalog_id = catalog_id
except AttributeError:
print("Asset catalog assignment skipped: catalog_id is read-only.")
else:
print(
"Asset catalog assignment skipped: asset_data does not have catalog_id attribute."
)
def unpack_asset(data):
"""Unpack asset data into the current Blender file.
This function handles unpacking textures, writing metadata,
applying previews, and assigning the asset to a catalog based on its type.
"""
asset_data = data["asset_data"]
# assume unpack is true
unpack = True
if data.get("prefs", {}).get("unpack_files") is False:
unpack = False
# assume write_metadata is true
write_metadata = True
if data.get("prefs", {}).get("write_asset_metadata") is False:
write_metadata = False
if unpack:
print("🗃️ unpacking asset")
resolution = get_resolution_from_file_path(bpy.data.filepath)
# TODO - passing resolution inside asset data might not be the best solution
tex_dir_path = paths.get_texture_directory(asset_data, resolution=resolution)
tex_dir_abs = bpy.path.abspath(tex_dir_path)
if not os.path.exists(tex_dir_abs):
try:
os.mkdir(tex_dir_abs)
except Exception as e:
traceback.print_exc()
bpy.data.use_autopack = False
for image in bpy.data.images:
if image.name == "Render Result":
continue # skip rendered images
# Keep per-packed-file paths (UDIM/sequence) so image.unpack writes all frames/tiles.
if len(image.packed_files) > 0:
unpack_paths = []
for pf in image.packed_files:
pf_path = get_texture_filepath(
tex_dir_path,
image,
resolution=resolution,
source_path=pf.filepath,
)
unpack_paths.append(pf_path)
pf.filepath = pf_path # bpy.path.abspath(pf_path)
image_path = get_texture_filepath(
tex_dir_path,
image,
resolution=resolution,
source_path=image.filepath,
)
image.filepath = image_path # bpy.path.abspath(image_path)
image.filepath_raw = image_path # bpy.path.abspath(image_path)
print(
f"🖼️ unpacking file: {image.name} - {image.filepath}, "
f"{len(unpack_paths)} packed file(s)"
)
image.unpack(method="WRITE_ORIGINAL")
else:
fp = get_texture_filepath(
tex_dir_path,
image,
resolution=resolution,
source_path=image.filepath,
)
print(f"🖼️ unpacking file: {image.name} - {image.filepath}, {fp}")
image.filepath = fp # bpy.path.abspath(fp)
image.filepath_raw = fp # bpy.path.abspath(fp)
# mark asset browser asset
print("🏷️ marking asset")
data_block = None
if asset_data["assetType"] in ("model", "printable"):
# Mark the main collection as the asset instead of the root object.
# When upload_bg.py prepares a model for upload it places ALL objects
# (including children) as direct members of a single named collection that
# is a direct child of the scene collection. If we mark only the root
# object, Blender's asset browser will import just that one object and
# its children are left behind, resulting in an empty appearing in the
# scene. Marking the collection lets Blender create a proper collection
# instance that shows every object in the hierarchy.
#
# upload_bg.py calls asset_mark() on the root object before uploading, so
# every downloaded .blend arrives with the root object already marked as an
# asset. Clear those object-level marks first so only the collection entry
# appears in the asset browser (no duplicates).
for ob in bpy.data.objects:
if ob.parent is None and ob in bpy.context.visible_objects:
if bpy.app.version >= (3, 0, 0):
ob.asset_mark()
# for c in bpy.data.collections:
# if c.get('asset_data') is not None:
# if bpy.app.version >= (3, 0, 0):
if ob.asset_data is not None:
ob.asset_clear()
# c.asset_mark()
# data_block = c
scene_collection = bpy.context.scene.collection
main_collection = None
for col in scene_collection.children:
has_root_objects = any(
ob.parent is None and ob in bpy.context.visible_objects
for ob in col.objects
)
if has_root_objects:
main_collection = col
break
if main_collection is not None:
if bpy.app.version >= (3, 0, 0):
main_collection.asset_mark()
# Store asset_data on the collection so that collection-instance
# EMPTYs added via Blender's native asset browser can be identified
# as BlenderKit assets (rating, bookmarking, etc.).
main_collection["asset_data"] = _sanitize_for_idprops(asset_data)
data_block = main_collection
else:
# Fallback: no suitable collection found mark root visible objects.
for ob in bpy.data.objects:
if ob.parent is None and ob in bpy.context.visible_objects:
if bpy.app.version >= (3, 0, 0):
ob.asset_mark()
data_block = ob
elif asset_data["assetType"] == "material":
for m in bpy.data.materials:
if bpy.app.version >= (3, 0, 0):
@@ -125,18 +637,34 @@ def unpack_asset(data):
elif asset_data["assetType"] == "scene":
if bpy.app.version >= (3, 0, 0):
bpy.context.scene.asset_mark()
data_block = bpy.context.scene
elif asset_data["assetType"] == "brush":
for b in bpy.data.brushes:
if b.get("asset_data") is not None:
if hasattr(b, "asset_data") and b.asset_data is not None:
if bpy.app.version >= (3, 0, 0):
b.asset_mark()
data_block = b
if bpy.app.version >= (3, 0, 0) and data_block is not None:
tags = data_block.asset_data.tags
for t in tags:
tags.remove(t)
tags.new("description: " + asset_data.get("description", ""))
tags.new("tags: " + ",".join(asset_data.get("tags", [])))
elif asset_data["assetType"] == "nodegroup":
for ng in bpy.data.node_groups:
if hasattr(ng, "asset_data") and ng.asset_data is not None:
if (
hasattr(ng.asset_data, "copyright")
and ng.asset_data.copyright == "Blender Foundation"
or ng.asset_data.is_property_readonly("author")
):
continue # skip official node groups, they are not assets
if bpy.app.version >= (3, 0, 0):
ng.asset_mark()
data_block = ng
if bpy.app.version >= (3, 0, 0) and data_block is not None and write_metadata:
_write_metadata(data_block, asset_data)
_apply_asset_preview(data_block, asset_data)
_assign_asset_catalog(
data_block,
asset_data,
blend_path=data.get("fpath"),
)
# if this isn't here, blender crashes when saving file.
if bpy.app.version >= (3, 0, 0):
@@ -194,6 +722,7 @@ if __name__ == "__main__":
from . import paths
print(json_path)
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
unpack_asset(data)
+364 -41
View File
@@ -20,12 +20,15 @@ import json
import logging
import os
import re
import sys
import requests
import tempfile
from pathlib import Path
from typing import Optional
from typing import Optional, Any
import bpy
from bpy.props import ( # TODO only keep the ones actually used when cleaning
IntProperty,
BoolProperty,
EnumProperty,
StringProperty,
@@ -49,7 +52,6 @@ from . import (
search,
)
NAME_MINIMUM = 3
NAME_MAXIMUM = 40
TAGS_MINIMUM = 3
@@ -64,6 +66,15 @@ licenses = (
)
def wire_thumbnail_upload_enabled() -> bool:
"""Feature gate for experimental wireframe thumbnail uploads."""
addon = bpy.context.preferences.addons.get(__package__)
if addon is None:
return False
preferences = addon.preferences
return getattr(preferences, "enable_wire_thumbnail_upload", False)
def add_version(data):
data["sourceAppName"] = "blender"
data["sourceAppVersion"] = utils.get_blender_version()
@@ -199,7 +210,7 @@ def check_missing_data(asset_type, props, upload_set):
)
if "THUMBNAIL" in upload_set:
if asset_type in ("MODEL", "SCENE", "MATERIAL", "PRINTABLE"):
if asset_type in ("MODEL", "SCENE", "MATERIAL", "PRINTABLE", "BRUSH"):
thumb_path = bpy.path.abspath(props.thumbnail)
if props.thumbnail == "":
write_to_report(
@@ -213,23 +224,6 @@ def check_missing_data(asset_type, props, upload_set):
"Thumbnail filepath does not exist on the disk.\n"
" Please check the filepath and try again.",
)
if asset_type == "BRUSH":
brush = utils.get_active_brush()
if brush is not None:
thumb_path = bpy.path.abspath(brush.icon_filepath)
if thumb_path == "":
write_to_report(
props,
"Brush Icon Filepath has not been provided.\n"
" Please check Custom Icon option add a Brush Icon in JPG or PNG format, ensuring at least 1024x1024 pixels.",
)
elif not os.path.exists(Path(thumb_path)):
write_to_report(
props,
"Brush Icon Filepath does not exist on the disk.\n"
" Please check the filepath and try again.",
)
if "PHOTO_THUMBNAIL" in upload_set: # for printable assets
# Add validation for the photo thumbnail for printable assets
# only if it's in the upload set
@@ -251,6 +245,24 @@ def check_missing_data(asset_type, props, upload_set):
" Please check the filepath and try again.",
)
if wire_thumbnail_upload_enabled() and "WIRE_THUMBNAIL" in upload_set:
if props.wire_thumbnail_will_upload_on_website:
pass
else:
wire_thumb_path = bpy.path.abspath(props.wire_thumbnail)
if props.wire_thumbnail == "":
write_to_report(
props,
"A wireframe thumbnail image has not been provided.\n"
" Please add a wireframe thumbnail in JPG or PNG format, ensuring at least 1024x1024 pixels.",
)
elif not os.path.exists(Path(wire_thumb_path)):
write_to_report(
props,
"Wireframe thumbnail filepath does not exist on the disk.\n"
" Please check the filepath and try again.",
)
if props.is_private == "PUBLIC":
check_public_requirements(props)
@@ -333,7 +345,7 @@ def sub_to_camel(content):
def get_upload_data(caller=None, context=None, asset_type=None):
"""
works though metadata from addom props and prepares it for upload to dicts.
works though metadata from addon props and prepares it for upload to dicts.
Parameters
----------
caller - upload operator or none
@@ -342,8 +354,8 @@ def get_upload_data(caller=None, context=None, asset_type=None):
Returns
-------
export_ddta- all extra data that the process needs to upload and communicate with UI from a thread.
- eval_path_computing - string path to UI prop that denots if upload is still running
export_data- all extra data that the process needs to upload and communicate with UI from a thread.
- eval_path_computing - string path to UI prop that denotes if upload is still running
- eval_path_state - string path to UI prop that delivers messages about upload to ui
- eval_path - path to object holding upload data to be able to access it with various further commands
- models - in case of model upload, list of objects
@@ -353,10 +365,13 @@ def get_upload_data(caller=None, context=None, asset_type=None):
"""
user_preferences = bpy.context.preferences.addons[__package__].preferences
export_data = {
export_data: dict[str, Any] = {
# "type": asset_type,
}
upload_params = {}
upload_params: dict[str, Any] = {}
# initialize here to prevent unbound
upload_data: dict[str, Any] = {}
if asset_type in ("MODEL", "PRINTABLE"):
# Prepare to save the file
mainmodel = utils.get_active_model()
@@ -375,6 +390,13 @@ def get_upload_data(caller=None, context=None, asset_type=None):
export_data["photo_thumbnail_path"] = bpy.path.abspath(
props.photo_thumbnail
)
# Add wire thumbnail path to export_data for models and printable assets
if (
wire_thumbnail_upload_enabled()
and asset_type in ("MODEL", "SCENE", "PRINTABLE")
and props.wire_thumbnail
):
export_data["wire_thumbnail_path"] = bpy.path.abspath(props.wire_thumbnail)
eval_path_computing = (
"bpy.data.objects['%s'].blenderkit.uploading" % mainmodel.name
@@ -511,9 +533,9 @@ def get_upload_data(caller=None, context=None, asset_type=None):
"animated": props.animated,
# "simulation": props.simulation,
"purePbr": props.pbr,
"faceCount": 1, # props.face_count,
"faceCountRender": 1, # props.face_count_render,
"objectCount": 1, # props.object_count,
"faceCount": max(0, props.face_count),
"faceCountRender": max(0, props.face_count_render),
"objectCount": max(0, props.object_count),
# "scene": props.is_scene,
}
if props.use_design_year:
@@ -588,7 +610,7 @@ def get_upload_data(caller=None, context=None, asset_type=None):
# props.name = brush.name
export_data["brush"] = str(brush.name)
export_data["thumbnail_path"] = bpy.path.abspath(brush.icon_filepath)
export_data["thumbnail_path"] = bpy.path.abspath(props.thumbnail)
eval_path_computing = "bpy.data.brushes['%s'].blenderkit.uploading" % brush.name
eval_path_state = "bpy.data.brushes['%s'].blenderkit.upload_state" % brush.name
@@ -749,7 +771,7 @@ def update_free_full(self, context):
message="Any material uploaded to BlenderKit is free."
" However, it can still earn money for the author,"
" based on our fair share system. "
"Part of subscription is sent to artists based on usage by paying users.",
"Part of subscription is sent to authors based on usage by paying users.",
)
@@ -883,6 +905,38 @@ class FastMetadata(bpy.types.Operator):
update=update_free_full,
)
# Design metadata
manufacturer: StringProperty( # type: ignore[valid-type]
name="Manufacturer",
description="Manufacturer, company making a design piece or product.",
default="",
)
designer: StringProperty( # type: ignore[valid-type]
name="Designer",
description="Author of the original design piece depicted.",
default="",
)
design_collection: StringProperty( # type: ignore[valid-type]
name="Design Collection",
description="Name of the collection this design belongs to.",
default="",
)
design_variant: StringProperty( # type: ignore[valid-type]
name="Design Variant",
description="Colour or material variant of the product.",
default="",
)
use_design_year: BoolProperty( # type: ignore[valid-type]
name="Use Design Year",
description="Whether to include the design year in the metadata. If enabled, the design year will be included as a parameter in the asset metadata.",
default=False,
)
design_year: IntProperty( # type: ignore[valid-type]
name="Design Year",
description="When this item was designed.",
default=1960,
)
####################
@classmethod
@@ -905,6 +959,13 @@ class FastMetadata(bpy.types.Operator):
layout.prop(self, "free_full", expand=True)
if self.is_private == "PUBLIC":
layout.prop(self, "license")
layout.prop(self, "manufacturer")
layout.prop(self, "designer")
layout.prop(self, "design_collection")
layout.prop(self, "design_variant")
layout.prop(self, "use_design_year")
if self.use_design_year:
layout.prop(self, "design_year")
# layout.label(text="Content Flags:")
content_flag_box = layout.box()
content_flag_box.alignment = "EXPAND"
@@ -934,9 +995,38 @@ class FastMetadata(bpy.types.Operator):
},
],
}
# Optional design-related parameters
extra_parameters = []
if self.designer:
extra_parameters.append(
{"parameterType": "designer", "value": self.designer}
)
if self.manufacturer:
extra_parameters.append(
{"parameterType": "manufacturer", "value": self.manufacturer}
)
if self.design_collection:
extra_parameters.append(
{
"parameterType": "designCollection",
"value": self.design_collection,
}
)
if self.design_variant:
extra_parameters.append(
{"parameterType": "designVariant", "value": self.design_variant}
)
if self.use_design_year:
extra_parameters.append(
{"parameterType": "designYear", "value": self.design_year}
)
if extra_parameters:
metadata["parameters"].extend(extra_parameters)
url = f"{paths.BLENDERKIT_API}/assets/{self.asset_id}/"
messages = {
"success": "Metadata upload succeded",
"success": "Metadata upload succeeded",
"error": "Metadata upload failed",
}
client_lib.nonblocking_request(url, "PATCH", {}, metadata, messages)
@@ -984,6 +1074,13 @@ class FastMetadata(bpy.types.Operator):
"sexualizedContent", False
)
params = asset_data.get("dictParameters", {})
self.designer = params.get("designer", "")
self.manufacturer = params.get("manufacturer", "")
self.design_collection = params.get("designCollection", "")
self.design_variant = params.get("designVariant", "")
self.design_year = params.get("designYear", 1960)
wm = context.window_manager
return wm.invoke_props_dialog(self, width=600)
@@ -991,7 +1088,7 @@ class FastMetadata(bpy.types.Operator):
def get_upload_location(props):
"""
not used by now, gets location of uploaded asset - potentially usefull if we draw a nice upload gizmo in viewport.
not used by now, gets location of uploaded asset - potentially useful if we draw a nice upload gizmo in viewport.
Parameters
----------
props
@@ -1037,6 +1134,179 @@ def storage_quota_available(props) -> bool:
return False
def _get_upload_datablock(asset_type: str):
if bpy.app.version < (3, 0, 0):
return None
if asset_type in ("MODEL", "PRINTABLE"):
return utils.get_active_model()
if asset_type == "SCENE":
return bpy.context.scene
if asset_type == "MATERIAL":
obj = bpy.context.active_object
if obj is not None:
return obj.active_material
return None
if asset_type == "BRUSH":
return utils.get_active_brush()
if asset_type == "NODEGROUP":
return bpy.context.window_manager.blenderkitUI.nodegroup_upload
return None
def ensure_asset_metadata_on_datablock(asset_type: str, props) -> None:
"""Write tags/description/author into the datablock before we save for upload."""
data_block = _get_upload_datablock(asset_type)
if data_block is None:
return
if getattr(data_block, "asset_data", None) is None:
mark_fn = getattr(data_block, "asset_mark", None)
if callable(mark_fn):
mark_fn()
asset_meta = getattr(data_block, "asset_data", None)
if asset_meta is None:
return
try:
tags_prop = asset_meta.tags
for tag in list(tags_prop):
tags_prop.remove(tag)
for tag in utils.string2list(props.tags):
tags_prop.new(str(tag))
profile = global_vars.BKIT_PROFILE
author_name = getattr(profile, "fullName", "") or getattr(
profile, "username", ""
)
asset_meta.author = author_name
asset_meta.description = props.description
# inject also additional metadata
other_meta = {}
if props.id:
other_meta["id"] = props.asset_base_id
# further custom meta from dictParameters
if props.condition:
other_meta["condition"] = props.condition
if props.pbr_type:
other_meta["pbr_type"] = props.pbr_type
if props.style:
other_meta["style"] = props.style
if props.engine:
other_meta["engine"] = props.engine
if props.animated:
other_meta["animated"] = "yes"
if props.simulation:
other_meta["simulation"] = "yes"
# ad additional metadata to tags
for key, value in other_meta.items():
tags_prop.new(f"{key}:{value}")
except Exception as e: # pragma: no cover - defensive for asset_data API quirks
bk_logger.warning("Failed to write asset metadata before upload: %s", e)
def _sanitize_preview_image(preview_path: str) -> str:
"""Some thumbnail images have issues libEx support.
This function tries to sanitize the image by re-saving it as PNG from the blender.
"""
if not preview_path or not os.path.exists(preview_path):
return ""
base_dir = os.path.dirname(preview_path)
base_name = os.path.splitext(os.path.basename(preview_path))[0]
sanitized_path = os.path.join(base_dir, f"{base_name}_clean.png")
if os.path.exists(sanitized_path):
return sanitized_path
img = None
try:
img = bpy.data.images.load(preview_path, check_existing=False)
img.filepath_raw = sanitized_path
img.file_format = "PNG"
img.save()
return sanitized_path
except Exception:
return ""
finally:
if img is not None:
try:
bpy.data.images.remove(img)
except Exception:
pass
def _op_poll(op_callable, data_block) -> bool:
"""Check if the operator can run in the context of the given data block."""
try:
if hasattr(bpy.context, "temp_override"):
with bpy.context.temp_override(id=data_block):
return op_callable.poll()
override = bpy.context.copy()
override["id"] = data_block
return op_callable.poll(override)
except Exception:
return False
def _op_call(op_callable, data_block, **kwargs):
"""Call the operator in the context of the given data block."""
if hasattr(bpy.context, "temp_override"):
with bpy.context.temp_override(id=data_block):
return op_callable(**kwargs)
override = bpy.context.copy()
override["id"] = data_block
return op_callable(override, **kwargs)
def apply_asset_preview(data_block, props) -> None:
"""Apply asset preview image to the asset data block.
It first tries to download the thumbnail from the URL provided in asset data.
If that fails, it falls back to generating a preview within Blender."""
if data_block is None:
return
thumbnail = getattr(props, "thumbnail", "")
if not thumbnail:
return
thmb_path = bpy.path.abspath(thumbnail)
if not os.path.exists(thmb_path):
return
if thmb_path:
clean_path = _sanitize_preview_image(thmb_path)
if clean_path:
thmb_path = clean_path
try:
loaded = False
if _op_poll(bpy.ops.ed.lib_id_load_custom_preview, data_block):
result = _op_call(
bpy.ops.ed.lib_id_load_custom_preview,
data_block,
filepath=thmb_path,
)
loaded = "FINISHED" in result
if loaded:
bk_logger.info("Thumbnail preview applied successfully.")
return
except Exception as e:
bk_logger.warning(
"Failed to load thumbnail preview, falling back to generating preview: "
f"{e}"
)
try:
if _op_poll(bpy.ops.ed.lib_id_generate_preview, data_block):
_op_call(bpy.ops.ed.lib_id_generate_preview, data_block)
bk_logger.info("Generated preview applied successfully.")
except Exception:
bk_logger.warning("Failed to generate preview, asset will have no preview")
return
def auto_fix(asset_type=""):
# this applies various procedures to ensure coherency in the database.
asset = utils.get_active_asset()
@@ -1067,6 +1337,9 @@ def prepare_asset_data(self, context, asset_type, reupload, upload_set):
if props.report != "":
return False, None, None
ensure_asset_metadata_on_datablock(asset_type, props)
apply_asset_preview(_get_upload_datablock(asset_type), props)
if not reupload:
props.asset_base_id = ""
props.id = ""
@@ -1097,6 +1370,13 @@ def prepare_asset_data(self, context, asset_type, reupload, upload_set):
props.uploading = False
return False, None, None
# check if we have wire_thumbnail
if wire_thumbnail_upload_enabled() and "wire_thumbnail" in upload_set:
if not os.path.exists(export_data.get("wire_thumbnail_path", "")):
props.upload_state = "0% - wire thumbnail not found"
props.uploading = False
return False, None, None
# save a copy of the file for processing. Only for blend files
_, ext = os.path.splitext(bpy.data.filepath)
if not ext:
@@ -1164,8 +1444,17 @@ class UploadOperator(Operator):
# Add new property for photo thumbnail
photo_thumbnail: BoolProperty(name="photo thumbnail", default=False, options={"SKIP_SAVE"}) # type: ignore[valid-type]
# Add new property for wire thumbnail
wire_thumbnail: BoolProperty(name="wire thumbnail", default=False, options={"SKIP_SAVE"}) # type: ignore[valid-type]
main_file: BoolProperty(name="main file", default=False, options={"SKIP_SAVE"}) # type: ignore[valid-type]
skip_hdr_tune_popup: BoolProperty( # type: ignore[valid-type]
name="Skip HDR tune popup",
default=False,
options={"SKIP_SAVE", "HIDDEN"},
)
@classmethod
def poll(cls, context):
return utils.uploadable_asset_poll()
@@ -1173,6 +1462,7 @@ class UploadOperator(Operator):
def execute(self, context):
bpy.ops.object.blenderkit_auto_tags()
props = utils.get_upload_props()
wire_upload_enabled = wire_thumbnail_upload_enabled()
upload_set = []
if not self.reupload:
@@ -1180,6 +1470,14 @@ class UploadOperator(Operator):
# Add photo_thumbnail to the upload set for printable assets
if self.asset_type == "PRINTABLE" and props.photo_thumbnail:
upload_set.append("photo_thumbnail")
# add wire_thumbnail for models if it exists
if (
wire_upload_enabled
and self.asset_type in {"MODEL", "SCENE", "PRINTABLE"}
and props.wire_thumbnail
):
upload_set.append("wire_thumbnail")
else:
if self.metadata:
upload_set.append("METADATA")
@@ -1187,6 +1485,8 @@ class UploadOperator(Operator):
upload_set.append("THUMBNAIL")
if self.photo_thumbnail:
upload_set.append("photo_thumbnail")
if wire_upload_enabled and self.wire_thumbnail:
upload_set.append("wire_thumbnail")
if self.main_file:
upload_set.append("MAINFILE")
@@ -1207,6 +1507,7 @@ class UploadOperator(Operator):
props.uploading = True
client_lib.asset_upload(upload_data, export_data, upload_set)
return {"FINISHED"}
def draw(self, context):
@@ -1227,6 +1528,14 @@ class UploadOperator(Operator):
if self.asset_type == "PRINTABLE":
layout.prop(self, "photo_thumbnail")
# Show wire_thumbnail option for models, scenes, and printable assets
if wire_thumbnail_upload_enabled() and self.asset_type in {
"MODEL",
"SCENE",
"PRINTABLE",
}:
layout.prop(self, "wire_thumbnail")
if props.asset_base_id != "" and not self.reupload:
utils.label_multiline(
layout,
@@ -1290,7 +1599,7 @@ class UploadOperator(Operator):
utils.label_multiline(
layout,
width=500,
text="Would you like tu upload your asset to BlenderKit?",
text="Would you like to upload your asset to BlenderKit?",
)
def invoke(self, context, event):
@@ -1300,6 +1609,20 @@ class UploadOperator(Operator):
)
return {"CANCELLED"}
ui_props = bpy.context.window_manager.blenderkitUI
if (
self.asset_type == "HDR"
and ui_props.hdr_use_custom_thumbnail_tone
and not self.skip_hdr_tune_popup
):
bpy.ops.wm.blenderkit_hdr_thumbnail_tune(
"INVOKE_DEFAULT",
trigger_upload=True,
upload_reupload=self.reupload,
)
return {"CANCELLED"}
if self.asset_type == "HDR":
# getting upload data for images ensures true_hdr check so users can be informed about their handling
# simple 360 photos or renders with LDR are hidden by default..
@@ -1425,7 +1748,7 @@ def handle_asset_upload(task: client_tasks.Task):
task.message, type="ERROR", details=task.message_detailed
)
# crazy shit to parse stupid Django incosistent error messages
# crazy shit to parse stupid Django inconsistent error messages
if "detail" in task.result:
if type(task.result["detail"]) == dict:
for key in task.result["detail"]:
@@ -1458,7 +1781,7 @@ def handle_asset_upload(task: client_tasks.Task):
if task.status == "finished":
asset.uploading = False
return reports.add_report("Upload successfull")
return reports.add_report("Upload successful")
def handle_asset_metadata_upload(task: client_tasks.Task):
@@ -1469,20 +1792,20 @@ def handle_asset_metadata_upload(task: client_tasks.Task):
new_asset_base_id = task.result.get("assetBaseId", "")
if new_asset_base_id != "":
asset.asset_base_id = new_asset_base_id
bk_logger.info(f"Assigned new asset.asset_base_id: {new_asset_base_id}")
bk_logger.info("Assigned new asset.asset_base_id: %s", new_asset_base_id)
else:
asset.asset_base_id = task.data["export_data"]["assetBaseId"]
bk_logger.info(f"Assigned original asset.asset_base_id: {asset.asset_base_id}")
bk_logger.info("Assigned original asset.asset_base_id: %s", asset.asset_base_id)
new_asset_id = task.result.get("id", "")
if new_asset_id != "":
asset.id = new_asset_id
bk_logger.info(f"Assigned new asset.id: {new_asset_id}")
bk_logger.info("Assigned new asset.id: %s", new_asset_id)
else:
asset.id = task.data["export_data"]["id"]
bk_logger.info(f"Assigned original asset.id: {asset.id}")
bk_logger.info("Assigned original asset.id: %s", asset.id)
return reports.add_report("Metadata upload successfull")
return reports.add_report("Metadata upload successful")
def patch_individual_parameter(asset_id="", param_name="", param_value="", api_key=""):
@@ -1593,7 +1916,7 @@ def mark_for_thumbnail(
asset_id, "markThumbnailRender", json_data, api_key
)
except Exception as e:
bk_logger.error(f"Failed to mark asset for thumbnail regeneration: {e}")
bk_logger.error("Failed to mark asset for thumbnail regeneration: %s", e)
return False
@@ -227,6 +227,28 @@ if __name__ == "__main__":
except Exception as e:
print(f"Exception {type(e)} during pack_all(): {e}")
# Blender 5.0+ does not pack all tiles of UDIM (source='TILED') images via
# pack_all(). Iterate and explicitly pack any tiled images whose tiles are not
# yet fully packed so that every UDIM tile file is included in the upload.
for image in bpy.data.images:
if getattr(image, "source", "") != "TILED":
continue
n_tiles = len(image.tiles) if hasattr(image, "tiles") else 0
n_packed = len(image.packed_files)
print(
f"UDIM image '{image.name}': {n_tiles} tile(s), "
f"{n_packed} packed file(s), filepath='{image.filepath}'"
)
if n_packed < n_tiles:
try:
image.pack()
print(
f" Explicitly packed UDIM image '{image.name}': "
f"{len(image.packed_files)} packed file(s)"
)
except Exception as e:
print(f" Could not pack UDIM image '{image.name}': {e}")
main_source.blenderkit.uploading = False
# write ID here.
main_source.blenderkit.asset_base_id = export_data["assetBaseId"]
+136 -7
View File
@@ -19,12 +19,12 @@
import datetime
import json
import logging
import numpy as np
import os
import platform
import re
import shutil
import sys
import tempfile
import uuid
from typing import Optional
@@ -43,7 +43,6 @@ from . import (
search,
)
bk_logger = logging.getLogger(__name__)
ABOVE_NORMAL_PRIORITY_CLASS = 0x00008000
@@ -123,8 +122,26 @@ def selection_set(sel):
bpy.context.view_layer.objects.active = sel[0]
for ob in sel[1]:
ob.select_set(True)
except Exception as e:
bk_logger.exception(f"failed to select objects: {str(e)}")
except Exception:
bk_logger.exception("Failed to select objects:")
def get_asset_data_from_ob(ob) -> Optional[dict]:
"""Return the BlenderKit asset_data dict for an object.
Checks the object's own IDProperty first. When the object is a
collection-instance EMPTY (imported from a local asset library) it falls
back to the asset_data stored on the instanced collection, which
unpack_asset_bg.py writes there during local-library processing.
"""
if ob is None:
return None
ad = ob.get("asset_data")
if ad is not None:
return ad
if ob.instance_collection is not None:
return ob.instance_collection.get("asset_data")
return None
def get_active_model() -> Optional[bpy.types.Object]:
@@ -309,6 +326,11 @@ def get_search_props():
if not hasattr(wm, "blenderkit_addon"):
return
props = wm.blenderkit_addon
if uiprops.asset_type == "AUTHOR":
if not hasattr(wm, "blenderkit_author"):
return
props = wm.blenderkit_author
return props
@@ -383,6 +405,8 @@ def get_active_asset():
return get_active_nodegroup()
elif ui_props.asset_type == "ADDON":
return None # Addons don't have an active asset concept
elif ui_props.asset_type == "AUTHOR":
return None # Authors don't have an active asset concept
return None
@@ -422,6 +446,8 @@ def get_upload_props():
return b.blenderkit
elif ui_props.asset_type == "ADDON":
return None # Addons don't have upload props
elif ui_props.asset_type == "AUTHOR":
return None # Authors don't have upload props
return None
@@ -444,6 +470,41 @@ def get_active_brush():
return brush
def get_brush_icon_path(brush) -> str:
"""Get the path to the brush icon image.
In Blender <= 4.5, brushes have icon_filepath. In Blender 5.0+, icon_filepath
was removed and brush preview is stored as in-memory pixels (brush.preview).
For 5.0+ we save the preview pixels to a temp PNG file.
"""
if bpy.app.version <= (4, 5, 0):
return bpy.path.abspath(brush.icon_filepath)
if brush.preview is None:
return ""
width, height = brush.preview.image_size
if width == 0 or height == 0:
return ""
filepath = os.path.join(
tempfile.gettempdir(), f"blenderkit_brush_{brush.name}_icon.png"
)
img = bpy.data.images.new(
name=f".bk_brush_preview_{brush.name}",
width=width,
height=height,
alpha=True,
)
try:
img.pixels.foreach_set(brush.preview.image_pixels_float)
img.filepath_raw = filepath
img.file_format = "PNG"
img.save()
finally:
bpy.data.images.remove(img)
return filepath
def get_scene_id():
"""gets scene id and possibly also generates a new one"""
bpy.context.scene["uuid"] = bpy.context.scene.get("uuid", str(uuid.uuid4()))
@@ -474,6 +535,7 @@ def get_preferences_as_dict():
"global_dir": user_preferences.global_dir,
"project_subdir": user_preferences.project_subdir,
"unpack_files": user_preferences.unpack_files,
"write_asset_metadata": user_preferences.write_asset_metadata,
# GUI
"show_on_start": user_preferences.show_on_start,
"thumb_size": user_preferences.thumb_size,
@@ -482,6 +544,7 @@ def get_preferences_as_dict():
"search_in_header": user_preferences.search_in_header,
"tips_on_start": user_preferences.tips_on_start,
"announcements_on_start": user_preferences.announcements_on_start,
"assetbar_follows_cursor": user_preferences.assetbar_follows_cursor,
# NETWORK
"client_port": user_preferences.client_port,
"ip_version": user_preferences.ip_version,
@@ -525,6 +588,7 @@ def get_preferences() -> datas.Prefs:
global_dir=user_preferences.global_dir, # type: ignore[union-attr]
project_subdir=user_preferences.project_subdir, # type: ignore[union-attr]
unpack_files=user_preferences.unpack_files, # type: ignore[union-attr]
write_asset_metadata=user_preferences.write_asset_metadata, # type: ignore[union-attr]
# GUI
show_on_start=user_preferences.show_on_start, # type: ignore[union-attr]
thumb_size=user_preferences.thumb_size, # type: ignore[union-attr]
@@ -533,6 +597,7 @@ def get_preferences() -> datas.Prefs:
search_in_header=user_preferences.search_in_header, # type: ignore[union-attr]
tips_on_start=user_preferences.tips_on_start, # type: ignore[union-attr]
announcements_on_start=user_preferences.announcements_on_start, # type: ignore[union-attr]
assetbar_follows_cursor=user_preferences.assetbar_follows_cursor, # type: ignore[union-attr]
# NETWORK
client_port=user_preferences.client_port, # type: ignore[union-attr]
ip_version=user_preferences.ip_version, # type: ignore[union-attr]
@@ -562,7 +627,15 @@ def save_prefs(user_preferences, context, **kwargs):
if bpy.app.background is True or bpy.app.factory_startup is True:
return
previous_global_dir = (
global_vars.PREFS.get("global_dir")
if isinstance(global_vars.PREFS, dict)
else None
)
global_vars.PREFS = get_preferences_as_dict()
paths.ensure_asset_library_path(
global_vars.PREFS.get("global_dir"), previous_global_dir
)
if user_preferences.preferences_lock is True:
return
@@ -637,6 +710,8 @@ def img_to_preview(img, copy_original=False):
if not copy_original:
return
import numpy as np
# Only process if image has alpha channel and needs filling
if img.channels == 4 and (
img.alpha_mode == "STRAIGHT" or img.alpha_mode == "PREMUL"
@@ -800,7 +875,7 @@ def copy_asset(fp1, fp2):
"""Synchronizes the asset between directories, including it's texture subdirectories."""
if 1:
bk_logger.debug("copy asset")
bk_logger.debug(fp1 + " " + fp2)
bk_logger.debug("%s %s", fp1, fp2)
if not os.path.exists(fp2):
shutil.copyfile(fp1, fp2)
bk_logger.debug("copied")
@@ -989,6 +1064,58 @@ def get_dimensions(obs):
return dim, bbmin, bbmax
def get_scene_dimensions(scene: bpy.types.Scene) -> tuple[Vector, Vector, Vector]:
"""Calculate world-space bounds for the entire scene."""
minx = miny = minz = float("inf")
maxx = maxy = maxz = float("-inf")
has_geometry = False
depsgraph = bpy.context.evaluated_depsgraph_get()
def accumulate(coord: Vector) -> None:
nonlocal minx, miny, minz, maxx, maxy, maxz, has_geometry
has_geometry = True
minx = min(minx, coord.x)
miny = min(miny, coord.y)
minz = min(minz, coord.z)
maxx = max(maxx, coord.x)
maxy = max(maxy, coord.y)
maxz = max(maxz, coord.z)
mesh_like_types = {"MESH", "CURVE", "SURFACE", "META", "FONT", "GPENCIL"}
for ob in scene.objects:
object_eval = ob.evaluated_get(depsgraph)
mw = object_eval.matrix_world
if ob.type in mesh_like_types:
to_mesh = getattr(object_eval, "to_mesh", None)
mesh = to_mesh() if callable(to_mesh) else None
if mesh:
for vert in mesh.vertices:
accumulate(mw @ vert.co)
to_mesh_clear = getattr(object_eval, "to_mesh_clear", None)
if callable(to_mesh_clear):
to_mesh_clear()
elif ob.type == "VOLUME":
for corner in object_eval.bound_box:
accumulate(mw @ Vector(corner))
elif hasattr(object_eval, "bound_box") and object_eval.bound_box:
for corner in object_eval.bound_box:
accumulate(mw @ Vector(corner))
else:
accumulate(mw @ Vector((0.0, 0.0, 0.0)))
if not has_geometry:
zero = Vector((0.0, 0.0, 0.0))
return zero.copy(), zero.copy(), zero.copy()
bbmin = Vector((minx, miny, minz))
bbmax = Vector((maxx, maxy, maxz))
dim = Vector((maxx - minx, maxy - miny, maxz - minz))
return dim, bbmin, bbmax
def get_simple_headers() -> dict[str, str]:
headers = {
"accept": "application/json",
@@ -1147,7 +1274,7 @@ def name_update(props, context=None):
fname = fname.replace("'", "")
fname = fname.replace('"', "")
if ui_props.asset_type == "HDR" or fname == "":
bk_logger.info(f"Skiping the rename")
bk_logger.info("Skipping the rename")
return # don't rename HDR's or with empty name
else:
asset = get_active_asset()
@@ -1273,6 +1400,8 @@ def asset_from_newer_blender_version(asset_data, blender_version=None):
# addons don't have a blender version, so we return False
if asset_data["assetType"] == "addon":
return False, ""
if asset_data["assetType"] == "author":
return False, ""
asset_ver = asset_data["sourceAppVersion"].split(".")
if blender_version is None:
blender_version = bpy.app.version
@@ -1543,7 +1672,7 @@ def check_globaldir_permissions():
)
return False
if not os.path.isdir(global_dir):
bk_logger.info(f"Global dir does not exist. Creating it at {global_dir}")
bk_logger.info("Global dir does not exist. Creating it at %s", global_dir)
try:
os.mkdir(global_dir)
except Exception as e:
@@ -0,0 +1,85 @@
"""Tolerant version comparator for real-world pipelines."""
import re
from functools import total_ordering
@total_ordering
class Version:
"""
Tolerant version comparator for real-world pipelines.
Handles:
v1.2
3.19
3.19.0-260127
3.19.0-rc1
3.19.0-rc1-260127
3.19_final
build_42
anything with numbers in it
"""
_PRERELEASE_RE = re.compile(r"(rc|alpha|beta|a|b)", re.I)
def __init__(self, raw: str):
self.raw = raw
self.parts = tuple(int(n) for n in re.findall(r"\d+", raw))
self.is_prerelease = bool(self._PRERELEASE_RE.search(raw))
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self.parts == other.parts and self.is_prerelease == other.is_prerelease
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
# compare numeric parts first
if self.parts != other.parts:
return self.parts < other.parts
# stable > prerelease
return self.is_prerelease and not other.is_prerelease
def __repr__(self):
return f"Version({self.raw!r})"
# -------------------------------
# Micro helpers
# -------------------------------
def version_gt(v1, v2):
"""Greater than"""
return Version(v1) > Version(v2)
def version_lt(v1, v2):
"""Less than"""
return Version(v1) < Version(v2)
def version_eq(v1, v2):
"""Equal"""
return Version(v1) == Version(v2)
def compare_versions(v1, v2):
"""Compare two version strings.
Returns:
-1 if v1 < v2
0 if v1 == v2
1 if v1 > v2
"""
ver1 = Version(v1)
ver2 = Version(v2)
if ver1 < ver2:
return -1
elif ver1 > ver2:
return 1
else:
return 0
@@ -0,0 +1,88 @@
import bpy
def _unique_window_regions(area):
if area is None:
return
seen = set()
def _register(region):
if getattr(region, "type", None) != "WINDOW":
return None
try:
handle = region.as_pointer()
except ReferenceError:
return None
if handle in seen:
return None
seen.add(handle)
return region
for region in getattr(area, "regions", []):
candidate = _register(region)
if candidate is not None:
yield candidate
space = None
try:
space = area.spaces.active
except ReferenceError:
space = None
if getattr(space, "type", None) != "VIEW_3D":
return
quadviews = getattr(space, "region_quadviews", None)
if not quadviews:
return
for quad in quadviews:
candidate = _register(getattr(quad, "region", None))
if candidate is not None:
yield candidate
def iter_view3d_window_regions(area):
"""Yield every VIEW_3D window region for the given area (quad view included)."""
yield from _unique_window_regions(area)
def region_data_for_view(area, region):
"""Return the RegionView3D that belongs to the given area/region pair."""
if region is None or getattr(region, "type", None) != "WINDOW":
return None
direct_data = getattr(region, "data", None)
if direct_data is not None:
return direct_data
if area is None:
return None
try:
region_ptr = region.as_pointer()
except ReferenceError:
return None
for space in getattr(area, "spaces", []):
if getattr(space, "type", None) != "VIEW_3D":
continue
quadviews = getattr(space, "region_quadviews", None)
if quadviews:
for quad in quadviews:
quad_region = getattr(quad, "region", None)
if quad_region is None:
continue
try:
if quad_region.as_pointer() == region_ptr:
return getattr(quad, "region_3d", None)
except ReferenceError:
continue
region_3d = getattr(space, "region_3d", None)
if region_3d is not None:
return region_3d
return None