work
save startup blend for animation tab & whatnot
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+55
-18
@@ -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
|
||||
|
||||
+7
-1
@@ -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)
|
||||
|
||||
+10
-7
@@ -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)
|
||||
|
||||
+226
-72
@@ -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()
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
+32
-8
@@ -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
|
||||
|
||||
|
||||
+19
-2
@@ -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"
|
||||
|
||||
+19
-4
@@ -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"
|
||||
|
||||
+3
@@ -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:
|
||||
|
||||
+2
-4
@@ -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.
|
||||
|
||||
+5
-5
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
+78
-7
@@ -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)
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+26
-7
@@ -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."""
|
||||
|
||||
+19
-8
@@ -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
|
||||
|
||||
|
||||
+6
-1
@@ -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 = {}
|
||||
|
||||
|
||||
+4
-2
@@ -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",
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
+17
-16
@@ -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).
|
||||
|
||||
+86
-13
@@ -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)
|
||||
|
||||
|
||||
+57
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+8
-6
@@ -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
|
||||
|
||||
-50
@@ -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
|
||||
+34
-7
@@ -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"""
|
||||
|
||||
|
||||
+37
-1
@@ -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):
|
||||
|
||||
+2
-2
@@ -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"]
|
||||
|
||||
Binary file not shown.
@@ -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):
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user