Files
blender-portable-repo/extensions/user_default/blenderkit/search.py
T
2026-03-17 15:25:32 -06:00

2063 lines
71 KiB
Python

# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import copy
import json
import logging
import math
from functools import lru_cache
import os
import re
import unicodedata
import urllib.parse
import uuid
from typing import Optional, Union
import bpy
from bpy.app.handlers import persistent
from bpy.props import BoolProperty, StringProperty
from bpy.types import Operator
from . import (
asset_bar_op,
client_lib,
client_tasks,
comments_utils,
datas,
download,
global_vars,
image_utils,
paths,
reports,
search_price,
resolutions,
tasks_queue,
utils,
)
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:
ad["assetBaseId"] = ad[
"asset_base_id"
] # this should stay ONLY for compatibility with older scenes
ad["assetType"] = ad[
"asset_type"
] # this should stay ONLY for compatibility with older scenes
ad["verificationStatus"] = ad[
"verification_status"
] # this should stay ONLY for compatibility with older scenes
ad["author"] = {}
ad["author"]["id"] = ad[
"author_id"
] # this should stay ONLY for compatibility with older scenes
ad["canDownload"] = ad[
"can_download"
] # this should stay ONLY for compatibility with older scenes
except Exception as e:
bk_logger.error("BlenderKit failed to update older asset data")
return ad
def update_assets_data(): # updates assets data on scene load.
"""updates some properties that were changed on scenes with older assets.
The properties were mainly changed from snake_case to CamelCase to fit the data that is coming from the server.
"""
datablocks = [
bpy.data.objects,
bpy.data.materials,
bpy.data.brushes,
]
for dtype in datablocks:
for block in dtype:
if block.get("asset_data") is not None:
update_ad(block["asset_data"])
dicts = [
"assets used",
]
for s in bpy.data.scenes:
for bkdict in dicts:
d = s.get(bkdict)
if not d:
continue
for asset_id in d.keys():
update_ad(d[asset_id])
# bpy.context.scene['assets used'][ad] = ad
@persistent
def undo_post_reload_previews(context):
load_previews()
@persistent
def undo_pre_end_assetbar(context):
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.turn_off = True
ui_props.assetbar_on = False
@persistent
def scene_load(context):
"""Load categories, check timers registration, and update scene asset data.
Should (probably) also update asset data from server (after user consent).
"""
update_assets_data()
last_clipboard = ""
def check_clipboard():
"""Check clipboard for an exact string containing asset ID.
The string is generated on www.blenderkit.com as for example here:
https://www.blenderkit.com/get-blenderkit/54ff5c85-2c73-49e9-ba80-aec18616a408/
"""
global last_clipboard
try: # could be problematic on Linux
current_clipboard = str(bpy.context.window_manager.clipboard)
except Exception as e:
bk_logger.warning(f"Failed to get clipboard: {e}")
return
if current_clipboard == last_clipboard:
return
asset_type_index = current_clipboard.find("asset_type:")
if asset_type_index == -1:
return
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"
elif asset_type_string.find("material") > -1:
target_asset_type = "MATERIAL"
elif asset_type_string.find("brush") > -1:
target_asset_type = "BRUSH"
elif asset_type_string.find("scene") > -1:
target_asset_type = "SCENE"
elif asset_type_string.find("hdr") > -1:
target_asset_type = "HDR"
elif asset_type_string.find("printable") > -1:
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"
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
# all modifications in
ui_props.search_keywords = current_clipboard[:asset_type_index].rstrip()
# 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:
"""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
"""
scene = bpy.context.scene
# TODO remove this fix when filesSize is fixed.
# this is a temporary fix for too big numbers from the server.
# can otherwise get the Python int too large to convert to C int
try:
r["filesSize"] = int(r["filesSize"] / 1024)
except:
utils.p("asset with no files-size")
asset_type = r["assetType"]
adata = r["author"]
social_networks = datas.parse_social_networks(adata.pop("socialNetworks", []))
author = datas.UserProfile(**adata, socialNetworks=social_networks)
generate_author_profile(author)
r["available_resolutions"] = []
use_webp = True
if bpy.app.version < (3, 4, 0) or r.get("webpGeneratedTimestamp", 0) == 0:
use_webp = False # WEBP was optimized in Blender 3.4.0
# BIG THUMB - HDR CASE
if r["assetType"] == "hdr":
if use_webp:
thumb_url = r.get("thumbnailLargeUrlNonsquaredWebp")
else:
thumb_url = r.get("thumbnailLargeUrlNonsquared")
# BIG THUMB - NON HDR CASE
else:
if use_webp:
thumb_url = r.get("thumbnailMiddleUrlWebp")
else:
thumb_url = r.get("thumbnailMiddleUrl")
# SMALL THUMB
if use_webp:
small_thumb_url = r.get("thumbnailSmallUrlWebp")
else:
small_thumb_url = r.get("thumbnailSmallUrl")
tname = paths.extract_filename_from_url(thumb_url)
small_tname = paths.extract_filename_from_url(small_thumb_url)
for f in r["files"]:
# if f['fileType'] == 'thumbnail':
# tname = paths.extract_filename_from_url(f['fileThumbnailLarge'])
# small_tname = paths.extract_filename_from_url(f['fileThumbnail'])
# allthumbs.append(tname) # TODO just first thumb is used now.
if f["fileType"] == "blend":
durl = f["downloadUrl"].split("?")[0]
# fname = paths.extract_filename_from_url(f['filePath'])
if f["fileType"].find("resolution") > -1:
r["available_resolutions"].append(resolutions.resolutions[f["fileType"]])
# code for more thumbnails
# tdict = {}
# for i, t in enumerate(allthumbs):
# tdict['thumbnail_%i'] = t
r["max_resolution"] = 0
if r["available_resolutions"]: # should check only for non-empty sequences
r["max_resolution"] = max(r["available_resolutions"])
# tooltip = generate_tooltip(r)
# for some reason, the id was still int on some occurances. investigate this.
r["author"]["id"] = str(r["author"]["id"])
# some helper props, but generally shouldn't be renaming/duplifiying original properties,
# so blender's data is same as on server.
asset_data = {
"thumbnail": tname,
"thumbnail_small": small_tname,
# 'tooltip': tooltip,
}
asset_data["downloaded"] = 0
# parse extra params needed for blender here
params = r["dictParameters"] # utils.params_to_dict(r['parameters'])
if asset_type in ["model", "printable"]:
if params.get("boundBoxMinX") != None:
bbox = {
"bbox_min": (
float(params["boundBoxMinX"]),
float(params["boundBoxMinY"]),
float(params["boundBoxMinZ"]),
),
"bbox_max": (
float(params["boundBoxMaxX"]),
float(params["boundBoxMaxY"]),
float(params["boundBoxMaxZ"]),
),
}
else:
bbox = {"bbox_min": (-0.5, -0.5, 0), "bbox_max": (0.5, 0.5, 1)}
asset_data.update(bbox)
if asset_type == "material":
asset_data["texture_size_meters"] = params.get("textureSizeMeters", 1.0)
# asset_data.update(tdict)
au = scene.get("assets used", {}) # type: ignore
if au == {}:
scene["assets used"] = au # type: ignore
if r["assetBaseId"] in au.keys():
asset_data["downloaded"] = 100
# transcribe all urls already fetched from the server
r_previous = au[r["assetBaseId"]]
if r_previous.get("files"):
for f in r_previous["files"]:
if f.get("url"):
for f1 in r["files"]:
if f1["fileType"] == f["fileType"]:
f1["url"] = f["url"]
# attempt to switch to use original data gradually, since the parsing as itself should become obsolete.
asset_data.update(r)
return asset_data
def clear_searches():
global search_tasks
search_tasks.clear()
def cleanup_search_results():
"""Cleanup all search results in history steps and global vars."""
# First clean up history steps
for history_step in get_history_steps().values():
history_step.pop("search_results", None)
history_step.pop("search_results_orig", None)
def handle_search_task_error(task: client_tasks.Task) -> None:
"""Handle incomming search task error."""
# First find the history step that the task belongs to
for history_step in get_history_steps().values():
if task.task_id in history_step.get("search_tasks", {}).keys():
history_step["is_searching"] = False
break
return reports.add_report(task.message, type="ERROR", details=task.message_detailed)
def handle_search_task(task: client_tasks.Task) -> bool:
"""Parse search results, try to load all available previews."""
global search_tasks
if len(search_tasks) == 0:
# First find the history step that the task belongs to
history_step = get_history_step(task.history_id)
history_step["is_searching"] = False
return True
# don't do anything while dragging - this could switch asset during drag, and make results list length different,
# causing a lot of trouble.
if bpy.context.window_manager.blenderkitUI.dragging: # type: ignore[attr-defined]
return False
# if original task was already removed (because user initiated another search), results are dropped- Returns True
# because that's OK.
orig_task = search_tasks.get(task.task_id)
search_tasks.pop(task.task_id)
# this fixes black thumbnails in asset bar, test if this bug still persist in blender and remove if it's fixed
if bpy.app.version < (3, 3, 0):
sys_prefs = bpy.context.preferences.system
sys_prefs.gl_texture_limit = "CLAMP_OFF"
###################
asset_type = task.data["asset_type"]
props = utils.get_search_props()
search_name = f"bkit {asset_type} search"
# Get current history step
history_step = get_history_step(orig_task.history_id)
if not task.data.get("get_next"):
result_field = [] # type: ignore
else:
result_field = []
for r in history_step.get("search_results", []): # type: ignore
result_field.append(r)
ui_props = bpy.context.window_manager.blenderkitUI # type: ignore[attr-defined]
for result in task.result["results"]:
asset_data = parse_result(result)
if not asset_data:
bk_logger.warning("Parsed asset data are empty for search result", result)
continue
result_field.append(asset_data)
if not utils.profile_is_validator():
continue
# VALIDATORS
# fetch all comments if user is validator to preview them faster
# these comments are also shown as part of the tooltip oh mouse hover in asset bar.
comments = comments_utils.get_comments_local(asset_data["assetBaseId"])
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
]
# 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
history_step["is_searching"] = False
if len(result_field) < ui_props.scroll_offset or not (task.data.get("get_next")):
# jump back
if asset_bar_op.asset_bar_operator is not None:
asset_bar_op.asset_bar_operator.scroll_offset = 0
ui_props.scroll_offset = 0
# show asset bar automatically, but only on first page - others are loaded also when asset bar is hidden.
if not ui_props.assetbar_on and not task.data.get("get_next"):
bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False) # type: ignore[attr-defined]
if len(result_field) < ui_props.scroll_offset or not (task.data.get("get_next")):
# jump back
ui_props.scroll_offset = 0
props.report = f"Found {task.result['count']} results."
if len(result_field) == 0:
tasks_queue.add_task((reports.add_report, ("No matching results found.",)))
else:
tasks_queue.add_task(
(
reports.add_report,
(f"Found {task.result['count']} results.",),
)
)
# show asset bar automatically, but only on first page - others are loaded also when asset bar is hidden.
if not ui_props.assetbar_on and not task.data.get("get_next"):
bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False) # type: ignore[attr-defined]
return True
def handle_thumbnail_download_task(task: client_tasks.Task) -> None:
if task.status == "finished":
global_vars.DATA["images available"][task.data["image_path"]] = True
elif task.status == "error":
global_vars.DATA["images available"][task.data["image_path"]] = False
if task.message != "":
reports.add_report(task.message, timeout=5, type="ERROR")
else:
return
if asset_bar_op.asset_bar_operator is None:
return
if task.data["thumbnail_type"] == "small":
asset_bar_op.asset_bar_operator.update_image(task.data["assetBaseId"])
return
if task.data["thumbnail_type"] == "full":
asset_bar_op.asset_bar_operator.update_tooltip_image(task.data["assetBaseId"])
def load_preview(asset):
# FIRST START SEARCH
props = bpy.context.window_manager.blenderkitUI
directory = paths.get_temp_dir("%s_search" % props.asset_type.lower())
tpath = os.path.join(directory, asset["thumbnail_small"])
tpath_exists = os.path.exists(tpath)
if (
not asset["thumbnail_small"]
or asset["thumbnail_small"] == ""
or not tpath_exists
):
# tpath = paths.get_addon_thumbnail_path('thumbnail_notready.jpg')
asset["thumb_small_loaded"] = False
iname = f".{asset['thumbnail_small']}"
# if os.path.exists(tpath): # sometimes we are unlucky...
img = bpy.data.images.get(iname)
if img is None or len(img.pixels) == 0:
if not tpath_exists:
return False
# wrap into try statement since sometimes
try:
img = bpy.data.images.load(tpath, check_existing=True)
img.name = iname
if len(img.pixels) > 0:
return True
except Exception as e:
print(f"search.py: could not load image {iname}: {e}")
return False
elif img.filepath != tpath:
if not tpath_exists:
# unload loaded previews from previous results
bpy.data.images.remove(img)
return False
# had to add this check for autopacking files...
if bpy.data.use_autopack and img.packed_file is not None:
img.unpack(method="USE_ORIGINAL")
img.filepath = tpath
try:
img.reload()
except Exception as e:
print(f"search.py: could not reload image {iname}: {e}")
return False
image_utils.set_colorspace(img)
asset["thumb_small_loaded"] = True
return True
def load_previews():
results = get_search_results()
if results is None:
return
for _, result in enumerate(results):
load_preview(result)
# line splitting for longer texts...
def split_subs(text, threshold=40):
if text == "":
return []
# temporarily disable this, to be able to do this in drawing code
text = text.rstrip()
text = text.replace("\r\n", "\n")
lines = []
while len(text) > threshold:
# first handle if there's an \n line ending
i_rn = text.find("\n")
if 1 < i_rn < threshold:
i = i_rn
text = text.replace("\n", "", 1)
else:
i = text.rfind(" ", 0, threshold)
i1 = text.rfind(",", 0, threshold)
i2 = text.rfind(".", 0, threshold)
i = max(i, i1, i2)
if i <= 0:
i = threshold
lines.append(text[:i])
text = text[i:]
lines.append(text)
return lines
def list_to_str(input):
output = ""
for i, text in enumerate(input):
output += text
if i < len(input) - 1:
output += ", "
return output
def writeblock(t, input, width=40): # for longer texts
dlines = split_subs(input, threshold=width)
for i, l in enumerate(dlines):
t += "%s\n" % l
return t
def write_block_from_value(tooltip, value, pretext="", width=2000): # for longer texts
if not value:
return tooltip
if type(value) == list:
intext = list_to_str(value)
elif type(value) == float:
intext = round(intext, 3)
else:
intext = value
intext = str(intext)
if intext.rstrip() == "":
return tooltip
if pretext != "":
pretext = pretext + ": "
text = pretext + intext
dlines = split_subs(text, threshold=width)
for _, line in enumerate(dlines):
tooltip += f"{line}\n"
return tooltip
def has(mdata, prop):
if (
mdata.get(prop) is not None
and mdata[prop] is not None
and mdata[prop] is not False
):
return True
else:
return False
def generate_tooltip(mdata):
col_w = 40
if type(mdata["parameters"]) == list:
mparams = utils.params_to_dict(mdata["parameters"])
else:
mparams = mdata["parameters"]
t = ""
t = writeblock(t, mdata["displayName"], width=int(col_w * 0.6))
# t += '\n'
# t = writeblockm(t, mdata, key='description', pretext='', width=col_w)
return t
def generate_author_textblock(first_name: str, last_name: str, about_me: str):
if len(first_name + last_name) == 0:
return ""
text = f"{first_name} {last_name}\n"
if about_me:
text = write_block_from_value(text, about_me)
return text
def handle_fetch_gravatar_task(task: client_tasks.Task):
"""Handle incomming fetch_gravatar_task which contains path to author's image on the disk."""
if task.status == "finished":
author_id = int(task.data["id"])
gravatar_path = task.result["gravatar_path"]
global_vars.BKIT_AUTHORS[author_id].gravatarImg = gravatar_path
def generate_author_profile(author_data: datas.UserProfile):
"""Generate author profile by creating author textblock and fetching gravatar image if needed.
Gravatar download is started in BlenderKit-Client and handled later."""
author_id = int(author_data.id)
if author_id in global_vars.BKIT_AUTHORS:
return
resp = client_lib.download_gravatar_image(author_data)
if resp.status_code != 200:
bk_logger.warning(resp.text)
# TODO: tooltip generation could be part of the __init__, right?
author_data.tooltip = generate_author_textblock(
author_data.firstName, author_data.lastName, author_data.aboutMe
)
global_vars.BKIT_AUTHORS[author_id] = author_data
return
def handle_get_user_profile(task: client_tasks.Task):
"""Handle incomming get_user_profile task which contains data about current logged-in user."""
if task.status not in ["finished", "error"]:
return
if task.status == "error":
bk_logger.warning(f"Could not load user profile: {task.message}")
return
user_data = task.result.get("user")
if not user_data:
bk_logger.warning("Got empty user profile")
return
can_edit_all_assets = task.result.get("canEditAllAssets", False)
social_networks = datas.parse_social_networks(user_data.pop("socialNetworks", []))
user = datas.MineProfile(
socialNetworks=social_networks,
canEditAllAssets=can_edit_all_assets,
**user_data,
)
user.tooltip = generate_author_textblock(
user.firstName, user.lastName, user.aboutMe
)
global_vars.BKIT_PROFILE = user
public_user = datas.UserProfile(
aboutMe=user.aboutMe,
aboutMeUrl=user.aboutMeUrl,
avatar128=user.avatar128,
firstName=user.firstName,
fullName=user.fullName,
gravatarHash=user.gravatarHash,
id=user.id,
lastName=user.lastName,
socialNetworks=user.socialNetworks,
avatar256=user.avatar256,
gravatarImg=user.gravatarImg,
tooltip=user.tooltip,
)
global_vars.BKIT_AUTHORS[user.id] = public_user
# after profile arrives, we can check for gravatar image
resp = client_lib.download_gravatar_image(public_user)
if resp.status_code != 200:
bk_logger.warning(resp.text)
if user.canEditAllAssets: # IS VALIDATOR
utils.enforce_prerelease_update_check()
def query_to_url(
query: Optional[dict] = None,
addon_version: str = "",
blender_version: str = "",
scene_uuid: str = "",
page_size: int = 15,
) -> str:
"""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/
"""
if query is None:
query = {}
url = f"{paths.BLENDERKIT_API}/search/"
requeststring = "?query="
if query.get("query") not in ("", None):
requeststring += urllib.parse.quote_plus(query["query"])
for q in query:
if q in ["query", "free_first", "search_order_by"]:
continue
requeststring += f"+{q}:{urllib.parse.quote_plus(str(query[q]))}"
# add dict_parameters to make results smaller
# query with category_subtree:model etc gives irrelevant results
if query.get("category_subtree") in (
"model",
"material",
"scene",
"brush",
"hdr",
"nodegroup",
"printable",
):
query["category_subtree"] = None
order = decide_ordering(query)
if requeststring.find("+order:") == -1:
requeststring += "+order:" + ",".join(order)
requeststring += "&dict_parameters=1"
requeststring += "&page_size=" + str(page_size)
requeststring += f"&addon_version={addon_version}"
if not (query.get("query") and query.get("query", "").find("asset_base_id") > -1):
requeststring += f"&blender_version={blender_version}"
if scene_uuid:
requeststring += f"&scene_uuid={scene_uuid}"
urlquery = url + requeststring
return urlquery
def decide_ordering(query: dict) -> list:
"""Decides which ordering should be used based on the search_order_by.
If search_order_by is not default, its value is used for the sorting (quality, uploaded, etc.).
Otherwise the 'legacy' mode is used which
"""
# result ordering: _score - relevance, score - BlenderKit score
order = []
if query.get("free_first", False):
order = [
"-is_free",
]
search_order_by = query.get("search_order_by", "default")
if search_order_by != "default":
order.append(search_order_by)
return order
# DEFAULT TRADITIONAL SMART ORDERING
if query.get("query") is None and query.get("category_subtree") == None:
# assumes no keywords and no category, thus an empty search that is triggered on start.
# orders by last core file upload
if query.get("verification_status") == "uploaded":
# for validators, sort uploaded from oldest
order.append("last_blend_upload")
else:
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
) and utils.profile_is_validator():
order.append("-created")
else:
if query.get("category_subtree") is not None:
order.append("-score,_score")
else:
order.append("_score")
return order
def build_query_common(query: dict, props, ui_props) -> dict:
"""Pure function to add shared parameters based on props to query dict.
Returns the updated version of the query dict.
"""
query = copy.deepcopy(query)
query_common = {}
if ui_props.search_keywords != "":
keywords = ui_props.search_keywords.replace("&", "%26")
query_common["query"] = keywords
if props.search_verification_status != "ALL" and utils.profile_is_validator():
query_common["verification_status"] = props.search_verification_status.lower()
if props.unrated_quality_only and utils.profile_is_validator():
query["quality_count"] = 0
if props.unrated_wh_only and utils.profile_is_validator():
query["working_hours_count"] = 0
if props.search_file_size:
query_common["files_size_gte"] = props.search_file_size_min * 1024 * 1024
query_common["files_size_lte"] = props.search_file_size_max * 1024 * 1024
if ui_props.quality_limit > 0:
query["quality_gte"] = ui_props.quality_limit
if ui_props.search_bookmarks:
query["bookmarks_rating"] = 1
if ui_props.search_license != "ANY":
query["license"] = ui_props.search_license
if ui_props.search_blender_version == True:
query["source_app_version_gte"] = ui_props.search_blender_version_min
query["source_app_version_lt"] = ui_props.search_blender_version_max
query.update(query_common)
return query
def build_query_model(props, ui_props, preferences) -> dict:
"""Use all search inputs (props) and add-on preferences
to build search query request to get results from server.
"""
query: dict[str, Union[str, bool]] = {"asset_type": "model"}
if props.search_style != "ANY":
if props.search_style != "OTHER":
query["modelStyle"] = props.search_style
else:
query["modelStyle"] = props.search_style_other
if props.search_condition != "UNSPECIFIED":
query["condition"] = props.search_condition
if props.search_design_year:
query["designYear_gte"] = props.search_design_year_min
query["designYear_lte"] = props.search_design_year_max
if props.search_polycount:
query["faceCount_gte"] = props.search_polycount_min
query["faceCount_lte"] = props.search_polycount_max
if props.search_texture_resolution:
query["textureResolutionMax_gte"] = props.search_texture_resolution_min
query["textureResolutionMax_lte"] = props.search_texture_resolution_max
if props.search_animated:
query["animated"] = True
if props.search_geometry_nodes:
query["modifiers"] = "nodes"
if (
preferences.nsfw_filter
): # nsfw_filter is toggle for predefined subsets (users could fine-tune in future)
query["sexualizedContent"] = False
# TODO: add here more subsets, NSFW is general switch for subsets defined by user (sexualized, violence, etc)
else:
query["sexualizedContent"] = ""
return build_query_common(query, props, ui_props)
def build_query_scene(
props,
ui_props,
) -> dict:
"""Use all search input to request results from server."""
query = {
"asset_type": "scene",
}
return build_query_common(query, props, ui_props)
def build_query_HDR(props, ui_props) -> dict:
"""Use all search input to request results from server."""
query = {
"asset_type": "hdr",
}
if props.search_texture_resolution:
query["textureResolutionMax_gte"] = props.search_texture_resolution_min
query["textureResolutionMax_lte"] = props.search_texture_resolution_max
if props.true_hdr:
query["trueHDR"] = props.true_hdr
return build_query_common(query, props, ui_props)
def build_query_material(
props,
ui_props,
) -> dict:
query: dict[str, Union[str, int]] = {"asset_type": "material"}
if props.search_style != "ANY":
if props.search_style != "OTHER":
query["style"] = props.search_style
else:
query["style"] = props.search_style_other
if props.search_procedural == "TEXTURE_BASED":
# todo this procedural hack should be replaced with the parameter
query["textureResolutionMax_gte"] = 0
# query["procedural"] = False
if props.search_texture_resolution:
query["textureResolutionMax_gte"] = props.search_texture_resolution_min
query["textureResolutionMax_lte"] = props.search_texture_resolution_max
elif props.search_procedural == "PROCEDURAL":
# todo this procedural hack should be replaced with the parameter
query["files_size_lte"] = 1024 * 1024
# query["procedural"] = True
return build_query_common(query, props, ui_props)
def build_query_brush(props, ui_props, image_paint_object) -> dict:
"""Pure function to construct search query dict for brushes."""
if image_paint_object: # could be just else, but for future p
brush_type = "texture_paint"
# automatically fallback to sculpt since most brushes are sculpt anyway.
else: # if bpy.context.sculpt_object is not None:
brush_type = "sculpt"
query = {"asset_type": "brush", "mode": brush_type}
return build_query_common(query, props, ui_props)
def build_query_nodegroup(
props,
ui_props,
) -> dict:
"""Pure function to construct search query dict for nodegroups."""
query = {"asset_type": "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
):
"""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()
scene_uuid = bpy.context.scene.get("uuid", "") # type: ignore[attr-defined]
tempdir = paths.get_temp_dir("%s_search" % query["asset_type"])
if get_next and next_url:
urlquery = next_url
else:
urlquery = query_to_url(
query, addon_version, blender_version, scene_uuid, page_size
)
search_data = datas.SearchData(
PREFS=utils.get_preferences(), # change this
tempdir=tempdir,
urlquery=urlquery,
asset_type=query["asset_type"],
scene_uuid=scene_uuid,
get_next=get_next,
page_size=page_size,
blender_version=blender_version,
is_validator=utils.profile_is_validator(),
history_id=history_id,
)
response = client_lib.asset_search(search_data)
search_tasks[response["task_id"]] = search_data
def get_search_simple(
parameters, filepath=None, page_size=100, max_results=100000000, api_key=""
):
"""Searches and returns the search results.
Parameters
----------
parameters - dict of blenderkit elastic parameters
filepath - a file to save the results. If None, results are returned
page_size - page size for retrieved results
max_results - max results of the search
api_key - BlenderKit api key
Returns
-------
Returns search results as a list, and optionally saves to filepath
"""
headers = utils.get_headers(api_key)
url = f"{paths.BLENDERKIT_API}/search/"
requeststring = url + "?query="
for p in parameters.keys():
requeststring += f"+{p}:{parameters[p]}"
requeststring += "&page_size=" + str(page_size)
requeststring += "&dict_parameters=1"
bk_logger.debug(requeststring)
response = client_lib.blocking_request(requeststring, "GET", headers)
# print(response.json())
search_results = response.json()
results = []
results.extend(search_results["results"])
page_index = 2
page_count = math.ceil(search_results["count"] / page_size)
while search_results.get("next") and len(results) < max_results:
bk_logger.info(f"getting page {page_index} , total pages {page_count}")
response = client_lib.blocking_request(search_results["next"], "GET", headers)
search_results = response.json()
results.extend(search_results["results"])
page_index += 1
if not filepath:
return results
with open(filepath, "w", encoding="utf-8") as s:
json.dump(results, s, ensure_ascii=False, indent=4)
bk_logger.info(f"retrieved {len(results)} assets from elastic search")
return results
def search(get_next=False, query=None, author_id=""):
"""Initialize searching
query : submit an already built query from search history
"""
if global_vars.CLIENT_ACCESSIBLE != True:
reports.add_report(
"Cannot search, Client is not accessible.", timeout=5, type="ERROR"
)
return
user_preferences = bpy.context.preferences.addons[__package__].preferences
wm = bpy.context.window_manager
ui_props = bpy.context.window_manager.blenderkitUI
# if search is locked, don't trigger search update
if ui_props.search_lock:
return
props = utils.get_search_props()
active_history_step = get_active_history_step()
# it's possible get_next was requested more than once.
if active_history_step.get("is_searching") and get_next == True:
# search already running, skipping
return
if not query:
if ui_props.asset_type == "MODEL":
if not hasattr(wm, "blenderkit_models"):
return
query = build_query_model(
bpy.context.window_manager.blenderkit_models,
ui_props=bpy.context.window_manager.blenderkitUI,
preferences=bpy.context.preferences.addons[__package__].preferences,
)
if ui_props.asset_type == "PRINTABLE":
if not hasattr(wm, "blenderkit_models"):
return
query = build_query_model(
bpy.context.window_manager.blenderkit_models,
ui_props=bpy.context.window_manager.blenderkitUI,
preferences=bpy.context.preferences.addons[__package__].preferences,
)
query["asset_type"] = "printable" # Override the asset type for PRINTABLE
if ui_props.asset_type == "SCENE":
if not hasattr(wm, "blenderkit_scene"):
return
query = build_query_scene(
bpy.context.window_manager.blenderkit_scene,
bpy.context.window_manager.blenderkitUI,
)
if ui_props.asset_type == "HDR":
if not hasattr(wm, "blenderkit_HDR"):
return
query = build_query_HDR(
bpy.context.window_manager.blenderkit_HDR,
bpy.context.window_manager.blenderkitUI,
)
if ui_props.asset_type == "MATERIAL":
if not hasattr(wm, "blenderkit_mat"):
return
query = build_query_material(
bpy.context.window_manager.blenderkit_mat,
bpy.context.window_manager.blenderkitUI,
)
if ui_props.asset_type == "TEXTURE":
if not hasattr(wm, "blenderkit_tex"):
return
# props = scene.blenderkit_tex
# query = build_query_texture()
if ui_props.asset_type == "BRUSH":
if not hasattr(wm, "blenderkit_brush"):
return
query = build_query_brush(
bpy.context.window_manager.blenderkit_brush,
bpy.context.window_manager.blenderkitUI,
bpy.context.image_paint_object,
)
if ui_props.asset_type == "NODEGROUP":
if not hasattr(wm, "blenderkit_nodegroup"):
return
query = build_query_nodegroup(
props=bpy.context.window_manager.blenderkit_nodegroup,
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:
query["query"] = strip_accents(query["query"])
if len(query["query"]) > 150:
idx = query["query"].find(" ", 142)
query["query"] = query["query"][:idx]
if props.search_category != "":
if utils.profile_is_validator() and user_preferences.categories_fix:
query["category"] = props.search_category
else:
query["category_subtree"] = props.search_category
if author_id != "":
query["author_id"] = author_id
elif ui_props.own_only:
# if user searches for [another] author, 'only my assets' is invalid. that's why in elif.
profile = global_vars.BKIT_PROFILE
if profile is not None:
query["author_id"] = str(profile.id)
# free first has to by in query to be evaluated as changed as another search, otherwise the filter is not updated.
query["free_first"] = ui_props.free_only
query["search_order_by"] = ui_props.search_order_by
active_history_step["is_searching"] = True
page_size = min(40, ui_props.wcount * user_preferences.maximized_assetbar_rows + 5)
next_url = ""
if get_next and active_history_step.get("search_results_orig"):
next_url = active_history_step["search_results_orig"].get("next", "")
add_search_process(query, get_next, page_size, next_url, active_history_step["id"])
props.report = "BlenderKit searching...."
def clean_filters():
"""Cleanup filters in case search needs to be reset, typically when asset id is copy pasted."""
sprops = utils.get_search_props()
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.property_unset("own_only")
sprops.property_unset("search_texture_resolution")
sprops.property_unset("search_file_size")
sprops.property_unset("search_procedural")
ui_props.property_unset("free_only")
ui_props.property_unset("quality_limit")
ui_props.property_unset("search_bookmarks")
if ui_props.asset_type == "MODEL":
sprops.property_unset("search_style")
sprops.property_unset("search_condition")
sprops.property_unset("search_design_year")
sprops.property_unset("search_polycount")
sprops.property_unset("search_animated")
sprops.property_unset("search_geometry_nodes")
if ui_props.asset_type == "HDR":
# Set without triggering update functions:
sprops["true_hdr"] = False
while True: # Wait until true_hdr is updated
sprops = utils.get_search_props()
if sprops["true_hdr"] == False:
break
print("waiting for sprops.true_hdr to be updated")
def update_filters():
"""Update filters for 2 reasons
- first to show if filters are active
- second to show login popup if user needs to log in
returns True if search should proceed, False to bounce search(like in the case of bookmarks)
"""
sprops = utils.get_search_props()
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.search_bookmarks and not utils.user_logged_in():
ui_props.search_bookmarks = False
bpy.ops.wm.blenderkit_login_dialog(
"INVOKE_DEFAULT", message="Please login to use bookmarks."
)
return False
if ui_props.own_only and not utils.user_logged_in():
ui_props.own_only = False
bpy.ops.wm.blenderkit_login_dialog(
"INVOKE_DEFAULT",
message="Please login to upload and filter your own assets.",
)
return False
fcommon = (
ui_props.own_only
or sprops.search_texture_resolution
or sprops.search_file_size
or sprops.search_procedural != "BOTH"
or ui_props.free_only
or ui_props.quality_limit > 0
or ui_props.search_bookmarks
or ui_props.search_license != "ANY"
or ui_props.search_blender_version
or ui_props.search_order_by != "default"
# NSFW filter is signaled in a special way and should not affect the filter icon
)
if ui_props.asset_type == "MODEL":
sprops.use_filters = (
fcommon
or sprops.search_style != "ANY"
or sprops.search_condition != "UNSPECIFIED"
or sprops.search_design_year
or sprops.search_polycount
or sprops.search_animated
or sprops.search_geometry_nodes
)
elif ui_props.asset_type == "SCENE":
sprops.use_filters = fcommon
elif ui_props.asset_type == "MATERIAL":
sprops.use_filters = fcommon
elif ui_props.asset_type == "BRUSH":
sprops.use_filters = fcommon
elif ui_props.asset_type == "HDR":
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
def search_update_delayed(self, context):
"""run search after user changes a search parameter,
but with a delay.
This reduces number of calls during slider UI interaction (like texture resolution, polycount)
"""
# when search is locked, don't trigger search update
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.search_lock:
return
tasks_queue.add_task((search_update, (None, None)), wait=0.5, only_last=True)
def search_update_verification_status(self, context):
"""run search after user changes a search parameter"""
# when search is locked, don't trigger search update
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.search_lock:
return
# if there is an author_id in search_keywords, we want to clear those for validators
if ui_props.search_keywords.find("+author_id:") > -1:
ui_props.search_keywords = ""
search_update(self, context)
def detect_asset_type_from_keywords(keywords: str) -> tuple[str, str]:
"""Detect asset type from keywords and return tuple of (asset_type, cleaned_keywords).
Returns ('', original_keywords) if no asset type is detected."""
# Dictionary mapping keyword variations to asset types
asset_type_map = {
"model": "MODEL",
"material": "MATERIAL",
"mat": "MATERIAL",
"brush": "BRUSH",
"scene": "SCENE",
"hdr": "HDR",
"hdri": "HDR",
"nodegroup": "NODEGROUP",
"node": "NODEGROUP",
"printable": "PRINTABLE",
"addon": "ADDON",
"add-on": "ADDON",
"extension": "ADDON",
}
# Convert to lowercase for matching
keywords_lower = keywords.lower()
# Check each word in the search string
for word in keywords_lower.split():
if word in asset_type_map:
# Remove the asset type word from keywords
cleaned_keywords = keywords_lower.replace(word, "").strip()
return asset_type_map[word], cleaned_keywords
return "", keywords
def search_update(self, context):
"""run search after user changes a search parameter"""
# when search is locked, don't trigger search update
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.search_lock:
return
# update filters
go_on = update_filters()
if not go_on:
return
ui_props = bpy.context.window_manager.blenderkitUI
# Remove this feature for now, but leave the code here for future reference
# Check if keywords contain asset type before processing clipboard
# if ui_props.search_keywords != "":
# detected_type, cleaned_keywords = detect_asset_type_from_keywords(
# ui_props.search_keywords
# )
# if detected_type and detected_type != ui_props.asset_type:
# # Store keywords before switching
# ui_props.search_lock = True
# ui_props.search_keywords = cleaned_keywords
# # Switch asset type
# ui_props.asset_type = detected_type
# ui_props.search_lock = False
# Return since changing keywords will trigger this function again
# not now - let's try it with lock
# if ui_props.down_up != "SEARCH":
# ui_props.down_up = "SEARCH"
# Input tweaks if user manually placed asset-link from website -> we need to get rid of asset type and set it in UI.
# This is not normally needed as check_clipboard() asset_type switching but without recursive shit.
instr = "asset_base_id:"
atstr = "asset_type:"
kwds = ui_props.search_keywords
id_index = kwds.find(instr)
if id_index > -1:
asset_type_index = kwds.find(atstr)
# if the asset type already isn't there it means this update function
# was triggered by it's last iteration and needs to cancel
if asset_type_index > -1:
asset_type_string = kwds[asset_type_index:].lower()
# uncertain length of the remaining string - find as better method to check the presence of asset type
if asset_type_string.find("model") > -1:
target_asset_type = "MODEL"
elif asset_type_string.find("material") > -1:
target_asset_type = "MATERIAL"
elif asset_type_string.find("brush") > -1:
target_asset_type = "BRUSH"
elif asset_type_string.find("scene") > -1:
target_asset_type = "SCENE"
elif asset_type_string.find("hdr") > -1:
target_asset_type = "HDR"
elif asset_type_string.find("nodegroup") > -1:
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 = ""
ui_props.asset_type = target_asset_type
# now we trim the input copypaste by anything extra that is there,
# this is also a way for this function to recognize that it already has parsed the clipboard
# the search props can have changed and this needs to transfer the data to the other field
# this complex behaviour is here for the case where the user needs to paste manually into blender,
# Otherwise it could be processed directly in the clipboard check function.
sprops = utils.get_search_props()
clean_filters()
ui_props.search_keywords = kwds[:asset_type_index].rstrip()
# return here since writing into search keywords triggers this update function once more.
return
if global_vars.CLIENT_ACCESSIBLE:
reports.add_report(f"Searching for: '{kwds}'", 2)
# create history step
active_tab = get_active_tab()
create_history_step(active_tab)
search()
# accented_string is of type 'unicode'
def strip_accents(s):
return "".join(
c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn"
)
def refresh_search():
"""Refresh search results. Useful after login/logout."""
props = utils.get_search_props()
if props is not None:
props.report = ""
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.assetbar_on:
ui_props.turn_off = True
ui_props.assetbar_on = False
cleanup_search_results() # TODO: is it possible to start this from Client automatically? probably YEA
# TODO: fix the tooltip?
class SearchOperator(Operator):
"""Tooltip"""
bl_idname = "view3d.blenderkit_search"
bl_label = "BlenderKit asset search"
bl_description = "Search online for assets"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
esc: BoolProperty( # type: ignore[valid-type]
name="Escape window",
description="Escape window right after start",
default=False,
options={"SKIP_SAVE"},
)
own: BoolProperty( # type: ignore[valid-type]
name="own assets only",
description="Find all own assets",
default=False,
options={"SKIP_SAVE"},
)
# category: StringProperty(
# name="category",
# description="search only subtree of this category",
# default="",
# options={"SKIP_SAVE"},
# )
author_id: StringProperty( # type: ignore[valid-type]
name="Author ID",
description="Author ID - search only assets by this author",
default="",
options={"SKIP_SAVE"},
)
get_next: BoolProperty( # type: ignore[valid-type]
name="next page",
description="get next page from previous search",
default=False,
options={"SKIP_SAVE"},
)
keywords: StringProperty( # type: ignore[valid-type]
name="Keywords", description="Keywords", default="", options={"SKIP_SAVE"}
)
# close_window: BoolProperty(name='Close window',
# description='Try to close the window below mouse before download',
# default=False)
tooltip: bpy.props.StringProperty( # type: ignore[valid-type]
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
@classmethod
def poll(cls, context):
return True
def execute(self, context):
# TODO ; this should all get transferred to properties of the search operator, so sprops don't have to be fetched here at all.
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
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)
return {"FINISHED"}
class UrlOperator(Operator):
""""""
bl_idname = "wm.blenderkit_url"
bl_label = ""
bl_description = "Search online for assets"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
tooltip: bpy.props.StringProperty(default="Open a web page") # type: ignore[valid-type]
url: bpy.props.StringProperty( # type: ignore[valid-type]
default="Runs search and displays the asset bar at the same time"
)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def execute(self, context):
bpy.ops.wm.url_open(url=self.url)
return {"FINISHED"}
class TooltipLabelOperator(Operator):
""""""
bl_idname = "wm.blenderkit_tooltip"
bl_label = ""
bl_description = "Empty operator to be able to create tooltips on labels in UI"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
tooltip: bpy.props.StringProperty(default="Open a web page") # type: ignore[valid-type]
@classmethod
def description(cls, context, properties):
return properties.tooltip
def execute(self, context):
return {"FINISHED"}
def get_search_similar_keywords(asset_data: dict) -> str:
"""Generate search similar keywords from the given asset_data.
Could be tuned in the future to provide better search results.
"""
keywords = asset_data["name"]
if asset_data.get("description"):
keywords += f" {asset_data.get('description')} "
keywords += " ".join(asset_data.get("tags", []))
return keywords
classes = [SearchOperator, UrlOperator, TooltipLabelOperator]
def register_search():
bpy.app.handlers.load_post.append(scene_load)
bpy.app.handlers.load_post.append(undo_post_reload_previews)
bpy.app.handlers.undo_post.append(undo_post_reload_previews)
bpy.app.handlers.undo_pre.append(undo_pre_end_assetbar)
for c in classes:
bpy.utils.register_class(c)
def unregister_search():
bpy.app.handlers.load_post.remove(scene_load)
for c in classes:
bpy.utils.unregister_class(c)
# Storing history steps
# History step is a dictionary with the following keys:
# - id: uuid
# - ui_state: dict
# - search_results: list
# - search_results_orig: list
# - scroll_offset: int - this is separate since it doesn't influence when a new history step can be created
# ui_state contains search_keywords, asset_type, all search filters, common ones and also those from advanced search panels for all asset types.
# if anything in UI that influences search and is in ui_state changes, a new history step is created.
# a history step isn't created when search results land or when more pages get retrieved
# each history step has own id. This id is used to identify the history step when a new search is started. It gets sent to the client.
# After the search results land, the results are written to the respective history step.
# first let's try to setup storing of ui_state
def get_ui_state():
"""Get the current UI state."""
ui_props = bpy.context.window_manager.blenderkitUI
ui_state = {
"ui_props": {
"search_keywords": ui_props.search_keywords,
"asset_type": ui_props.asset_type,
"free_only": ui_props.free_only,
"own_only": ui_props.own_only,
"search_bookmarks": ui_props.search_bookmarks,
"quality_limit": ui_props.quality_limit,
"search_license": ui_props.search_license,
"search_blender_version": ui_props.search_blender_version,
"search_blender_version_min": ui_props.search_blender_version_min,
"search_blender_version_max": ui_props.search_blender_version_max,
},
"search_props": {},
}
# we need to add all props manually since they are a mess now and some should not be stored.
# model props
common_search_props = [
"search_category",
"search_texture_resolution",
"search_texture_resolution_min",
"search_texture_resolution_max",
"search_file_size",
"search_file_size_min",
"search_file_size_max",
"search_procedural",
"search_verification_status",
"unrated_quality_only",
"unrated_wh_only",
]
store_model_props = [
"search_animated",
"search_condition",
"search_design_year",
"search_design_year_max",
"search_design_year_min",
"search_engine",
"search_engine_other",
"search_geometry_nodes",
"search_polycount",
"search_polycount_max",
"search_polycount_min",
"search_style",
"search_style_other",
]
store_material_props = [
"search_style",
"search_style_other",
]
store_brush_props = []
store_nodegroup_props = []
store_hdr_props = [
"true_hdr",
]
store_scene_props = [
"search_style",
]
store_props = []
# we could use match here but older blender versions have older python and don't support it
asset_type = ui_props.asset_type
if asset_type == "MODEL":
store_props = store_model_props
elif asset_type == "MATERIAL":
store_props = store_material_props
elif asset_type == "BRUSH":
store_props = store_brush_props
elif asset_type == "NODEGROUP":
store_props = store_nodegroup_props
elif asset_type == "HDR":
store_props = store_hdr_props
elif asset_type == "SCENE":
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()
store_props.extend(common_search_props)
# Store all properties from each property group
for prop_name in store_props:
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
def update_tab_name(active_tab):
"""Update the name of the active tab."""
history_step = get_active_history_step()
ui_state = history_step.get("ui_state", {})
# Update tab name based on search or category
search_keywords = ui_state.get("ui_props", {}).get("search_keywords", "").strip()
# if there's author_id let's get the author's name from db of authors
# we need to get the number after +author_id:
author_id = re.search(r"\+author_id:(\d+)", search_keywords)
author_name = None
if author_id is not None:
author_id = author_id.group(1)
author = global_vars.BKIT_AUTHORS.get(int(author_id))
if author:
author_name = author.fullName
search_category = (
ui_state.get("search_props", {}).get("search_category", "").strip()
)
asset_type = ui_state.get("ui_props", {}).get("asset_type", "").strip()
if author_name is not None:
tab_name = author_name
elif search_keywords:
# Use search keywords for tab name
tab_name = search_keywords
elif search_category:
# Use category name if no search keywords
tab_name = search_category.split("/")[-1] # Get last part of category path
else:
# Keep existing name if no keywords or category
tab_name = asset_type.lower()
# Crop name to max 9 characters
if len(tab_name) > 9:
tab_name = tab_name[:8] + ""
# Update tab name
active_tab["name"] = tab_name
# Update UI if asset bar exists and is properly initialized
asset_bar = asset_bar_op.asset_bar_operator
if asset_bar and hasattr(asset_bar, "tab_buttons"):
active_tab_index = global_vars.TABS["active_tab"]
if 0 <= active_tab_index < len(asset_bar.tab_buttons):
try:
asset_bar.tab_buttons[active_tab_index].text = tab_name
# Only try to redraw if we have a valid region
if asset_bar.area and asset_bar.area.region:
asset_bar.area.tag_redraw()
except Exception as e:
bk_logger.debug(f"Could not update tab name in UI: {e}")
return history_step
# now let's create a history function that creates a new history step
def create_history_step(active_tab):
"""Create a new history step and update tab name."""
ui_props = bpy.context.window_manager.blenderkitUI
ui_state = get_ui_state()
history_step = {
"id": str(uuid.uuid4()),
"ui_state": ui_state,
"scroll_offset": ui_props.scroll_offset,
}
# Delete any future history steps
if active_tab["history_index"] < len(active_tab["history"]) - 1:
# Remove future steps from global history steps dict first
for step in active_tab["history"][active_tab["history_index"] + 1 :]:
global_vars.DATA["history steps"].pop(step["id"], None)
# Then truncate the tab's history list
active_tab["history"] = active_tab["history"][: active_tab["history_index"] + 1]
active_tab["history"].append(history_step)
active_tab["history_index"] = len(active_tab["history"]) - 1
# Add this history step to the global history steps dictionary
global_vars.DATA["history steps"][history_step["id"]] = history_step
print(f"Created history step {history_step['id']}")
reports.add_report("Created new search history step", 1, "INFO")
# Update tab name and history button visibility
update_tab_name(active_tab)
# Update history button visibility if asset bar exists
# if history length is 1, hide the back button
asset_bar = asset_bar_op.asset_bar_operator
if asset_bar and hasattr(asset_bar, "history_back_button"):
asset_bar.history_back_button.visible = active_tab["history_index"] > 0
asset_bar.history_forward_button.visible = (
False # forward is never possible if we create new history step
)
asset_bar.update_tab_icons()
return history_step
def append_history_step(
search_keywords,
search_results,
active_tab=None,
asset_type=None,
search_results_orig=None,
) -> dict:
"""Append a complete history step consisting of search keywords and results. No search is triggered.
Use this function when you already have search results data and want to add them to the history step.
Function also switches the asset type to the one provided, refreshes the UI and updates the tab name.
Parameters
----------
search_keywords : str
The search keywords to use for this history step
search_results : list
List of parsed search results to store in the history step
active_tab : dict
The active tab to add the history step to
asset_type : str, optional
The asset type to use. If None, current asset type will be used
search_results_orig : dict, optional
The original search results from the server. If None, will be constructed from search_results
Returns
-------
dict
The newly created history step
"""
if active_tab is None:
active_tab = get_active_tab()
ui_state = get_ui_state()
ui_state["ui_props"]["search_keywords"] = search_keywords
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.search_lock = True
if asset_type:
ui_state["ui_props"]["asset_type"] = asset_type
ui_props.asset_type = asset_type
ui_props.search_keywords = search_keywords
ui_props.search_lock = False
# Create the history step
history_step = {
"id": str(uuid.uuid4()),
"ui_state": ui_state,
"scroll_offset": 0, # Reset scroll offset for new search
"search_results": search_results,
"is_searching": False,
}
# Add original search results if provided, otherwise construct from search_results
if search_results_orig:
history_step["search_results_orig"] = search_results_orig
else:
history_step["search_results_orig"] = {
"results": search_results,
"count": len(search_results),
}
# Delete any future history steps
if active_tab["history_index"] < len(active_tab["history"]) - 1:
# Remove future steps from global history steps dict first
for step in active_tab["history"][active_tab["history_index"] + 1 :]:
global_vars.DATA["history steps"].pop(step["id"], None)
# Then truncate the tab's history list
active_tab["history"] = active_tab["history"][: active_tab["history_index"] + 1]
# Add to tab history
active_tab["history"].append(history_step)
active_tab["history_index"] = len(active_tab["history"]) - 1
# Add to global history steps
global_vars.DATA["history steps"][history_step["id"]] = history_step
# Update tab name
update_tab_name(active_tab)
# Update history button visibility if asset bar exists
asset_bar = asset_bar_op.asset_bar_operator
if asset_bar and hasattr(asset_bar, "history_back_button"):
asset_bar.history_back_button.visible = active_tab["history_index"] > 0
asset_bar.history_forward_button.visible = False
asset_bar.update_tab_icons()
return history_step
def get_history_step(history_step_id):
return global_vars.DATA["history steps"].get(history_step_id)
def get_history_steps():
return global_vars.DATA["history steps"]
def get_active_history_step():
"""Get the currently active history step from the active tab."""
active_tab = global_vars.TABS["tabs"][global_vars.TABS["active_tab"]]
# if there's no history step, create one
if len(active_tab["history"]) == 0:
history_step = create_history_step(active_tab)
else:
history_step = active_tab["history"][active_tab["history_index"]]
return history_step
def get_search_results() -> list[dict]:
"""Get search results from the active history step."""
history_step = get_active_history_step()
return history_step.get("search_results", [])
def get_active_tab():
"""Get the active tab."""
return global_vars.TABS["tabs"][global_vars.TABS["active_tab"]]