2026-02-16

This commit is contained in:
2026-03-17 15:25:32 -06:00
parent d5dd373de0
commit 60100fbab2
560 changed files with 33397 additions and 20776 deletions
+55 -18
View File
@@ -20,7 +20,7 @@
bl_info = {
"name": "BlenderKit Online Asset Library",
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik",
"version": (3, 18, 0, 251121), # X.Y.Z.yymmdd
"version": (3, 18, 1, 251219), # X.Y.Z.yymmdd
"blender": (3, 0, 0),
"location": "View3D > Properties > BlenderKit",
"description": "Boost your workflow with drag&drop assets from the community driven library.",
@@ -28,7 +28,7 @@ bl_info = {
"tracker_url": "https://github.com/BlenderKit/blenderkit/issues",
"category": "3D View",
}
VERSION = (3, 18, 0, 251121)
VERSION = (3, 18, 1, 251219)
import logging
import random
@@ -242,7 +242,7 @@ engines = (
("CYCLES", "Cycles", "Blender Cycles"),
("EEVEE", "Eevee", "Blender eevee renderer"),
("EEEVE_NEXT", "Eevee Next", "Blender eevee renderer (new)"),
("OCTANE", "Octane", "Octane render enginge"),
("OCTANE", "Octane", "Octane render engine"),
("ARNOLD", "Arnold", "Arnold render engine"),
("V-RAY", "V-Ray", "V-Ray renderer"),
("UNREAL", "Unreal", "Unreal engine"),
@@ -267,6 +267,12 @@ mesh_poly_types = (
)
EXTRA_PATH_OPTIONS = {}
if bpy.app.version >= (4, 5, 0):
EXTRA_PATH_OPTIONS = {"options": {"PATH_SUPPORTS_BLEND_RELATIVE"}}
def udate_down_up(self, context):
"""Perform a search if results are empty."""
props = bpy.context.window_manager.blenderkitUI
@@ -461,12 +467,12 @@ class BlenderKitUIProps(PropertyGroup):
search_blender_version: BoolProperty(
name="Asset Blender Version",
description="Limit the assets by version of Blender (minimum, maximum) in which they were created. "
+ "Use maximum version limit to exclude incompatible assets from newer Blender versions than yours. Or set the minumum version to exclude assets created in quite old Blender versions",
+ "Use maximum version limit to exclude incompatible assets from newer Blender versions than yours. Or set the minimum version to exclude assets created in quite old Blender versions",
)
search_blender_version_min: StringProperty(
name="Minimal version (including, higher than or equal)",
default="0.0",
description="Limit the assets by minimum version of Blender in which they were created, including also the specified version and exluding all older versions from the search results. "
description="Limit the assets by minimum version of Blender in which they were created, including also the specified version and excluding all older versions from the search results. "
+ "Only assets created in HIGHER THAN OR EQUAL (>= min) minimum version will be shown. Use semantic versioning format: X.Y.Z.\n\n"
+ "E.g.: exclude all Blender 2 assets by specifying 3, 3.0, or 3.0.0. Assets created in 3.0 or higher will be shown",
update=search.search_update,
@@ -474,7 +480,7 @@ class BlenderKitUIProps(PropertyGroup):
search_blender_version_max: StringProperty(
name="Maximum version (excluding, lower than)",
default="5.99",
description="Limit the assets by maximum version of Blender in which they were created, exluding the specified version and all newer versions from the search results. "
description="Limit the assets by maximum version of Blender in which they were created, excluding the specified version and all newer versions from the search results. "
+ "Only assets created in LOWER THAN (< max) maximum version will be shown. Use semantic versioning format: X.Y.Z.\n\n"
+ "E.g.: exclude all Blender 4 assets by specifying 4, 4.0, or 4.0.0. Assets created in 3.6 and lower will be shown",
update=search.search_update,
@@ -580,7 +586,7 @@ class BlenderKitUIProps(PropertyGroup):
rating_ui_width: IntProperty(name="Rating UI Width", default=rating_ui_scale * 600)
rating_ui_height: IntProperty(
name="Rating UI Heightt", default=rating_ui_scale * 256
name="Rating UI Height", default=rating_ui_scale * 256
)
quality_stars_x: IntProperty(name="Rating UI Stars X", default=rating_ui_scale * 90)
@@ -1130,6 +1136,7 @@ class BlenderKitMaterialUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_material_preview,
**EXTRA_PATH_OPTIONS,
)
is_generating_thumbnail: BoolProperty(
@@ -1213,13 +1220,14 @@ class BlenderKitBrushUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
)
class BlenderKitNodeGroulUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
class BlenderKitNodeGroupUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail: StringProperty(
name="Thumbnail",
description="Thumbnail path - minimum 1024x1024 square .jpg\n"
"And make it beautiful!",
subtype="FILE_PATH",
default="",
**EXTRA_PATH_OPTIONS,
# update=autothumb.update_upload_model_preview,
)
# mode: EnumProperty(
@@ -1326,6 +1334,7 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_model_preview,
**EXTRA_PATH_OPTIONS,
)
thumbnail_background_lightness: FloatProperty(
@@ -1529,6 +1538,7 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
description="Photo of the 3D printed object (JPG or PNG, preferred size is 1024x1024 or higher)",
subtype="FILE_PATH",
default="",
**EXTRA_PATH_OPTIONS,
)
photo_thumbnail_will_upload_on_website: BoolProperty(
name="I will upload photo on website",
@@ -1603,6 +1613,7 @@ class BlenderKitSceneUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_scene_preview,
**EXTRA_PATH_OPTIONS,
)
use_design_year: BoolProperty(
@@ -1766,7 +1777,7 @@ class BlenderKitModelSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
update=search.search_update,
)
search_design_year: BoolProperty(
name="Sesigned in Year",
name="Designed in Year",
description="When the object was approximately designed. \n"
"Useful for search of historical or future objects",
default=False,
@@ -1966,7 +1977,7 @@ def fix_subdir(self, context):
ui_panels.ui_message(
title="Fixed to relative path",
message="This path should be always realative.\n"
message="This path should be always relative.\n"
" It's a directory BlenderKit creates where your .blend is \n "
"and uses it for storing assets.",
)
@@ -1992,7 +2003,7 @@ class BlenderKitAddonPreferences(AddonPreferences):
preferences_lock: BoolProperty(
name="Preferences Locked",
description="When this is on, preferences will not be saved. Used for programatical changes of preferences",
description="When this is on, preferences will not be saved. Used for programmatic changes of preferences",
default=False,
)
@@ -2120,6 +2131,14 @@ class BlenderKitAddonPreferences(AddonPreferences):
update=utils.save_prefs,
)
# USE OF CLIPBOARD SCAN
use_clipboard_scan: BoolProperty(
name="Use Clipboard Scan",
description="Use the info from BlenderKit website clipboard for visual search",
default=True,
update=utils.save_prefs,
)
unpack_files: BoolProperty(
name="Unpack Files",
description="Unpack assets after download \n "
@@ -2233,8 +2252,8 @@ class BlenderKitAddonPreferences(AddonPreferences):
proxy_address: StringProperty(
name="Custom proxy address",
description="""Set custom HTTP proxy for HTTPS requests of add-on. This setting preceeds any system wide proxy settings. If left empty custom proxy will not be set.
description="""Set custom HTTP proxy for HTTPS requests of add-on. This setting precedes any system wide proxy settings. If left empty custom proxy will not be set.
If you use simple HTTP proxy, set in format http://ip:port, or http://username:password@ip:port if your HTTP proxy requires authentication (make sure to escape special characters like #$%:^&*() etc. in username and password). You have to specify the address with http:// prefix.
HTTPS proxies are not supported! We wait for support in Python 3.11 and in aiohttp module. You can specify the HTTPS proxy with https:// prefix for hacking around and development purposes, but functionality cannot be guaranteed.
@@ -2430,12 +2449,21 @@ In this case you should also set path to your system CA bundle containing proxy'
default="[]",
)
# EXPERIMENTAL AND DEBUG FEATURES CAN GO BELOW
ignore_env_for_thumbnails: BoolProperty(
name="Ignore ENVIRONMENT variables for thumbnails",
description="If enabled, we will not modify the system environment variables for background thumbnail rendering.",
default=False,
# do not save prefs here, it's experimental
options={"SKIP_SAVE"},
)
def draw(self, context):
layout = self.layout
if self.api_key.strip() == "":
ui_panels.draw_login_buttons(layout)
layout.label(
text="Sign up to bookmark your favourite assets. Get 200 MiB of private storage in Free Plan."
text="Sign up to bookmark your favorite assets. Get 200 MiB of private storage in Free Plan."
)
else:
layout.operator("wm.blenderkit_logout", text="Logout", icon="URL")
@@ -2470,8 +2498,9 @@ In this case you should also set path to your system CA bundle containing proxy'
gui_settings.prop(self, "show_VIEW3D_MT_blenderkit_model_properties")
gui_settings.prop(self, "tips_on_start")
gui_settings.prop(self, "announcements_on_start")
gui_settings.prop(self, "use_clipboard_scan")
# NETWORKING SETINGS
# NETWORKING SETTINGS
network_settings = layout.box()
network_settings.alignment = "EXPAND"
network_settings.label(text="Networking settings")
@@ -2487,6 +2516,14 @@ In this case you should also set path to your system CA bundle containing proxy'
# UPDATER SETTINGS
addon_updater_ops.update_settings_ui(self, context)
# EXPERIMENTAL SETTINGS
# only if experimental features enabled
if self.experimental_features:
experimental_settings = layout.box()
experimental_settings.alignment = "EXPAND"
experimental_settings.label(text="Experimental settings")
experimental_settings.prop(self, "ignore_env_for_thumbnails")
# RUNTIME INFO
globdir_op = layout.operator(
"wm.blenderkit_open_global_directory",
@@ -2535,7 +2572,7 @@ classes = (
BlenderKitBrushSearchProps,
BlenderKitBrushUploadProps,
BlenderKitGeoToolSearchProps,
BlenderKitNodeGroulUploadProps,
BlenderKitNodeGroupUploadProps,
BlenderKitAddonSearchProps,
)
@@ -2598,10 +2635,10 @@ def register():
type=BlenderKitGeoToolSearchProps
)
bpy.types.NodeGroup.blenderkit = PointerProperty( # for uploads, not now...
type=BlenderKitNodeGroulUploadProps
type=BlenderKitNodeGroupUploadProps
)
bpy.types.NodeTree.blenderkit = PointerProperty( # for uploads, not now...
type=BlenderKitNodeGroulUploadProps
type=BlenderKitNodeGroupUploadProps
)
bpy.types.WindowManager.blenderkit_addon = PointerProperty(
type=BlenderKitAddonSearchProps
@@ -97,7 +97,13 @@ def make_annotations(cls):
if bl_props:
if "__annotations__" not in cls.__dict__:
setattr(cls, "__annotations__", {})
annotations = cls.__dict__["__annotations__"]
try:
annotations = cls.__dict__["__annotations__"]
except KeyError:
# Fedora 43 bug workaround #1823
annotations = getattr(cls, "__annotations__")
for k, v in bl_props.items():
annotations[k] = v
delattr(cls, k)
@@ -42,15 +42,19 @@ def find_layer_collection(layer_collection, collection_name):
def append_brush(file_name, brushname=None, link=False, fake_user=True):
"""append a brush"""
brushes_before = bpy.data.brushes[:]
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for m in data_from.brushes:
if m == brushname or brushname is None:
if brushname is None or m.strip() == brushname.strip():
data_to.brushes = [m]
brushname = m
brush = bpy.data.brushes[brushname]
for b in bpy.data.brushes:
if b not in brushes_before:
brush = b
break
brush.use_fake_user = fake_user
return brush
@@ -93,8 +97,7 @@ def append_nodegroup(
data_to,
):
for g in data_from.node_groups:
print(g)
if g == nodegroupname or nodegroupname is None:
if nodegroupname is None or g.strip() == nodegroupname.strip():
data_to.node_groups = [g]
nodegroupname = g
nodegroup = bpy.data.node_groups[nodegroupname]
@@ -281,7 +284,7 @@ def append_material(file_name, matname=None, link=False, fake_user=True):
):
found = False
for m in data_from.materials:
if m == matname or matname is None:
if matname is None or m.strip() == matname.strip():
data_to.materials = [m]
matname = m
found = True
@@ -319,7 +322,7 @@ def append_scene(file_name, scenename=None, link=False, fake_user=False):
data_to,
):
for s in data_from.scenes:
if s == scenename or scenename is None:
if scenename is None or s.strip() == scenename.strip():
data_to.scenes = [s]
scenename = s
scene = bpy.data.scenes[scenename]
@@ -448,7 +451,7 @@ def link_collection(
data_to,
):
for col in data_from.collections:
if col == kwargs["name"]:
if col.strip() == kwargs["name"].strip():
data_to.collections = [col]
rotation = (0, 0, 0)
@@ -21,7 +21,7 @@ import math
import os
import re
import time
from typing import Any, Dict
from typing import Any, Dict, Union
import bpy
from bpy.props import BoolProperty, StringProperty
@@ -286,6 +286,12 @@ def modal_inside(self, context, event):
if self.check_ui_resized(context) or self.check_new_search_results(context):
self.update_assetbar_sizes(context)
self.update_assetbar_layout(context)
# also update tooltip visibility
# if there's less results and active button is not visible, hide tooltip
# happened only when e.g. running new search from web browser (copying assetbaseid to clipboard)
# fixes issue #1766
if self.active_index >= len(search.get_search_results()):
self.hide_tooltip()
self.scroll_update(
always=True
) # one extra update for scroll for correct redraw, updates all buttons
@@ -395,6 +401,17 @@ def get_tooltip_data(asset_data):
# Add pricing information
price_text = ""
price_color = colors.WHITE
price_background = (0, 0, 0, 0)
def format_price(value):
if value is None:
return ""
value_str = str(value).strip()
if not value_str:
return ""
if value_str.startswith("$"):
return value_str
return f"${value_str}"
# Check if asset is free or paid (works for all asset types)
is_free = asset_data.get("isFree", True)
@@ -403,23 +420,38 @@ def get_tooltip_data(asset_data):
if asset_data.get("assetType") == "addon":
# Get pricing info from extensions cache.
# Pricing info is shown only for add-ons.
base_price = asset_data.get("basePrice")
base_price = format_price(asset_data.get("basePrice"))
user_price = format_price(asset_data.get("userPrice"))
is_for_sale = asset_data.get("isForSale")
if is_for_sale and not can_download and base_price:
price_text = f"${base_price}"
price_color = colors.PURPLE
if utils.profile_is_validator():
segments = []
if user_price:
segments.append(f"User {user_price}")
if base_price:
segments.append(f"Base {base_price}")
price_text = " | ".join(segments)
price_background = colors.PURPLE_PRICE
elif is_for_sale and not can_download and user_price and base_price:
price_text = f"{user_price} (was {base_price})"
price_background = colors.PURPLE_PRICE
elif is_for_sale and not can_download and base_price:
price_text = base_price
price_background = colors.PURPLE_PRICE
elif not is_free and not is_for_sale:
price_text = "Full Plan"
price_color = colors.PURPLE
elif (
is_for_sale and can_download
): # purchased, but not yet downloaded, so we can't show price
price_text = f"Purchased (${base_price})"
price_color = colors.PURPLE
price_background = colors.ORANGE_FULL
elif is_for_sale and can_download:
price_text = "Purchased"
price_background = colors.PURPLE_PRICE
else:
price_text = "Free"
price_color = colors.GREEN_FREE
price_background = colors.GREEN_PRICE
tooltip_data = {
"aname": aname,
@@ -427,12 +459,15 @@ def get_tooltip_data(asset_data):
"quality": quality,
"price_text": price_text,
"price_color": price_color,
"price_background": price_background,
}
asset_data["tooltip_data"] = tooltip_data
def set_thumb_check(
element: BL_UI_Button, asset: Dict[str, Any], thumb_type: str = "thumbnail_small"
element: Union[BL_UI_Button, BL_UI_Image],
asset: Dict[str, Any],
thumb_type: str = "thumbnail_small",
) -> None:
"""Set image in case it is loaded in search results. Checks global_vars.DATA["images available"].
- if image download failed, it will be set to 'thumbnail_not_available.jpg'
@@ -457,6 +492,8 @@ def set_thumb_check(
class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""BlenderKit Asset Bar Operator."""
bl_idname = "view3d.blenderkit_asset_bar_widget"
bl_label = "BlenderKit asset bar refresh"
bl_description = "BlenderKit asset bar refresh"
@@ -508,8 +545,23 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""Initialize the tooltip panel and its widgets."""
self.tooltip_widgets = []
self.tooltip_scale = 1.0
self.tooltip_height = self.tooltip_size
self.tooltip_width = self.tooltip_size
# Fallbacks in case update_tooltip_size was not called yet
self.tooltip_width = getattr(self, "tooltip_width", self.tooltip_size)
image_height = getattr(self, "tooltip_image_height", self.tooltip_size)
info_height = getattr(
self,
"tooltip_info_height",
max(
int(image_height * self.bottom_panel_fraction),
self.asset_name_text_size * 3,
),
)
self.tooltip_image_height = image_height
self.tooltip_info_height = info_height
self.tooltip_height = self.tooltip_image_height + self.tooltip_info_height
self.labels_start = self.tooltip_image_height
# total_size = tooltip# + 2 * self.margin
self.tooltip_panel = BL_UI_Drag_Panel(
0, 0, self.tooltip_width, self.tooltip_height
@@ -520,20 +572,16 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tooltip_image = BL_UI_Image(0, 0, 1, 1)
img_path = paths.get_addon_thumbnail_path("thumbnail_notready.jpg")
tooltip_image.set_image(img_path)
tooltip_image.set_image_size((self.tooltip_width, self.tooltip_height))
tooltip_image.set_image_size((self.tooltip_width, self.tooltip_image_height))
tooltip_image.set_image_position((0, 0))
tooltip_image.set_image_colorspace("")
self.tooltip_image = tooltip_image
self.tooltip_widgets.append(tooltip_image)
self.bottom_panel_fraction = 0.15
self.labels_start = self.tooltip_height * (1 - self.bottom_panel_fraction)
dark_panel = BL_UI_Widget(
0,
self.labels_start,
self.tooltip_width,
self.tooltip_height * self.bottom_panel_fraction,
self.tooltip_info_height,
)
dark_panel.bg_color = (0.0, 0.0, 0.0, 0.7)
self.tooltip_dark_panel = dark_panel
@@ -549,8 +597,9 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.asset_name = name_label
self.tooltip_widgets.append(name_label)
self.gravatar_size = int(
self.tooltip_height * self.bottom_panel_fraction - self.tooltip_margin
self.gravatar_size = max(
int(self.tooltip_info_height - 2 * self.tooltip_margin),
self.asset_name_text_size,
)
authors_name = self.new_text(
@@ -566,8 +615,8 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_widgets.append(authors_name)
gravatar_image = BL_UI_Image(
self.tooltip_width - self.gravatar_size,
self.tooltip_height - self.gravatar_size,
self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_height - self.gravatar_size - self.tooltip_margin,
1,
1,
)
@@ -575,8 +624,8 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
gravatar_image.set_image(img_path)
gravatar_image.set_image_size(
(
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size,
self.gravatar_size,
)
)
gravatar_image.set_image_position((0, 0))
@@ -617,7 +666,14 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
height=self.asset_name_text_size,
text_size=self.asset_name_text_size,
)
price_label.text_color = (1.0, 0.8, 0.2, 1.0) # Golden color for price
price_label.background = True
price_label.padding = (3, 4)
price_label.text_color = (
1.0,
0.8,
0.2,
1.0,
) # Golden color for price
self.tooltip_widgets.append(price_label)
self.price_label = price_label
@@ -728,14 +784,30 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""Calculate all important sizes for the tooltip"""
region = context.region
ui_props = bpy.context.window_manager.blenderkitUI
ui_scale = bpy.context.preferences.view.ui_scale
ui_scale = self.get_ui_scale()
base_panel_height = self.tooltip_base_size_pixels * (
1 + self.bottom_panel_fraction
)
if hasattr(self, "tooltip_panel"):
tooltip_y_offset = abs(region.height - self.tooltip_panel.y_screen)
tooltip_y_available_height = abs(
region.height - self.tooltip_panel.y_screen
)
# if tooltip is above, we need to reduce it's size if its y is out of region height
if self.tooltip_panel.y_screen <= 0:
tooltip_y_available_height = (
base_panel_height * ui_scale + self.tooltip_panel.y_screen
)
self.tooltip_panel.set_location(self.tooltip_panel.x, 0)
else:
tooltip_y_offset = abs(region.height - (self.bar_height + self.bar_y))
tooltip_y_available_height = abs(
region.height - (self.bar_height + self.bar_y)
)
self.tooltip_scale = min(
1.0, tooltip_y_offset / (self.tooltip_base_size_pixels * ui_scale)
1.0, tooltip_y_available_height / (base_panel_height * ui_scale)
)
self.asset_name_text_size = int(
0.039 * self.tooltip_base_size_pixels * ui_scale * self.tooltip_scale
@@ -750,14 +822,33 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
if ui_props.asset_type == "HDR":
self.tooltip_width = self.tooltip_size * 2
self.tooltip_height = self.tooltip_size
self.tooltip_image_height = self.tooltip_size
else:
self.tooltip_width = self.tooltip_size
self.tooltip_height = self.tooltip_size
self.tooltip_image_height = self.tooltip_size
self.gravatar_size = int(
self.tooltip_height * self.bottom_panel_fraction - self.tooltip_margin
self.tooltip_info_height = max(
int(self.tooltip_image_height * self.bottom_panel_fraction),
self.asset_name_text_size * 3,
)
self.labels_start = self.tooltip_image_height
self.tooltip_height = self.tooltip_image_height + self.tooltip_info_height
self.gravatar_size = max(
int(self.tooltip_info_height - 2 * self.tooltip_margin),
self.asset_name_text_size,
)
def get_ui_scale(self):
"""Get the UI scale"""
ui_scale = bpy.context.preferences.view.ui_scale
pixel_size = bpy.context.preferences.system.pixel_size
if pixel_size > 1:
# for a reason unknown,
# the pixel size is modified only on mac
# where pixel size is 2.0
ui_scale = pixel_size
return ui_scale
def update_assetbar_sizes(self, context):
"""Calculate all important sizes for the asset bar"""
@@ -766,8 +857,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
ui_props = bpy.context.window_manager.blenderkitUI
user_preferences = bpy.context.preferences.addons[__package__].preferences
ui_scale = bpy.context.preferences.view.ui_scale
ui_scale = self.get_ui_scale()
# assetbar scaling
self.button_margin = int(0 * ui_scale)
self.assetbar_margin = int(2 * ui_scale)
@@ -793,6 +883,10 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.bar_x = int(
tools_width + self.button_margin + ui_props.bar_x_offset * ui_scale
)
# self.bar_y = region.height - ui_props.bar_y_offset * ui_scale
self.bar_y = int(ui_props.bar_y_offset * ui_scale)
self.bar_end = int(ui_width + 180 * ui_scale + self.other_button_size)
self.bar_width = int(region.width - self.bar_x - self.bar_end)
@@ -810,6 +904,16 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
if search_results is not None and self.wcount > 0:
if user_preferences.assetbar_expanded:
max_rows = user_preferences.maximized_assetbar_rows
available_height = (
region.height
- self.bar_y
- 2 * self.assetbar_margin
- self.other_button_size
)
max_rows_by_height = math.floor(available_height / self.button_size)
max_rows = (
min(max_rows, max_rows_by_height) if max_rows_by_height > 0 else 1
)
else:
max_rows = 1
self.hcount = min(
@@ -821,8 +925,6 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.hcount = 1
self.bar_height = (self.button_size) * self.hcount + 2 * self.assetbar_margin
# self.bar_y = region.height - ui_props.bar_y_offset * ui_scale
self.bar_y = int(ui_props.bar_y_offset * ui_scale)
if ui_props.down_up == "UPLOAD":
self.reports_y = region.height - self.bar_y - 600
ui_props.reports_y = region.height - self.bar_y - 600
@@ -886,26 +988,28 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_panel.width = self.tooltip_width
self.tooltip_panel.height = self.tooltip_height
self.tooltip_image.width = self.tooltip_width
self.tooltip_image.height = self.tooltip_height
self.tooltip_image.height = self.tooltip_image_height
self.labels_start = self.tooltip_height * (1 - self.bottom_panel_fraction)
self.labels_start = self.tooltip_image_height
self.tooltip_image.set_image_size((self.tooltip_width, self.tooltip_height))
self.tooltip_image.set_image_size(
(self.tooltip_width, self.tooltip_image_height)
)
self.tooltip_image.set_location(0, 0)
self.gravatar_image.set_location(
self.tooltip_width - self.gravatar_size,
self.tooltip_height - self.gravatar_size,
self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_height - self.gravatar_size - self.tooltip_margin,
)
self.gravatar_image.set_image_size(
(
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size,
self.gravatar_size,
)
)
self.authors_name.set_location(
self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_width - self.gravatar_size - (self.tooltip_margin * 2),
self.tooltip_height - self.author_text_size - self.tooltip_margin,
)
self.authors_name.text_size = self.author_text_size
@@ -922,9 +1026,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
0,
self.labels_start,
)
self.tooltip_dark_panel.height = (
self.tooltip_height * self.bottom_panel_fraction
)
self.tooltip_dark_panel.height = self.tooltip_info_height
self.tooltip_dark_panel.width = self.tooltip_width
self.quality_label.set_location(
@@ -942,6 +1044,15 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
(self.asset_name_text_size, self.asset_name_text_size)
)
# right after the asset name
self.price_label.set_location(
self.tooltip_margin,
self.labels_start + (self.tooltip_margin * 3) + self.asset_name.height,
)
self.price_label.width = self.tooltip_width - 2 * self.tooltip_margin
self.price_label.height = self.asset_name_text_size
self.price_label.text_size = self.asset_name_text_size
def update_layout(self, context, event):
"""update UI sizes after their recalculation"""
self.update_assetbar_layout(context)
@@ -1044,6 +1155,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.button_bg_color = (0.2, 0.2, 0.2, 1.0)
self.button_hover_color = (0.8, 0.8, 0.8, 1.0)
self.button_selected_color = (0.5, 0.5, 0.5, 1.0)
self.button_selected_color_dim = (0.3, 0.3, 0.3, 1.0)
self.buttons = []
self.asset_buttons = []
@@ -1072,7 +1184,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.other_button_size, # Same height as tab buttons
)
# dark blue
self.tab_area_bg.bg_color = (0.2, 0.25, 0.4, 1.0)
self.tab_area_bg.bg_color = colors.TOP_BAR_BLUE
# Add widgets to panel - add tab background first so it's behind everything
self.widgets_panel.append(self.tab_area_bg)
@@ -1162,8 +1274,11 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Add tab navigation elements
button_size = self.other_button_size
margin = int(button_size * 0.05)
space = int(button_size * 0.4)
tab_icon_size = int(button_size * 0.7) # Size for the asset type icon
tab_width = button_size * 4 # Wider tabs to accommodate icon
tab_width = (
button_size * 4 + tab_icon_size
) # Widen the tabs to accommodate type icon
# Back/Forward history buttons
self.history_back_button = BL_UI_Button(
@@ -1199,10 +1314,14 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tabs = global_vars.TABS["tabs"]
tab_x_start = margin * 4 + button_size * 3 # Starting x position of first tab
tabs_end_x = 0
for i, tab in enumerate(tabs):
is_active = i == global_vars.TABS["active_tab"]
# Calculate positions
tab_x = tab_x_start + i * (
tab_width + button_size + margin
tab_width + button_size + margin + space
) # Space for tab and close button
# Tab button
@@ -1212,13 +1331,15 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tab_width, # Width of tab
button_size,
)
tab_button.bg_color = self.button_bg_color
if i == global_vars.TABS["active_tab"]:
tab_button.bg_color = self.button_selected_color
tab_button.hover_bg_color = self.button_hover_color
tab_button.text = tab["name"]
tab_button.text_size = button_size * 0.5
tab_button.text_color = self.text_color
tab_button.bg_color = self.button_bg_color
if is_active:
tab_button.bg_color = self.button_selected_color
tab_button.tab_index = i # Store tab index
tab_button.set_mouse_down(self.switch_tab) # Add click handler
self.tab_buttons.append(tab_button)
@@ -1226,7 +1347,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Set asset type icon as tab button image
tab_button.set_image_size((tab_icon_size, tab_icon_size))
tab_button.set_image_position(
(margin, (button_size - tab_icon_size) / 2)
(margin * 2, (button_size - tab_icon_size) / 2)
) # Center vertically
# Only create close button if there's more than one tab
@@ -1243,22 +1364,24 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
close_tab.text = "×" # Set text after creation
close_tab.text_size = button_size * 0.8
close_tab.text_color = self.text_color
if is_active:
close_tab.bg_color = self.button_selected_color_dim
close_tab.tab_index = i # Store tab index
# if there's only one tab, the button closes asset bar instead of closing tab
if len(tabs) > 1:
close_tab.set_mouse_down(self.remove_tab) # Add click handler
else:
close_tab.set_mouse_down(self.cancel_press)
self.close_tab_buttons.append(close_tab)
tabs_end_x = close_x + button_size
# New tab button - position after all tabs and close buttons
if len(tabs) > 0:
last_tab_index = len(tabs) - 1
last_tab_x = tab_x_start + last_tab_index * (
tab_width + button_size + margin
)
new_tab_x = (
last_tab_x + tab_width + button_size + margin * 2
space + tabs_end_x + margin * 2
) # After last tab and its close button
else:
new_tab_x = tab_x_start # If no tabs, start at the beginning
@@ -1302,8 +1425,6 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
active_tab["history_index"] < len(active_tab["history"]) - 1
)
# self.update_buttons()
def set_element_images(self):
"""set ui elements images, has to be done after init of UI."""
# img_fp = paths.get_addon_thumbnail_path("vs_rejected.png")
@@ -1345,7 +1466,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
icon_path = paths.get_addon_thumbnail_path(
f"asset_type_{asset_type}.png"
)
if not os.path.exists(icon_path):
if not paths.icon_path_exists(icon_path):
icon_path = paths.get_addon_thumbnail_path(
"asset_type_model.png"
)
@@ -1444,7 +1565,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""Initialize the asset bar operator."""
self.tooltip_base_size_pixels = 512
self.tooltip_scale = 1.0
self.bottom_panel_fraction = 0.15
self.bottom_panel_fraction = 0.18
self.needs_tooltip_update = False
self.update_ui_size(bpy.context)
@@ -1679,9 +1800,13 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
price_color = asset_data["tooltip_data"].get(
"price_color", (1.0, 0.8, 0.2, 1.0)
)
price_background = asset_data["tooltip_data"].get(
"price_background", (0.2, 0.2, 0.2, 0.0)
)
self.price_label.text = price_text
self.price_label.text_color = price_color
self.price_label.visible = bool(price_text)
self.price_label.bg_color = price_background
# preview comments for validators
self.update_comments_for_validators(asset_data)
@@ -1721,7 +1846,22 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
- properties_width
),
)
tooltip_y = int(widget.y_screen + widget.height)
# Calculate space above and below the button
ui_scale = self.get_ui_scale()
full_tooltip_height = self.tooltip_panel.height
space_above = widget.y_screen
space_below = bpy.context.region.height - (widget.y_screen + widget.height)
# If space below is insufficient (would make tooltip < 70% size), position above
if (
space_below < full_tooltip_height
and space_below < full_tooltip_height * 0.7
and space_below < space_above
):
tooltip_y = int(widget.y_screen - full_tooltip_height)
else:
tooltip_y = int(widget.y_screen + widget.height)
# need to set image here because of context issues.
img_path = paths.get_addon_thumbnail_path("star_grey.png")
self.quality_star.set_image(img_path)
@@ -1730,7 +1870,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_panel.set_location(tooltip_x, tooltip_y)
self.update_tooltip_size(bpy.context)
self.update_tooltip_layout(bpy.context)
self.tooltip_panel.set_location(tooltip_x, tooltip_y)
self.tooltip_panel.set_location(self.tooltip_panel.x, self.tooltip_panel.y)
self.tooltip_panel.layout_widgets()
# show bookmark button - always on mouse enter
if widget.bookmark_button:
@@ -2317,6 +2457,10 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
return # Already on this tab and history step
# make original tab original background color
self.tab_buttons[global_vars.TABS["active_tab"]].bg_color = self.button_bg_color
# make also tab close button original background color
self.close_tab_buttons[global_vars.TABS["active_tab"]].bg_color = (
self.button_bg_color
)
global_vars.TABS["active_tab"] = tab_index
global_vars.TABS["tabs"][tab_index]["history_index"] = history_index
@@ -2354,14 +2498,24 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Update history button visibility
active_tab = global_vars.TABS["tabs"][tab_index]
self.history_back_button.visible = active_tab["history_index"] > 0
self.history_forward_button.visible = (
active_tab["history_index"] < len(active_tab["history"]) - 1
)
# make active tab a bit darker
if len(self.tab_buttons) > tab_index:
self.tab_buttons[tab_index].bg_color = self.button_selected_color
# update tab colors
for tab_button in self.tab_buttons:
c_tab_index = tab_button.tab_index
if c_tab_index == tab_index:
tab_button.bg_color = self.button_selected_color
self.close_tab_buttons[tab_index].bg_color = (
self.button_selected_color_dim
)
else:
tab_button.bg_color = self.button_bg_color
self.close_tab_buttons[c_tab_index].bg_color = self.button_bg_color
# update filters
search.update_filters()
@@ -1467,8 +1467,18 @@ class AssetDragOperator(bpy.types.Operator):
Tuple[None, None, None],
]:
"""Find the window, region and area under the mouse cursor."""
# Iterate windows backwards, so we go from the top-most window to the bottommost window
for window in reversed(bpy.context.window_manager.windows):
wins = bpy.context.window_manager.windows[:]
# reverse the list, seemed to work well at least on windows.
wins.reverse()
context_win = bpy.context.window
# let's prioritize the context window
if context_win is not None:
wins.remove(context_win)
wins.insert(0, context_win)
for window in wins:
# first let's test if it's in this window, so we know we shall continue
window_x = window.x * self.resolution_factor
window_y = window.y * self.resolution_factor
@@ -27,6 +27,8 @@ from . import utils
RENDER_OBTYPES = ["MESH", "CURVE", "SURFACE", "METABALL", "TEXT"]
_BLE_5_PLUS = bpy.app.version >= (5, 0, 0)
def check_material(props, mat):
e = bpy.context.scene.render.engine
@@ -217,17 +219,39 @@ def check_rig(props, obs):
props.rig = True
def has_keyframes(obj):
"""Checks if object has animation data with keyframes.
This function only checks for keyframes,
may return false negatives for objects animated with constraints, drivers, etc.
"""
if obj.animation_data is None:
return False
a = obj.animation_data.action
if a is None:
return False
# should work from at least Blender4.2+
if _BLE_5_PLUS:
# combined fcurves ranges
# check if start and end frames are different
if a.curve_frame_range[0] != a.curve_frame_range[1]:
return True
else:
# older Blender versions
for c in a.fcurves:
if len(c.keyframe_points) > 1:
return True
return False
def check_anim(props, obs):
animated = False
for ob in obs:
if ob.animation_data is not None:
a = ob.animation_data.action
if a is not None:
for c in a.fcurves:
if len(c.keyframe_points) > 1:
animated = True
# c.keyframe_points.remove(c.keyframe_points[0])
if has_keyframes(ob):
animated = True
break
if animated:
props.animated = True
@@ -209,8 +209,17 @@ def start_model_thumbnailer(
blender_user_scripts_dir = (
Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ)
# both must be enabled
if (
user_preferences.experimental_features
and user_preferences.ignore_env_for_thumbnails
):
env = None
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
@@ -218,7 +227,7 @@ def start_model_thumbnailer(
creationflags=utils.get_process_flags(),
env=env,
)
bk_logger.info(f"Started Blender executing {SCRIPT_NAME} on file {datafile}")
bk_logger.info("Started Blender executing %s on file %s", SCRIPT_NAME, datafile)
eval_path_computing = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
eval_path = f"bpy.data.objects['{json_args['asset_name']}']"
@@ -284,8 +293,16 @@ def start_material_thumbnailer(
blender_user_scripts_dir = (
Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ)
if (
user_preferences.experimental_features
and user_preferences.ignore_env_for_thumbnails
):
env = None
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
@@ -293,7 +310,7 @@ def start_material_thumbnailer(
creationflags=utils.get_process_flags(),
env=env,
)
bk_logger.info(f"Started Blender executing {SCRIPT_NAME} on file {datafile}")
bk_logger.info("Started Blender executing %s on file %s", SCRIPT_NAME, datafile)
eval_path_computing = f"bpy.data.materials['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.materials['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
@@ -164,11 +164,20 @@ if __name__ == "__main__":
ob.data.texspace_size.x = 1 # / tscale
ob.data.texspace_size.y = 1 # / tscale
ob.data.texspace_size.z = 1 # / tscale
if data["adaptive_subdivision"] == True:
ob.cycles.use_adaptive_subdivision = True
# this option was moved in Blender 5.0 from cycles directly to modifier
if bpy.app.version >= (5, 0, 0):
for mod in ob.modifiers:
if mod.type == "SUBSURF":
if data["adaptive_subdivision"] == True:
mod.use_adaptive_subdivision = True
else:
mod.use_adaptive_subdivision = False
else:
ob.cycles.use_adaptive_subdivision = False
if data["adaptive_subdivision"] == True:
ob.cycles.use_adaptive_subdivision = True
else:
ob.cycles.use_adaptive_subdivision = False
ts = data["texture_size_meters"]
if data["thumbnail_type"] in ["BALL", "BALL_COMPLEX", "CLOTH"]:
utils.automap(
@@ -179,7 +188,13 @@ if __name__ == "__main__":
)
bpy.context.view_layer.update()
s.cycles.volume_step_size = tscale * 0.1
# this option was removed in Blender 5.0
# but we have option to set biased volumes
if bpy.app.version >= (5, 0, 0):
# usually small speedup with little quality loss
s.cycles.volume_biased = True
else:
s.cycles.volume_step_size = tscale * 0.1
if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU"
@@ -71,6 +71,9 @@ def threadread(tcom: ThreadCom):
return # process terminated
inline = tcom.proc.stdout.readline()
inline = inline.decode("utf-8")
# ignore empty lines
if inline.strip() == "":
continue
bk_logger.info(inline.strip())
progress = re.findall(r"progress\{(.*?)\}", inline)
if len(progress) > 0:
@@ -139,8 +139,8 @@ def login(signup: bool) -> None:
def generate_pkce_pair() -> tuple[str, str]:
"""Generate PKCE pair - a code verifier and code challange.
The challange should be sent first to the server, the verifier is used in next steps to verify identity (handles Client).
"""Generate PKCE pair - a code verifier and code challenge.
The challenge should be sent first to the server, the verifier is used in next steps to verify identity (handles Client).
"""
rand = random.SystemRandom()
code_verifier = "".join(rand.choices(string.ascii_letters + string.digits, k=128))
@@ -162,8 +162,6 @@ def write_tokens(auth_token, refresh_token, oauth_response):
override_extension_draw.ensure_repository(api_key=auth_token)
override_extension_draw.clear_repo_cache()
#
def ensure_token_refresh() -> bool:
"""Check if API token needs refresh, call refresh and return True if so.
@@ -20,7 +20,7 @@
bl_info = {
"name": "BlenderKit Online Asset Library",
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik",
"version": (3, 17, 0, 251008), # X.Y.Z.yymmdd
"version": (3, 18, 0, 251121), # 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, 17, 0, 251008)
VERSION = (3, 18, 0, 251121)
import logging
import random
@@ -297,6 +297,11 @@ def asset_type_callback(self, context):
6,
),
]
# Only add addon option for Blender 4.2+
if bpy.app.version >= (4, 2, 0):
items.append(("ADDON", "Add-ons", "Find add-ons", "PLUGIN", 7))
else:
items = [
("MODEL", "Model", "Upload a model", "OBJECT_DATAMODE", 0),
@@ -314,6 +319,11 @@ def asset_type_callback(self, context):
),
]
# Only add addon option for Blender 4.2+
if bpy.app.version >= (4, 2, 0):
items.append(("ADDON", "Add-on", "Upload an addon", "PLUGIN", 7))
return items
@@ -1164,6 +1174,19 @@ class BlenderKitGeoToolSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
pass
class BlenderKitAddonSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
search_installed: BoolProperty(
name="Installed Only",
description="Show only addons that are already installed in Blender",
default=False,
update=lambda self, context: (
search.refresh_search()
if context.window_manager.blenderkitUI.asset_type == "ADDON"
else None
),
)
class BlenderKitHDRUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
texture_resolution_max: IntProperty(
name="Texture Resolution Max",
@@ -2401,6 +2424,12 @@ In this case you should also set path to your system CA bundle containing proxy'
update=search.search_update,
) # In future we can subsets like sexualized, pornography or violence subset. And allow users choose what is part of NSFW.
temp_enabled_addons: StringProperty(
name="Temporarily Enabled Addons",
description="JSON string of temporarily enabled addon package IDs that should be disabled on next session",
default="[]",
)
def draw(self, context):
layout = self.layout
if self.api_key.strip() == "":
@@ -2507,6 +2536,7 @@ classes = (
BlenderKitBrushUploadProps,
BlenderKitGeoToolSearchProps,
BlenderKitNodeGroulUploadProps,
BlenderKitAddonSearchProps,
)
@@ -2573,6 +2603,9 @@ def register():
bpy.types.NodeTree.blenderkit = PointerProperty( # for uploads, not now...
type=BlenderKitNodeGroulUploadProps
)
bpy.types.WindowManager.blenderkit_addon = PointerProperty(
type=BlenderKitAddonSearchProps
)
if bpy.app.factory_startup is False:
user_preferences = bpy.context.preferences.addons[__package__].preferences
global_vars.PREFS = utils.get_preferences_as_dict()
@@ -0,0 +1,12 @@
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
@@ -101,6 +101,7 @@ def append_nodegroup(
nodegroup.use_fake_user = fake_user
# Create target object automatically for geometry nodegroups when no target is provided
auto_created_target: Optional[bpy.types.Object] = None
if nodegroup.bl_rna.identifier == "GeometryNodeTree" and not target_object:
# Create a default mesh cube
bpy.ops.mesh.primitive_cube_add(
@@ -109,6 +110,7 @@ def append_nodegroup(
target_obj = bpy.context.active_object
target_obj.name = "GeometryNodeTarget"
target_object = target_obj.name
auto_created_target = target_obj
# Make sure it's selected and active
bpy.context.view_layer.objects.active = target_obj
@@ -245,6 +247,21 @@ def append_nodegroup(
added_to_editor = True
break
# Ensure automatically created targets receive the nodegroup as modifier
if auto_created_target:
gn_mod = None
for mod in auto_created_target.modifiers:
if mod.type == "NODES":
gn_mod = mod
break
if not gn_mod:
gn_mod = auto_created_target.modifiers.new(
name=nodegroup.name, type="NODES"
)
gn_mod.node_group = nodegroup
auto_created_target.select_set(True)
bpy.context.view_layer.objects.active = auto_created_target
return nodegroup, added_to_editor
@@ -82,7 +82,7 @@ def get_texture_ui(tpath, iname):
def check_thumbnail(props, imgpath):
# TODO implement check if the file exists, if size is corect etc. needs some care
# TODO implement check if the file exists, if size is correct etc. needs some care
if imgpath == "":
props.has_thumbnail = False
return None
@@ -104,7 +104,7 @@ if __name__ == "__main__":
asset_data["files"][0]["file_name"] = file_name
if not has_url:
bg_blender.progress(
"couldn't download asset for thumnbail re-rendering"
"couldn't download asset for thumbnail re-rendering"
)
exit()
# download first, or rather make sure if it's already downloaded
@@ -163,7 +163,7 @@ if __name__ == "__main__":
asset_data["files"][0]["file_name"] = file_name
if has_url is not True:
bg_blender.progress(
"couldn't download asset for thumnbail re-rendering"
"couldn't download asset for thumbnail re-rendering"
)
bg_blender.progress("downloading asset")
fpath = bg_utils.download_asset_file(
@@ -31,6 +31,8 @@ from bpy.props import BoolProperty
from . import client_lib, client_tasks, datas, global_vars, reports, tasks_queue, utils
if bpy.app.version >= (4, 2, 0):
from . import override_extension_draw
CLIENT_ID = "IdFRwa3SGA8eMpzhRVFMg5Ts8sPK93xBjif93x0F"
REFRESH_RESERVE = 60 * 60 * 24 * 3 # 3 days
@@ -103,10 +105,9 @@ def clean_login_data():
preferences.api_key_timeout = 0
global_vars.BKIT_PROFILE = datas.MineProfile()
# Cleanup also the api key in the extensions repository setting and clean the cache
from . import override_extension_draw
override_extension_draw.ensure_repository(api_key="")
override_extension_draw.clear_repo_cache()
if bpy.app.version >= (4, 2, 0):
override_extension_draw.ensure_repository(api_key="")
override_extension_draw.clear_repo_cache()
def logout() -> None:
@@ -158,8 +159,6 @@ def write_tokens(auth_token, refresh_token, oauth_response):
preferences.api_key = auth_token # triggers api_key update function
# write token also to extensions repository setting and clear the cache
if bpy.app.version >= (4, 2, 0):
from . import override_extension_draw
override_extension_draw.ensure_repository(api_key=auth_token)
override_extension_draw.clear_repo_cache()
@@ -1,17 +1,17 @@
{
"last_check": "2025-12-01 11:02:25.858363",
"backup_date": "October-27-2025",
"last_check": "2026-01-12 10:24:10.400844",
"backup_date": "December-1-2025",
"update_ready": true,
"ignore": false,
"just_restored": false,
"just_updated": false,
"version_text": {
"link": "https://github.com/BlenderKit/BlenderKit/releases/download/v3.18.0.251121/blenderkit-v3.18.0.251121.zip",
"link": "https://github.com/BlenderKit/BlenderKit/releases/download/v3.18.1.251219/blenderkit-v3.18.1.251219.zip",
"version": [
3,
18,
0,
251121
1,
251219
]
}
}
@@ -1,4 +1,6 @@
import os
import logging
from typing import Optional
import blf
import bpy
@@ -6,9 +8,14 @@ import gpu
from .. import image_utils, ui_bgl
from .bl_ui_widget import BL_UI_Widget
from .bl_ui_image import BL_UI_Image
bk_logger = logging.getLogger(__name__)
class BL_UI_Button(BL_UI_Widget):
"""Image Button for assets in asset bar."""
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self._text_color = (1.0, 1.0, 1.0, 1.0)
@@ -89,7 +96,7 @@ class BL_UI_Button(BL_UI_Widget):
except Exception as e:
self.__image = None
def set_image_colorspace(self, colorspace):
def set_image_colorspace(self, colorspace: str = ""):
image_utils.set_colorspace(self.__image, colorspace)
def set_image(self, rel_filepath):
@@ -98,22 +105,10 @@ class BL_UI_Button(BL_UI_Widget):
try:
if self.__image is None or self.__image.filepath != rel_filepath:
imgname = f".{os.path.basename(rel_filepath)}"
img = bpy.data.images.get(imgname)
if img is not None:
self.__image = img
else:
self.__image = bpy.data.images.load(
rel_filepath, check_existing=True
)
self.__image.name = imgname
self.__image = image_utils.IMG(name=imgname, filepath=rel_filepath)
self.__image.gl_load()
if self.__image and len(self.__image.pixels) == 0:
self.__image.reload()
self.__image.gl_load()
except Exception as e:
print(f"BL_UI_BUTTON set_image() error: {e}")
except Exception:
bk_logger.exception("BL_UI_BUTTON set_image() error:")
self.__image = None
def get_image_path(self):
@@ -185,7 +180,7 @@ 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
ui_bgl.draw_image(
ui_bgl.draw_image_runtime(
self.x_screen + off_x,
y_screen_flip - off_y - sy,
sx,
@@ -206,10 +201,22 @@ class BL_UI_Button(BL_UI_Widget):
self.__state = 1
try:
self.mouse_down_func(self)
except Exception as e:
import traceback
except Exception:
bk_logger.exception("BL_UI_BUTTON mouse_down() error:")
traceback.print_exc()
return True
return False
def set_mouse_down_right(self, mouse_down_right_func):
self.mouse_down_right_func = mouse_down_right_func
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:")
return True
@@ -1,7 +1,11 @@
import traceback
import logging
from typing import Optional
import bpy
from bpy.types import Operator
bk_logger = logging.getLogger(__name__)
class BL_UI_OT_draw_operator(Operator):
bl_idname = "object.bl_ui_ot_draw_operator"
@@ -23,7 +27,7 @@ class BL_UI_OT_draw_operator(Operator):
for widget in self.widgets:
widget.init(context)
def on_invoke(self, context, event):
def on_invoke(self, context, event) -> Optional[bool]:
pass
def on_finish(self, context):
@@ -105,7 +109,7 @@ class BL_UI_OT_draw_operator(Operator):
def draw_callback_px_separated(self, op, context):
# separated only for puprpose of profiling
# separated only for purpose of profiling
try:
# hide during animation playback, to improve performance
if context.screen.is_animation_playing:
@@ -113,5 +117,5 @@ def draw_callback_px_separated(self, op, context):
if context.area.as_pointer() == self.active_area_pointer:
for widget in self.widgets:
widget.draw()
except Exception as e:
traceback.print_exc()
except Exception:
bk_logger.exception("Error in draw_callback_px_separated: ")
@@ -1,12 +1,21 @@
import os
import logging
import bpy
import gpu
from .. import image_utils, ui_bgl
from .bl_ui_widget import BL_UI_Widget
bk_logger = logging.getLogger(__name__)
class BL_UI_Image(BL_UI_Widget):
"""A simple image widget.
Used to display bigger thumbnail with additional info,
while hover over a button.
"""
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
@@ -36,25 +45,12 @@ class BL_UI_Image(BL_UI_Widget):
try:
if self.__image is None or self.__image.filepath != rel_filepath:
imgname = f".{os.path.basename(rel_filepath)}"
img = bpy.data.images.get(imgname)
if img is not None:
self.__image = img
else:
self.__image = bpy.data.images.load(
rel_filepath, check_existing=True
)
self.__image.name = imgname
self.__image.gl_load()
if self.__image and len(self.__image.pixels) == 0:
self.__image.reload()
self.__image.gl_load()
self.__image = image_utils.IMG(name=imgname, filepath=rel_filepath)
except Exception as e:
print(f"BL_UI_BUTTON: exception in set_image(): {e}")
bk_logger.exception("BL_UI_BUTTON: exception in set_image(): %s", e)
self.__image = None
def set_image_colorspace(self, colorspace):
def set_image_colorspace(self, colorspace: str = ""):
image_utils.set_colorspace(self.__image, colorspace)
def get_image_path(self):
@@ -69,9 +65,9 @@ class BL_UI_Image(BL_UI_Widget):
def draw(self):
if not self._is_visible:
return
gpu.state.blend_set("ALPHA")
self.shader.bind()
self.batch_panel.draw(self.shader)
self.draw_image()
@@ -81,7 +77,7 @@ class BL_UI_Image(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
ui_bgl.draw_image(
ui_bgl.draw_image_runtime(
self.x_screen + off_x,
y_screen_flip - off_y - sy,
sx,
@@ -20,6 +20,11 @@ class BL_UI_Widget:
self._is_visible = True
self._is_active = True # if the widget needs to be disabled
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")
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()
@@ -71,6 +76,8 @@ class BL_UI_Widget:
if not self._is_visible:
return
gpu.state.blend_set("ALPHA")
self.shader.bind()
self.shader.uniform_float("color", self._bg_color)
@@ -97,11 +104,6 @@ class BL_UI_Widget:
(self.x_screen + self.width, y_screen_flip),
)
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")
self.batch_panel = batch_for_shader(
self.shader, "TRIS", {"pos": vertices}, indices=indices
)
@@ -187,8 +189,8 @@ class BL_UI_Widget:
):
# print('is in rect!?')
# print('area height', area_height)
# print ('x sceen ',self.x_screen,'x ', x, 'width', self.width)
# print ('widghet y', widget_y,'y', y, 'height',self.height)
# print ('x screen ',self.x_screen,'x ', x, 'width', self.width)
# print ('widget y', widget_y,'y', y, 'height',self.height)
return True
return False
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "blenderkit"
type = "add-on"
version = "3.17.0-251008" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
version = "3.18.0-251121" # 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"
@@ -124,6 +124,7 @@ def handle_categories_task(task: client_tasks.Task):
"BRUSH": ["brush"],
"NODEGROUP": ["nodegroup"],
"PRINTABLE": ["printable"],
"ADDON": ["addon"],
}
if task.status == "finished":
@@ -679,6 +679,8 @@ def start_blenderkit_client():
def decide_client_binary_name() -> str:
"""Decide the name of the BlenderKit-Client binary based on the current operating system and architecture.
We unify the OS and CPU architecture naming to make it more accessible for general public.
Darwin is renamed to MacOS. The CPU architecture is aligned to x86_64 or arm64.
Possible return values:
- blenderkit-client-windows-x86_64.exe
- blenderkit-client-windows-arm64.exe
@@ -687,14 +689,17 @@ def decide_client_binary_name() -> str:
- blenderkit-client-macos-x86_64
- blenderkit-client-macos-arm64
"""
os_name = platform.system()
architecture = platform.machine()
if os_name == "Darwin": # more user-friendly name for macOS
os_name = platform.system().lower()
if os_name == "darwin": # more user-friendly name for macOS
os_name = "macos"
if architecture == "AMD64": # fix for windows
architecture = "x86_64"
if os_name == "Windows":
architecture = platform.machine().lower()
if architecture == "amd64": # align Windows convention
architecture = "x86_64"
elif architecture == "aarch64": # align Linux convention
architecture = "arm64"
if os_name == "windows":
return f"blenderkit-client-{os_name}-{architecture}.exe".lower()
return f"blenderkit-client-{os_name}-{architecture}".lower()
@@ -24,7 +24,18 @@ 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)
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."""
GRAY = (0.7, 0.7, 0.7, 0.6)
"""Default color for debug reports."""
# pure colors
PURE_WHITE = (1, 1, 1, 1)
PURE_BLACK = (0, 0, 0, 1)
PURE_GREEN = (0, 1, 0, 1)
PURE_RED = (1, 0, 0, 1)
PURE_BLUE = (0, 0, 1, 1)
@@ -99,7 +99,8 @@ class BlenderKitDisclaimerOperator(BL_UI_OT_draw_operator):
self.hover_bg_color = (0.127, 0.034, 1, 1.0)
self.text_color = (0.9, 0.9, 0.9, 1)
print("@ BlenderKitDisclaimerOperator.__init__ message is: ", self.message)
bk_logger.info("%s", self.message)
pix_size = get_text_size(
font_id=1,
text=self.message,
@@ -16,16 +16,22 @@
#
# ##### END GPL LICENSE BLOCK #####
import addon_utils
import copy
import json
import logging
import os
import shutil
import tempfile
import time
import urllib.request
from . import (
append_link,
client_lib,
client_tasks,
global_vars,
paths,
reports,
resolutions,
@@ -34,10 +40,10 @@ from . import (
utils,
)
bk_logger = logging.getLogger(__name__)
import bpy
if bpy.app.version >= (4, 2, 0):
from . import override_extension_draw
from bpy.app.handlers import persistent
from bpy.props import (
BoolProperty,
@@ -48,6 +54,232 @@ from bpy.props import (
StringProperty,
)
bk_logger = logging.getLogger(__name__)
def get_blenderkit_repository():
"""Find the BlenderKit extensions repository index.
Returns:
int: Repository index if found, -1 otherwise
"""
enabled_repos = [
repo for repo in bpy.context.preferences.extensions.repos if repo.enabled
]
for i, repo in enumerate(enabled_repos):
if (
repo.remote_url and global_vars.SERVER in repo.remote_url
) or "blenderkit" in repo.name.lower():
return repo, i
return None, -1
def get_addon_installation_status(asset_data):
"""Get the installation and enablement status of an addon.
Returns:
dict: {
"installed": bool,
"enabled": bool,
"pkg_id": str,
"cached_pkg": dict or None
}
"""
# Get the correct package ID
extension_id = asset_data.get("dictParameters", {}).get("extensionId")
if not extension_id:
return {
"installed": False,
"enabled": False,
"pkg_id": None,
"cached_pkg": None,
}
# Check if addon is installed and enabled using Blender's addon system
# Method 1: Check if it's in the enabled addons list
# For new extension system, addons have format: bl_ext.repository_name.package_name
enabled_addons = [addon.module for addon in bpy.context.preferences.addons]
# Check direct match first
is_enabled = extension_id in enabled_addons
# If not found, check for extension format: bl_ext.www_blenderkit_com.package_name
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}"
)
# Also try other possible repository name formats
if not is_enabled:
for addon_module in enabled_addons:
if addon_module.endswith(
f".{extension_id}"
) and addon_module.startswith("bl_ext."):
is_enabled = True
bk_logger.info(
f"Found enabled addon with extension format: {addon_module}"
)
break
# Method 2: Check if it's installed (may be disabled) using addon_utils
is_installed = False
try:
for addon_module in addon_utils.modules():
# Check direct match
if addon_module.__name__ == extension_id:
is_installed = True
break
# Check extension format match
elif addon_module.__name__.endswith(
f".{extension_id}"
) and addon_module.__name__.startswith("bl_ext."):
is_installed = True
bk_logger.info(
f"Found installed addon with extension format: {addon_module.__name__}"
)
break
except Exception as e:
bk_logger.warning(f"Error checking addon_utils.modules(): {e}")
# If found through addon_utils, we know it's installed
# But we need to double-check enabled status using the correct module name
if is_installed and not is_enabled:
# Try to find the correct module name format for this addon
try:
for addon_module in addon_utils.modules():
if addon_module.__name__ == extension_id or (
addon_module.__name__.endswith(f".{extension_id}")
and addon_module.__name__.startswith("bl_ext.")
):
# 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__}")
break
except Exception as e:
bk_logger.warning(f"Error double-checking enabled status: {e}")
# Method 3: If not found through traditional addon system, check extensions system
if not is_installed:
try:
override_extension_draw.ensure_repo_cache()
bk_ext_cache = bpy.context.window_manager.get(
"blenderkit_extensions_repo_cache", {}
)
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)
# For extensions, enabled status might be in the cache
if is_installed and not is_enabled:
is_enabled = pkg_data.get("enabled", False)
break
except Exception as e:
bk_logger.warning(f"Error checking extension cache: {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:
continue
if not (
(repo.remote_url and global_vars.SERVER in repo.remote_url)
or "blenderkit" in repo.name.lower()
):
continue
# This is a BlenderKit repository, try to find our package
# Note: The actual package checking would require deeper access to the repository data
# For now, we'll rely on the previous methods
break
except Exception as e:
bk_logger.warning(f"Error checking extension repositories: {e}")
# Debug: Show some enabled addons for reference
blenderkit_addons = [
addon
for addon in enabled_addons
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.info(
f"Addon status check for '{extension_id}': installed={is_installed}, enabled={is_enabled}"
)
return {
"installed": is_installed,
"enabled": is_enabled,
"pkg_id": extension_id,
"cached_pkg": None, # Not using cached_pkg anymore
}
def install_addon_from_local_file(asset_data, file_path, enable_on_install=True):
"""Install an addon from a local zip file using Blender's extensions API.
Args:
asset_data: Asset metadata dictionary
file_path: Path to the downloaded zip file
enable_on_install: If True, enable the addon after installation (default: True)
"""
addon_name = asset_data.get("name", "Unknown Addon")
if bpy.app.version < (4, 2, 0):
error_msg = f"Addon installation requires Blender 4.2 or newer. Current version: {'.'.join(map(str, bpy.app.version[:2]))}"
reports.add_report(error_msg, type="ERROR")
raise Exception(error_msg)
if not os.path.exists(file_path):
error_msg = f"Addon file not found: {file_path}"
reports.add_report(error_msg, type="ERROR")
raise Exception(error_msg)
bk_logger.info(f"Installing addon '{addon_name}' from local file: {file_path}")
status = get_addon_installation_status(asset_data)
if status["installed"]:
reports.add_report(f"Addon '{addon_name}' is already installed", type="INFO")
return
# Find the BlenderKit repository to install the addon to
repo, repo_index = get_blenderkit_repository()
if repo is None:
error_msg = "BlenderKit repository not found. Please ensure the BlenderKit extensions repository is enabled in preferences."
reports.add_report(error_msg, type="ERROR")
raise Exception(error_msg)
# Install from file to the BlenderKit repository
result = bpy.ops.extensions.package_install_files(
repo=repo.module,
filepath=file_path,
enable_on_install=enable_on_install,
)
if "FINISHED" not in result:
raise Exception(f"Installation failed - operation returned: {result}")
post_install_status = get_addon_installation_status(asset_data)
if not post_install_status["installed"]:
raise Exception(
f"Installation verification failed: '{addon_name}' was not installed. "
f"This may be due to version compatibility issues or other requirements not being met."
)
status_text = "enabled" if enable_on_install else "disabled"
reports.add_report(
f"Successfully installed addon '{addon_name}' ({status_text})", type="INFO"
)
download_tasks = {}
@@ -117,6 +349,68 @@ def check_unused():
l.user_clear()
def get_temp_enabled_addons():
"""Get list of temporarily enabled addons from preferences."""
try:
prefs = bpy.context.preferences.addons[__package__].preferences
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}")
return []
def set_temp_enabled_addons(addon_list):
"""Save list of temporarily enabled addons to preferences."""
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")
except Exception as e:
bk_logger.error(f"Error saving temporary addons to preferences: {e}")
def add_temp_enabled_addon(pkg_id):
"""Add an addon to the temporary enabled list."""
temp_enabled = get_temp_enabled_addons()
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")
def cleanup_temp_enabled_addons():
"""Disable temporarily enabled addons."""
try:
temp_enabled = get_temp_enabled_addons()
if not temp_enabled:
bk_logger.info("No temporarily enabled addons to clean up")
return
bk_logger.info(f"Cleaning up {len(temp_enabled)} temporarily enabled addons")
# 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}")
except Exception as e:
bk_logger.warning(
f"Failed to disable temporarily enabled addon {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}")
@persistent
def scene_save(context):
"""Do cleanup of blenderkit props and send a message to the server about assets used."""
@@ -131,6 +425,49 @@ def scene_save(context):
client_lib.report_usages(report_data)
def refresh_addon_search_results_status():
"""Refresh installation status in addon search results after installation operations."""
try:
# Get current search results
sr = search.get_search_results()
if not sr:
return
# Check if we're currently viewing addons
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.asset_type != "ADDON":
return
# Update installation status for all addon search results
for asset_data in sr:
if asset_data.get("assetType") == "addon":
try:
status = get_addon_installation_status(asset_data)
is_installed = status.get("installed", False)
is_enabled = status.get("enabled", False)
# Update the status in search results
asset_data["downloaded"] = 100 if is_installed else 0
asset_data["enabled"] = is_enabled
except Exception as e:
bk_logger.warning(
f"Could not refresh status for addon {asset_data.get('name', 'Unknown')}: {e}"
)
asset_data["downloaded"] = 0
asset_data["enabled"] = False
except Exception as e:
bk_logger.warning(f"Error refreshing addon search results status: {e}")
@persistent
def scene_load_pre(context):
"""Clean up temporarily enabled addons before loading new file."""
cleanup_temp_enabled_addons()
@persistent
def scene_load(context):
"""Restart broken downloads on scene load."""
@@ -636,7 +973,8 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
asset_data["resolution"] = kwargs["resolution"]
udpate_asset_data_in_dicts(asset_data)
update_asset_metadata(asset_main, asset_data)
if asset_main is not None:
update_asset_metadata(asset_main, asset_data)
bpy.ops.ed.undo_push(
"INVOKE_REGION_WIN", message="add %s to scene" % asset_data["name"]
@@ -949,6 +1287,25 @@ def download_post(task: client_tasks.Task) -> None:
return
orig_task.update(task.data)
# For addons, install from the downloaded file instead of appending
if at == "addon":
if file_paths:
# Check if addon should be enabled after installation (default: True)
enable_on_install = task.data.get("enable_on_install", True)
install_addon_from_local_file(
task.data["asset_data"],
file_paths[-1],
enable_on_install=enable_on_install,
)
else:
bk_logger.error("No file paths available for addon installation")
reports.add_report(
"Addon download completed but no file found", type="ERROR"
)
return
try_finished_append(
file_paths=file_paths, **task.data
) # exception is handled in calling function
@@ -966,7 +1323,6 @@ def download(asset_data, **kwargs):
report = f"Maximum retries exceeded for {asset_data['name']}"
sprops.report = report
reports.add_report(report, type="ERROR")
bk_logger.debug(sprops.report)
return
@@ -980,6 +1336,8 @@ def download(asset_data, **kwargs):
# inject resolution into prefs.
prefs = utils.get_preferences_as_dict()
prefs["resolution"] = kwargs.get("resolution", "original")
if "unpack_files" in kwargs: # for add-on download
prefs["unpack_files"] = kwargs["unpack_files"]
data = {
"asset_data": asset_data,
@@ -1271,10 +1629,391 @@ asset_types = (
("MATERIAL", "Material", "any .blend Material"),
("TEXTURE", "Texture", "a texture, or texture set"),
("BRUSH", "Brush", "brush, can be any type of blender brush"),
("ADDON", "Addon", "addnon"),
("ADDON", "Addon", "addon"),
)
class BlenderkitAddonManagerOperator(bpy.types.Operator):
"""Manage BlenderKit addon installation, enabling, and disabling"""
bl_idname = "scene.blenderkit_addon_manager"
bl_label = "Addon Manager"
bl_options = {"REGISTER", "INTERNAL"}
asset_data: bpy.props.StringProperty() # JSON encoded asset data
action: bpy.props.EnumProperty(
items=[
("INSTALL", "Install", "Install the addon"),
("UNINSTALL", "Uninstall", "Uninstall the addon"),
("ENABLE", "Enable", "Enable the addon"),
("DISABLE", "Disable", "Disable the addon"),
("TEMP_ENABLE", "Enable Temporarily", "Enable until end of session"),
]
)
def execute(self, context):
try:
asset_data = json.loads(self.asset_data)
except:
reports.add_report("Invalid asset data", type="ERROR")
return {"CANCELLED"}
addon_name = asset_data.get("name", "Unknown Addon")
status = get_addon_installation_status(asset_data)
pkg_id = status["pkg_id"]
# For non-install actions, we need the repository and pkg_id
repo_index = -1
if self.action != "INSTALL":
if not pkg_id:
reports.add_report("No extension ID found for this addon", type="ERROR")
return {"CANCELLED"}
# Find the BlenderKit repository
repo, repo_index = get_blenderkit_repository()
if repo is None:
reports.add_report("BlenderKit repository not found", type="ERROR")
return {"CANCELLED"}
try:
if self.action == "INSTALL":
# Trigger download which will automatically install after completion
reports.add_report(f"Downloading addon '{addon_name}'...", type="INFO")
# Check if addon is already downloading
if check_downloading(asset_data):
reports.add_report(
f"Addon '{addon_name}' is already being downloaded", type="INFO"
)
return {"FINISHED"}
# Start the download
download(asset_data, resolution="blend")
return {"FINISHED"}
elif self.action == "UNINSTALL":
result = bpy.ops.extensions.package_uninstall(
repo_index=repo_index, pkg_id=pkg_id
)
if "FINISHED" not in result:
raise Exception(
f"Uninstallation failed - operation returned: {result}"
)
reports.add_report(
f"Successfully uninstalled '{addon_name}'", type="INFO"
)
self.report({"INFO"}, f"Successfully uninstalled '{addon_name}'")
refresh_addon_search_results_status()
elif self.action == "ENABLE":
result = bpy.ops.extensions.package_enable(
repo_index=repo_index, pkg_id=pkg_id
)
if "FINISHED" not in result:
raise Exception(f"Enable failed - operation returned: {result}")
reports.add_report(f"Successfully enabled '{addon_name}'", type="INFO")
self.report({"INFO"}, f"Successfully enabled '{addon_name}'")
refresh_addon_search_results_status()
elif self.action == "DISABLE":
result = bpy.ops.extensions.package_disable(
repo_index=repo_index, pkg_id=pkg_id
)
if "FINISHED" not in result:
raise Exception(f"Disable failed - operation returned: {result}")
reports.add_report(f"Successfully disabled '{addon_name}'", type="INFO")
self.report({"INFO"}, f"Successfully disabled '{addon_name}'")
refresh_addon_search_results_status()
elif self.action == "TEMP_ENABLE":
result = bpy.ops.extensions.package_enable(
repo_index=repo_index, pkg_id=pkg_id
)
if "FINISHED" not in result:
raise Exception(
f"Temporary enable failed - operation returned: {result}"
)
# Store the package for later disabling
wm = context.window_manager
temp_enabled = wm.get("blenderkit_temp_enabled_addons", [])
if pkg_id not in temp_enabled:
temp_enabled.append(pkg_id)
wm["blenderkit_temp_enabled_addons"] = temp_enabled
reports.add_report(
f"Temporarily enabled '{addon_name}' (will disable on session end)",
type="INFO",
)
self.report({"INFO"}, f"Temporarily enabled '{addon_name}'")
refresh_addon_search_results_status()
except Exception as e:
error_msg = f"Failed to {self.action.lower()} '{addon_name}': {e}"
reports.add_report(error_msg, type="ERROR")
self.report({"ERROR"}, error_msg)
return {"CANCELLED"}
return {"FINISHED"}
class BlenderkitAddonChoiceOperator(bpy.types.Operator):
"""Show addon management options popup"""
bl_idname = "scene.blenderkit_addon_choice"
bl_label = "Addon Options"
bl_options = {"REGISTER", "INTERNAL"}
asset_data: bpy.props.StringProperty() # JSON encoded asset data
# Actions for not installed addons
action_not_installed: bpy.props.EnumProperty(
name="Action",
description="Choose what to do with this addon",
items=[
(
"INSTALL_AND_ENABLE",
"Install and Enable",
"Install the addon and enable it immediately",
"CHECKBOX_HLT",
0,
),
(
"INSTALL_AND_TEMP_ENABLE",
"Install and Enable Temporarily",
"Install and enable until end of session",
"TIME",
1,
),
(
"INSTALL_ONLY",
"Install Only",
"Install the addon but keep it disabled",
"IMPORT",
2,
),
],
)
# Actions for installed and enabled addons
action_installed_enabled: bpy.props.EnumProperty(
name="Action",
description="Choose what to do with this addon",
items=[
("DISABLE", "Disable", "Disable the addon", "CHECKBOX_DEHLT", 0),
("UNINSTALL", "Uninstall", "Completely remove the addon", "CANCEL", 1),
],
)
# Actions for installed but disabled addons
action_installed_disabled: bpy.props.EnumProperty(
name="Action",
description="Choose what to do with this addon",
items=[
("ENABLE", "Enable", "Enable the addon permanently", "CHECKBOX_HLT", 0),
(
"TEMP_ENABLE",
"Enable Temporarily",
"Enable until end of session",
"TIME",
1,
),
("UNINSTALL", "Uninstall", "Completely remove the addon", "CANCEL", 2),
],
)
def draw(self, context):
layout = self.layout
try:
asset_data = json.loads(self.asset_data)
except:
layout.label(text="Invalid asset data")
return
addon_name = asset_data.get("name", "Unknown Addon")
status = get_addon_installation_status(asset_data)
layout.label(text=f"Addon: {addon_name}")
layout.separator()
layout = layout.column()
# Show current status and appropriate action enum
if not status["installed"]:
layout.label(text="Status: Not Installed", icon="QUESTION")
layout.separator()
layout.prop(self, "action_not_installed", expand=True)
elif status["enabled"]:
layout.label(text="Status: Installed and Enabled", icon="CHECKMARK")
layout.separator()
layout.prop(self, "action_installed_enabled", expand=True)
else:
layout.label(text="Status: Installed but Disabled", icon="X")
layout.separator()
layout.prop(self, "action_installed_disabled", expand=True)
def invoke(self, context, event):
# Set default values for each enum
self.action_not_installed = "INSTALL_AND_ENABLE"
self.action_installed_enabled = "DISABLE"
self.action_installed_disabled = "ENABLE"
wm = context.window_manager
return wm.invoke_props_dialog(self, width=350)
def execute(self, context):
try:
asset_data = json.loads(self.asset_data)
except:
reports.add_report("Invalid asset data", type="ERROR")
return {"CANCELLED"}
addon_name = asset_data.get("name", "Unknown Addon")
status = get_addon_installation_status(asset_data)
pkg_id = status["pkg_id"]
# Get the selected action based on addon status
if not status["installed"]:
selected_action = self.action_not_installed
elif status["enabled"]:
selected_action = self.action_installed_enabled
else:
selected_action = self.action_installed_disabled
# For non-install actions, we need the repository and pkg_id
repo_index = -1
if selected_action not in (
"INSTALL_AND_ENABLE",
"INSTALL_AND_TEMP_ENABLE",
"INSTALL_ONLY",
):
if not pkg_id:
reports.add_report("No extension ID found for this addon", type="ERROR")
return {"CANCELLED"}
# Find the BlenderKit repository
repo, repo_index = get_blenderkit_repository()
if repo is None:
reports.add_report("BlenderKit repository not found", type="ERROR")
return {"CANCELLED"}
try:
if selected_action in (
"INSTALL_AND_ENABLE",
"INSTALL_AND_TEMP_ENABLE",
"INSTALL_ONLY",
):
# Trigger download which will automatically install and enable after completion
reports.add_report(f"Downloading addon '{addon_name}'...", type="INFO")
# Check if addon is already downloading
if check_downloading(asset_data):
reports.add_report(
f"Addon '{addon_name}' is already being downloaded", type="INFO"
)
return {"FINISHED"}
if selected_action == "INSTALL_AND_TEMP_ENABLE":
add_temp_enabled_addon(pkg_id)
# Enable on install for both INSTALL_AND_ENABLE and INSTALL_AND_TEMP_ENABLE
enable_on_install = selected_action != "INSTALL_ONLY"
# Start the download, disable unpacking
download(
asset_data,
resolution="blend",
unpack_files=False,
enable_on_install=enable_on_install,
)
return {"FINISHED"}
elif selected_action == "UNINSTALL":
result = bpy.ops.extensions.package_uninstall(
repo_index=repo_index, pkg_id=pkg_id
)
if "FINISHED" not in result:
raise Exception(
f"Uninstallation failed - operation returned: {result}"
)
reports.add_report(
f"Successfully uninstalled '{addon_name}'", type="INFO"
)
self.report({"INFO"}, f"Successfully uninstalled '{addon_name}'")
refresh_addon_search_results_status()
elif selected_action == "ENABLE":
# Enable using preferences API
full_module_name = f"bl_ext.www_blenderkit_com.{pkg_id}"
try:
result = bpy.ops.preferences.addon_enable(module=full_module_name)
if "FINISHED" not in result:
raise Exception(f"Enable operation failed - returned: {result}")
reports.add_report(
f"Successfully enabled '{addon_name}'", type="INFO"
)
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}")
reports.add_report(
f"Failed to enable '{addon_name}': {e}", type="ERROR"
)
elif selected_action == "DISABLE":
# Disable using preferences API
full_module_name = f"bl_ext.www_blenderkit_com.{pkg_id}"
try:
result = bpy.ops.preferences.addon_disable(module=full_module_name)
if "FINISHED" not in result:
raise Exception(
f"Disable operation failed - returned: {result}"
)
reports.add_report(
f"Successfully disabled '{addon_name}'", type="INFO"
)
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}")
reports.add_report(
f"Failed to disable '{addon_name}': {e}", type="ERROR"
)
elif selected_action == "TEMP_ENABLE":
# Temporarily enable using preferences API
full_module_name = f"bl_ext.www_blenderkit_com.{pkg_id}"
try:
result = bpy.ops.preferences.addon_enable(module=full_module_name)
if "FINISHED" not in result:
raise Exception(
f"Temporary enable operation failed - returned: {result}"
)
except Exception as e:
bk_logger.error(f"Failed to temp enable addon: {e}")
reports.add_report(
f"Failed to enable '{addon_name}': {e}", type="ERROR"
)
return {"CANCELLED"}
# Store the package for later disabling
add_temp_enabled_addon(pkg_id)
reports.add_report(
f"Temporarily enabled '{addon_name}' (will disable on session end)",
type="INFO",
)
self.report({"INFO"}, f"Temporarily enabled '{addon_name}'")
refresh_addon_search_results_status()
except Exception as e:
bk_logger.error(f"Addon operation failed for '{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)
return {"CANCELLED"}
return {"FINISHED"}
class BlenderkitKillDownloadOperator(bpy.types.Operator):
"""Kill a download"""
@@ -1315,6 +2054,10 @@ def available_resolutions_callback(self, context):
def has_asset_files(asset_data):
"""Check if asset has files."""
# Addons are handled separately by the extension system
if asset_data["assetType"] == "addon":
return True
for f in asset_data["files"]:
if f["fileType"] in ("blend", "zip_file"):
return True
@@ -1491,6 +2234,14 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
return {"CANCELLED"}
asset_type = self.asset_data["assetType"]
# Handle addon assets with popup
if asset_type == "addon":
bpy.ops.scene.blenderkit_addon_choice(
"INVOKE_DEFAULT", asset_data=json.dumps(self.asset_data)
)
return {"FINISHED"}
if (
(asset_type == "model" or asset_type == "material")
and (bpy.context.mode != "OBJECT")
@@ -1636,12 +2387,20 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
def register_download():
bpy.utils.register_class(BlenderkitDownloadOperator)
bpy.utils.register_class(BlenderkitKillDownloadOperator)
# bpy.utils.register_class(BlenderkitAddonManagerOperator) # Replaced by BlenderkitAddonChoiceOperator
bpy.utils.register_class(BlenderkitAddonChoiceOperator)
bpy.app.handlers.load_post.append(scene_load)
bpy.app.handlers.save_pre.append(scene_save)
bpy.app.handlers.load_post.append(scene_load_pre)
def unregister_download():
bpy.utils.unregister_class(BlenderkitDownloadOperator)
bpy.utils.unregister_class(BlenderkitKillDownloadOperator)
# bpy.utils.unregister_class(BlenderkitAddonManagerOperator) # Replaced by BlenderkitAddonChoiceOperator
bpy.utils.unregister_class(BlenderkitAddonChoiceOperator)
bpy.app.handlers.load_post.remove(scene_load)
bpy.app.handlers.save_pre.remove(scene_save)
bpy.app.handlers.load_post.remove(scene_load_pre)
# Clean up any remaining temporarily enabled addons
cleanup_temp_enabled_addons()
@@ -24,7 +24,7 @@ from typing import Any, Optional
from . import datas
CLIENT_VERSION = "v1.6.0"
CLIENT_VERSION = "v1.7.0"
CLIENT_ACCESSIBLE = False
"""Is Client accessible? Can add-on access it and call stuff which uses it?"""
CLIENT_RUNNING = False
@@ -18,10 +18,26 @@
import os
import time
from functools import lru_cache
import logging
from dataclasses import dataclass
import bpy
bk_logger = logging.getLogger(__name__)
@dataclass
class IMG:
name: str
filepath: str
def gl_load(self):
"""Imitates bpy.types.Image.gl_load() behavior."""
return None
def get_orig_render_settings():
rs = bpy.context.scene.render
ims = rs.image_settings
@@ -91,21 +107,68 @@ def set_colorspace(img, colorspace: str = ""):
if colorspace == "":
colorspace = guess_colorspace()
if colorspace == "Non-Color":
img.colorspace_settings.is_data = True
else:
img.colorspace_settings.name = colorspace
except Exception as e:
print(f"Colorspace {colorspace} not found: {e}")
if hasattr(img, "colorspace_settings") and colorspace:
if colorspace == "Non-Color":
img.colorspace_settings.is_data = True
else:
img.colorspace_settings.name = colorspace
except Exception:
bk_logger.exception("Colorspace '%s' not found: ", colorspace)
def guess_colorspace():
@lru_cache(maxsize=1)
def list_available_image_colorspaces():
"""Lists available color spaces in blender by creating a temporary image if needed.
Returns:
List of color space names.
"""
# Check if there are existing images
temp_image = None
if bpy.data.images:
img = bpy.data.images[0]
else:
# Create temporary image
temp_image = bpy.data.images.new(
"TempImage_ForColorSpaceList", width=1, height=1
)
img = temp_image
# Get available color spaces
color_spaces = [
cs.identifier
for cs in img.colorspace_settings.bl_rna.properties["name"].enum_items
]
# Clean up temporary image if created
if temp_image:
bpy.data.images.remove(temp_image)
return color_spaces
def guess_colorspace() -> str:
"""Tries to guess the colorspace from the current display device and available color spaces."""
display_device = bpy.context.scene.display_settings.display_device
if display_device == "sRGB":
return "sRGB"
if display_device == "ACES":
return "aces"
# detect available color spaces on image data
all_clr_spaces = list_available_image_colorspaces()
# try to match display device with color space
for cs in all_clr_spaces:
if display_device.lower() in cs.lower():
return cs
# fallback
if "sRGB" in all_clr_spaces:
return "sRGB"
return ""
def analyze_image_is_true_hdr(image):
import numpy
@@ -9,6 +9,7 @@ The original method is then called from the new method, with the same arguments,
import json
import os
import time
import logging
from . import icons
@@ -20,6 +21,8 @@ from bpy.types import Operator
EXTENSIONS_API_URL = "https://www.blenderkit.com/api/v1/extensions/"
bk_logger = logging.getLogger(__name__)
# --- New Modal Operator ---
class BK_OT_buy_extension_and_watch(Operator):
@@ -56,7 +59,7 @@ class BK_OT_buy_extension_and_watch(Operator):
# Open the URL
try:
bpy.ops.wm.url_open(url=self.url)
print(f"BlenderKit: Opening buy URL: {self.url}")
bk_logger.info("Opening buy URL: %s.", self.url)
except Exception as e:
self.report({"ERROR"}, f"Could not open URL: {e}")
# Don't cancel, maybe the user still wants the refresh?
@@ -72,10 +75,11 @@ class BK_OT_buy_extension_and_watch(Operator):
self._last_refresh_time = (
self._start_time
) # Initialize to avoid immediate refresh
print(
f"BlenderKit: Started watching repository index {self.repo_index} for updates."
bk_logger.info(
"Started watching repository index %s for updates.", self.repo_index
)
context.area.tag_redraw() # Update UI to show operator is running if needed
if context and context.area:
context.area.tag_redraw() # Update UI to show operator is running if needed
return {"RUNNING_MODAL"}
def modal(self, context, event):
@@ -84,19 +88,19 @@ class BK_OT_buy_extension_and_watch(Operator):
# --- Exit Conditions ---
# 1. User closed Preferences or changed area
if context.area is None or context.area.type != "PREFERENCES":
print("BlenderKit: Preferences window closed or changed, stopping watcher.")
bk_logger.info("Preferences window closed or changed, stopping watcher.")
self.cancel(context)
return {"CANCELLED"}
# 2. Timeout
if current_time - self._start_time > self._max_duration:
print("BlenderKit: Watcher timed out, stopping.")
bk_logger.info("Watcher timed out, stopping.")
self.cancel(context)
return {"CANCELLED"}
# 3. User cancellation
if event.type in {"RIGHTMOUSE", "ESC"}:
print("BlenderKit: Watcher cancelled by user.")
bk_logger.info("Watcher cancelled by user.")
self.cancel(context)
return {"CANCELLED"}
@@ -104,24 +108,25 @@ class BK_OT_buy_extension_and_watch(Operator):
if event.type == "TIMER":
# Check if refresh interval has passed
if current_time - self._last_refresh_time >= self._refresh_interval:
print(
f"BlenderKit: Refresh interval reached, attempting sync for repo index {self.repo_index}..."
bk_logger.info(
"Refresh interval reached, attempting sync for repo index %s...",
self.repo_index,
)
try:
# Check if repo still exists at that index
if self.repo_index < len(context.preferences.extensions.repos):
bpy.ops.extensions.repo_sync(repo_index=self.repo_index)
print(
f"BlenderKit: repo_sync called for index {self.repo_index}."
bk_logger.info(
"repo_sync called for index %s.", self.repo_index
)
else:
print(
f"BlenderKit: Repository index {self.repo_index} no longer valid."
bk_logger.info(
"Repository index %s no longer valid.", self.repo_index
)
# Optionally cancel here if repo is gone
except Exception as e:
except:
# This might fail if another operation is in progress
print(f"BlenderKit: extensions.repo_sync failed: {e}")
bk_logger.exception("extensions.repo_sync failed.")
finally:
self._last_refresh_time = (
current_time # Reset timer regardless of success
@@ -134,13 +139,32 @@ class BK_OT_buy_extension_and_watch(Operator):
wm = context.window_manager
wm.event_timer_remove(self._timer)
self._timer = None
print("BlenderKit: Watcher timer removed.")
context.area.tag_redraw() # Update UI
bk_logger.info("Watcher timer removed.")
if context and context.area:
context.area.tag_redraw() # Update UI
# --- End New Modal Operator ---
def redraw_preferences_once():
"""Tag the redraw on the Blender preferences.
Meant to be registered as a timer, runs just once.
"""
for window in bpy.context.window_manager.windows:
screen = window.screen
if not screen:
continue
for area in screen.areas:
if area.type != "PREFERENCES":
continue
for region in area.regions:
if region.type in {"UI", "WINDOW"}:
region.tag_redraw()
return None
def extension_draw_item_blenderkit(
layout,
*,
@@ -163,14 +187,17 @@ def extension_draw_item_blenderkit(
cache_reloaded = ensure_repo_cache()
if cache_reloaded:
# If cache was just reloaded, tag UI for redraw
layout.tag_redraw()
print("BlenderKit: Cache reloaded, tagging layout for redraw.")
# as UILayout doesn't have tag_redraw we call a custom function
if bpy.app.timers.is_registered(redraw_preferences_once):
bpy.app.timers.unregister(redraw_preferences_once)
bpy.app.timers.register(redraw_preferences_once, first_interval=0.01)
bk_logger.info("Cache reloaded, tagging preferences for redraw.")
# check if the cache is already in the window manager
if "blenderkit_extensions_repo_cache" not in bpy.context.window_manager:
# Log if cache is missing after trying to ensure it
print(
"BlenderKit: Extension cache not available in window_manager after ensure_repo_cache call."
bk_logger.info(
"Extension cache not available in window_manager after ensure_repo_cache call."
)
# Optionally draw a minimal representation or return early to avoid errors
# For now, just return to avoid potential errors accessing bk_ext_cache
@@ -510,7 +537,7 @@ def clear_repo_cache():
def ensure_repo_cache():
"""
r"""
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.
@@ -525,10 +552,10 @@ def ensure_repo_cache():
# If repo doesn't exist, clear cache if it exists in window manager
if cache_key in wm:
del wm[cache_key]
print(f"BlenderKit: Cleared stale extension cache for missing repository.")
bk_logger.info("Cleared stale extension cache for missing repository.")
if mtime_key in wm:
del wm[mtime_key]
print(f"BlenderKit Debug: Repository not found, exiting check.")
bk_logger.debug("Repository not found, exiting check.")
return False # No repo, nothing loaded
# get the path to the cache file which is in repository directory under /.blender_ext/index.json
@@ -541,13 +568,11 @@ def ensure_repo_cache():
if os.path.exists(cache_file):
current_mtime = os.path.getmtime(cache_file)
except OSError as e: # Handle potential race condition or permission issue
print(
f"BlenderKit: Warning - Could not get modification time for {cache_file}: {e}"
)
bk_logger.exception("Could not get modification time for %s.", cache_file)
# Clear cache if we can't verify its freshness? Safer approach.
if cache_key in wm:
del wm[cache_key]
print(f"BlenderKit: Cleared extension cache due to mtime access error.")
bk_logger.info("Cleared extension cache due to mtime access error.")
if mtime_key in wm:
del wm[mtime_key]
return False # Error, nothing loaded
@@ -601,7 +626,7 @@ def ensure_repo_cache():
): # Ensure pkg is a dict and 'id' key exists
new_cache[pkg["id"][:32]] = pkg
else:
print(f"BlenderKit: Skipping invalid package entry in cache: {pkg}")
bk_logger.info("Skipping invalid package entry in cache: %s.", pkg)
wm[cache_key] = new_cache
wm[mtime_key] = current_mtime # Update mtime only on successful load
@@ -609,21 +634,21 @@ def ensure_repo_cache():
reloaded_flag = True # Mark that we reloaded successfully
except json.JSONDecodeError:
print(
f"BlenderKit: Error decoding JSON from {cache_file}. Cache not loaded/updated."
bk_logger.warning(
"Error decoding JSON from %s. Cache not loaded/updated.", cache_file
)
# Clear potentially corrupt cache? Or leave old one? Clearing is safer.
if cache_key in wm:
del wm[cache_key]
print("BlenderKit: Cleared cache due to JSON error.")
bk_logger.info("Cleared cache due to JSON error.")
if mtime_key in wm:
del wm[mtime_key]
except Exception as e:
print(f"BlenderKit: Error reading or processing cache file {cache_file}: {e}")
except Exception:
bk_logger.exception("Error reading or processing cache file %s.", cache_file)
# Clear potentially corrupt cache?
if cache_key in wm:
del wm[cache_key]
print("BlenderKit: Cleared cache due to file processing error.")
bk_logger.info("Cleared cache due to file processing error.")
if mtime_key in wm:
del wm[mtime_key]
@@ -45,6 +45,7 @@ BLENDERKIT_MATERIAL_UPLOAD_INSTRUCTIONS_URL = (
BLENDERKIT_BRUSH_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/uploading-brush/"
BLENDERKIT_HDR_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/uploading-hdr/"
BLENDERKIT_SCENE_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/uploading-scene/"
BLENDERKIT_ADDON_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/add-ons-upload-beta/"
BLENDERKIT_LOGIN_URL = f"{global_vars.SERVER}/accounts/login"
BLENDERKIT_SIGNUP_URL = f"{global_vars.SERVER}/accounts/register"
@@ -152,6 +153,7 @@ def get_download_dirs(asset_type):
"hdr": "hdrs",
"nodegroup": "nodegroups",
"printable": "printables",
"addon": "addons",
}
dirs = []
@@ -2,44 +2,62 @@
# It is not intended for manual editing.
[metadata]
groups = ["default"]
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:c76a62dd151343ab8bade20c52ba9ab72a8c97afba4d30e648c98eb624af333e"
content_hash = "sha256:e66407ebe96aea59816d07e7d141f2564da883f1073e87bb85c544e60644b85a"
[[metadata.targets]]
requires_python = ">=3.10"
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 = "24.1.0"
requires_python = ">=3.8"
version = "25.9.0"
requires_python = ">=3.9"
summary = "The uncompromising code formatter."
groups = ["default"]
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-24.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94d5280d020dadfafc75d7cae899609ed38653d3f5e82e7ce58f75e76387ed3d"},
{file = "black-24.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aaf9aa85aaaa466bf969e7dd259547f4481b712fe7ee14befeecc152c403ee05"},
{file = "black-24.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec489cae76eac3f7573629955573c3a0e913641cafb9e3bfc87d8ce155ebdb29"},
{file = "black-24.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5a0100b4bdb3744dd68412c3789f472d822dc058bb3857743342f8d7f93a5a7"},
{file = "black-24.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6cc5a6ba3e671cfea95a40030b16a98ee7dc2e22b6427a6f3389567ecf1b5262"},
{file = "black-24.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0e367759062dcabcd9a426d12450c6d61faf1704a352a49055a04c9f9ce8f5a"},
{file = "black-24.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be305563ff4a2dea813f699daaffac60b977935f3264f66922b1936a5e492ee4"},
{file = "black-24.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a8977774929b5db90442729f131221e58cc5d8208023c6af9110f26f75b6b20"},
{file = "black-24.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d74d4d0da276fbe3b95aa1f404182562c28a04402e4ece60cf373d0b902f33a0"},
{file = "black-24.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39addf23f7070dbc0b5518cdb2018468ac249d7412a669b50ccca18427dba1f3"},
{file = "black-24.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:827a7c0da520dd2f8e6d7d3595f4591aa62ccccce95b16c0e94bb4066374c4c2"},
{file = "black-24.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:0cd59d01bf3306ff7e3076dd7f4435fcd2fafe5506a6111cae1138fc7de52382"},
{file = "black-24.1.0-py3-none-any.whl", hash = "sha256:5134a6f6b683aa0a5592e3fd61dd3519d8acd953d93e2b8b76f9981245b65594"},
{file = "black-24.1.0.tar.gz", hash = "sha256:30fbf768cd4f4576598b1db0202413fafea9a227ef808d1a12230c643cefe9fc"},
{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]]
@@ -126,17 +144,16 @@ files = [
[[package]]
name = "click"
version = "8.1.7"
requires_python = ">=3.7"
version = "8.3.0"
requires_python = ">=3.10"
summary = "Composable command line interface toolkit"
groups = ["default"]
groups = ["dev"]
dependencies = [
"colorama; platform_system == \"Windows\"",
"importlib-metadata; python_version < \"3.8\"",
]
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
]
[[package]]
@@ -144,13 +161,24 @@ 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 = ["default"]
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"
@@ -164,13 +192,38 @@ files = [
[[package]]
name = "isort"
version = "5.13.2"
requires_python = ">=3.8.0"
version = "7.0.0"
requires_python = ">=3.10.0"
summary = "A Python utility / library to sort Python imports."
groups = ["default"]
groups = ["dev"]
files = [
{file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
{file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
{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]]
@@ -178,7 +231,7 @@ name = "mypy"
version = "1.13.0"
requires_python = ">=3.8"
summary = "Optional static typing for Python"
groups = ["default"]
groups = ["dev"]
dependencies = [
"mypy-extensions>=1.0.0",
"tomli>=1.1.0; python_version < \"3.11\"",
@@ -211,13 +264,13 @@ files = [
[[package]]
name = "mypy-extensions"
version = "1.0.0"
requires_python = ">=3.5"
version = "1.1.0"
requires_python = ">=3.8"
summary = "Type system extensions for programs checked with the mypy type checker."
groups = ["default"]
groups = ["dev"]
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
{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]]
@@ -256,13 +309,13 @@ files = [
[[package]]
name = "packaging"
version = "24.2"
version = "25.0"
requires_python = ">=3.8"
summary = "Core utilities for Python packages"
groups = ["default"]
groups = ["dev"]
files = [
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
@@ -270,7 +323,7 @@ name = "pathspec"
version = "0.12.1"
requires_python = ">=3.8"
summary = "Utility library for gitignore style pattern matching of file paths."
groups = ["default"]
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"},
@@ -278,13 +331,89 @@ files = [
[[package]]
name = "platformdirs"
version = "4.3.6"
requires_python = ">=3.8"
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 = ["default"]
groups = ["dev"]
files = [
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
{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]]
@@ -304,16 +433,110 @@ files = [
{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.1.0"
version = "2.3.0"
requires_python = ">=3.8"
summary = "A lil' TOML parser"
groups = ["default"]
groups = ["dev"]
marker = "python_version < \"3.11\""
files = [
{file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"},
{file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"},
{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]]
@@ -332,13 +555,13 @@ files = [
[[package]]
name = "typing-extensions"
version = "4.12.2"
requires_python = ">=3.8"
summary = "Backported and Experimental Type Hints for Python 3.8+"
groups = ["default"]
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.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
{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]]
@@ -1,12 +1,23 @@
[project]
requires-python = ">=3.10"
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<2.0.0",
"black==24.1.0",
"isort==5.13.2",
"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]
@@ -23,11 +34,50 @@ exclude = '''
'''
[tool.ruff]
exclude = ["lib", "out", "addon_updater.py", "addon_updater_ops.py"]
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
]
target-version = "py310"
[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']
@@ -46,3 +96,10 @@ module = [
]
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"
@@ -21,6 +21,7 @@ from logging import getLogger
from os.path import basename
from re import search
from time import time
from typing import Literal
import bpy
@@ -32,7 +33,12 @@ reports = []
# check for same reports and just make them longer by the timeout.
def add_report(text="", timeout=-1, type="INFO", details=""):
def add_report(
text: str = "",
timeout: float = -1,
type: Literal["INFO", "ERROR", "VALIDATOR"] = "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.
@@ -41,6 +47,7 @@ def add_report(text="", timeout=-1, type="INFO", details=""):
text = text.strip()
full_message = text
details = details.strip()
color = colors.GRAY
if details != "":
full_message = f"{text} {details}"
@@ -38,6 +38,7 @@ from . import (
client_tasks,
comments_utils,
datas,
download,
global_vars,
image_utils,
paths,
@@ -166,6 +167,8 @@ def check_clipboard():
target_asset_type = "PRINTABLE"
elif asset_type_string.find("nodegroup") > -1:
target_asset_type = "NODEGROUP"
elif asset_type_string.find("addon") > -1 or asset_type_string.find("add-on") > -1:
target_asset_type = "ADDON"
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
@@ -177,7 +180,8 @@ def check_clipboard():
# 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:
"""Needed to generate some extra data in the result(by now)
"""Parse search result into an asset_data by tweaking some of its parameters.
We need to generate some extra data in the result (for now).
Parameters
----------
r - search result, also called asset_data
@@ -192,10 +196,6 @@ def parse_result(r) -> dict:
utils.p("asset with no files-size")
asset_type = r["assetType"]
# TODO remove this condition so all assets are parsed?
if len(r["files"]) == 0:
return {}
adata = r["author"]
social_networks = datas.parse_social_networks(adata.pop("socialNetworks", []))
author = datas.UserProfile(**adata, socialNetworks=social_networks)
@@ -389,6 +389,20 @@ def handle_search_task(task: client_tasks.Task) -> bool:
if comments is None:
client_lib.get_comments(asset_data["assetBaseId"])
# Apply addon-specific status checking and filtering if needed
if ui_props.asset_type == "ADDON":
# Always process addon search results to store installation status
result_field = filter_addon_search_results(
result_field, filter_installed_only=False
)
addon_props = bpy.context.window_manager.blenderkit_addon
if addon_props.search_installed:
# Filter to only show installed addons
result_field = [
asset for asset in result_field if asset.get("downloaded", 0) > 0
]
# Store results in history step
history_step["search_results"] = result_field
history_step["search_results_orig"] = task.result
@@ -769,7 +783,11 @@ def decide_ordering(query: dict) -> list:
# for validators, sort uploaded from oldest
order.append("last_blend_upload")
else:
order.append("-last_blend_upload")
if query.get("asset_type") == "addon":
# addons don't have athe blend so need to sort by created
order.append("-created")
else:
order.append("-last_blend_upload")
elif (
query.get("author_id") is not None
or query.get("query", "").find("+author_id:") > -1
@@ -929,6 +947,68 @@ def build_query_nodegroup(
return build_query_common(query, props, ui_props)
def build_query_addon(props, ui_props) -> dict:
"""Pure function to construct search query dict for addons."""
query = {"asset_type": "addon"}
return build_query_common(query, props, ui_props)
def filter_addon_search_results(search_results, filter_installed_only=False):
"""
Filter addon search results based on local installation status.
This is called after search results arrive since installation info isn't stored on server.
Also stores installation and enablement status in the search results data.
Args:
search_results: List of addon asset data from search
filter_installed_only: If True, only return installed addons
Returns:
Filtered list of add-on assets with installation status stored
"""
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)
continue
# Check installation and enablement status for addon
try:
status = download.get_addon_installation_status(asset)
is_installed = status.get("installed", False)
is_enabled = status.get("enabled", False)
# Store installation status in asset data using existing 'downloaded' field
# Use 100 for installed, 0 for not installed (matching existing pattern)
asset["downloaded"] = 100 if is_installed else 0
# Store enablement status in new 'enabled' field
asset["enabled"] = is_enabled
if filter_installed_only:
if is_installed:
filtered_results.append(asset)
else:
filtered_results.append(asset)
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}"
)
asset["downloaded"] = 0
asset["enabled"] = False
if not filter_installed_only:
filtered_results.append(asset)
return filtered_results
def add_search_process(
query, get_next: bool, page_size: int, next_url: str, history_id: str
):
@@ -1106,6 +1186,12 @@ def search(get_next=False, query=None, author_id=""):
ui_props=bpy.context.window_manager.blenderkitUI,
)
if ui_props.asset_type == "ADDON":
query = build_query_addon(
props=bpy.context.window_manager.blenderkit_addon,
ui_props=bpy.context.window_manager.blenderkitUI,
)
# crop long searches
if query.get("query"):
if len(query["query"]) > 50:
@@ -1232,6 +1318,8 @@ def update_filters():
sprops.use_filters = sprops.true_hdr
elif ui_props.asset_type == "NODEGROUP":
sprops.use_filters = fcommon
elif ui_props.asset_type == "ADDON":
sprops.use_filters = fcommon
return True
@@ -1280,6 +1368,9 @@ def detect_asset_type_from_keywords(keywords: str) -> tuple[str, str]:
"nodegroup": "NODEGROUP",
"node": "NODEGROUP",
"printable": "PRINTABLE",
"addon": "ADDON",
"add-on": "ADDON",
"extension": "ADDON",
}
# Convert to lowercase for matching
@@ -1357,6 +1448,8 @@ def search_update(self, context):
target_asset_type = "NODEGROUP"
elif asset_type_string.find("printable") > -1:
target_asset_type = "PRINTABLE"
elif asset_type_string.find("addon") > -1:
target_asset_type = "ADDON"
if ui_props.asset_type != target_asset_type:
ui_props.search_keywords = ""
@@ -1655,6 +1748,8 @@ def get_ui_state():
store_props = store_scene_props
elif asset_type == "PRINTABLE":
store_props = store_model_props
elif asset_type == "ADDON":
store_props = [] # Addons don't need to store specific props
search_props = utils.get_search_props()
@@ -1664,6 +1759,13 @@ def get_ui_state():
if prop_name != "rna_type":
ui_state["search_props"][prop_name] = getattr(search_props, prop_name)
# Store addon-specific search properties
if ui_props.asset_type == "ADDON":
addon_props = bpy.context.window_manager.blenderkit_addon
ui_state["addon_props"] = {
"search_installed": addon_props.search_installed,
}
return ui_state
@@ -225,7 +225,7 @@ class ParticlesDropDialog(bpy.types.Operator):
layout = self.layout
message = (
"This asset is a particle setup. BlenderKit can apply particles to the active/drag-drop object."
"The number of particles is caluclated automatically, but if there are too many particles,"
"The number of particles is calculated automatically, but if there are too many particles,"
" BlenderKit can do the following steps to make sure Blender continues to run:\n"
"\n1.Switch to bounding box view of the particles."
"\n2.Turn down number of particles that are shown in the view."
@@ -16,16 +16,161 @@
#
# ##### END GPL LICENSE BLOCK #####
import blf
import gpu
import os
import logging
from typing import Optional, Tuple, Union
import blf
import bpy
from bpy import app
import gpu
from gpu_extras.batch import batch_for_shader
from .image_utils import IMG
bk_logger = logging.getLogger(__name__)
cached_images = {}
cached_gpu_textures = {}
_cached_image_shader: Optional[gpu.types.GPUShader] = None
VERTEX_SHADER_LEGACY = """
uniform mat4 ModelViewProjectionMatrix;
in vec2 pos;
in vec2 texCoord;
out vec2 uv;
void main()
{
uv = texCoord;
gl_Position = ModelViewProjectionMatrix * vec4(pos.xy, 0.0, 1.0);
}
"""
FRAGMENT_SHADER_LEGACY = """
in vec2 uv;
out vec4 fragColor;
uniform sampler2D image;
uniform float transparency;
uniform int color_space_mode;
vec3 linear_to_srgb(vec3 linear_color)
{
vec3 cutoff = vec3(0.0031308);
vec3 lower = linear_color * 12.92;
vec3 higher = 1.055 * pow(max(linear_color, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;
return mix(lower, higher, step(cutoff, linear_color));
}
void main()
{
vec4 color = texture(image, uv);
if (color_space_mode == 1) {
color.rgb = linear_to_srgb(color.rgb);
}
color.a *= transparency;
fragColor = color;
}
"""
def create_image_shader_info():
"""Return GPU shader info for the runtime image shader."""
shader_info = gpu.types.GPUShaderCreateInfo()
shader_info.vertex_in(0, "VEC2", "pos")
shader_info.vertex_in(1, "VEC2", "texCoord")
stage_iface = gpu.types.GPUStageInterfaceInfo("uv_iface")
stage_iface.smooth("VEC2", "uv")
shader_info.vertex_out(stage_iface)
shader_info.push_constant("MAT4", "ModelViewProjectionMatrix")
shader_info.push_constant("FLOAT", "transparency")
shader_info.push_constant("INT", "color_space_mode")
shader_info.sampler(0, "FLOAT_2D", "image")
shader_info.fragment_out(0, "VEC4", "fragColor")
shader_info.vertex_source(
"""
void main()
{
uv = texCoord;
gl_Position = ModelViewProjectionMatrix * vec4(pos.xy, 0.0, 1.0);
}
"""
)
shader_info.fragment_source(
"""
void main()
{
vec4 color = texture(image, uv);
if (color_space_mode == 1) {
vec3 cutoff = vec3(0.0031308);
vec3 lower = color.rgb * 12.92;
vec3 higher = 1.055 * pow(max(color.rgb, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;
color.rgb = mix(lower, higher, step(cutoff, color.rgb));
}
color.a *= transparency;
fragColor = color;
}
"""
)
return shader_info
def create_image_shader():
"""Return a cached shader that supports transparency across Blender versions.
Features:
- sRGB conversion for UI overlays
- transparency
"""
global _cached_image_shader
if _cached_image_shader is not None:
return _cached_image_shader
shader = None
create_info_supported = (
hasattr(gpu, "shader")
and hasattr(gpu.shader, "create_from_info")
and hasattr(gpu.types, "GPUShaderCreateInfo")
)
if create_info_supported:
try:
shader_info = create_image_shader_info()
shader = gpu.shader.create_from_info(shader_info)
except Exception: # noqa: BLE001
bk_logger.exception("Failed to create image shader")
shader = None
if shader is None:
try:
shader = gpu.types.GPUShader(VERTEX_SHADER_LEGACY, FRAGMENT_SHADER_LEGACY)
except Exception: # noqa: BLE001
bk_logger.exception("Failed to create image shader")
if shader is None:
# fallback to builtin shader
# mainly for MacOS builds that have issues with custom shaders
if app.version < (4, 0, 0):
shader = gpu.shader.from_builtin("2D_IMAGE")
else:
shader = gpu.shader.from_builtin("IMAGE")
_cached_image_shader = shader
return shader
def draw_rect(x, y, width, height, color):
"""Used for drawing 2D rectangle backgrounds."""
xmax = x + width
ymax = y + height
points = (
@@ -48,6 +193,31 @@ def draw_rect(x, y, width, height, color):
batch.draw(shader)
def draw_rect_outline(x, y, width, height, color, line_width=1.0):
"""Used for drawing 2D rectangle outlines."""
xmax = x + width
ymax = y + height
coords = (
(x, y), # (x, y)
(x, ymax), # (x, y)
(xmax, ymax), # (x, y)
(xmax, y), # (x, y)
)
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")
batch = batch_for_shader(shader, "LINES", {"pos": coords}, indices=indices)
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(line_width)
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
def draw_line2d(x1, y1, x2, y2, width, color):
"""Used for drawing line from dragged thumbnail to the 3D bounding box."""
coords = ((x1, y1), (x2, y2))
@@ -115,30 +285,59 @@ def draw_lines(vertices, indices, color):
def draw_rect_3d(coords, color):
"""Used for drawing 3D rectangle backgrounds."""
indices = [(0, 1, 2), (2, 3, 0)]
if app.version < (4, 0, 0):
shader = gpu.shader.from_builtin("3D_UNIFORM_COLOR")
else:
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
batch = batch_for_shader(shader, "TRIS", {"pos": coords}, indices=indices)
shader.uniform_float("color", color)
gpu.state.blend_set("ALPHA")
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
cached_images = {}
def _resolve_color_space_mode() -> int:
"""Return shader color conversion mode for the current drawing context.
area over non-3D means UI overlay, so we need to apply sRGB conversion."""
area = getattr(bpy.context, "area", None)
if area is None:
return 0
# Blender 5.0+ node editors already expect linear data, so avoid extra conversion there
node_editor_types = {"NODE_EDITOR", "VIEW_3D"}
if area.type in node_editor_types:
return 0
return 1
def draw_image(x, y, width, height, image, transparency, crop=(0, 0, 1, 1), batch=None):
# draw_rect(x,y, width, height, (.5,0,0,.5))
def draw_image_runtime(
x: float,
y: float,
width: float,
height: float,
image: Union[bpy.types.Image, IMG],
transparency: Optional[float] = 1.0,
crop: Tuple[float, float, float, float] = (0, 0, 1, 1),
batch: Optional[gpu.types.GPUBatch] = None,
) -> Optional[gpu.types.GPUBatch]:
"""Draws an image at given location with given size.
try:
image.name
except:
print("Image is invalid- draw function")
return
Returns:
The batch object if successful, or None if the image is invalid.
"""
if not image.name or not image.filepath:
return None
ci = cached_images.get(image.filepath)
image_shader = create_image_shader()
texture = None
ci = cached_images.get(image.filepath + "GPU_TEXTURE")
if ci is not None:
if (
ci["x"] == x
@@ -149,6 +348,7 @@ def draw_image(x, y, width, height, image, transparency, crop=(0, 0, 1, 1), batc
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)]
@@ -161,16 +361,14 @@ def draw_image(x, y, width, height, image, transparency, crop=(0, 0, 1, 1), batc
indices = [(0, 1, 2), (2, 1, 3)]
if app.version < (4, 0, 0):
image_shader = gpu.shader.from_builtin("2D_IMAGE")
else:
image_shader = gpu.shader.from_builtin("IMAGE")
batch = batch_for_shader(
image_shader, "TRIS", {"pos": coords, "texCoord": uvs}, indices=indices
)
texture = gpu.texture.from_image(image)
texture = path_to_gpu_texture(image.filepath)
# tell shader to use the image that is bound to image unit 0
cached_images[image.filepath] = {
cached_images[image.filepath + "GPU_TEXTURE"] = {
"x": x,
"y": y,
"width": width,
@@ -179,19 +377,56 @@ def draw_image(x, y, width, height, image, transparency, crop=(0, 0, 1, 1), batc
"image_shader": image_shader,
"texture": texture,
}
# send image to gpu if it isn't there already
if image.gl_load():
raise Exception()
# texture = gpu.texture.from_image(image)
gpu.state.blend_set("ALPHA")
image_shader.bind()
image_shader.uniform_sampler("image", texture)
batch.draw(image_shader)
if batch is None:
return None
if image_shader and texture:
color_space_mode = _resolve_color_space_mode()
gpu.state.blend_set("ALPHA")
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
def path_to_gpu_texture(path: str) -> Optional[gpu.types.GPUTexture]:
"""Convert a Blender image to a GPU texture.
Returns:
The GPU texture if successful, or None if the image is invalid.
"""
# check if exists and is file [prevent exception for missing files]
if path in cached_gpu_textures:
return cached_gpu_textures[path]
if not os.path.exists(path) or not os.path.isfile(path):
# do not spam log with warnings, just return None
return None
img = bpy.data.images.load(path, check_existing=False)
img.gl_load()
tex = gpu.texture.from_image(img)
cached_gpu_textures[path] = tex
# # Clean up Blender image
bpy.data.images.remove(img)
return tex
def get_text_size(font_id=0, text="", text_size=16, dpi=72):
if app.version < (4, 0, 0):
blf.size(font_id, text_size, dpi)
@@ -16,10 +16,8 @@
#
# ##### END GPL LICENSE BLOCK #####
import ctypes
import logging
import os
import platform
import random
import time
from webbrowser import open_new_tab
@@ -87,6 +85,9 @@ def draw_upload_common(layout, props, asset_type, context):
url = (
paths.BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL
) # Reuse model instructions since prints are similar
if asset_type == "ADDON":
asset_type_text = asset_type
url = paths.BLENDERKIT_ADDON_UPLOAD_INSTRUCTIONS_URL
op = layout.operator(
"wm.url_open", text=f"Read {asset_type} upload instructions", icon="QUESTION"
)
@@ -225,6 +226,19 @@ def draw_panel_hdr_search(self, context):
utils.label_multiline(layout, text=props.report)
def draw_panel_addon_search(self, context):
wm = context.window_manager
ui_props = wm.blenderkitUI
addon_props = wm.blenderkit_addon
layout = self.layout
row = layout.row()
row.prop(ui_props, "search_keywords", text="", icon="VIEWZOOM")
draw_assetbar_show_hide(row, addon_props)
utils.label_multiline(layout, text=addon_props.report)
def draw_panel_nodegroup_upload(self, context):
layout = self.layout
ui_props = bpy.context.window_manager.blenderkitUI
@@ -458,6 +472,8 @@ def draw_model_context_menu(self, context):
layout = self.layout
o = utils.get_active_model()
if not o:
return
if o.get("asset_data") is None:
utils.label_multiline(
layout,
@@ -1563,6 +1579,32 @@ class VIEW3D_PT_blenderkit_advanced_nodegroup_search(Panel):
draw_common_filters(self.layout, ui_props)
class VIEW3D_PT_blenderkit_advanced_addon_search(Panel):
bl_category = "BlenderKit"
bl_idname = "VIEW3D_PT_blenderkit_advanced_addon_search"
bl_parent_id = "VIEW3D_PT_blenderkit_unified"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_label = "Search filters"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
ui_props = bpy.context.window_manager.blenderkitUI
if not global_vars.CLIENT_RUNNING:
return False
return ui_props.down_up == "SEARCH" and ui_props.asset_type == "ADDON"
def draw(self, context):
ui_props = bpy.context.window_manager.blenderkitUI
draw_common_filters(self.layout, ui_props)
layout = self.layout
addon_props = bpy.context.window_manager.blenderkit_addon
# Add installed filter for addons
row = layout.row()
row.prop(addon_props, "search_installed", text="Installed Only")
class VIEW3D_PT_blenderkit_advanced_printable_search(Panel):
bl_category = "BlenderKit"
bl_idname = "VIEW3D_PT_blenderkit_advanced_printable_search"
@@ -1819,6 +1861,9 @@ class VIEW3D_PT_blenderkit_unified(Panel):
if ui_props.asset_type == "NODEGROUP":
return draw_panel_nodegroup_search(self, context)
if ui_props.asset_type == "ADDON":
return draw_panel_addon_search(self, context)
def draw_upload(self, context, layout, ui_props):
obj = utils.get_active_asset()
props = getattr(obj, "blenderkit", None)
@@ -1861,6 +1906,15 @@ class VIEW3D_PT_blenderkit_unified(Panel):
if ui_props.asset_type == "NODEGROUP":
return draw_panel_nodegroup_upload(self, context)
if ui_props.asset_type == "ADDON":
layout.label(text="Add-on uploads are managed through")
layout.label(text="the BlenderKit website.")
op = layout.operator(
"wm.url_open", text="Go to BlenderKit Website", icon="URL"
)
op.url = paths.BLENDERKIT_ADDON_UPLOAD_INSTRUCTIONS_URL
return
class BlenderKitWelcomeOperator(bpy.types.Operator):
"""Login online on BlenderKit webpage"""
@@ -2125,10 +2179,11 @@ def draw_asset_context_menu(
op.asset_base_id = asset_data["assetBaseId"]
if asset_data["assetType"] == "model":
o = utils.get_active_model()
op.model_location = o.location
op.model_rotation = o.rotation_euler
op.target_object = o.name
op.material_target_slot = o.active_material_index
if o is not None:
op.model_location = o.location
op.model_rotation = o.rotation_euler
op.target_object = o.name
op.material_target_slot = o.active_material_index
elif asset_data["assetType"] == "material":
aob = bpy.context.active_object
@@ -2438,8 +2493,8 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
icon=icon,
emboss=emboss,
)
# additional questionmark icon where it's important?
# Embossed elements are visibly clickable, so we don't need the questionmark icon
# additional 'question mark' icon where it's important?
# Embossed elements are visibly clickable, so we don't need the 'question mark' icon
if url != "" and not emboss:
split = split.split()
op = split.operator("wm.blenderkit_url", text="", icon="QUESTION")
@@ -2512,15 +2567,15 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
box.separator()
if self.asset_data.get("license") == "cc_zero":
t = "CC Zero "
text = "CC Zero "
icon = pcoll["cc0"]
else:
t = "Royalty free"
text = "Royalty free"
icon = pcoll["royalty_free"]
self.draw_property(
box,
"License",
t,
text,
# icon_value=icon.icon_id,
url=f"{global_vars.SERVER}/docs/licenses/",
tooltip="All BlenderKit assets are available for commercial use. \n"
@@ -2614,8 +2669,8 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
self.draw_asset_parameter(box, key="modelStyle", pretext="Style")
if utils.get_param(self.asset_data, "dimensionX"):
t = utils.fmt_dimensions(mparams)
self.draw_property(box, "Size", t)
text = utils.fmt_dimensions(mparams)
self.draw_property(box, "Size", text)
if self.asset_data.get("filesSize"):
fs = self.asset_data["filesSize"] * 1024
# multiply because the number is reduced when search is done to avoind C intiger limit with large files
@@ -2655,38 +2710,147 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
# self.draw_property(box, 'Tags', self.asset_data['tags']) #TODO make them clickable!
# Free/Full plan or private Access
# Free/Full plan or private Access - with special handling for addons
plans_tooltip = (
"BlenderKit has 2 plans:\n"
" * Free plan - more than 50% of all assets\n"
" * Full plan - unlimited access to everything\n"
"Click to go to subscriptions page"
)
if self.asset_data["isPrivate"]:
t = "Private"
self.draw_property(box, "Access", t, icon="LOCKED")
elif self.asset_data["isFree"]:
t = "Free plan"
icon = pcoll["free"]
self.draw_property(
box,
"Access",
t,
icon_value=icon.icon_id,
tooltip=plans_tooltip,
url=paths.BLENDERKIT_PLANS_URL,
)
# Special pricing display for addons
if self.asset_data.get("assetType") == "addon":
can_download = self.asset_data.get("canDownload")
is_free = self.asset_data.get("isFree")
# Get pricing info from extensions cache
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 base_price:
text = f"${base_price} (Not purchased)"
icon = pcoll["full"]
self.draw_property(
box,
"Price",
text,
icon_value=icon.icon_id,
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"]
self.draw_property(
box,
"Price",
text,
icon_value=icon.icon_id,
tooltip="You have purchased this addon",
)
elif not is_free and not is_for_sale:
text = "Full plan required"
icon = pcoll["full"]
self.draw_property(
box,
"Access",
text,
icon_value=icon.icon_id,
tooltip=plans_tooltip,
url=paths.BLENDERKIT_PLANS_URL,
)
else:
text = "Free"
icon = pcoll["free"]
self.draw_property(
box,
"Access",
text,
icon_value=icon.icon_id,
tooltip="This addon is free to use",
)
# Display Blender version requirements for addons
dict_params = self.asset_data.get("dictParameters", {})
min_version = dict_params.get("blenderVersionMin")
max_version = dict_params.get("blenderVersionMax")
if min_version:
min_version_tuple = tuple(map(int, min_version.split(".")))
if max_version:
max_version_tuple = tuple(map(int, max_version.split(".")))
if min_version or max_version:
version_text = ""
if min_version and max_version:
version_text = f"{min_version} - {max_version}"
elif min_version:
version_text = f"{min_version}+"
elif max_version:
version_text = f"{max_version}"
# Check if current Blender version is compatible
current_version = (
f"{bpy.app.version[0]}.{bpy.app.version[1]}.{bpy.app.version[2]}"
)
is_compatible = True
if min_version:
if bpy.app.version < min_version_tuple:
is_compatible = False
if max_version and is_compatible:
if bpy.app.version > max_version_tuple:
is_compatible = False
# Display version requirement with appropriate warning
if not is_compatible:
box.alert = True
self.draw_property(
box,
"Blender versions",
f"{version_text} (Incompatible!)",
icon="ERROR",
tooltip=f"This addon requires Blender {version_text}, but you're using {current_version}",
)
box.alert = False
else:
self.draw_property(
box,
"Blender versions",
version_text,
icon="CHECKMARK",
tooltip=f"This addon is compatible with your Blender version ({current_version})",
)
else:
t = "Full plan"
icon = pcoll["full"]
self.draw_property(
box,
"Access",
t,
icon_value=icon.icon_id,
tooltip=plans_tooltip,
url=paths.BLENDERKIT_PLANS_URL,
)
# Regular asset access display
if self.asset_data["isPrivate"]:
text = "Private"
self.draw_property(box, "Access", text, icon="LOCKED")
elif self.asset_data["isFree"]:
text = "Free plan"
icon = pcoll["free"]
self.draw_property(
box,
"Access",
text,
icon_value=icon.icon_id,
tooltip=plans_tooltip,
url=paths.BLENDERKIT_PLANS_URL,
)
else:
text = "Full plan"
icon = pcoll["full"]
self.draw_property(
box,
"Access",
text,
icon_value=icon.icon_id,
tooltip=plans_tooltip,
url=paths.BLENDERKIT_PLANS_URL,
)
if utils.profile_is_validator():
date = self.asset_data["created"][:10]
@@ -2730,7 +2894,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
)
# Add TwinBru specific parameters for material assets
# only if they have twinbruReference in the dictparameters
# only if they have 'twinbruReference' in the 'dictParameters'
if self.asset_data.get("dictParameters").get("twinbruReference"):
box.separator()
box.label(text="TwinBru physical material categories")
@@ -3220,10 +3384,15 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
split_ratio = 0.45
split_left = row.split(factor=split_ratio)
left_column = split_left.column()
self.draw_thumbnail_box(left_column, width=int(self.width * split_ratio))
if not utils.user_is_owner(asset_data=self.asset_data):
if (
not utils.user_is_owner(asset_data=self.asset_data)
and self.asset_data.get("assetType") != "addon"
):
# Draw ratings, but not for owners of assets - doesn't make sense.
# also addons are now disabled until we figure out how to handle them.
ratings_box = left_column.box()
self.prefill_ratings()
ratings.draw_ratings_menu(self, context, ratings_box)
@@ -3412,35 +3581,18 @@ class ClearSearchKeywords(bpy.types.Operator):
class ClosePopupButton(bpy.types.Operator):
"""Close popup window"""
"""Close the popup window. It can also be closed by pressing Esc or clicking outside it."""
bl_idname = "view3d.close_popup_button"
bl_label = "Close popup"
bl_label = "Close Popup"
bl_options = {"REGISTER", "INTERNAL"}
@classmethod
def poll(cls, context):
return True
def win_close(self):
VK_ESCAPE = 0x1B
ctypes.windll.user32.keybd_event(VK_ESCAPE)
return True
def mouse_trick(self, context, x, y):
# import time
context.area.tag_redraw()
w = context.window
w.cursor_warp(w.x + 15, w.y + w.height - 15)
# time.sleep(.12)
w.cursor_warp(x, y)
context.area.tag_redraw()
def invoke(self, context, event):
if platform.system() == "Windows":
self.win_close()
else:
self.mouse_trick(context, event.mouse_x, event.mouse_y)
"""Force the (containing, parent) popup to close.
This was done by emulating Esc or hacking mouse, but stopped working in B5.
But can be effectively done by just tweaking screen: https://blender.stackexchange.com/a/329900
"""
bpy.context.window.screen = bpy.context.window.screen
return {"FINISHED"}
@@ -3492,9 +3644,13 @@ class UrlPopupDialog(bpy.types.Operator):
layout.active_default = True
op = layout.operator("wm.url_open", text=self.link_text, icon="QUESTION")
if not utils.user_logged_in():
if self.message.find("purchased") != -1:
text = "purchased"
else:
text = "subscribed"
utils.label_multiline(
layout,
text="Already subscribed? Log in to access your account.",
text=f"Already {text}? Log in to access your account.",
width=300,
)
@@ -3695,6 +3851,7 @@ def header_search_draw(self, context):
"HDR": wm.blenderkit_HDR,
"SCENE": wm.blenderkit_scene,
"NODEGROUP": wm.blenderkit_nodegroup,
"ADDON": wm.blenderkit_addon,
}
props = props_dict[ui_props.asset_type]
pcoll = icons.icon_collections["main"]
@@ -3708,6 +3865,7 @@ def header_search_draw(self, context):
"HDR": "WORLD",
"SCENE": "SCENE_DATA",
"NODEGROUP": "NODETREE",
"ADDON": "PLUGIN",
}
asset_type_icon = icons_dict[ui_props.asset_type]
@@ -3845,6 +4003,12 @@ def header_search_draw(self, context):
text="",
icon_value=icon_id,
)
elif ui_props.asset_type == "ADDON":
layout.popover(
panel="VIEW3D_PT_blenderkit_advanced_addon_search",
text="",
icon_value=icon_id,
)
elif ui_props.asset_type == "PRINTABLE":
layout.popover(
panel="VIEW3D_PT_blenderkit_advanced_printable_search",
@@ -4039,7 +4203,7 @@ class NodegroupDropDialog(bpy.types.Operator):
# When adding as a node, use node positioning; when adding as modifier, use 3D positioning
if self.add_mode == "NODE":
bpy.ops.scene.blenderkit_download(
True,
"EXEC_DEFAULT",
asset_index=self.asset_search_index,
node_x=self.node_x,
node_y=self.node_y,
@@ -4050,7 +4214,7 @@ class NodegroupDropDialog(bpy.types.Operator):
)
else: # MODIFIER mode
bpy.ops.scene.blenderkit_download(
True,
"EXEC_DEFAULT",
asset_index=self.asset_search_index,
model_location=self.snapped_location,
model_rotation=self.snapped_rotation,
@@ -4080,6 +4244,7 @@ classes = (
VIEW3D_PT_blenderkit_advanced_HDR_search,
VIEW3D_PT_blenderkit_advanced_brush_search,
VIEW3D_PT_blenderkit_advanced_nodegroup_search,
VIEW3D_PT_blenderkit_advanced_addon_search,
VIEW3D_PT_blenderkit_advanced_printable_search,
VIEW3D_PT_blenderkit_categories,
VIEW3D_PT_blenderkit_import_settings,
@@ -304,6 +304,11 @@ def get_search_props():
if not hasattr(wm, "blenderkit_nodegroup"):
return
props = wm.blenderkit_nodegroup
if uiprops.asset_type == "ADDON":
if not hasattr(wm, "blenderkit_addon"):
return
props = wm.blenderkit_addon
return props
@@ -357,6 +362,8 @@ def get_active_asset():
return get_active_brush()
elif ui_props.asset_type == "NODEGROUP":
return get_active_nodegroup()
elif ui_props.asset_type == "ADDON":
return None # Addons don't have an active asset concept
return None
@@ -394,6 +401,8 @@ def get_upload_props():
b = get_active_nodegroup()
if b is not None:
return b.blenderkit
elif ui_props.asset_type == "ADDON":
return None # Addons don't have upload props
return None
@@ -647,10 +656,11 @@ def img_to_preview(img, copy_original=False):
def get_hidden_image(
tpath, bdata_name, force_reload: bool = False, colorspace: str = ""
):
"""Get hidden image by name. If not found, load it from tpath."""
if bdata_name[0] == ".":
hidden_name = bdata_name
else:
hidden_name = ".%s" % bdata_name
hidden_name = f".{bdata_name}"
img = bpy.data.images.get(hidden_name) # type: ignore[union-attr]
if tpath.startswith("//"):
@@ -687,14 +697,14 @@ def get_hidden_image(
def get_thumbnail(name):
"""Get addon thumbnail image by name."""
p = paths.get_addon_thumbnail_path(name)
name = ".%s" % name
name = f".{name}"
img = bpy.data.images.get(name)
if img == None:
if img is None:
img = bpy.data.images.load(p, check_existing=True)
image_utils.set_colorspace(img)
img.name = name
img.name = name
return img
@@ -1224,6 +1234,9 @@ def user_is_owner(asset_data: Optional[dict] = None) -> bool:
def asset_from_newer_blender_version(asset_data, blender_version=None):
"""Check if asset is from a newer blender version, to avoid incompatibility. Give info if difference is in major, minor or patch version."""
# addons don't have a blender version, so we return False
if asset_data["assetType"] == "addon":
return False, ""
asset_ver = asset_data["sourceAppVersion"].split(".")
if blender_version is None:
blender_version = bpy.app.version
@@ -1266,27 +1279,38 @@ def guard_from_crash():
def get_largest_area(context=None, area_type="VIEW_3D"):
"""Get the largest area of the given type."""
maxsurf = 0
maxa = None
maxw = None
region = None
if context is None:
windows = bpy.data.window_managers[0].windows
if bpy.context.window is not None:
windows = [bpy.context.window]
else:
windows = bpy.data.window_managers.windows
else:
windows = context.window_manager.windows
for w in windows:
for a in w.screen.areas:
if a.type == area_type:
asurf = a.width * a.height
if asurf > maxsurf:
maxa = a
maxw = w
maxsurf = asurf
if bpy.context.area is not None and bpy.context.area.type == area_type:
maxa = bpy.context.area
maxw = bpy.context.window
maxsurf = maxa.width * maxa.height
region = maxa.regions[-1]
else:
areas = w.screen.areas
for a in w.screen.areas:
if a.type == area_type:
asurf = a.width * a.height
if asurf > maxsurf:
maxa = a
maxw = w
maxsurf = asurf
region = a.regions[-1]
# for r in a.regions:
# if r.type == 'WINDOW':
# region = r
region = a.regions[-1]
# for r in a.regions:
# if r.type == 'WINDOW':
# region = r
if maxw is None or maxa is None:
return None, None, None
@@ -1,6 +1,6 @@
{
"last_check": "2025-12-22 09:58:29.267931",
"backup_date": "December-1-2025",
"last_check": "2026-02-12 10:20:22.908483",
"backup_date": "January-12-2026",
"update_ready": false,
"ignore": false,
"just_restored": false,
@@ -1,5 +1,10 @@
import blf
import bpy
import gpu
from typing import Tuple, Union
from gpu_extras.batch import batch_for_shader
from .bl_ui_widget import BL_UI_Widget
@@ -17,6 +22,9 @@ class BL_UI_Label(BL_UI_Widget):
self.multiline = False
self.row_height = 20
self.padding: Union[Tuple[float, float], float] = 0
self.background = False
@property
def text_color(self):
return self._text_color
@@ -61,6 +69,30 @@ class BL_UI_Label(BL_UI_Widget):
blf.size(font_id, self._text_size, 72)
else:
blf.size(font_id, self._text_size)
lines = self._text.split("\n") if self.multiline else [self._text]
if not lines:
return
default_line_height = self.row_height if self.multiline else self._text_size
line_metrics = []
max_line_width = 0.0
total_height = 0.0
for line in lines:
width, height = blf.dimensions(font_id, line)
if height == 0:
height = default_line_height
line_height = (
self.row_height if self.multiline else max(height, self._text_size)
)
if line_height == 0:
line_height = default_line_height
line_metrics.append((line, width, line_height))
max_line_width = max(max_line_width, width)
total_height += line_height
if not line_metrics:
return
textpos_y = area_height - self.y_screen - self.height
@@ -76,16 +108,55 @@ class BL_UI_Label(BL_UI_Widget):
if self._valign == "CENTER":
y -= height // 2
# bottom could be here but there's no reason for it
first_line_height = line_metrics[0][2]
if self.background and (max_line_width > 0 or total_height > 0):
pad_x, pad_y = self._padding_tuple()
text_top = y + first_line_height
text_bottom = text_top - total_height
left = x - pad_x
right = x + max_line_width + pad_x
top = text_top + pad_y
bottom = text_bottom - pad_y
self._draw_background_rect(left, right, bottom, top)
current_y = y
if not self.multiline:
blf.position(font_id, x, y, 0)
blf.position(font_id, x, current_y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, self._text)
else:
lines = self._text.split("\n")
for line in lines:
blf.position(font_id, x, y, 0)
for line, _, line_height in line_metrics:
blf.position(font_id, x, current_y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, line)
y -= self.row_height
current_y -= line_height
def _padding_tuple(self) -> Tuple[float, float]:
pad = self.padding
if isinstance(pad, (list, tuple)):
if len(pad) == 0:
return (0.0, 0.0)
if len(pad) == 1:
value = float(pad[0])
return (value, value)
return (float(pad[0]), float(pad[1]))
value = float(pad)
return (value, value)
def _draw_background_rect(self, left, right, bottom, top):
vertices = (
(left, top),
(left, bottom),
(right, bottom),
(right, top),
)
indices = ((0, 1, 2), (0, 2, 3))
gpu.state.blend_set("ALPHA")
self.shader.bind()
self.shader.uniform_float("color", self._bg_color)
batch = batch_for_shader(
self.shader, "TRIS", {"pos": vertices}, indices=indices
)
batch.draw(self.shader)
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "blenderkit"
type = "add-on"
version = "3.18.0-251121" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
version = "3.18.1-251219" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
name = "BlenderKit Online Asset Library"
tagline = "Drag & drop of assets from the community driven library"
@@ -91,7 +91,7 @@ def ensure_minimal_data(data: Optional[dict] = None) -> dict:
return data
def ensure_minimal_data_class(data_class):
def ensure_minimal_data_class(data_class: datas.SearchData) -> datas.SearchData:
"""Ensure that the data send to the BlenderKit-Client contains:
- app_id is the process ID of the Blender instance, so BlenderKit-client can return reports to the correct instance.
- api_key is the authentication token for the BlenderKit server, so BlenderKit-Client can authenticate the user.
+26 -7
View File
@@ -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
View File
@@ -109,8 +109,10 @@ def get_addon_installation_status(asset_data):
if not is_enabled:
extension_module_name = f"bl_ext.www_blenderkit_com.{extension_id}"
is_enabled = extension_module_name in enabled_addons
bk_logger.info(
f"Checking extension format: {extension_module_name} -> enabled: {is_enabled}"
bk_logger.debug(
"Checking extension format: %s -> enabled: %s",
extension_module_name,
is_enabled,
)
# Also try other possible repository name formats
@@ -210,10 +212,15 @@ def get_addon_installation_status(asset_data):
if "blenderkit" in addon.lower() or addon.endswith(extension_id)
]
if blenderkit_addons:
bk_logger.info(f"Found BlenderKit-related enabled addons: {blenderkit_addons}")
bk_logger.debug(
"Found BlenderKit-related enabled addons: %s", blenderkit_addons
)
bk_logger.info(
f"Addon status check for '{extension_id}': installed={is_installed}, enabled={is_enabled}"
bk_logger.debug(
"Addon status check for '%s': installed=%s, enabled=%s",
extension_id,
is_installed,
is_enabled,
)
return {
@@ -877,8 +884,13 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
if asset_blender_version < (4, 3, 0) and bpy.app.version >= (4, 3, 0):
brush.asset_clear()
brush.asset_mark()
brush.icon_filepath = asset_thumb_path
if bpy.app.version <= (4, 5, 0):
brush.icon_filepath = asset_thumb_path
else:
# load asset thumbnail into brush if it's not already present
if brush.preview is None:
with bpy.context.temp_override(id=brush):
bpy.ops.ed.lib_id_load_custom_preview(filepath=asset_thumb_path)
# set the brush active
if bpy.context.view_layer.objects.active.mode == "SCULPT":
if bpy.app.version < (4, 3, 0):
@@ -897,7 +909,6 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
relative_asset_identifier=f"Brush{os.sep}{brush.name}"
)
# TODO add grease pencil brushes!
# bpy.context.tool_settings.image_paint.brush = brush
asset_main = brush
@@ -16,7 +16,7 @@
#
# ##### END GPL LICENSE BLOCK #####
from logging import INFO, WARN
from logging import DEBUG, INFO, WARN
from os import environ
from subprocess import Popen
from typing import Any, Optional
@@ -59,6 +59,11 @@ BKIT_AUTHORS: dict[int, datas.UserProfile] = {}
"""All loaded profiles of other users. Current user is also present in stripped down version. Key is the UserProfile.id."""
LOGGING_LEVEL_BLENDERKIT = INFO
# read special DEBUG env var to set logging level to DEBUG
if environ.get("BLENDERKIT_DEBUG", "0") == "1":
LOGGING_LEVEL_BLENDERKIT = DEBUG
LOGGING_LEVEL_IMPORTED = WARN
PREFS = {}
+4 -2
View File
@@ -27,8 +27,10 @@ import bpy
icon_collections = {}
icons_read = {
"fp.png": "free",
"flp.png": "full",
"free_plan.png": "free",
"full_plan.png": "full",
"promo_sale_symbol.png": "promo_sale_symbol",
"sale_purple.png": "for_sale",
"trophy.png": "trophy",
"dumbbell.png": "dumbbell",
"cc0.png": "cc0",
@@ -24,6 +24,7 @@ import shutil
import subprocess
import sys
import tempfile
from functools import lru_cache
import bpy
@@ -39,6 +40,9 @@ BLENDERKIT_REPORT_URL = f"{global_vars.SERVER}/usage_report"
BLENDERKIT_USER_ASSETS_URL = f"{global_vars.SERVER}/my-assets"
BLENDERKIT_MANUAL_URL = "https://youtu.be/0P8ZjfbUjeA"
BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/upload/"
BLENDERKIT_PRINTABLE_UPLOAD_INSTRUCTIONS_URL = (
f"{global_vars.SERVER}/docs/upload-printables/"
)
BLENDERKIT_MATERIAL_UPLOAD_INSTRUCTIONS_URL = (
f"{global_vars.SERVER}/docs/uploading-material/"
)
@@ -463,6 +467,8 @@ def get_addon_file(subpath=""):
script_path = os.path.dirname(os.path.realpath(__file__))
# cache this for minor performance boost
@lru_cache(maxsize=128)
def get_addon_thumbnail_path(name):
global script_path
# fpath = os.path.join(p, subpath)
@@ -474,6 +480,13 @@ def get_addon_thumbnail_path(name):
return os.path.join(script_path, subpath)
# cache this for minor performance boost
@lru_cache(maxsize=128)
def icon_path_exists(path: str) -> bool:
"""Cached version of os.path.exists"""
return os.path.exists(path)
def get_config_dir_path() -> str:
"""Get the path to the config directory in global_dir."""
global_dir = bpy.context.preferences.addons[__package__].preferences.global_dir # type: ignore
+17 -16
View File
@@ -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
View File
@@ -20,6 +20,7 @@ import copy
import json
import logging
import math
from functools import lru_cache
import os
import re
import unicodedata
@@ -43,6 +44,7 @@ from . import (
image_utils,
paths,
reports,
search_price,
resolutions,
tasks_queue,
utils,
@@ -53,6 +55,51 @@ bk_logger = logging.getLogger(__name__)
search_tasks = {}
def _inject_user_price_data(assets: list[dict]) -> None:
"""Augment search results with per-user pricing info when available."""
if not assets:
bk_logger.debug("User price lookup skipped: empty assets list.")
return
version_uuids: list[str] = [ass["id"] for ass in assets]
if not version_uuids:
bk_logger.debug("User price lookup skipped: empty version UUIDs list.")
return
try:
price_response = search_price.query_user_price(
version_uuids=version_uuids,
page_size=len(version_uuids),
)
except Exception as exc:
bk_logger.warning("Failed to fetch user prices: %s", exc)
return
if not price_response:
bk_logger.debug(
"User price lookup skipped: %s",
price_response,
)
return
price_by_uuid: dict[str, dict] = {}
for entry in price_response:
version_uuid = entry.get("versionUuid") # maybe assetUuid ?
if not version_uuid:
continue
price_by_uuid[version_uuid] = entry
if not price_by_uuid:
return
for asset in assets:
version_uuid = asset["id"]
price_info = price_by_uuid.get(version_uuid)
if not price_info:
continue
asset["userPrice"] = price_info["discountedPrice"]
def update_ad(ad):
if not ad.get("assetBaseId"):
try:
@@ -136,22 +183,23 @@ def check_clipboard():
"""
global last_clipboard
try: # could be problematic on Linux
current_clipboard = bpy.context.window_manager.clipboard
current_clipboard = str(bpy.context.window_manager.clipboard)
except Exception as e:
bk_logger.warning(f"Failed to get clipboard: {e}")
return
if current_clipboard == last_clipboard:
return
last_clipboard = current_clipboard
asset_type_index = last_clipboard.find("asset_type:")
asset_type_index = current_clipboard.find("asset_type:")
if asset_type_index == -1:
return
if not last_clipboard.startswith("asset_base_id:"):
if not current_clipboard.startswith("asset_base_id:"):
return
last_clipboard = current_clipboard
asset_type_string = current_clipboard[asset_type_index:].lower()
if asset_type_string.find("model") > -1:
target_asset_type = "MODEL"
@@ -169,6 +217,10 @@ def check_clipboard():
target_asset_type = "NODEGROUP"
elif asset_type_string.find("addon") > -1 or asset_type_string.find("add-on") > -1:
target_asset_type = "ADDON"
else:
bk_logger.debug("Clipboard does not contain valid asset type.")
return
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.asset_type != target_asset_type:
ui_props.asset_type = target_asset_type # switch asset type before placing keywords, so it does not search under wrong asset type
@@ -341,7 +393,7 @@ def handle_search_task(task: client_tasks.Task) -> bool:
return True
# don't do anything while dragging - this could switch asset during drag, and make results list length different,
# causing a lot of throuble.
# causing a lot of trouble.
if bpy.context.window_manager.blenderkitUI.dragging: # type: ignore[attr-defined]
return False
@@ -403,6 +455,10 @@ def handle_search_task(task: client_tasks.Task) -> bool:
asset for asset in result_field if asset.get("downloaded", 0) > 0
]
# TODO: if ever needed, implement for other future types
if result_field:
_inject_user_price_data(result_field)
# Store results in history step
history_step["search_results"] = result_field
history_step["search_results_orig"] = task.result
@@ -710,7 +766,7 @@ def query_to_url(
scene_uuid: str = "",
page_size: int = 15,
) -> str:
"""Build a new search request by parsing query dictionaty into appropriate URL.
"""Build a new search request by parsing query dictionary into appropriate URL.
Also modifies query and adds some stuff in there which is very misleading anti-pattern.
TODO: just convert to URL here and move the sorting and adding of params to separate function.
https://www.blenderkit.com/api/v1/search/
@@ -1012,6 +1068,7 @@ def filter_addon_search_results(search_results, filter_installed_only=False):
def add_search_process(
query, get_next: bool, page_size: int, next_url: str, history_id: str
):
"""Initialize search task and add it to the task queue."""
global search_tasks
addon_version = utils.get_addon_version()
blender_version = utils.get_blender_version()
@@ -1232,7 +1289,7 @@ def search(get_next=False, query=None, author_id=""):
def clean_filters():
"""Cleanup filters in case search needs to be reset, typicaly when asset id is copy pasted."""
"""Cleanup filters in case search needs to be reset, typically when asset id is copy pasted."""
sprops = utils.get_search_props()
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.property_unset("own_only")
@@ -1551,6 +1608,13 @@ class SearchOperator(Operator):
default="Runs search and displays the asset bar at the same time"
)
force_clear: BoolProperty( # type: ignore[valid-type]
name="Force clear keywords, before programmatic search",
description="Force clear keywords before search",
default=True,
options={"SKIP_SAVE"},
)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@@ -1564,16 +1628,25 @@ class SearchOperator(Operator):
if self.esc:
bpy.ops.view3d.close_popup_button("INVOKE_DEFAULT")
ui_props = bpy.context.window_manager.blenderkitUI
search_keywords = str(ui_props.search_keywords)
if self.keywords != "":
search_keywords = self.keywords
# remove all search keywords if force_clear is set
if self.force_clear:
# self.force_clear = False # reset the force clear
search_keywords = ""
if self.author_id != "":
bk_logger.info(f"Author ID: {self.author_id}")
# if there is already an author id in the search keywords, remove it first, the author_id can be any so
# use regex to find it
ui_props.search_keywords = re.sub(
r"\+author_id:\d+", "", ui_props.search_keywords
)
ui_props.search_keywords += f"+author_id:{self.author_id}"
if self.keywords != "":
ui_props.search_keywords = self.keywords
search_keywords = re.sub(r"\+author_id:\d+", "", search_keywords)
search_keywords += f"+author_id:{self.author_id}"
ui_props.search_keywords = search_keywords
search(get_next=self.get_next)
@@ -0,0 +1,57 @@
from typing import Iterable, List, Optional, Tuple
from . import client_lib, paths, utils
def _normalize_version_uuid_list(values: Optional[Iterable[str]]) -> List[str]:
if values is None:
return []
normalized: List[str] = []
for value in values:
if not value:
continue
as_str = str(value)
if as_str not in normalized:
normalized.append(as_str)
return normalized
def query_user_price(
version_uuids: list[str] = [],
page_size: int = 15,
timeout: Tuple[float, float] = (1, 30),
) -> dict:
"""Return results for price lookup of multiple asset versions.
The server endpoint now expects a POST body with `version_uuids`, so we keep
the helper focused on returning the correct URL alongside the JSON payload
that should be sent in the request.
"""
if isinstance(version_uuids, str):
version_uuids = [version_uuids]
version_uuid_list = _normalize_version_uuid_list(version_uuids)
if page_size > 0:
version_uuid_list = version_uuid_list[:page_size]
payload: dict = {"version_uuids": version_uuid_list}
url = f"{paths.BLENDERKIT_API}/cart/request-price-bulk/"
if not payload["version_uuids"]:
raise ValueError("No version UUIDs provided for price lookup.")
headers = utils.get_simple_headers()
headers.setdefault("Content-Type", "application/json")
response = client_lib.blocking_request(
url,
"POST",
headers,
json_data=payload,
timeout=timeout,
)
search_results = response.json()
return search_results
Binary file not shown.
+8 -6
View File
@@ -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
@@ -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
View File
@@ -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):