2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
+116 -10
View File
@@ -20,7 +20,7 @@
bl_info = {
"name": "BlenderKit Online Asset Library",
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik",
"version": (3, 16, 1, 250612), # 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, 16, 1, 250612)
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
@@ -380,6 +390,61 @@ class BlenderKitUIProps(PropertyGroup):
max=10,
update=search.search_update_delayed,
)
search_order_by: EnumProperty(
name="Order",
description="Search result order",
items=(
(
"default",
"Default",
"By default, the sorting algorithm changes dynamically based on search filters.",
),
("-created", "Newest", "Sort results from newest to oldest."),
("created", "Oldest", "Sort results from oldest to newest."),
(
"-bookmarks",
"▼ Bookmarks",
"Sort results from most bookmarked to least.",
),
(
"bookmarks",
"▲ Bookmarks",
"Sort results from least bookmarked to most.",
),
(
"-score",
"▼ Score",
"Sort results from highest asset score to the lowest.",
),
(
"score",
"▲ Score",
"Sort results from lowest asset score to the highest.",
),
(
"-working_hours",
"▼ Complexity",
"Sort results from most complex to the least.",
),
(
"working_hours",
"▲ Complexity",
"Sort results from least complex to the most.",
),
(
"-quality",
"▼ Quality",
"Sort results from highest quality rating to the lowest.",
),
(
"quality",
"▲ Quality",
"Sort results from lowest quality rating to the highest.",
),
),
default="default",
update=search.search_update,
)
search_license: EnumProperty(
name="License",
items=(
@@ -408,7 +473,7 @@ class BlenderKitUIProps(PropertyGroup):
)
search_blender_version_max: StringProperty(
name="Maximum version (excluding, lower than)",
default="4.99",
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. "
+ "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",
@@ -1109,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",
@@ -1927,7 +2005,11 @@ class BlenderKitAddonPreferences(AddonPreferences):
api_key: StringProperty(
name="BlenderKit API Key",
description="Your blenderkit API Key. Get it from your page on the website",
description=(
"Your unique API key authenticates downloads and requests inside the add-on. "
"No manual setup is required, the API Key is auto-filled at login and cleared at logout. "
"However, you can also paste the key from your profile settings on the BlenderKit website."
),
default="",
subtype="PASSWORD",
update=utils.api_key_property_updated,
@@ -1980,6 +2062,13 @@ class BlenderKitAddonPreferences(AddonPreferences):
update=utils.save_prefs,
)
sidebar_panels: BoolProperty(
name="Hide sidebar panels",
description="Hide BlenderKit sidebar panels (search, upload, and selected model functionality). This prevents upload and it's also the only place for import settings. Reenable this to access these features.",
default=False,
update=utils.save_prefs,
)
header_menu_fold: BoolProperty(
name="Header menu fold", default=False, update=ui_panels.update_header_menu_fold
)
@@ -2209,15 +2298,21 @@ In this case you should also set path to your system CA bundle containing proxy'
update=utils.save_prefs,
)
max_assetbar_rows: IntProperty(
name="Max Assetbar Rows",
description="max rows of assetbar in the 3D view",
default=1,
min=1,
maximized_assetbar_rows: IntProperty(
name="Maximized Assetbar Rows",
description="Maximum rows of assetbar in the 3D view when expanded",
default=4,
min=2,
max=20,
update=utils.save_prefs,
)
assetbar_expanded: BoolProperty(
name="Assetbar Expanded",
description="Whether the assetbar is currently expanded to show maximum rows",
default=False,
)
thumb_size: IntProperty(
name="Assetbar Thumbnail Size",
default=96,
@@ -2329,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() == "":
@@ -2362,9 +2463,10 @@ In this case you should also set path to your system CA bundle containing proxy'
gui_settings.label(text="GUI settings")
gui_settings.prop(self, "show_on_start")
gui_settings.prop(self, "thumb_size")
gui_settings.prop(self, "max_assetbar_rows")
gui_settings.prop(self, "maximized_assetbar_rows")
gui_settings.prop(self, "search_field_width")
gui_settings.prop(self, "search_in_header")
gui_settings.prop(self, "sidebar_panels")
gui_settings.prop(self, "show_VIEW3D_MT_blenderkit_model_properties")
gui_settings.prop(self, "tips_on_start")
gui_settings.prop(self, "announcements_on_start")
@@ -2434,6 +2536,7 @@ classes = (
BlenderKitBrushUploadProps,
BlenderKitGeoToolSearchProps,
BlenderKitNodeGroulUploadProps,
BlenderKitAddonSearchProps,
)
@@ -2500,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
+211 -86
View File
@@ -19,6 +19,7 @@
import logging
import uuid
from typing import Optional
import bpy
@@ -55,11 +56,34 @@ def append_brush(file_name, brushname=None, link=False, fake_user=True):
def append_nodegroup(
file_name, nodegroupname=None, link=False, fake_user=True, node_x=0, node_y=0
file_name,
nodegroupname=None,
link=False,
fake_user=True,
node_x=0,
node_y=0,
target_object=None,
nodegroup_mode="",
model_location=(0, 0, 0),
model_rotation=(0, 0, 0),
**kwargs,
):
"""Append selected node group. If nodegroupname is None, first node group is appended.
If node group with the same name is already in the scene, it is not appended again.
Try to look for a suitable node editor and insert the node group there, in the middle of the area.
Try to look for a suitable node editor and insert the node group there, or create/use modifier based on mode.
For geometry nodegroups, if no target object is provided, a target object will be created automatically.
Args:
file_name: Path to the .blend file containing the nodegroup
nodegroupname: Name of the nodegroup to append
link: Whether to link or append
fake_user: Whether to set fake user
node_x: X position for node placement in editor
node_y: Y position for node placement in editor
target_object: Target object for modifier mode (name string). If None and nodegroup is geometry type, a target object will be created
nodegroup_mode: How to add the nodegroup - "MODIFIER" for new modifier, "NODE" for node in editor, "" for default behavior
model_location: Location for the target object (used when creating new target)
model_rotation: Rotation for the target object (used when creating new target)
Returns:
tuple: (nodegroup, added_to_editor) - The nodegroup and whether it was added to an editor
@@ -76,6 +100,22 @@ def append_nodegroup(
nodegroup = bpy.data.node_groups[nodegroupname]
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(
size=2, location=model_location, rotation=model_rotation
)
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
target_obj.select_set(True)
# Mapping dict for node editor tree types to node group node types
sdict = {
"GeometryNodeTree": "GeometryNodeGroup",
@@ -86,25 +126,119 @@ def append_nodegroup(
# Get the nodegroup type
nodegroup_type = nodegroup.bl_rna.identifier
# Find a suitable node editor
# If no explicit mode is set, try to detect if we should add to an existing editor first
# This allows drag-drop into existing node editors to work properly
if not nodegroup_mode:
# Find a suitable node editor
for area in bpy.context.screen.areas:
if area.type != "NODE_EDITOR":
continue
if area.spaces.active.tree_type == nodegroup_type:
nt = area.spaces.active.edit_tree
if nt is None:
continue
# Add node to this editor
for n in nt.nodes:
n.select = False
node_type = sdict.get(nodegroup_type)
if node_type:
node = nt.nodes.new(node_type)
node.node_tree = nodegroup
node.location = (node_x, node_y)
node.select = True
nt.nodes.active = node
return (nodegroup, True)
# Handle modifier mode for geometry nodegroups
if nodegroup_mode == "MODIFIER" and target_object:
target_obj = bpy.data.objects.get(target_object)
if target_obj and nodegroup.bl_rna.identifier == "GeometryNodeTree":
# Create a new geometry nodes modifier with this nodegroup
gn_mod = target_obj.modifiers.new(name=nodegroup.name, type="NODES")
gn_mod.node_group = nodegroup
# Select the target object to make the change visible
bpy.context.view_layer.objects.active = target_obj
if target_obj not in bpy.context.selected_objects:
target_obj.select_set(True)
return (
nodegroup,
True,
) # Return True as we "added" it successfully to the modifier
# Handle node mode for geometry nodegroups with target object
# Create a modifier setup and then add the nodegroup as a node to the tree
if (
nodegroup_mode == "NODE"
and target_object
and nodegroup.bl_rna.identifier == "GeometryNodeTree"
):
target_obj = bpy.data.objects.get(target_object)
if target_obj:
# Select the target object to make it active
bpy.context.view_layer.objects.active = target_obj
if target_obj not in bpy.context.selected_objects:
target_obj.select_set(True)
# look for the geometry nodes modifier
gn_mod = None
for mod in target_obj.modifiers:
if mod.type == "NODES" and mod.node_group:
gn_mod = mod
break
if not gn_mod:
# create a new geometry nodes modifier
gn_mod = target_obj.modifiers.new(name="GeometryNodes", type="NODES")
if not gn_mod.node_group:
# create a new node group
bpy.ops.node.new_geometry_node_group_assign()
node_tree = gn_mod.node_group
if node_tree:
# Add the nodegroup as a node to the tree
group_node = node_tree.nodes.new("GeometryNodeGroup")
group_node.node_tree = nodegroup
group_node.location = (node_x, node_y)
group_node.select = True
node_tree.nodes.active = group_node
return (nodegroup, True)
# If not added yet through modes or if no mode specified, try to find any compatible editor
added_to_editor = False
# First try: exact match for tree type
# Try any compatible editor
for area in bpy.context.screen.areas:
if area.type != "NODE_EDITOR":
continue
if area.spaces.active.tree_type == nodegroup_type:
nt = area.spaces.active.edit_tree
if nt is None:
continue
nt = area.spaces.active.edit_tree
if nt is None:
continue
# Check if this editor type is compatible
if area.spaces.active.tree_type in sdict:
# Add node to this editor
for n in nt.nodes:
n.select = False
node_type = sdict.get(nodegroup_type)
node_type = sdict.get(area.spaces.active.tree_type)
if node_type:
# Check if nodegroup is compatible with this editor
# For example, don't add shader nodegroups to geometry node editor
if (
nodegroup_type == "ShaderNodeTree"
and area.spaces.active.tree_type != "ShaderNodeTree"
) or (
nodegroup_type == "GeometryNodeTree"
and area.spaces.active.tree_type != "GeometryNodeTree"
):
continue
node = nt.nodes.new(node_type)
node.node_tree = nodegroup
node.location = (node_x, node_y)
@@ -113,42 +247,20 @@ def append_nodegroup(
added_to_editor = True
break
# If not added yet, try any compatible editor
if not added_to_editor:
for area in bpy.context.screen.areas:
if area.type != "NODE_EDITOR":
continue
nt = area.spaces.active.edit_tree
if nt is None:
continue
# Check if this editor type is compatible
if area.spaces.active.tree_type in sdict:
# Add node to this editor
for n in nt.nodes:
n.select = False
node_type = sdict.get(area.spaces.active.tree_type)
if node_type:
# Check if nodegroup is compatible with this editor
# For example, don't add shader nodegroups to geometry node editor
if (
nodegroup_type == "ShaderNodeTree"
and area.spaces.active.tree_type != "ShaderNodeTree"
) or (
nodegroup_type == "GeometryNodeTree"
and area.spaces.active.tree_type != "GeometryNodeTree"
):
continue
node = nt.nodes.new(node_type)
node.node_tree = nodegroup
node.location = (node_x, node_y)
node.select = True
nt.nodes.active = node
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
@@ -246,15 +358,18 @@ def hdr_swap(name, hdr):
:return: None
"""
w = bpy.context.scene.world
if w:
if not w:
new_hdr_world(name, hdr)
if bpy.app.version < (5, 0, 0):
w.use_nodes = True
w.name = name
nt = w.node_tree
for n in nt.nodes:
if "ShaderNodeTexEnvironment" == n.bl_rna.identifier:
env_node = n
env_node.image = hdr
return
w.name = name
nt = w.node_tree
for n in nt.nodes:
if "ShaderNodeTexEnvironment" == n.bl_rna.identifier:
env_node = n
env_node.image = hdr
return
new_hdr_world(name, hdr)
@@ -266,7 +381,8 @@ def new_hdr_world(name, hdr):
:return: None
"""
w = bpy.data.worlds.new(name=name)
w.use_nodes = True
if bpy.app.version < (5, 0, 0):
w.use_nodes = True
bpy.context.scene.world = w
nt = w.node_tree
@@ -302,11 +418,11 @@ def load_HDR(file_name, name):
def link_collection(
file_name,
obnames=None,
obnames: Optional[list] = None,
location=(0, 0, 0),
link=False,
parent=None,
collection="",
link: bool = False,
parent: Optional[str] = None,
collection: str = "",
**kwargs,
):
"""link an instanced group - model type asset"""
@@ -314,7 +430,7 @@ def link_collection(
obnames = []
sel = utils.selection_get()
# Store the original active collection
orig_active_collection = bpy.context.view_layer.active_layer_collection
orig_active_collection = bpy.context.view_layer.active_layer_collection # type: ignore[union-attr]
# Activate target collection if specified
if collection:
@@ -322,10 +438,10 @@ def link_collection(
if target_collection:
# Find and activate the layer collection
layer_collection = find_layer_collection(
bpy.context.view_layer.layer_collection, collection
bpy.context.view_layer.layer_collection, collection # type: ignore[union-attr]
)
if layer_collection:
bpy.context.view_layer.active_layer_collection = layer_collection
bpy.context.view_layer.active_layer_collection = layer_collection # type: ignore[union-attr]
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
@@ -340,33 +456,33 @@ def link_collection(
rotation = kwargs["rotation"]
bpy.ops.object.empty_add(type="PLAIN_AXES", location=location, rotation=rotation)
main_object = bpy.context.view_layer.objects.active
main_object.instance_type = "COLLECTION"
main_object = bpy.context.view_layer.objects.active # type: ignore[union-attr]
main_object.instance_type = "COLLECTION" # type: ignore[union-attr]
if parent is not None:
main_object.parent = bpy.data.objects.get(parent)
if parent is not None and parent != "":
main_object.parent = bpy.data.objects.get(parent) # type: ignore[union-attr]
main_object.matrix_world.translation = location
main_object.matrix_world.translation = location # type: ignore[union-attr]
for col in bpy.data.collections:
if col.library is not None:
fp = bpy.path.abspath(col.library.filepath)
fp1 = bpy.path.abspath(file_name)
if fp == fp1:
main_object.instance_collection = col
main_object.instance_collection = col # type: ignore[union-attr]
break
# sometimes, the lib might already be without the actual link.
if not main_object.instance_collection and kwargs["name"]:
if not main_object.instance_collection and kwargs["name"]: # type: ignore[union-attr]
col = bpy.data.collections.get(kwargs["name"])
if col:
main_object.instance_collection = col
main_object.instance_collection = col # type: ignore[union-attr]
main_object.name = main_object.instance_collection.name
main_object.name = main_object.instance_collection.name # type: ignore[union-attr]
# Restore original active collection
if orig_active_collection:
bpy.context.view_layer.active_layer_collection = orig_active_collection
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr]
utils.selection_set(sel)
return main_object, []
@@ -449,7 +565,13 @@ def append_particle_system(
def append_objects(
file_name, obnames=None, location=(0, 0, 0), link=False, collection="", **kwargs
file_name,
obnames: Optional[list] = None,
location=(0, 0, 0),
link: bool = False,
parent: Optional[str] = None,
collection: str = "",
**kwargs,
):
"""Append object into scene individually. 2 approaches based in definition of name argument.
TODO: really split this function into 2 functions: kwargs.get('name')==None and else.
@@ -461,7 +583,7 @@ def append_objects(
scene = bpy.context.scene
sel = utils.selection_get()
# Store the original active collection
orig_active_collection = bpy.context.view_layer.active_layer_collection
orig_active_collection = bpy.context.view_layer.active_layer_collection # type: ignore[union-attr]
# Activate target collection if specified
if collection:
@@ -469,10 +591,10 @@ def append_objects(
if target_collection:
# Find and activate the layer collection
layer_collection = find_layer_collection(
bpy.context.view_layer.layer_collection, collection
bpy.context.view_layer.layer_collection, collection # type: ignore[union-attr]
)
if layer_collection:
bpy.context.view_layer.active_layer_collection = layer_collection
bpy.context.view_layer.active_layer_collection = layer_collection # type: ignore[union-attr]
try:
bpy.ops.object.select_all(action="DESELECT")
@@ -486,6 +608,9 @@ def append_objects(
path = file_name + "/Collection"
collection_name = kwargs.get("name")
if collection_name is None:
bk_logger.warning("collection_name is None")
collection_name = ""
bpy.ops.wm.append(filename=collection_name, directory=path)
# fc = utils.get_fake_context(bpy.context, area_type='VIEW_3D')
@@ -496,13 +621,13 @@ def append_objects(
appended_collection = None
main_object = None
# get first at least one parent for sure
for ob in bpy.context.scene.objects:
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get():
if not ob.parent:
main_object = ob
ob.location = location
# do once again to ensure hidden objects are hidden
for ob in bpy.context.scene.objects:
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get():
return_obs.append(ob)
# check for object that should be hidden
@@ -521,14 +646,14 @@ def append_objects(
if kwargs.get("rotation"):
main_object.rotation_euler = kwargs["rotation"]
if kwargs.get("parent") is not None:
main_object.parent = bpy.data.objects[kwargs["parent"]]
if parent is not None and parent != "":
main_object.parent = bpy.data.objects[parent]
main_object.matrix_world.translation = location
# move objects that should be hidden to a sub collection
if len(to_hidden_collection) > 0 and appended_collection is not None:
hidden_collections = []
scene_collection = bpy.context.scene.collection
scene_collection = bpy.context.scene.collection # type: ignore[union-attr]
for ob in to_hidden_collection:
hide_collection = ob.users_collection[0]
@@ -577,7 +702,7 @@ def append_objects(
# Restore original active collection
if orig_active_collection:
bpy.context.view_layer.active_layer_collection = orig_active_collection
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr]
utils.selection_set(sel)
# let collection also store info that it was created by BlenderKit, for purging reasons
@@ -618,7 +743,7 @@ def append_objects(
for obj in data_to.objects:
if obj is not None:
# if obj.name not in scene.objects:
scene.collection.objects.link(obj)
scene.collection.objects.link(obj) # type: ignore[union-attr]
if obj.parent is None:
obj.location = location
main_object = obj
@@ -637,11 +762,11 @@ def append_objects(
ob.hide_viewport = True
if kwargs.get("rotation") is not None:
main_object.rotation_euler = kwargs["rotation"]
main_object.rotation_euler = kwargs["rotation"] # type: ignore[union-attr]
if kwargs.get("parent") is not None:
main_object.parent = bpy.data.objects[kwargs["parent"]]
main_object.matrix_world.translation = location
if parent is not None and parent != "":
main_object.parent = bpy.data.objects[parent] # type: ignore[union-attr]
main_object.matrix_world.translation = location # type: ignore[union-attr]
try:
bpy.ops.object.select_all(action="DESELECT")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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()
@@ -20,7 +20,7 @@
bl_info = {
"name": "BlenderKit Online Asset Library",
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik",
"version": (3, 15, 1, 250403), # X.Y.Z.yymmdd
"version": (3, 17, 0, 251008), # 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,9 +28,10 @@ bl_info = {
"tracker_url": "https://github.com/BlenderKit/blenderkit/issues",
"category": "3D View",
}
VERSION = (3, 15, 1, 250403)
VERSION = (3, 17, 0, 251008)
import logging
import random
import sys
from importlib import reload
from os import path
@@ -279,52 +280,39 @@ def asset_type_callback(self, context):
items for Enum property, depending on the down_up property - BlenderKit is either in search or in upload mode.
"""
pcoll = icons.icon_collections["main"]
preferences = bpy.context.preferences.addons[__package__].preferences
if self.down_up == "SEARCH":
items = [
("MODEL", "Models", "Find models", "OBJECT_DATAMODE", 0),
("MATERIAL", "Materials", "Find materials", "MATERIAL", 2),
("SCENE", "Scenes", "Find scenes", "SCENE_DATA", 3),
("HDR", "HDRs", "Find HDRs", "WORLD", 4),
("BRUSH", "Brushes", "Find brushes", "BRUSH_DATA", 5),
("NODEGROUP", "Node Groups", "Find tools", "NODETREE", 6),
("MATERIAL", "Materials", "Find materials", "MATERIAL", 1),
("SCENE", "Scenes", "Find scenes", "SCENE_DATA", 2),
("HDR", "HDRs", "Find HDRs", "WORLD", 3),
("BRUSH", "Brushes", "Find brushes", "BRUSH_DATA", 4),
("NODEGROUP", "Node Groups", "Find tools", "NODETREE", 5),
(
"PRINTABLE",
"Printables",
"Find 3D printable models",
pcoll["asset_type_printable"].icon_id,
6,
),
]
# Add printable under experimental features
if preferences.experimental_features:
# Insert printable after MODEL (at index 1)
items.insert(
1,
(
"PRINTABLE",
"Printable",
"Find 3D printable models",
pcoll["asset_type_printable"].icon_id,
1,
),
)
else:
items = [
("MODEL", "Model", "Upload a model", "OBJECT_DATAMODE", 0),
("MATERIAL", "Material", "Upload a material", "MATERIAL", 2),
("SCENE", "Scene", "Upload a scene", "SCENE_DATA", 3),
("HDR", "HDR", "Upload a HDR", "WORLD", 4),
("BRUSH", "Brush", "Upload a brush", "BRUSH_DATA", 5),
("NODEGROUP", "Node Groups", "Upload a tool", "NODETREE", 6),
("MATERIAL", "Material", "Upload a material", "MATERIAL", 1),
("SCENE", "Scene", "Upload a scene", "SCENE_DATA", 2),
("HDR", "HDR", "Upload a HDR", "WORLD", 3),
("BRUSH", "Brush", "Upload a brush", "BRUSH_DATA", 4),
("NODEGROUP", "Node Groups", "Upload a tool", "NODETREE", 5),
(
"PRINTABLE",
"Printable",
"Upload a 3D printable model",
pcoll["asset_type_printable"].icon_id,
6,
),
]
# Add printable under experimental features
if preferences.experimental_features:
# Insert printable after MODEL (at index 1)
items.insert(
1,
(
"PRINTABLE",
"Printable",
"Upload a 3D printable model",
pcoll["asset_type_printable"].icon_id,
1,
),
)
return items
@@ -392,6 +380,61 @@ class BlenderKitUIProps(PropertyGroup):
max=10,
update=search.search_update_delayed,
)
search_order_by: EnumProperty(
name="Order",
description="Search result order",
items=(
(
"default",
"Default",
"By default, the sorting algorithm changes dynamically based on search filters.",
),
("-created", "Newest", "Sort results from newest to oldest."),
("created", "Oldest", "Sort results from oldest to newest."),
(
"-bookmarks",
"▼ Bookmarks",
"Sort results from most bookmarked to least.",
),
(
"bookmarks",
"▲ Bookmarks",
"Sort results from least bookmarked to most.",
),
(
"-score",
"▼ Score",
"Sort results from highest asset score to the lowest.",
),
(
"score",
"▲ Score",
"Sort results from lowest asset score to the highest.",
),
(
"-working_hours",
"▼ Complexity",
"Sort results from most complex to the least.",
),
(
"working_hours",
"▲ Complexity",
"Sort results from least complex to the most.",
),
(
"-quality",
"▼ Quality",
"Sort results from highest quality rating to the lowest.",
),
(
"quality",
"▲ Quality",
"Sort results from lowest quality rating to the highest.",
),
),
default="default",
update=search.search_update,
)
search_license: EnumProperty(
name="License",
items=(
@@ -420,7 +463,7 @@ class BlenderKitUIProps(PropertyGroup):
)
search_blender_version_max: StringProperty(
name="Maximum version (excluding, lower than)",
default="4.99",
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. "
+ "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",
@@ -938,12 +981,6 @@ class BlenderKitMaterialSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
"aren't editable directly, but also don't increase your file size",
default="APPEND",
)
automap: BoolProperty(
name="Auto-Map",
description="reset object texture space and also add automatically a cube mapped UV "
"to the object. \n this allows most materials to apply instantly to any mesh",
default=True,
)
class BlenderKitMaterialUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
@@ -1031,7 +1068,7 @@ class BlenderKitMaterialUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail_background_lightness: FloatProperty(
name="Thumbnail Background Lightness",
description="Set to make your material stand out with enough contrast",
default=0.9,
default=0.7,
min=0.00001,
max=1,
)
@@ -1271,15 +1308,23 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail_background_lightness: FloatProperty(
name="Thumbnail Background Lightness",
description="Set to make your Model stand out",
default=1.0,
default=0.7,
min=0.01,
max=10,
)
# for printable models
thumbnail_material_color: FloatVectorProperty(
name="Thumbnail Material Color",
description="Color of the material for printable models",
default=(random.random(), random.random(), random.random()),
subtype="COLOR",
)
thumbnail_angle: EnumProperty(
name="Thumbnail Angle",
items=autothumb.thumbnail_angles,
default="DEFAULT",
default="ANGLE_1",
description="Thumbnailer angle",
)
@@ -1455,6 +1500,19 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
default=False,
)
# Add this new property for printable assets
photo_thumbnail: StringProperty(
name="Photo Thumbnail",
description="Photo of the 3D printed object (JPG or PNG, preferred size is 1024x1024 or higher)",
subtype="FILE_PATH",
default="",
)
photo_thumbnail_will_upload_on_website: BoolProperty(
name="I will upload photo on website",
description="True if the photo thumbnail will upload on the website\n please read upload tutorial for more information",
default=False,
)
class BlenderKitSceneUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
style: EnumProperty(
@@ -1924,7 +1982,11 @@ class BlenderKitAddonPreferences(AddonPreferences):
api_key: StringProperty(
name="BlenderKit API Key",
description="Your blenderkit API Key. Get it from your page on the website",
description=(
"Your unique API key authenticates downloads and requests inside the add-on. "
"No manual setup is required, the API Key is auto-filled at login and cleared at logout. "
"However, you can also paste the key from your profile settings on the BlenderKit website."
),
default="",
subtype="PASSWORD",
update=utils.api_key_property_updated,
@@ -1977,6 +2039,13 @@ class BlenderKitAddonPreferences(AddonPreferences):
update=utils.save_prefs,
)
sidebar_panels: BoolProperty(
name="Hide sidebar panels",
description="Hide BlenderKit sidebar panels (search, upload, and selected model functionality). This prevents upload and it's also the only place for import settings. Reenable this to access these features.",
default=False,
update=utils.save_prefs,
)
header_menu_fold: BoolProperty(
name="Header menu fold", default=False, update=ui_panels.update_header_menu_fold
)
@@ -2056,6 +2125,16 @@ class BlenderKitAddonPreferences(AddonPreferences):
default="2048",
)
material_import_automap: BoolProperty(
name="Auto-Map",
description="Reset object texture space and also add automatically a cube mapped UV to the object.\n"
"This allows most materials to apply instantly to any mesh",
default=True,
update=utils.save_prefs,
)
# NETWORKING
ip_version: EnumProperty(
name="IP version",
items=(
@@ -2133,7 +2212,7 @@ class BlenderKitAddonPreferences(AddonPreferences):
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.
If you use simple HTTP proxy, set in format http://ip:port, or http://username:password@ip:port if your HTTP proxy requires authentication. You have to specify the address with http:// prefix.
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.
In this case you should also set path to your system CA bundle containing proxy's certificates in the field "Custom CA certificates path" below""",
@@ -2196,15 +2275,21 @@ In this case you should also set path to your system CA bundle containing proxy'
update=utils.save_prefs,
)
max_assetbar_rows: IntProperty(
name="Max Assetbar Rows",
description="max rows of assetbar in the 3D view",
default=1,
min=1,
maximized_assetbar_rows: IntProperty(
name="Maximized Assetbar Rows",
description="Maximum rows of assetbar in the 3D view when expanded",
default=4,
min=2,
max=20,
update=utils.save_prefs,
)
assetbar_expanded: BoolProperty(
name="Assetbar Expanded",
description="Whether the assetbar is currently expanded to show maximum rows",
default=False,
)
thumb_size: IntProperty(
name="Assetbar Thumbnail Size",
default=96,
@@ -2225,7 +2310,7 @@ In this case you should also set path to your system CA bundle containing proxy'
experimental_features: BoolProperty(
name="Enable experimental features",
description="""Enable experimental features of BlenderKit: \n - 3D printable assets (search and upload models optimized for 3D printing)""",
description="Enable experimental features of BlenderKit. Note: There are no experimental features in this version.",
default=False,
update=utils.save_prefs,
)
@@ -2349,9 +2434,10 @@ In this case you should also set path to your system CA bundle containing proxy'
gui_settings.label(text="GUI settings")
gui_settings.prop(self, "show_on_start")
gui_settings.prop(self, "thumb_size")
gui_settings.prop(self, "max_assetbar_rows")
gui_settings.prop(self, "maximized_assetbar_rows")
gui_settings.prop(self, "search_field_width")
gui_settings.prop(self, "search_in_header")
gui_settings.prop(self, "sidebar_panels")
gui_settings.prop(self, "show_VIEW3D_MT_blenderkit_model_properties")
gui_settings.prop(self, "tips_on_start")
gui_settings.prop(self, "announcements_on_start")
@@ -19,15 +19,27 @@
import logging
import uuid
from typing import Optional
import bpy
from . import utils
from . import utils, reports
bk_logger = logging.getLogger(__name__)
def find_layer_collection(layer_collection, collection_name):
"""Helper function to find a layer_collection by name"""
if layer_collection.collection.name == collection_name:
return layer_collection
for child in layer_collection.children:
result = find_layer_collection(child, collection_name)
if result:
return result
return None
def append_brush(file_name, brushname=None, link=False, fake_user=True):
"""append a brush"""
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
@@ -43,10 +55,38 @@ def append_brush(file_name, brushname=None, link=False, fake_user=True):
return brush
def append_nodegroup(file_name, nodegroupname=None, link=False, fake_user=True):
def append_nodegroup(
file_name,
nodegroupname=None,
link=False,
fake_user=True,
node_x=0,
node_y=0,
target_object=None,
nodegroup_mode="",
model_location=(0, 0, 0),
model_rotation=(0, 0, 0),
**kwargs,
):
"""Append selected node group. If nodegroupname is None, first node group is appended.
If node group with the same name is already in the scene, it is not appended again.
Try to look for a suitable node editor and insert the node group there, in the middle of the area.
Try to look for a suitable node editor and insert the node group there, or create/use modifier based on mode.
For geometry nodegroups, if no target object is provided, a target object will be created automatically.
Args:
file_name: Path to the .blend file containing the nodegroup
nodegroupname: Name of the nodegroup to append
link: Whether to link or append
fake_user: Whether to set fake user
node_x: X position for node placement in editor
node_y: Y position for node placement in editor
target_object: Target object for modifier mode (name string). If None and nodegroup is geometry type, a target object will be created
nodegroup_mode: How to add the nodegroup - "MODIFIER" for new modifier, "NODE" for node in editor, "" for default behavior
model_location: Location for the target object (used when creating new target)
model_rotation: Rotation for the target object (used when creating new target)
Returns:
tuple: (nodegroup, added_to_editor) - The nodegroup and whether it was added to an editor
"""
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
@@ -59,35 +99,153 @@ def append_nodegroup(file_name, nodegroupname=None, link=False, fake_user=True):
nodegroupname = g
nodegroup = bpy.data.node_groups[nodegroupname]
nodegroup.use_fake_user = fake_user
# if there's an open node editor, let's find if it matches the type of the asset group and insert it
# in middle of the area.
# mapping dict for editor type to node group node types
# Create target object automatically for geometry nodegroups when no target is provided
if nodegroup.bl_rna.identifier == "GeometryNodeTree" and not target_object:
# Create a default mesh cube
bpy.ops.mesh.primitive_cube_add(
size=2, location=model_location, rotation=model_rotation
)
target_obj = bpy.context.active_object
target_obj.name = "GeometryNodeTarget"
target_object = target_obj.name
# Make sure it's selected and active
bpy.context.view_layer.objects.active = target_obj
target_obj.select_set(True)
# Mapping dict for node editor tree types to node group node types
sdict = {
"GeometryNodeTree": "GeometryNodeGroup",
"ShaderNodeTree": "ShaderNodeGroup",
"CompositorNodeTree": "CompositorNodeGroup",
}
# Look for a suitable node editor and insert the node group there, in the middle of the area.
# Get the nodegroup type
nodegroup_type = nodegroup.bl_rna.identifier
# If no explicit mode is set, try to detect if we should add to an existing editor first
# This allows drag-drop into existing node editors to work properly
if not nodegroup_mode:
# Find a suitable node editor
for area in bpy.context.screen.areas:
if area.type != "NODE_EDITOR":
continue
if area.spaces.active.tree_type == nodegroup_type:
nt = area.spaces.active.edit_tree
if nt is None:
continue
# Add node to this editor
for n in nt.nodes:
n.select = False
node_type = sdict.get(nodegroup_type)
if node_type:
node = nt.nodes.new(node_type)
node.node_tree = nodegroup
node.location = (node_x, node_y)
node.select = True
nt.nodes.active = node
return (nodegroup, True)
# Handle modifier mode for geometry nodegroups
if nodegroup_mode == "MODIFIER" and target_object:
target_obj = bpy.data.objects.get(target_object)
if target_obj and nodegroup.bl_rna.identifier == "GeometryNodeTree":
# Create a new geometry nodes modifier with this nodegroup
gn_mod = target_obj.modifiers.new(name=nodegroup.name, type="NODES")
gn_mod.node_group = nodegroup
# Select the target object to make the change visible
bpy.context.view_layer.objects.active = target_obj
if target_obj not in bpy.context.selected_objects:
target_obj.select_set(True)
return (
nodegroup,
True,
) # Return True as we "added" it successfully to the modifier
# Handle node mode for geometry nodegroups with target object
# Create a modifier setup and then add the nodegroup as a node to the tree
if (
nodegroup_mode == "NODE"
and target_object
and nodegroup.bl_rna.identifier == "GeometryNodeTree"
):
target_obj = bpy.data.objects.get(target_object)
if target_obj:
# Select the target object to make it active
bpy.context.view_layer.objects.active = target_obj
if target_obj not in bpy.context.selected_objects:
target_obj.select_set(True)
# look for the geometry nodes modifier
gn_mod = None
for mod in target_obj.modifiers:
if mod.type == "NODES" and mod.node_group:
gn_mod = mod
break
if not gn_mod:
# create a new geometry nodes modifier
gn_mod = target_obj.modifiers.new(name="GeometryNodes", type="NODES")
if not gn_mod.node_group:
# create a new node group
bpy.ops.node.new_geometry_node_group_assign()
node_tree = gn_mod.node_group
if node_tree:
# Add the nodegroup as a node to the tree
group_node = node_tree.nodes.new("GeometryNodeGroup")
group_node.node_tree = nodegroup
group_node.location = (node_x, node_y)
group_node.select = True
node_tree.nodes.active = group_node
return (nodegroup, True)
# If not added yet through modes or if no mode specified, try to find any compatible editor
added_to_editor = False
# Try any compatible editor
for area in bpy.context.screen.areas:
if area.type != "NODE_EDITOR":
continue
if area.spaces.active.tree_type != nodegroup.bl_rna.identifier:
continue
nt = area.spaces.active.edit_tree
if nt is None:
continue
# deselect all nodes
for n in nt.nodes:
n.select = False
node = nt.nodes.new(sdict[area.spaces.active.tree_type])
node.node_tree = nodegroup
area.spaces.active.node_tree = nodegroup
break
return nodegroup
# Check if this editor type is compatible
if area.spaces.active.tree_type in sdict:
# Add node to this editor
for n in nt.nodes:
n.select = False
node_type = sdict.get(area.spaces.active.tree_type)
if node_type:
# Check if nodegroup is compatible with this editor
# For example, don't add shader nodegroups to geometry node editor
if (
nodegroup_type == "ShaderNodeTree"
and area.spaces.active.tree_type != "ShaderNodeTree"
) or (
nodegroup_type == "GeometryNodeTree"
and area.spaces.active.tree_type != "GeometryNodeTree"
):
continue
node = nt.nodes.new(node_type)
node.node_tree = nodegroup
node.location = (node_x, node_y)
node.select = True
nt.nodes.active = node
added_to_editor = True
break
return nodegroup, added_to_editor
def append_material(file_name, matname=None, link=False, fake_user=True):
@@ -183,15 +341,18 @@ def hdr_swap(name, hdr):
:return: None
"""
w = bpy.context.scene.world
if w:
if not w:
new_hdr_world(name, hdr)
if bpy.app.version < (5, 0, 0):
w.use_nodes = True
w.name = name
nt = w.node_tree
for n in nt.nodes:
if "ShaderNodeTexEnvironment" == n.bl_rna.identifier:
env_node = n
env_node.image = hdr
return
w.name = name
nt = w.node_tree
for n in nt.nodes:
if "ShaderNodeTexEnvironment" == n.bl_rna.identifier:
env_node = n
env_node.image = hdr
return
new_hdr_world(name, hdr)
@@ -203,7 +364,8 @@ def new_hdr_world(name, hdr):
:return: None
"""
w = bpy.data.worlds.new(name=name)
w.use_nodes = True
if bpy.app.version < (5, 0, 0):
w.use_nodes = True
bpy.context.scene.world = w
nt = w.node_tree
@@ -238,16 +400,36 @@ def load_HDR(file_name, name):
def link_collection(
file_name, obnames=[], location=(0, 0, 0), link=False, parent=None, **kwargs
file_name,
obnames: Optional[list] = None,
location=(0, 0, 0),
link: bool = False,
parent: Optional[str] = None,
collection: str = "",
**kwargs,
):
"""link an instanced group - model type asset"""
if obnames is None:
obnames = []
sel = utils.selection_get()
# Store the original active collection
orig_active_collection = bpy.context.view_layer.active_layer_collection # type: ignore[union-attr]
# Activate target collection if specified
if collection:
target_collection = bpy.data.collections.get(collection)
if target_collection:
# Find and activate the layer collection
layer_collection = find_layer_collection(
bpy.context.view_layer.layer_collection, collection # type: ignore[union-attr]
)
if layer_collection:
bpy.context.view_layer.active_layer_collection = layer_collection # type: ignore[union-attr]
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
scols = []
for col in data_from.collections:
if col == kwargs["name"]:
data_to.collections = [col]
@@ -257,46 +439,44 @@ def link_collection(
rotation = kwargs["rotation"]
bpy.ops.object.empty_add(type="PLAIN_AXES", location=location, rotation=rotation)
main_object = bpy.context.view_layer.objects.active
main_object.instance_type = "COLLECTION"
main_object = bpy.context.view_layer.objects.active # type: ignore[union-attr]
main_object.instance_type = "COLLECTION" # type: ignore[union-attr]
if parent is not None:
main_object.parent = bpy.data.objects.get(parent)
if parent is not None and parent != "":
main_object.parent = bpy.data.objects.get(parent) # type: ignore[union-attr]
main_object.matrix_world.translation = location
main_object.matrix_world.translation = location # type: ignore[union-attr]
for col in bpy.data.collections:
if col.library is not None:
fp = bpy.path.abspath(col.library.filepath)
fp1 = bpy.path.abspath(file_name)
if fp == fp1:
main_object.instance_collection = col
main_object.instance_collection = col # type: ignore[union-attr]
break
# sometimes, the lib might already be without the actual link.
if not main_object.instance_collection and kwargs["name"]:
if not main_object.instance_collection and kwargs["name"]: # type: ignore[union-attr]
col = bpy.data.collections.get(kwargs["name"])
if col:
main_object.instance_collection = col
main_object.instance_collection = col # type: ignore[union-attr]
main_object.name = main_object.instance_collection.name
main_object.name = main_object.instance_collection.name # type: ignore[union-attr]
# bpy.ops.wm.link(directory=file_name + "/Collection/", filename=kwargs['name'], link=link, instance_collections=True,
# autoselect=True)
# main_object = bpy.context.view_layer.objects.active
# if kwargs.get('rotation') is not None:
# main_object.rotation_euler = kwargs['rotation']
# main_object.location = location
# Restore original active collection
if orig_active_collection:
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr]
utils.selection_set(sel)
return main_object, []
def append_particle_system(
file_name, obnames=[], location=(0, 0, 0), link=False, **kwargs
file_name, obnames=None, location=(0, 0, 0), link=False, **kwargs
):
"""link an instanced group - model type asset"""
if obnames is None:
obnames = []
pss = []
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
@@ -367,18 +547,53 @@ def append_particle_system(
return target_object, []
def append_objects(file_name, obnames=[], location=(0, 0, 0), link=False, **kwargs):
def append_objects(
file_name,
obnames: Optional[list] = None,
location=(0, 0, 0),
link: bool = False,
parent: Optional[str] = None,
collection: str = "",
**kwargs,
):
"""Append object into scene individually. 2 approaches based in definition of name argument.
TODO: really split this function into 2 functions: kwargs.get('name')==None and else.
"""
if obnames is None:
obnames = []
# simplified version of append
if kwargs.get("name"):
scene = bpy.context.scene
sel = utils.selection_get()
bpy.ops.object.select_all(action="DESELECT")
# Store the original active collection
orig_active_collection = bpy.context.view_layer.active_layer_collection # type: ignore[union-attr]
# Activate target collection if specified
if collection:
target_collection = bpy.data.collections.get(collection)
if target_collection:
# Find and activate the layer collection
layer_collection = find_layer_collection(
bpy.context.view_layer.layer_collection, collection # type: ignore[union-attr]
)
if layer_collection:
bpy.context.view_layer.active_layer_collection = layer_collection # type: ignore[union-attr]
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"append_objects.1: {str(e)}",
3,
type="ERROR",
)
raise e
path = file_name + "/Collection"
collection_name = kwargs.get("name")
if collection_name is None:
bk_logger.warning("collection_name is None")
collection_name = ""
bpy.ops.wm.append(filename=collection_name, directory=path)
# fc = utils.get_fake_context(bpy.context, area_type='VIEW_3D')
@@ -386,22 +601,22 @@ def append_objects(file_name, obnames=[], location=(0, 0, 0), link=False, **kwar
return_obs = []
to_hidden_collection = []
collection = None
appended_collection = None
main_object = None
# get first at least one parent for sure
for ob in bpy.context.scene.objects:
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get():
if not ob.parent:
main_object = ob
ob.location = location
# do once again to ensure hidden objects are hidden
for ob in bpy.context.scene.objects:
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get():
return_obs.append(ob)
# check for object that should be hidden
if ob.users_collection[0].name == collection_name:
collection = ob.users_collection[0]
collection["is_blenderkit_asset"] = True
appended_collection = ob.users_collection[0]
appended_collection["is_blenderkit_asset"] = True
if not ob.parent:
main_object = ob
ob.location = location
@@ -414,14 +629,14 @@ def append_objects(file_name, obnames=[], location=(0, 0, 0), link=False, **kwar
if kwargs.get("rotation"):
main_object.rotation_euler = kwargs["rotation"]
if kwargs.get("parent") is not None:
main_object.parent = bpy.data.objects[kwargs["parent"]]
if parent is not None and parent != "":
main_object.parent = bpy.data.objects[parent]
main_object.matrix_world.translation = location
# move objects that should be hidden to a sub collection
if len(to_hidden_collection) > 0 and collection is not None:
if len(to_hidden_collection) > 0 and appended_collection is not None:
hidden_collections = []
scene_collection = bpy.context.scene.collection
scene_collection = bpy.context.scene.collection # type: ignore[union-attr]
for ob in to_hidden_collection:
hide_collection = ob.users_collection[0]
@@ -434,7 +649,11 @@ def append_objects(file_name, obnames=[], location=(0, 0, 0), link=False, **kwar
h_col = bpy.data.collections.get(hidden_collection_name)
if h_col is None:
h_col = bpy.data.collections.new(name=hidden_collection_name)
collection.children.link(h_col)
# If target collection is specified, make the hidden collection a child of target collection
if collection and bpy.data.collections.get(collection):
bpy.data.collections.get(collection).children.link(h_col)
else:
appended_collection.children.link(h_col)
utils.exclude_collection(hidden_collection_name)
ob.users_collection[0].objects.unlink(ob)
@@ -443,12 +662,31 @@ def append_objects(file_name, obnames=[], location=(0, 0, 0), link=False, **kwar
if hide_collection in hidden_collections:
continue
# All other collections are moved to be children of the model collection
bk_logger.info(f"{hide_collection}, {collection}")
utils.move_collection(hide_collection, collection)
bk_logger.info(f"{hide_collection}, {appended_collection}")
# If target collection is specified, move collections there instead
if collection and bpy.data.collections.get(collection):
utils.move_collection(
hide_collection, bpy.data.collections.get(collection)
)
else:
utils.move_collection(hide_collection, appended_collection)
utils.exclude_collection(hide_collection.name)
hidden_collections.append(hide_collection)
bpy.ops.object.select_all(action="DESELECT")
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"append_objects.2: {str(e)}",
3,
type="ERROR",
)
raise e
# Restore original active collection
if orig_active_collection:
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr]
utils.selection_set(sel)
# let collection also store info that it was created by BlenderKit, for purging reasons
@@ -471,7 +709,15 @@ def append_objects(file_name, obnames=[], location=(0, 0, 0), link=False, **kwar
# link them to scene
scene = bpy.context.scene
sel = utils.selection_get()
bpy.ops.object.select_all(action="DESELECT")
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"append_objects.3: {str(e)}",
3,
type="ERROR",
)
raise e
return_obs = [] # this might not be needed, but better be sure to rewrite the list.
main_object = None
@@ -480,7 +726,7 @@ def append_objects(file_name, obnames=[], location=(0, 0, 0), link=False, **kwar
for obj in data_to.objects:
if obj is not None:
# if obj.name not in scene.objects:
scene.collection.objects.link(obj)
scene.collection.objects.link(obj) # type: ignore[union-attr]
if obj.parent is None:
obj.location = location
main_object = obj
@@ -499,13 +745,21 @@ def append_objects(file_name, obnames=[], location=(0, 0, 0), link=False, **kwar
ob.hide_viewport = True
if kwargs.get("rotation") is not None:
main_object.rotation_euler = kwargs["rotation"]
main_object.rotation_euler = kwargs["rotation"] # type: ignore[union-attr]
if kwargs.get("parent") is not None:
main_object.parent = bpy.data.objects[kwargs["parent"]]
main_object.matrix_world.translation = location
if parent is not None and parent != "":
main_object.parent = bpy.data.objects[parent] # type: ignore[union-attr]
main_object.matrix_world.translation = location # type: ignore[union-attr]
bpy.ops.object.select_all(action="DESELECT")
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"append_objects.4: {str(e)}",
3,
type="ERROR",
)
raise e
utils.selection_set(sel)
return main_object, return_obs
@@ -30,7 +30,6 @@ from . import (
global_vars,
paths,
ratings_utils,
reports,
search,
ui,
ui_panels,
@@ -76,6 +75,27 @@ def modal_inside(self, context, event):
ui_props = bpy.context.window_manager.blenderkitUI
user_preferences = bpy.context.preferences.addons[__package__].preferences
# HANDLE PHOTO THUMBNAIL SWITCH
if hasattr(self, "needs_tooltip_update") and self.needs_tooltip_update:
self.needs_tooltip_update = False
sr = search.get_search_results()
if sr and self.active_index < len(sr):
asset_data = sr[self.active_index]
if asset_data["assetType"].lower() == "printable":
if self.show_photo_thumbnail:
photo_img = ui.get_full_photo_thumbnail(asset_data)
if photo_img:
self.tooltip_image.set_image(photo_img.filepath)
self.tooltip_image.set_image_colorspace("")
else:
self.tooltip_image.set_image(
paths.get_addon_thumbnail_path("thumbnail_notready.jpg")
)
else:
set_thumb_check(
self.tooltip_image, asset_data, thumb_type="thumbnail"
)
if ui_props.turn_off:
ui_props.turn_off = False
self.finish()
@@ -96,9 +116,15 @@ def modal_inside(self, context, event):
if sr is not None:
# this check runs more search, usefull especially for first search. Could be moved to a better place where the check
# doesn't run that often.
# Calculate current max rows based on expanded state
if user_preferences.assetbar_expanded:
current_max_rows = user_preferences.maximized_assetbar_rows
else:
current_max_rows = 1
if (
len(sr) - ui_props.scroll_offset
< (ui_props.wcount * user_preferences.max_assetbar_rows) + 15
< (ui_props.wcount * current_max_rows) + 15
):
self.search_more()
@@ -427,6 +453,13 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
default="Runs search and displays the asset bar at the same time"
)
show_photo_thumbnail: BoolProperty( # type: ignore[valid-type]
name="Show Photo Thumbnail",
description="Toggle between normal and photo thumbnail - use [ or ] to cycle through thumbnails. Currently used only for printables.",
default=False,
options={"SKIP_SAVE"},
)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@@ -446,9 +479,6 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_scale = 1.0
self.tooltip_height = self.tooltip_size
self.tooltip_width = self.tooltip_size
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.asset_type == "HDR":
self.tooltip_width = self.tooltip_size * 2
# total_size = tooltip# + 2 * self.margin
self.tooltip_panel = BL_UI_Drag_Panel(
0, 0, self.tooltip_width, self.tooltip_height
@@ -722,15 +752,19 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.max_hcount = math.floor(
max(region.width, context.window.width) / self.button_size
)
self.max_wcount = user_preferences.max_assetbar_rows
self.max_wcount = user_preferences.maximized_assetbar_rows
history_step = search.get_active_history_step()
search_results = history_step.get("search_results")
# we need to init all possible thumb previews in advance/
# self.hcount = user_preferences.max_assetbar_rows
# Calculate hcount based on expanded state
if search_results is not None and self.wcount > 0:
if user_preferences.assetbar_expanded:
max_rows = user_preferences.maximized_assetbar_rows
else:
max_rows = 1
self.hcount = min(
user_preferences.max_assetbar_rows,
max_rows,
math.ceil(len(search_results) / self.wcount),
)
self.hcount = max(self.hcount, 1)
@@ -768,6 +802,9 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.button_close.set_location(
self.bar_width - self.other_button_size, -self.other_button_size
)
self.button_expand.set_location(
self.bar_width - self.other_button_size, self.bar_height
)
# if hasattr(self, 'button_notifications'):
# self.button_notifications.set_location(self.bar_width - self.other_button_size * 2, -self.other_button_size)
self.button_scroll_up.set_location(self.bar_width, 0)
@@ -1026,6 +1063,25 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.widgets_panel.append(self.button_close)
# Expand/collapse button (positioned at bottom of assetbar)
self.button_expand = BL_UI_Button(
self.bar_width - self.other_button_size,
self.bar_height,
self.other_button_size,
self.other_button_size,
)
self.button_expand.bg_color = self.button_bg_color
self.button_expand.hover_bg_color = self.button_hover_color
self.button_expand.text = ""
self.button_expand.text_size = self.other_button_size * 0.8
self.button_expand.set_image_position((0, 0))
self.button_expand.set_image_size(
(self.other_button_size, self.other_button_size)
)
self.button_expand.set_mouse_down(self.toggle_expand)
self.widgets_panel.append(self.button_expand)
self.scroll_width = 30
self.button_scroll_down = BL_UI_Button(
-self.scroll_width, 0, self.scroll_width, self.bar_height
@@ -1069,9 +1125,14 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
)
self.history_back_button.bg_color = self.button_bg_color
self.history_back_button.hover_bg_color = self.button_hover_color
self.history_back_button.text = ""
self.history_back_button.text_size = button_size * 0.5
self.history_back_button.text_color = self.text_color
self.history_back_button.text = ""
icon_size = int(button_size * 0.6)
margin_lr = int((button_size - icon_size) / 2)
self.history_back_button.set_image(
paths.get_addon_thumbnail_path("history_back.png")
)
self.history_back_button.set_image_size((icon_size, icon_size))
self.history_back_button.set_image_position((margin_lr, margin_lr))
self.history_forward_button = BL_UI_Button(
margin * 2 + button_size,
@@ -1081,9 +1142,12 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
)
self.history_forward_button.bg_color = self.button_bg_color
self.history_forward_button.hover_bg_color = self.button_hover_color
self.history_forward_button.text = ""
self.history_forward_button.text_size = button_size * 0.5
self.history_forward_button.text_color = self.text_color
self.history_forward_button.text = ""
self.history_forward_button.set_image(
paths.get_addon_thumbnail_path("history_forward.png")
)
self.history_forward_button.set_image_size((icon_size, icon_size))
self.history_forward_button.set_image_position((margin_lr, margin_lr))
# Tab buttons
tabs = global_vars.TABS["tabs"]
@@ -1211,6 +1275,9 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Update tab icons
self.update_tab_icons()
# Update expand button icon
self.update_expand_button_icon()
def update_tab_icons(self):
"""Update tab icons based on the active history step's asset type"""
tabs = global_vars.TABS["tabs"]
@@ -1240,6 +1307,16 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tab_button.set_image(icon_path)
tab_button.set_image_colorspace("")
def update_expand_button_icon(self):
"""Update expand button icon based on current expanded state."""
user_preferences = bpy.context.preferences.addons[__package__].preferences
if user_preferences.assetbar_expanded:
# Show up arrow when expanded (to collapse)
self.button_expand.text = ""
else:
# Show down arrow when collapsed (to expand)
self.button_expand.text = ""
def position_and_hide_buttons(self):
# position and layout buttons
sr = search.get_search_results()
@@ -1320,6 +1397,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_base_size_pixels = 512
self.tooltip_scale = 1.0
self.bottom_panel_fraction = 0.15
self.needs_tooltip_update = False
self.update_ui_size(bpy.context)
# todo move all this to update UI size
@@ -1441,7 +1519,6 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
def update_tooltip_image(self, asset_id):
"""Update tootlip image when it finishes downloading and the downloaded image matches the active one."""
search_results = search.get_search_results()
if search_results is None:
return
@@ -1499,7 +1576,23 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# self.tooltip = asset_data['tooltip']
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.active_index = search_index # + self.scroll_offset
set_thumb_check(self.tooltip_image, asset_data, thumb_type="thumbnail")
# Update tooltip size based on asset type
if (
asset_data["assetType"].lower() == "printable"
and self.show_photo_thumbnail
):
photo_img = ui.get_full_photo_thumbnail(asset_data)
if photo_img:
self.tooltip_image.set_image(photo_img.filepath)
self.tooltip_image.set_image_colorspace("")
else:
self.tooltip_image.set_image(
paths.get_addon_thumbnail_path("thumbnail_notready.jpg")
)
else:
set_thumb_check(self.tooltip_image, asset_data, thumb_type="thumbnail")
get_tooltip_data(asset_data)
an = asset_data["displayName"]
max_name_length = 30
@@ -1615,7 +1708,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
now = time.time()
# avoid double click to download assets under panels, mainly category panel
if now - ui_panels.last_time_dropdown_active < 0.5:
if now - ui_panels.last_time_overlay_panel_active < 0.5:
return
# start drag drop
bpy.ops.view3d.asset_drag_drop(
@@ -1626,6 +1719,17 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
def cancel_press(self, widget):
self.finish()
def toggle_expand(self, widget):
"""Toggle the expanded state of the assetbar."""
user_preferences = bpy.context.preferences.addons[__package__].preferences
user_preferences.assetbar_expanded = not user_preferences.assetbar_expanded
# Update the button icon
self.update_expand_button_icon()
# Restart the asset bar to apply the new layout
self.restart_asset_bar()
def asset_menu(self, widget):
self.hide_tooltip()
bpy.ops.wm.blenderkit_asset_popup("INVOKE_DEFAULT")
@@ -1668,14 +1772,21 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
bookmark_button.set_image(img_fp)
def update_progress_bar(self, asset_button, asset_data):
if asset_data["downloaded"] > 0:
pb = asset_button.progress_bar
w = int(self.button_size * asset_data["downloaded"] / 100.0)
asset_button.progress_bar.width = w
asset_button.progress_bar.update(pb.x_screen, pb.y_screen)
asset_button.progress_bar.visible = True
else:
"""Update progress bar for an asset button."""
pb = asset_button.progress_bar
if pb is None:
return
if asset_data["downloaded"] == 0:
asset_button.progress_bar.visible = False
return
w = int(self.button_size * asset_data["downloaded"] / 100.0)
asset_button.progress_bar.width = w
asset_button.progress_bar.update(pb.x_screen, pb.y_screen)
asset_button.progress_bar.visible = True
if bpy.context.region is not None:
bpy.context.region.tag_redraw()
def update_validation_icon(self, asset_button, asset_data: dict):
if utils.profile_is_validator():
@@ -1853,6 +1964,30 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
search.search()
def handle_key_input(self, event):
# Check if enough time has passed since last popup/text input activity
# to prevent shortcuts from triggering while typing in text fields
now = time.time()
if now - ui_panels.last_time_overlay_panel_active < 0.5:
return False
# Shortcut: Toggle between normal and photo thumbnail
if event.type in {"ONE"}:
if self.show_photo_thumbnail == True:
self.show_photo_thumbnail = False
self.needs_tooltip_update = True
if event.type in {"TWO"}:
if self.show_photo_thumbnail == False:
self.show_photo_thumbnail = True
self.needs_tooltip_update = True
if (
event.type in {"LEFT_BRACKET", "RIGHT_BRACKET"}
and not event.shift
and self.active_index > -1
):
self.show_photo_thumbnail = not self.show_photo_thumbnail
self.needs_tooltip_update = True
return True
# Shortcut: Search by author
if event.type == "A":
self.search_by_author(self.active_index)
@@ -2166,6 +2301,49 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
)
def handle_bkclientjs_get_asset(task: search.client_tasks.Task):
"""Handle incoming bkclientjs/get_asset task after the user asked for download in online gallery. How it goes:
1. set search in the history
2. set the results in the history step
3. open the asset bar
We handle the task in asset_bar_op because we need access to the asset_bar_operator without circular import from search.
"""
bk_logger.info(f"handle_bkclientjs_get_asset: {task.result['asset_data']['name']}")
# Get asset data from task result
asset_data = task.result.get("asset_data")
if not asset_data:
bk_logger.error("No asset data found in task")
return
# Parse the asset data
parsed_asset_data = search.parse_result(asset_data)
if not parsed_asset_data:
bk_logger.error("Failed to parse asset data")
return
search.append_history_step(
search_keywords=f"asset_base_id:{asset_data['assetBaseId']}",
search_results=[parsed_asset_data],
asset_type=asset_data.get("assetType", "").upper(),
search_results_orig={"results": [asset_data], "count": 1},
)
# If asset bar is not open, try to open it
if asset_bar_operator is None:
try:
bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False) # type: ignore[attr-defined]
except Exception as e:
bk_logger.error(f"Failed to open asset bar: {e}")
return
# Force redraw of the region if asset bar exists
if asset_bar_operator and asset_bar_operator.area:
search.load_preview(parsed_asset_data)
asset_bar_operator.update_image(parsed_asset_data["assetBaseId"])
asset_bar_operator.area.tag_redraw()
BlenderKitAssetBarOperator.modal = asset_bar_modal # type: ignore[method-assign]
BlenderKitAssetBarOperator.invoke = asset_bar_invoke # type: ignore[method-assign]
@@ -20,12 +20,19 @@
import json
import logging
import os
import random
import subprocess
import tempfile
from pathlib import Path
import bpy
from bpy.props import BoolProperty, EnumProperty, FloatProperty, IntProperty
from bpy.props import (
BoolProperty,
EnumProperty,
FloatProperty,
IntProperty,
FloatVectorProperty,
)
from . import bg_blender, global_vars, paths, tasks_queue, utils, upload, search
@@ -41,7 +48,8 @@ thumbnail_resolutions = (
)
thumbnail_angles = (
("DEFAULT", "default", ""),
("ANGLE_1", "Angle 1", "Lower hanging camera angle"),
("ANGLE_2", "Angle 2", "Higher hanging camera angle"),
("FRONT", "front", ""),
("SIDE", "side", ""),
("TOP", "top", ""),
@@ -320,13 +328,17 @@ class GenerateThumbnailOperator(bpy.types.Operator):
return bpy.context.view_layer.objects.active is not None
def draw(self, context):
ob = bpy.context.active_object
while ob.parent is not None:
ob = ob.parent
ui_props = bpy.context.window_manager.blenderkitUI
asset_type = ui_props.asset_type
ob = utils.get_active_model()
props = ob.blenderkit
layout = self.layout
layout.label(text="thumbnailer settings")
layout.prop(props, "thumbnail_background_lightness")
# for printable models
if asset_type == "PRINTABLE":
layout.prop(props, "thumbnail_material_color")
layout.prop(props, "thumbnail_angle")
layout.prop(props, "thumbnail_snap_to")
layout.prop(props, "thumbnail_samples")
@@ -382,20 +394,27 @@ class GenerateThumbnailOperator(bpy.types.Operator):
obnames = []
for ob in obs:
obnames.append(ob.name)
# asset type can be model or printable
ui_props = bpy.context.window_manager.blenderkitUI
asset_type = ui_props.asset_type
args_dict = {
"type": "material",
"type": asset_type,
"asset_name": asset.name,
"filepath": filepath,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
}
thumbnail_args = {
"type": "model",
"type": asset_type,
"models": str(obnames),
"thumbnail_angle": bkit.thumbnail_angle,
"thumbnail_snap_to": bkit.thumbnail_snap_to,
"thumbnail_background_lightness": bkit.thumbnail_background_lightness,
"thumbnail_material_color": (
bkit.thumbnail_material_color[0],
bkit.thumbnail_material_color[1],
bkit.thumbnail_material_color[2],
),
"thumbnail_resolution": bkit.thumbnail_resolution,
"thumbnail_samples": bkit.thumbnail_samples,
"thumbnail_denoising": bkit.thumbnail_denoising,
@@ -409,12 +428,6 @@ class GenerateThumbnailOperator(bpy.types.Operator):
def invoke(self, context, event):
wm = context.window_manager
# if bpy.data.filepath == '':
# ui_panels.ui_message(
# title="Can't render thumbnail",
# message="please save your file first")
#
# return {'FINISHED'}
return wm.invoke_props_dialog(self, width=400)
@@ -448,10 +461,17 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
max=10,
)
thumbnail_material_color: FloatVectorProperty(
name="Thumbnail Material Color",
description="Color of the material for printable models",
default=(random.random(), random.random(), random.random()),
subtype="COLOR",
)
thumbnail_angle: EnumProperty( # type: ignore[valid-type]
name="Thumbnail Angle",
items=thumbnail_angles,
default="DEFAULT",
default="ANGLE_1",
description="thumbnailer angle",
)
@@ -491,6 +511,9 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
layout.label(text="Server-side rendering may take several hours", icon="INFO")
layout.label(text="thumbnailer settings")
layout.prop(props, "thumbnail_background_lightness")
# for printable models
if self.asset_type == "PRINTABLE":
layout.prop(props, "thumbnail_material_color")
layout.prop(props, "thumbnail_angle")
layout.prop(props, "thumbnail_snap_to")
layout.prop(props, "thumbnail_samples")
@@ -503,17 +526,12 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
if not self.asset_index > -1:
return {"CANCELLED"}
# Get search results from history
history_step = search.get_active_history_step()
sr = history_step.get("search_results", [])
asset_data = sr[self.asset_index]
preferences = bpy.context.preferences.addons[__package__].preferences
if not self.render_locally:
# Use server-side thumbnail regeneration
success = upload.mark_for_thumbnail(
asset_id=asset_data["id"],
asset_id=self.asset_data["id"],
api_key=preferences.api_key,
use_gpu=preferences.thumbnail_use_gpu,
samples=self.thumbnail_samples,
@@ -536,13 +554,16 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
# Local thumbnail generation (original functionality)
tempdir = tempfile.mkdtemp()
an_slug = paths.slugify(asset_data["name"])
an_slug = paths.slugify(self.asset_data["name"])
thumb_path = os.path.join(tempdir, an_slug)
# asset type can be model or printable
ui_props = bpy.context.window_manager.blenderkitUI
self.asset_type = ui_props.asset_type
args_dict = {
"type": "material",
"asset_name": asset_data["name"],
"asset_data": asset_data,
"type": self.asset_type,
"asset_name": self.asset_data["name"],
"asset_data": self.asset_data,
# "filepath": filepath,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
@@ -550,7 +571,7 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
"upload_after_render": True,
}
thumbnail_args = {
"type": "model",
"type": self.asset_type,
"thumbnail_angle": self.thumbnail_angle,
"thumbnail_snap_to": self.thumbnail_snap_to,
"thumbnail_background_lightness": self.thumbnail_background_lightness,
@@ -565,6 +586,11 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
def invoke(self, context, event):
wm = context.window_manager
# Get search results from history
history_step = search.get_active_history_step()
sr = history_step.get("search_results", [])
self.asset_data = sr[self.asset_index]
return wm.invoke_props_dialog(self, width=400)
@@ -21,6 +21,8 @@
import json
import math
import os
import random
import colorsys
import sys
from traceback import print_exc
@@ -110,6 +112,25 @@ def patch_imports(addon_module_name: str):
print(f"- Local repository {parts[1]} added")
def replace_materials(obs, material_name):
"""Replace all materials on objects with the specified material
Args:
obs: List of objects to process
material_name: Name of the material to apply to all objects
"""
material = bpy.data.materials.get(material_name)
if not material:
bg_blender.progress(f"Material {material_name} not found")
return
for ob in obs:
if ob.type == "MESH":
# Clear all material slots and add the specified material
ob.data.materials.clear()
ob.data.materials.append(material)
return material
if __name__ == "__main__":
try:
# args order must match the order in blenderkit/autothumb.py:get_thumbnailer_args()!
@@ -187,10 +208,11 @@ if __name__ == "__main__":
bpy.context.preferences.addons["cycles"].preferences.refresh_devices()
fdict = {
"DEFAULT": 1,
"FRONT": 2,
"SIDE": 3,
"TOP": 4,
"ANGLE_1": 1,
"ANGLE_2": 2,
"FRONT": 3,
"SIDE": 4,
"TOP": 5,
}
s = bpy.context.scene
s.frame_set(fdict[data["thumbnail_angle"]])
@@ -210,9 +232,41 @@ if __name__ == "__main__":
collection.hide_select = False
main_object.rotation_euler = (0, 0, 0)
# Add material replacement for printable assets
# works directly with the specific material that has a color node for input
if data.get("type") == "PRINTABLE":
material = replace_materials(allobs, "PrintableMaterial")
# Find the BaseColor node in this material
base_color_node = material.node_tree.nodes.get("BaseColor")
if base_color_node:
# randomize the color value, needs to be defined by random hue and saturation = 0.95, we need to convert it to RGB then
# random_color = (random.random(), 0.95, 0.5)
# # convert to RGB
# random_color = colorsys.hsv_to_rgb(
# random_color[0], random_color[1], random_color[2]
# )
random_color = data["thumbnail_material_color"]
base_color_node.outputs[0].default_value = (
random_color[0],
random_color[1],
random_color[2],
1,
)
# now let's make background color complementary to the material color
bpy.data.materials["bkit background"].node_tree.nodes[
"BaseColor"
].outputs["Color"].default_value = (
1 - random_color[0],
1 - random_color[1],
1 - random_color[2],
1,
)
bpy.data.materials["bkit background"].node_tree.nodes["Value"].outputs[
"Value"
].default_value = data["thumbnail_background_lightness"]
s.cycles.samples = data["thumbnail_samples"]
bpy.context.view_layer.cycles.use_denoising = data["thumbnail_denoising"]
bpy.context.view_layer.update()
@@ -237,11 +291,11 @@ if __name__ == "__main__":
bg_blender.progress("rendering thumbnail")
render_thumbnails()
if not data.get("upload_after_render") or not data.get("asset_data"):
bg_blender.progress(
"background autothumbnailer finished successfully (no upload)"
)
sys.exit(0)
bg_blender.progress("uploading thumbnail")
@@ -102,6 +102,11 @@ def clean_login_data():
preferences.api_key = ""
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()
def logout() -> None:
@@ -151,11 +156,12 @@ def write_tokens(auth_token, refresh_token, oauth_response):
preferences.login_attempt = False
preferences.api_key_refresh = refresh_token
preferences.api_key = auth_token # triggers api_key update function
# write token also to extensions repository setting
# 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-06-23 09:38:03.256719",
"backup_date": "",
"last_check": "2025-12-01 11:02:25.858363",
"backup_date": "October-27-2025",
"update_ready": true,
"ignore": false,
"just_restored": false,
"just_updated": false,
"version_text": {
"link": "https://github.com/BlenderKit/BlenderKit/releases/download/v3.16.1.250612/blenderkit-v3.16.1.250612.zip",
"link": "https://github.com/BlenderKit/BlenderKit/releases/download/v3.18.0.251121/blenderkit-v3.18.0.251121.zip",
"version": [
3,
16,
1,
250612
18,
0,
251121
]
}
}
@@ -159,8 +159,11 @@ class BL_UI_Button(BL_UI_Widget):
def draw_text(self, area_height):
font_id = 1
if bpy.app.version < (4, 0, 0):
if bpy.app.version < (3, 1, 0):
# Blender 3.0 requires size:int https://docs.blender.org/api/3.0/blf.html#blf.size
# but assetBar's search tab text is float - needs conversion in here
blf.size(font_id, int(self._text_size), 72)
elif bpy.app.version < (4, 0, 0):
blf.size(font_id, self._text_size, 72)
else:
blf.size(font_id, self._text_size)
@@ -204,7 +207,9 @@ class BL_UI_Button(BL_UI_Widget):
try:
self.mouse_down_func(self)
except Exception as e:
print(e)
import traceback
traceback.print_exc()
return True
@@ -1,3 +1,4 @@
import traceback
import bpy
from bpy.types import Operator
@@ -113,4 +114,4 @@ def draw_callback_px_separated(self, op, context):
for widget in self.widgets:
widget.draw()
except Exception as e:
print(e)
traceback.print_exc()
@@ -35,9 +35,9 @@ class BL_UI_Widget:
@bg_color.setter
def bg_color(self, value):
if value != self._bg_color:
bpy.context.region.tag_redraw()
self._bg_color = value
if bpy.context.region is not None:
bpy.context.region.tag_redraw()
@property
def visible(self):
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "blenderkit"
type = "add-on"
version = "3.15.1-250403" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
version = "3.17.0-251008" # 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"
@@ -134,7 +134,7 @@ def handle_categories_task(task: client_tasks.Task):
) # TODO: do this in Client, just saving the file so next time it is updated even without internet
return
bk_logger.warning(task.message)
bk_logger.warning(f"Could not load categories: {task.message}")
if not os.path.exists(categories_filepath):
source_path = paths.get_addon_file(subpath="data" + os.sep + "categories.json")
try:
@@ -74,7 +74,7 @@ def handle_notifications_task(task: client_tasks.Task):
global_vars.DATA["bkit notifications"] = task.result
return
if task.status == "error":
return bk_logger.warning(f"Notifications fetching failed: {task.message}")
return bk_logger.warning(f"Could not load notifications: {task.message}")
def check_notifications_read():
@@ -27,7 +27,7 @@ class Prefs:
unpack_files: bool
show_on_start: bool
thumb_size: int
max_assetbar_rows: int
maximized_assetbar_rows: int
search_field_width: int
search_in_header: bool
tips_on_start: bool
@@ -43,6 +43,7 @@ class Prefs:
updater_interval_months: int
updater_interval_days: int
resolution: str
material_import_automap: bool
@dataclasses.dataclass
@@ -110,7 +111,12 @@ class UserProfile:
@dataclasses.dataclass
class MineProfile:
"""This is private information about current user's profile."""
"""
This is private information about current user's profile. Fields can be also None.
Because API can just return null just for fun (https://github.com/BlenderKit/BlenderKit/issues/1545#event-17220997340).
None/null is not 0 or "" however, so we keep the None to distinguish both states.
As result the Nones has to be catched later in code, types are just hints in here!
"""
aboutMe: str = ""
aboutMeUrl: str = ""
@@ -31,7 +31,7 @@ from .bl_ui_widgets.bl_ui_image import BL_UI_Image
from .ui_bgl import get_text_size
bk_logger = logging.getLogger("blenderkit")
bk_logger = logging.getLogger(__name__)
disclaimer_counter = 0
@@ -99,6 +99,7 @@ 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)
pix_size = get_text_size(
font_id=1,
text=self.message,
@@ -241,6 +242,7 @@ class BlenderKitDisclaimerOperator(BL_UI_OT_draw_operator):
def run_disclaimer_task(message: str, url: str, tip: bool):
message = " ".join(message.split())
fake_context = utils.get_fake_context(bpy.context)
if bpy.app.version < (4, 0, 0):
bpy.ops.view3d.blenderkit_disclaimer_widget( # type: ignore[attr-defined]
@@ -287,8 +289,7 @@ def handle_disclaimer_task(task: client_tasks.Task):
return
if task.status == "error":
msg = f"Error downloading disclaimer info: {task.message}"
reports.add_report(msg, timeout=5, type="ERROR")
bk_logger.warning(f"Could not load disclaimer: {task.message}")
return show_random_tip()
@@ -21,18 +21,15 @@ import logging
import os
import shutil
import time
import traceback
from . import (
append_link,
client_lib,
client_tasks,
global_vars,
paths,
reports,
resolutions,
search,
timer,
ui_panels,
utils,
)
@@ -46,6 +43,7 @@ from bpy.props import (
BoolProperty,
EnumProperty,
FloatVectorProperty,
FloatProperty,
IntProperty,
StringProperty,
)
@@ -53,6 +51,9 @@ from bpy.props import (
download_tasks = {}
INT32_MIN = -2_147_483_648
INT32_MAX = 2_147_483_647
def check_missing():
"""Checks for missing files, and possibly starts re-download of these into the scene"""
@@ -259,6 +260,19 @@ def get_asset_usages():
return usage_report
def _sanitize_for_idprops(value):
"""Recursively sanitize a value for storage in Blender IDProperties."""
if isinstance(value, int):
if value < INT32_MIN or value > INT32_MAX:
return str(value)
return value
if isinstance(value, dict):
return {k: _sanitize_for_idprops(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_sanitize_for_idprops(v) for v in value]
return value
def udpate_asset_data_in_dicts(asset_data):
"""
updates asset data in all relevant dictionaries, after a threaded download task \
@@ -267,23 +281,34 @@ def udpate_asset_data_in_dicts(asset_data):
----------
asset_data - data coming back from thread, thus containing also download urls
"""
data = asset_data.copy()
# filesSize is not needed, causes troubles: github.com/BlenderKit/BlenderKit/issues/1601
if "filesSize" in data:
del data["filesSize"]
scene = bpy.context.scene
scene["assets used"] = scene.get("assets used", {})
scene["assets used"][asset_data["assetBaseId"]] = asset_data.copy()
# Reuse (or define if not yet present) a sanitizer for Blender IDProperties.
sanitized = _sanitize_for_idprops(data)
scene["assets used"][asset_data["assetBaseId"]] = sanitized
# Get search results from history
history_step = search.get_active_history_step()
sr = history_step.get("search_results")
if not sr:
search_results = history_step.get("search_results")
if not search_results:
return
for i, r in enumerate(sr):
if r["assetBaseId"] == asset_data["assetBaseId"]:
for f in asset_data["files"]:
if f.get("url"):
for f1 in r["files"]:
if f1["fileType"] == f["fileType"]:
f1["url"] = f["url"]
for result in search_results:
if result["assetBaseId"] != asset_data["assetBaseId"]:
continue
for file in asset_data["files"]:
if not file.get("url"):
continue
for f1 in result["files"]:
if f1["fileType"] != file["fileType"]:
continue
f1["url"] = file["url"]
def assign_material(object, material, target_slot):
@@ -320,15 +345,18 @@ def assign_material(object, material, target_slot):
def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
"""Link asset to the scene."""
"""Link or append an asset to the scene based on its type and settings.
This function handles the process of bringing an asset into the scene, supporting different
asset types (model, material, brush, scene, hdr, etc.) and different import methods
(link vs append).
"""
file_names = kwargs.get("file_paths")
if file_names is None:
file_names = paths.get_download_filepaths(asset_data, kwargs["resolution"])
props = None
#####
# how to do particle drop:
# link the group we are interested in( there are more groups in File!!!! , have to get the correct one!)
s = bpy.context.scene
wm = bpy.context.window_manager
user_preferences = bpy.context.preferences.addons[__package__].preferences
user_preferences.download_counter += 1
@@ -340,14 +368,12 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
file_names[0], link=sprops.append_link == "LINK", fake_user=False
)
if scene is not None:
props = scene.blenderkit
asset_main = scene
if sprops.switch_after_append:
bpy.context.window_manager.windows[0].scene = scene
if asset_data["assetType"] == "hdr":
hdr = append_link.load_HDR(file_name=file_names[0], name=asset_data["name"])
props = hdr.blenderkit
asset_main = hdr
if asset_data["assetType"] in ("model", "printable"):
@@ -424,6 +450,7 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
link=link,
name=asset_data["name"],
parent=kwargs.get("parent"),
collection=kwargs.get("target_collection", ""),
)
else:
@@ -434,6 +461,7 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
link=link,
name=asset_data["name"],
parent=kwargs.get("parent"),
collection=kwargs.get("target_collection", ""),
)
if asset_main.type == "EMPTY" and link:
bmin = asset_data["bbox_min"]
@@ -453,6 +481,7 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
link=link,
name=asset_data["name"],
parent=kwargs.get("parent"),
collection=kwargs.get("target_collection", ""),
)
else:
asset_main, new_obs = append_link.append_objects(
@@ -462,6 +491,7 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
link=link,
name=asset_data["name"],
parent=kwargs.get("parent"),
collection=kwargs.get("target_collection", ""),
)
# scale Empty for assets, so they don't clutter the scene.
@@ -482,7 +512,6 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
elif asset_data["assetType"] == "brush":
inscene = False
for b in bpy.data.brushes:
if b.blenderkit.id == asset_data["id"]:
inscene = True
brush = b
@@ -533,7 +562,6 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
# TODO add grease pencil brushes!
# bpy.context.tool_settings.image_paint.brush = brush
props = brush.blenderkit
asset_main = brush
elif asset_data["assetType"] == "material":
@@ -565,13 +593,45 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
nodegroup = g
break
if not inscene:
nodegroup = append_link.append_nodegroup(
nodegroup, added_to_editor = append_link.append_nodegroup(
file_names[-1],
nodegroupname=asset_data["name"],
link=False,
fake_user=False,
node_x=kwargs.get("node_x", 0),
node_y=kwargs.get("node_y", 0),
target_object=kwargs.get("target_object"),
nodegroup_mode=kwargs.get("nodegroup_mode", ""),
model_location=kwargs.get("model_location", (0, 0, 0)),
model_rotation=kwargs.get("model_rotation", (0, 0, 0)),
)
print("appended nodegroup", nodegroup)
# Show a message to the user if the node was not added to an editor or modifier
if not added_to_editor:
if kwargs.get("nodegroup_mode") == "MODIFIER":
reports.add_report(
f"Node group '{nodegroup.name}' was added to the Blender file but could not be applied as a modifier.",
type="WARNING",
)
else:
reports.add_report(
f"Node group '{nodegroup.name}' was added to the Blender file but no suitable node editor was found to place the node.",
type="INFO",
)
else:
# If nodegroup was already in scene, we still want to try to add it to the editor or modifier
_, added_to_editor = append_link.append_nodegroup(
file_names[-1],
nodegroupname=asset_data["name"],
link=False,
fake_user=False,
node_x=kwargs.get("node_x", 0),
node_y=kwargs.get("node_y", 0),
target_object=kwargs.get("target_object"),
nodegroup_mode=kwargs.get("nodegroup_mode", ""),
model_location=kwargs.get("model_location", (0, 0, 0)),
model_rotation=kwargs.get("model_rotation", (0, 0, 0)),
)
bk_logger.info(f"appended nodegroup: {nodegroup}")
asset_main = nodegroup
asset_data["resolution"] = kwargs["resolution"]
@@ -592,16 +652,16 @@ def update_asset_metadata(asset_main, asset_data):
asset_main.blenderkit.description = asset_data["description"]
asset_main.blenderkit.tags = utils.list2string(asset_data["tags"])
# BUG #554: categories needs update, but are not in asset_data
asset_main["asset_data"] = (
asset_data # TODO remove this??? should write to blenderkit Props?
)
sanitized = _sanitize_for_idprops(asset_data)
# TODO consider reducing stored fields for filesize.
asset_main["asset_data"] = sanitized
def replace_resolution_linked(file_paths, asset_data):
# replace one asset resolution for another.
# this is the much simpler case
# - find the library,
# - replace the path and name of the library, reload.
"""Replace one asset resolution for another. This is the much simpler case.
- Find the library.
- Replace the path and name of the library, reload.
"""
file_name = os.path.basename(file_paths[-1])
for l in bpy.data.libraries:
@@ -621,12 +681,10 @@ def replace_resolution_linked(file_paths, asset_data):
def replace_resolution_appended(file_paths, asset_data, resolution):
# In this case the texture paths need to be replaced.
# Find the file path pattern that is present in texture paths
# replace the pattern with the new one.
file_name = os.path.basename(file_paths[-1])
new_filename_pattern = os.path.splitext(file_name)[0]
"""In this case the texture paths need to be replaced.
- Find the file path pattern that is present in texture paths.
- Replace the pattern with the new one.
"""
all_patterns = []
for suff in paths.resolution_suffix.values():
pattern = f"{asset_data['id']}{os.sep}textures{suff}{os.sep}"
@@ -771,20 +829,19 @@ def handle_download_task(task: client_tasks.Task):
Update progress. Print messages. Fire post-download functions.
"""
global download_tasks
if task.status == "finished":
# we still write progress since sometimes the progress bars wouldn't end on 100%
download_write_progress(task.task_id, task)
# try to parse, in some states task gets returned to be pending (e.g. in editmode)
successful = download_post(task)
if successful == True:
try:
download_post(task)
download_tasks.pop(task.task_id)
return
task.status = "error"
if isinstance(successful, Exception):
task.message = f"Append failed {successful}"
else:
task.message = f"Append failed, download_post() returned: {successful}"
except Exception as e:
bk_logger.exception(f"Asset appending/linking has failed")
task.message = f"Append failed: {e}"
task.status = "error"
if task.status == "error":
reports.add_report(task.message, type="ERROR")
@@ -804,7 +861,7 @@ def download_write_progress(task_id, task):
global download_tasks
task_addon = download_tasks.get(task.task_id)
if task_addon is None:
print("couldn't write download progress to ", task.progress)
bk_logger.warning(f"couldn't write download progress to {task.progress}")
return
task_addon["progress"] = task.progress
task_addon["text"] = task.message
@@ -818,9 +875,8 @@ def download_write_progress(task_id, task):
# TODO might get moved to handle all blenderkit stuff, not to slow down.
def download_post(task: client_tasks.Task):
"""
Check for running and finished downloads.
def download_post(task: client_tasks.Task) -> None:
"""Check for running and finished downloads.
Running downloads get checked for progress which is passed to UI.
Finished downloads are processed and linked/appended to scene.
Finished downloads can become pending tasks, if Blender isn't ready to append the files.
@@ -829,14 +885,11 @@ def download_post(task: client_tasks.Task):
orig_task = download_tasks.get(task.task_id)
if orig_task is None:
return
done = False
return # What does this mean? Is it a failure? Or expected?
file_paths = task.result.get("file_paths", [])
if file_paths == []:
bk_logger.debug("library names not found in asset data after download")
done = True
bk_logger.info("library names not found in asset data after download")
# SUPER IMPORTANT CODE HERE
# Writing this back into the asset file data means it can be reused in the scene or file.
@@ -850,16 +903,17 @@ def download_post(task: client_tasks.Task):
# don't do this stuff in editmode and other modes, just wait...
# we don't remove the task before it's actually possible to remove it.
if bpy.context.mode != "OBJECT" and (at == "model" or at == "material"):
return done
# try to switch to object mode - if it's not possible, propagate exception higher up
bpy.ops.object.mode_set(mode="OBJECT")
# don't append brushes if not in sculpt/paint mode
# don't append brushes if not in sculpt/paint mode - WHY?
if (at == "brush") and wm.get("appendable") == False: # type: ignore
return done
# try to switch to sculpt mode - if it's not possible, propagate exception higher up
bpy.ops.object.mode_set(mode="SCULPT")
# duplicate file if the global and subdir are used in prefs
if (
len(file_paths) == 2
): # todo this should try to check if both files exist and are ok.
if len(file_paths) == 2:
# TODO this should try to check if both files exist and are ok.
utils.copy_asset(file_paths[0], file_paths[1])
# shutil.copyfile(file_paths[0], file_paths[1])
@@ -892,22 +946,21 @@ def download_post(task: client_tasks.Task):
replace_resolution_appended(
file_paths, task.data["asset_data"], task.data["resolution"]
)
return True
return
orig_task.update(task.data)
return try_finished_append(file_paths=file_paths, **task.data)
try_finished_append(
file_paths=file_paths, **task.data
) # exception is handled in calling function
# TODO add back re-download capability for deamon - used for lost libraries
# tcom.passargs['retry_counter'] = tcom.passargs.get('retry_counter', 0) + 1
# download(asset_data, **tcom.passargs)
#
# utils.p('end download timer')
return done
return
def download(asset_data, **kwargs):
"""Init download data and request task from BlenderKit-Client."""
if kwargs.get("retry_counter", 0) > 3:
sprops = utils.get_search_props()
report = f"Maximum retries exceeded for {asset_data['name']}"
@@ -932,7 +985,7 @@ def download(asset_data, **kwargs):
"asset_data": asset_data,
"PREFS": prefs,
"progress": 0,
"text": f'downloading {asset_data["name"]}',
"text": f"downloading {asset_data['name']}",
}
for arg, value in kwargs.items():
data[arg] = value
@@ -940,48 +993,9 @@ def download(asset_data, **kwargs):
data["download_dirs"] = paths.get_download_dirs(asset_data["assetType"])
if "downloaders" in kwargs:
data["downloaders"] = kwargs["downloaders"]
response = client_lib.asset_download(data)
download_tasks[response["task_id"]] = data
def handle_bkclientjs_get_asset(task: client_tasks.Task):
"""Handle incoming bkclientjs/get_asset task. User asked for download in online gallery. How it goes:
1. Webpage tries to connect to Client, gets data about connected Softwares
2. User choosed Blender with appID of this Blender
2. Client gets asset data from API
3. Client creates finished task bkclientjs/get_asset containing asset data
4. We handle the task in here
5. We request the download of the asset as if user has clicked it inside Blender
TODO: #1262Implement append to universal search results instead.
"""
pass
""" UNUSED CODE for direct download:
prefs = utils.get_preferences_as_dict()
prefs["resolution"] = task.result["resolution"]
prefs["scene_id"] = utils.get_scene_id()
download_dirs = paths.get_download_dirs(task.result["asset_data"]["assetType"])
data = {
"asset_data": task.result["asset_data"],
"download_dirs": download_dirs,
"PREFS": prefs,
"progress": 0,
"text": f'Getting asset {task.result["asset_data"]["name"]}',
"downloaders": [{'location': (0.0, 0.0, 0.0), 'rotation': (0.0, 0.0, 0.0)}],
"cast_parent": "",
"target_object": task.result["asset_data"]["name"],
"material_target_slot": 0,
"model_location": (0.0, 0.0, 0.0),
"model_rotation": (0.0, 0.0, 0.0),
"replace": False,
"replace_resolution": False,
"resolution": task.result["resolution"],
}
response = client_lib.asset_download(data)
download_tasks[response["task_id"]] = data
"""
def check_downloading(asset_data, **kwargs) -> bool:
@@ -1035,51 +1049,52 @@ def check_existing(asset_data, resolution="blend", can_return_others=False):
return False
def try_finished_append(asset_data, **kwargs): # location=None, material_target=None):
def try_finished_append(asset_data, **kwargs):
"""Try to append asset, if not successfully delete source files.
This means probably wrong download, so download should restart.
Returns True if successful, False if file_names are empty or file_names[-1] is not file.
Returns Exception if append_asset() failed.
"""
file_names = kwargs.get("file_paths")
if file_names is None or len(file_names) == 0:
file_names = paths.get_download_filepaths(asset_data, kwargs["resolution"])
file_paths = kwargs.get("file_paths")
if file_paths is None or len(file_paths) == 0:
file_paths = paths.get_download_filepaths(asset_data, kwargs["resolution"])
bk_logger.debug("try to append already existing asset")
if len(file_names) == 0:
return False
if len(file_paths) == 0:
raise utils.BlenderkitAppendException("No file_paths found")
if not os.path.isfile(file_names[-1]):
bk_logger.debug("library file doesnt exist", file_names[-1])
return False
if not os.path.isfile(file_paths[-1]):
raise utils.BlenderkitAppendException(
f"Library file does not exist: {file_paths[-1]}"
)
kwargs["name"] = asset_data["name"]
try:
append_asset(asset_data, **kwargs)
# Update downloaded status in search results
sr = search.get_search_results()
if sr is not None:
for sres in sr:
if asset_data["id"] == sres["id"]:
sres["downloaded"] = 100
return True
except Exception as e:
# TODO: this should distinguis if the appending failed (wrong file)
# or something else happened(shouldn't delete the files)
traceback.print_exc(limit=20)
reports.add_report(f"Append failed (try_finished_append): {e}", type="ERROR")
for f in file_names:
# TODO: this should distinguish if the appending failed (wrong file)
# or something else happened (shouldn't delete the files)
for file_path in file_paths:
try:
os.remove(f)
os.remove(file_path)
except Exception as e1:
bk_logger.error(f"{e1}")
return e
bk_logger.error(f"removing file {file_path} failed: {e1}")
raise e
# Update downloaded status in search results
sr = search.get_search_results()
if sr is None:
return
for sres in sr:
if asset_data["id"] != sres["id"]:
continue
sres["downloaded"] = 100
def get_asset_in_scene(asset_data):
"""tries to find an appended copy of particular asset and duplicate it - so it doesn't have to be appended again."""
scene = bpy.context.scene
for ob in bpy.context.scene.objects:
ad1 = ob.get("asset_data")
if not ad1:
@@ -1108,7 +1123,9 @@ def check_selectible(obs):
return True
def duplicate_asset(source, **kwargs):
def duplicate_asset(
source, **kwargs
) -> tuple[bpy.types.Object, list[bpy.types.Object]]:
"""
Duplicate asset when it's already appended in the scene,
so that blender's append doesn't create duplicated data.
@@ -1116,15 +1133,23 @@ def duplicate_asset(source, **kwargs):
bk_logger.debug("duplicate asset instead")
# we need to save selection
sel = utils.selection_get()
bpy.ops.object.select_all(action="DESELECT")
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"duplicate_asset: {str(e)}",
3,
type="ERROR",
)
raise e
# check visibility
obs = utils.get_hierarchy(source)
if not check_all_visible(obs):
return None
return None, []
# check selectability and select in one run
if not check_selectible(obs):
return None
return None, []
# duplicate the asset objects
bpy.ops.object.duplicate(linked=True)
@@ -1204,8 +1229,9 @@ def asset_in_scene(asset_data):
def start_download(asset_data, **kwargs) -> bool:
"""Check if file isn't downloading or is not in scene, then start new download.
Return true if new download was started.
"""Start download of an asset. But first check if the asset is not already in scene.
Or if file is not being downloaded already.
Return true if new download was started. Otherwise return false.
"""
# first check if the asset is already in scene. We can use that asset without checking with server
ain, _ = asset_in_scene(asset_data)
@@ -1220,10 +1246,11 @@ def start_download(asset_data, **kwargs) -> bool:
if ain and not kwargs.get("replace_resolution"):
# this goes to appending asset - where it should duplicate the original asset already in scene.
bk_logger.info("try append or asset from drive without download")
append_ok = try_finished_append(asset_data, **kwargs)
if append_ok:
try:
try_finished_append(asset_data, **kwargs)
return False
bk_logger.info(f"Failed to append asset: {append_ok}")
except Exception as e:
bk_logger.info(f"Failed to append asset: {e}, continuing with download")
if asset_data["assetType"] in ("model", "material"):
downloader = {
@@ -1289,7 +1316,7 @@ def available_resolutions_callback(self, context):
def has_asset_files(asset_data):
"""Check if asset has files."""
for f in asset_data["files"]:
if f["fileType"] == "blend":
if f["fileType"] in ("blend", "zip_file"):
return True
return False
@@ -1323,6 +1350,18 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
default="",
)
target_collection: StringProperty( # type: ignore[valid-type]
name="Target Collection",
description="Collection to place the asset in",
default="",
)
parent: StringProperty( # type: ignore[valid-type]
name="Parent Object",
description="Object to parent the new asset to",
default="",
)
material_target_slot: IntProperty( # type: ignore[valid-type]
name="Asset Index", description="asset index in search results", default=0
)
@@ -1380,6 +1419,25 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
name="Particles Target Object", description="", default=""
)
node_x: FloatProperty( # type: ignore[valid-type]
name="Node X Position",
description="X position to place the node group in node editor",
default=0.0,
)
node_y: FloatProperty( # type: ignore[valid-type]
name="Node Y Position",
description="Y position to place the node group in node editor",
default=0.0,
)
nodegroup_mode: StringProperty( # type: ignore[valid-type]
name="Nodegroup Mode",
description="How to add the nodegroup: 'MODIFIER' for new modifier, 'NODE' for node in existing tree, 'SHOW_DIALOG' to show dialog",
default="",
options={"SKIP_SAVE"},
)
# close_window: BoolProperty(name='Close window',
# description='Try to close the window below mouse before download',
# default=False)
@@ -1468,6 +1526,7 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
kwargs = {
"cast_parent": self.cast_parent,
"target_object": ob.name,
"target_collection": self.target_collection,
"material_target_slot": ob.active_material_index,
"model_location": tuple(ob.matrix_world.translation),
"model_rotation": tuple(ob.matrix_world.to_euler()),
@@ -1475,7 +1534,13 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
"replace_resolution": False,
"parent": parent,
"resolution": resolution,
"node_x": self.node_x,
"node_y": self.node_y,
"nodegroup_mode": self.nodegroup_mode,
}
bk_logger.debug(
f"Replace kwargs with target_collection={kwargs['target_collection']}"
)
# TODO - move this After download, not before, so that the replacement
utils.delete_hierarchy(ob)
start_download(self.asset_data, **kwargs)
@@ -1487,20 +1552,27 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
kwargs = {
"cast_parent": self.cast_parent,
"target_object": self.target_object,
"target_collection": self.target_collection,
"material_target_slot": self.material_target_slot,
"model_location": tuple(self.model_location),
"model_rotation": tuple(self.model_rotation),
"replace": False,
"replace_resolution": self.replace_resolution,
"parent": self.parent,
"resolution": resolution,
"node_x": self.node_x,
"node_y": self.node_y,
"nodegroup_mode": self.nodegroup_mode,
}
bk_logger.debug(
f"Final kwargs with target_collection={kwargs['target_collection']}"
)
start_download(self.asset_data, **kwargs)
return {"FINISHED"}
def draw(self, context):
# this timer is there to not let double clicks thorugh the popups down to the asset bar.
ui_panels.last_time_dropdown_active = time.time()
ui_panels.last_time_overlay_panel_active = time.time()
layout = self.layout
if self.invoke_resolution:
layout.prop(self, "resolution", expand=True, icon_only=False)
@@ -1529,6 +1601,28 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
if self.invoke_scene_settings:
return wm.invoke_props_dialog(self)
# Handle nodegroup dialog for geometry nodes
if self.nodegroup_mode == "SHOW_DIALOG":
self.asset_data = self.get_asset_data(context)
if (
self.asset_data["assetType"] == "nodegroup"
and self.asset_data["dictParameters"].get("nodeType") == "geometry"
):
# Show the nodegroup drop dialog
# Use active object if available, otherwise append_nodegroup will create one
active_object = context.active_object
target_object_name = active_object.name if active_object else ""
bpy.ops.wm.blenderkit_nodegroup_drop_dialog(
"INVOKE_DEFAULT",
asset_search_index=self.asset_index,
target_object_name=target_object_name,
snapped_location=self.model_location,
snapped_rotation=self.model_rotation,
)
return {"FINISHED"}
# if self.close_window:
# time.sleep(0.1)
# context.region.tag_redraw()
@@ -16,16 +16,15 @@
#
# ##### END GPL LICENSE BLOCK #####
from collections import deque
from logging import INFO, WARN
from os import environ
from subprocess import Popen
from typing import Optional
from typing import Any, Optional
from . import datas
CLIENT_VERSION = "v1.4.0"
CLIENT_VERSION = "v1.6.0"
CLIENT_ACCESSIBLE = False
"""Is Client accessible? Can add-on access it and call stuff which uses it?"""
CLIENT_RUNNING = False
@@ -42,7 +41,7 @@ DATA: dict = { # TODO: move these
"asset comments": {},
}
TABS = {
TABS: dict[str, Any] = {
"active_tab": 0, # Index of currently active tab
"tabs": [ # List of all tabs
{
@@ -175,6 +174,14 @@ TIPS = [
"Jump directly to a specific tab using Ctrl+1 through Ctrl+9 in the asset bar.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
(
"Use keys 1 and 2 to toggle photo thumbnail over printable assets in the asset bar.",
"",
),
(
"Use keys [ and ] to toggle between normal and photo thumbnail over printable assets.",
"",
),
]
VERSION = [0, 0, 0, 0] # filled in register()
@@ -8,19 +8,139 @@ The original method is then called from the new method, with the same arguments,
import json
import os
import time
from . import icons
import bpy
import bl_pkg.bl_extension_ui as exui
from . import icons
from bl_ui.space_userpref import (
USERPREF_PT_addons,
USERPREF_PT_extensions,
USERPREF_MT_extensions_active_repo,
)
from bpy.props import EnumProperty
from bpy.props import IntProperty, StringProperty
from bpy.types import Operator
EXTENSIONS_API_URL = "https://www.blenderkit.com/api/v1/extensions/"
# --- New Modal Operator ---
class BK_OT_buy_extension_and_watch(Operator):
"""Opens URL to buy extension and starts a modal timer to refresh repo periodically."""
bl_idname = "bk.buy_extension_and_watch"
bl_label = "Buy Extension Online and Watch"
bl_options = {"REGISTER", "UNDO"}
url: StringProperty(
name="URL",
description="Website URL to open",
)
repo_index: IntProperty(
name="Repository Index",
description="Index of the repository to refresh",
default=-1,
)
_timer = None
_last_refresh_time = 0
_start_time = 0
_refresh_interval = 60 # seconds
_max_duration = 300 # seconds (5 minutes timeout)
def execute(self, context):
if not self.url:
self.report({"ERROR"}, "No URL specified.")
return {"CANCELLED"}
if self.repo_index == -1:
self.report({"ERROR"}, "No repository index specified.")
return {"CANCELLED"}
# Open the URL
try:
bpy.ops.wm.url_open(url=self.url)
print(f"BlenderKit: Opening buy URL: {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?
# Decide if you want modal to continue even if URL fails
# Add modal handler and timer
wm = context.window_manager
self._timer = wm.event_timer_add(
1.0, window=context.window
) # Check every second
wm.modal_handler_add(self)
self._start_time = time.time()
self._last_refresh_time = (
self._start_time
) # Initialize to avoid immediate refresh
print(
f"BlenderKit: Started watching repository index {self.repo_index} for updates."
)
context.area.tag_redraw() # Update UI to show operator is running if needed
return {"RUNNING_MODAL"}
def modal(self, context, event):
current_time = time.time()
# --- 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.")
self.cancel(context)
return {"CANCELLED"}
# 2. Timeout
if current_time - self._start_time > self._max_duration:
print("BlenderKit: Watcher timed out, stopping.")
self.cancel(context)
return {"CANCELLED"}
# 3. User cancellation
if event.type in {"RIGHTMOUSE", "ESC"}:
print("BlenderKit: Watcher cancelled by user.")
self.cancel(context)
return {"CANCELLED"}
# --- Timer Logic ---
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}..."
)
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}."
)
else:
print(
f"BlenderKit: Repository index {self.repo_index} no longer valid."
)
# Optionally cancel here if repo is gone
except Exception as e:
# This might fail if another operation is in progress
print(f"BlenderKit: extensions.repo_sync failed: {e}")
finally:
self._last_refresh_time = (
current_time # Reset timer regardless of success
)
return {"PASS_THROUGH"} # Pass other events through
def cancel(self, context):
if self._timer:
wm = context.window_manager
wm.event_timer_remove(self._timer)
self._timer = None
print("BlenderKit: Watcher timer removed.")
context.area.tag_redraw() # Update UI
# --- End New Modal Operator ---
def extension_draw_item_blenderkit(
layout,
*,
@@ -39,12 +159,23 @@ def extension_draw_item_blenderkit(
show_developer_ui, # `bool`
):
### BlenderKit cache code
# Ensure cache is up-to-date before drawing
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.")
# check if the cache is already in the window manager
if "blenderkit_extensions_repo_cache" not in bpy.context.window_manager:
ensure_repo_cache()
# if still not present, return
if "blenderkit_extensions_repo_cache" not in bpy.context.window_manager:
return
# Log if cache is missing after trying to ensure it
print(
"BlenderKit: 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
return
bk_ext_cache = bpy.context.window_manager["blenderkit_extensions_repo_cache"]
bk_cache_pkg = bk_ext_cache.get(pkg_id[:32], None)
### end of BlenderKit cache code
@@ -135,11 +266,19 @@ def extension_draw_item_blenderkit(
if bk_cache_pkg is not None:
# Free , purchased and subscribed add-ons, probably also private add-ons
if bk_cache_pkg.get("can_download") is True:
props = row_right.operator(
"extensions.package_install",
text="Install",
icon_value=icon_value,
)
# if the addon is also for sale, it means the user purchased it and we write "install purchased"
if bk_cache_pkg.get("is_for_sale") is True:
props = row_right.operator(
"extensions.package_install",
text="Install purchased",
icon_value=icon_value,
)
else:
props = row_right.operator(
"extensions.package_install",
text="Install",
icon_value=icon_value,
)
props.repo_index = repo_index
props.pkg_id = pkg_id
@@ -157,12 +296,14 @@ def extension_draw_item_blenderkit(
# Paid addons get a buy button and lead to their website link
else:
# Use the new modal operator
props = row_right.operator(
"wm.url_open",
BK_OT_buy_extension_and_watch.bl_idname, # Use bl_idname
text=f"Buy online ${bk_cache_pkg.get('base_price')}",
icon_value=icon_value,
)
props.url = bk_cache_pkg.get("website")
props.url = bk_cache_pkg.get("website", "") # Pass URL
props.repo_index = repo_index # Pass repo index
### end of BlenderKit specific code
else:
# Right space for alignment with the button.
@@ -186,6 +327,7 @@ def extension_draw_item_blenderkit(
if show:
import os
from bpy.app.translations import pgettext_iface as iface_
col = layout.column()
@@ -359,34 +501,133 @@ def get_repository_by_url(url: str):
return None
def clear_repo_cache():
"""Clear the repository cache."""
wm = bpy.context.window_manager
cache_key = "blenderkit_extensions_repo_cache"
if cache_key in wm:
del wm[cache_key]
def ensure_repo_cache():
"""
Reads the .json file blender stores in \extensions\www_blenderkit_com\.blender_ext
and parses it to a dict from json, we can use it then for drawing purposes and have the extra data BlenderKit api provides
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.
"""
# return if cache already exists
if "blenderkit_extensions_repo_cache" in bpy.context.window_manager:
return
reloaded_flag = False # Track if we actually reloaded
wm = bpy.context.window_manager
cache_key = "blenderkit_extensions_repo_cache"
mtime_key = "blenderkit_extensions_repo_cache_mtime"
blenderkit_repository = get_repository_by_url(EXTENSIONS_API_URL)
if blenderkit_repository is None:
return
# 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.")
if mtime_key in wm:
del wm[mtime_key]
print(f"BlenderKit 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
cache_file = os.path.join(
blenderkit_repository.directory, ".blender_ext", "index.json"
)
if not os.path.exists(cache_file):
return
with open(cache_file, "r") as f:
data = f.read()
# the data needs to be written to a location in memory where it's possibly accessible from all addons but doesn't get saved in blender file
# we can use window manager for that
wm = bpy.context.window_manager
data = json.loads(data)
# store the data as a dict with keys being the package names
wm["blenderkit_extensions_repo_cache"] = {}
for pkg in data["data"]:
wm["blenderkit_extensions_repo_cache"][pkg["id"][:32]] = pkg
current_mtime = None
try:
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}"
)
# 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.")
if mtime_key in wm:
del wm[mtime_key]
return False # Error, nothing loaded
stored_mtime = wm.get(mtime_key, None)
# --- Determine if reload is needed ---
should_reload = False
if cache_key not in wm:
if current_mtime is not None: # Only load if file actually exists
should_reload = True # Cache doesn't exist, need initial load.
else:
# Cache doesn't exist and file doesn't exist/accessible. Fall through to check if we need to clear.
pass
elif current_mtime is None:
# Cache exists in wm, but file is gone/inaccessible. Clear stale cache.
del wm[cache_key]
if mtime_key in wm:
del wm[mtime_key]
return False # Cleared stale cache, did not load new data
elif cache_key not in wm and current_mtime is None:
# Cache doesn't exist, and file doesn't exist. Nothing to do or load.
return False
elif (
cache_key in wm and (stored_mtime is None or stored_mtime != current_mtime)
) or (
cache_key not in wm and current_mtime is not None
): # Reload if cache exists and is outdated, OR if cache doesn't exist but file does
should_reload = True # Cache exists but is outdated or missing mtime.
if not should_reload:
# Cache exists and is up-to-date
return False # Nothing reloaded
# --- (Re)Load cache ---
try:
with open(cache_file, "r", encoding="utf-8") as f: # Specify encoding
data_str = f.read()
data = json.loads(data_str)
# store the data as a dict with keys being the package names (first 32 chars)
new_cache = {}
for pkg in data.get(
"data", []
): # Handle case where 'data' key might be missing
if (
isinstance(pkg, dict) and "id" in pkg
): # Ensure pkg is a dict and 'id' key exists
new_cache[pkg["id"][:32]] = pkg
else:
print(f"BlenderKit: Skipping invalid package entry in cache: {pkg}")
wm[cache_key] = new_cache
wm[mtime_key] = current_mtime # Update mtime only on successful load
reloaded_flag = True # Mark that we reloaded successfully
except json.JSONDecodeError:
print(
f"BlenderKit: Error decoding JSON from {cache_file}. Cache not loaded/updated."
)
# 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.")
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}")
# Clear potentially corrupt cache?
if cache_key in wm:
del wm[cache_key]
print("BlenderKit: Cleared cache due to file processing error.")
if mtime_key in wm:
del wm[mtime_key]
return reloaded_flag # Return whether cache was actually reloaded
def ensure_repo_order():
@@ -470,7 +711,10 @@ def ensure_repository(api_key: str = ""):
if api_key != "":
blenderkit_repository.use_access_token = True
blenderkit_repository.access_token = api_key
# except:
else:
# clear after logout
blenderkit_repository.use_access_token = False
blenderkit_repository.access_token = ""
# pass
# ensure_repo_order()
ensure_repo_cache()
@@ -480,8 +724,10 @@ def register():
ensure_repository()
override_draw_function()
bpy.utils.register_class(BK_OT_buy_extension_and_watch) # Register new operator
def unregister():
exui.extension_draw_item = exui.extension_draw_item_original
del exui.extension_draw_item_original
bpy.utils.unregister_class(BK_OT_buy_extension_and_watch) # Unregister new operator
@@ -16,9 +16,14 @@
#
# ##### END GPL LICENSE BLOCK #####
import logging
import bpy
import mathutils
from bpy.types import Operator
from . import reports
bk_logger = logging.getLogger(__name__)
def getNodes(nt, node_type="OUTPUT_MATERIAL"):
@@ -219,7 +224,9 @@ class BringToScene(Operator):
parent = ob
bpy.context.view_layer.objects.active = parent
except Exception as e:
print(e)
bk_logger.exception(
f"BringToScene.execute: failed to link an object to the collection"
)
bpy.ops.object.make_local(type="ALL")
@@ -229,10 +236,9 @@ class BringToScene(Operator):
try:
ob.select_set(True)
except Exception as e:
print(
"failed to select an object from the collection, getting a replacement."
bk_logger.exception(
f"BringToScene.execute: failed to select an object from the collection, getting a replacement."
)
print(e)
related = []
@@ -249,7 +255,15 @@ class BringToScene(Operator):
)
for relation in related:
bpy.ops.object.select_all(action="DESELECT")
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"BringToScene.execute: {str(e)}",
3,
type="ERROR",
)
raise e
bpy.context.view_layer.objects.active = relation[0]
relation[0].select_set(True)
relation[1].select_set(True)
@@ -273,6 +273,7 @@ def get_res_file(asset_data, resolution, find_closest_with_url=False):
resolution, so that other processess can pass correctly which resolution is downloaded.
"""
orig = None
zipf = None
res = None
closest = None
target_resolution = resolutions.get(resolution)
@@ -284,6 +285,8 @@ def get_res_file(asset_data, resolution, find_closest_with_url=False):
if resolution == "blend":
# orig file found, return.
return orig, "blend"
if f.get("fileType") == "zip_file":
zipf = f
if f["fileType"] == resolution:
# exact match found, return.
@@ -296,7 +299,10 @@ def get_res_file(asset_data, resolution, find_closest_with_url=False):
closest = f
mindist = rdiff
if not res and not closest:
return orig, "blend"
if orig is not None:
return orig, "blend"
if zipf is not None:
return zipf, "zip_file"
return closest, closest["fileType"]
@@ -106,8 +106,8 @@ def load_preferences_from_JSON():
"show_on_start", user_preferences.show_on_start
)
user_preferences.thumb_size = prefs.get("thumb_size", user_preferences.thumb_size)
user_preferences.max_assetbar_rows = prefs.get(
"max_assetbar_rows", user_preferences.max_assetbar_rows
user_preferences.maximized_assetbar_rows = prefs.get(
"maximized_assetbar_rows", user_preferences.maximized_assetbar_rows
)
user_preferences.search_field_width = prefs.get(
"search_field_width", user_preferences.search_field_width
@@ -17,6 +17,7 @@
# ##### END GPL LICENSE BLOCK #####
import logging
import time
import bpy
from bpy.props import StringProperty
@@ -186,6 +187,11 @@ class FastRateMenu(Operator, ratings_utils.RatingProperties):
ratings_utils.ensure_rating(self.asset_id)
self.prefill_ratings()
# Update last popup activity time to prevent shortcut interference
from . import ui_panels
ui_panels.last_time_dropdown_active = time.time()
if self.asset_type in ("model", "scene"):
# spawn a wider one for validators for the enum buttons
return wm.invoke_popup(self, width=400)
@@ -80,10 +80,12 @@ def handle_get_bookmarks_task(task: client_tasks.Task):
if task.status == "created":
return
if task.status == "error":
return bk_logger.warning(f"{task.task_type} task failed: {task.message}")
bk_logger.warning(f"Could not load bookmarks: {task.message}")
return
for asset in task.result["results"]:
store_rating_local(asset["id"], "bookmarks", 1)
bk_logger.info("Bookmarks loaded")
def handle_send_rating_task(task: client_tasks.Task):
@@ -241,7 +243,7 @@ def stars_enum_callback(self, context):
def wh_enum_callback(self, context):
"""Regenerates working hours enum."""
if self.asset_type in ("model", "scene"):
if self.asset_type in ("model", "scene", "printable", "nodegroup"):
possible_wh_values = [
0,
0.5,
@@ -29,10 +29,7 @@ from typing import Optional, Union
import bpy
from bpy.app.handlers import persistent
from bpy.props import ( # TODO only keep the ones actually used when cleaning
BoolProperty,
StringProperty,
)
from bpy.props import BoolProperty, StringProperty
from bpy.types import Operator
from . import (
@@ -44,7 +41,6 @@ from . import (
global_vars,
image_utils,
paths,
ratings_utils,
reports,
resolutions,
tasks_queue,
@@ -331,7 +327,7 @@ def handle_search_task_error(task: client_tasks.Task) -> None:
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")
return reports.add_report(task.message, type="ERROR", details=task.message_detailed)
def handle_search_task(task: client_tasks.Task) -> bool:
@@ -643,50 +639,54 @@ def generate_author_profile(author_data: datas.UserProfile):
def handle_get_user_profile(task: client_tasks.Task):
"""Handle incomming get_user_profile task which contains data about current logged-in user."""
if task.status == "finished":
user_data = task.result.get("user")
if not user_data:
bk_logger.warning("Got empty user profile")
return
if task.status not in ["finished", "error"]:
return
can_edit_all_assets = task.result.get("canEditAllAssets", False)
social_networks = datas.parse_social_networks(
user_data.pop("socialNetworks", [])
)
if task.status == "error":
bk_logger.warning(f"Could not load user profile: {task.message}")
return
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
user_data = task.result.get("user")
if not user_data:
bk_logger.warning("Got empty user profile")
return
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
can_edit_all_assets = task.result.get("canEditAllAssets", False)
social_networks = datas.parse_social_networks(user_data.pop("socialNetworks", []))
# 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)
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
if user.canEditAllAssets: # IS VALIDATOR
utils.enforce_prerelease_update_check()
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(
@@ -705,25 +705,16 @@ def query_to_url(
query = {}
url = f"{paths.BLENDERKIT_API}/search/"
if query is None:
query = {}
requeststring = "?query="
if query.get("query") not in ("", None):
requeststring += urllib.parse.quote_plus(query["query"]) # .lower()
requeststring += urllib.parse.quote_plus(query["query"])
for q in query:
if q != "query" and q != "free_first":
requeststring += (
f"+{q}:{urllib.parse.quote_plus(str(query[q]))}" # .lower()
)
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
# result ordering: _score - relevance, score - BlenderKit score
order = []
if query.get("free_first", False):
order = [
"-is_free",
]
# query with category_subtree:model etc gives irrelevant results
if query.get("category_subtree") in (
@@ -737,6 +728,40 @@ def query_to_url(
):
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
@@ -755,19 +780,8 @@ def query_to_url(
order.append("-score,_score")
else:
order.append("_score")
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
return order
def build_query_common(query: dict, props, ui_props) -> dict:
@@ -1109,7 +1123,6 @@ def search(get_next=False, query=None, author_id=""):
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
@@ -1118,10 +1131,11 @@ def search(get_next=False, query=None, author_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.max_assetbar_rows + 5)
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"):
@@ -1194,6 +1208,7 @@ def update_filters():
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
)
@@ -1296,23 +1311,24 @@ def search_update(self, context):
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.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"
# 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.
@@ -1691,15 +1707,18 @@ def update_tab_name(active_tab):
# Update tab name
active_tab["name"] = tab_name
# Update UI if asset bar exists
# 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):
asset_bar.tab_buttons[active_tab_index].text = tab_name
# Force redraw of the region
if asset_bar.area:
asset_bar.area.tag_redraw()
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
@@ -1748,6 +1767,96 @@ def create_history_step(active_tab):
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)
@@ -25,6 +25,7 @@ import bpy
from . import (
addon_updater_ops,
asset_bar_op,
bg_blender,
bkit_oauth,
categories,
@@ -151,6 +152,7 @@ def client_communication_timer():
app_id=task["app_id"],
task_type=task["task_type"],
message=task["message"],
message_detailed=task["message_detailed"],
progress=task["progress"],
status=task["status"],
result=task["result"],
@@ -341,7 +343,7 @@ def handle_task(task: client_tasks.Task):
# BKCLIENTJS - Download from web
if task.task_type == "bkclientjs/get_asset":
return download.handle_bkclientjs_get_asset(task)
return asset_bar_op.handle_bkclientjs_get_asset(task)
# HANDLE MESSAGE FROM CLIENT
if (
@@ -103,6 +103,41 @@ def get_large_thumbnail_image(asset_data):
return img
def get_full_photo_thumbnail(asset_data):
"""Get full photo thumbnail from asset data. This is different from the large thumbnail
as the photo_thumbnails are not available on the asset data root, but inside the files[].
We need to get the data from files[] where assetType=='photo_thumbnail'."""
# Find the photo thumbnail file
photo_file = None
for file in asset_data.get("files", []):
if file.get("fileType") == "photo_thumbnail":
photo_file = file
break
if photo_file is None:
bk_logger.warning("No photo thumbnail file found in asset data")
return None
photo_url = photo_file.get("thumbnailMiddleUrl")
if photo_url is None:
bk_logger.warning("No thumbnail URL found in photo file")
return None
# Get the directory and construct the path
ui_props = bpy.context.window_manager.blenderkitUI
directory = paths.get_temp_dir(f"{ui_props.asset_type.lower()}_search")
photo_name = os.path.basename(photo_url)
tpath = os.path.join(directory, photo_name)
# Load the image into Blender
if os.path.exists(tpath):
img = utils.get_hidden_image(tpath, photo_name, colorspace="")
return img
bk_logger.info(f"Photo thumbnail file not found at path: {tpath}")
return None
def is_rating_possible() -> tuple[bool, bool, Any, Any]:
# TODO remove this, but first check and reuse the code for new rating system...
ao = bpy.context.active_object
@@ -18,9 +18,12 @@
import blf
import gpu
import logging
from bpy import app
from gpu_extras.batch import batch_for_shader
bk_logger = logging.getLogger(__name__)
def draw_rect(x, y, width, height, color):
xmax = x + width
@@ -46,14 +49,18 @@ def draw_rect(x, y, width, height, color):
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))
indices = ((0, 1),)
if app.version < (4, 0, 0):
shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR")
else:
elif app.version < (4, 5, 0):
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
else:
shader_info = create_shader_info()
shader = gpu.shader.create_from_info(shader_info)
batch = batch_for_shader(shader, "LINES", {"pos": coords}, indices=indices)
gpu.state.blend_set("ALPHA")
shader.bind()
@@ -61,11 +68,45 @@ def draw_line2d(x1, y1, x2, y2, width, color):
batch.draw(shader)
def create_shader_info():
"""Added for Blender 4.5+ in which the gpu.shader.from_builtin("UNIFORM_COLOR") silently stopped working.
Interestingly for draw_rect_3d UNIFORM_COLOR still works just fine.
https://github.com/BlenderKit/BlenderKit/issues/1574
"""
if app.version < (4, 5, 0):
return bk_logger.warning("Unexpected call to create_shader_info()!")
shader_info = gpu.types.GPUShaderCreateInfo()
shader_info.vertex_in(0, "VEC3", "pos")
shader_info.push_constant("MAT4", "ModelViewProjectionMatrix")
shader_info.push_constant("VEC4", "color")
shader_info.fragment_out(0, "VEC4", "fragColor")
shader_info.vertex_source(
"""
void main() {
gl_Position = ModelViewProjectionMatrix * vec4(pos, 1.0);
}
"""
)
shader_info.fragment_source(
"""
void main() {
fragColor = color;
}
"""
)
return shader_info
def draw_lines(vertices, indices, color):
"""Used for drawing 3D bounding box."""
if app.version < (4, 0, 0):
shader = gpu.shader.from_builtin("3D_UNIFORM_COLOR")
else:
elif app.version < (4, 5, 0):
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
else:
shader_info = create_shader_info()
shader = gpu.shader.create_from_info(shader_info)
batch = batch_for_shader(shader, "LINES", {"pos": vertices}, indices=indices)
gpu.state.blend_set("ALPHA")
shader.bind()
@@ -25,7 +25,7 @@ import time
from webbrowser import open_new_tab
import bpy
from bpy.props import IntProperty, StringProperty
from bpy.props import IntProperty, StringProperty, FloatVectorProperty, EnumProperty
from bpy.types import Context, Menu, Panel, UILayout
from . import (
@@ -53,7 +53,7 @@ ACCEPTABLE_ENGINES = ("CYCLES", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT")
bk_logger = logging.getLogger(__name__)
last_time_dropdown_active = 0.0
last_time_overlay_panel_active = 0.0
def draw_not_logged_in(source, message="Please Login/Signup to use this feature"):
@@ -256,6 +256,24 @@ def draw_panel_nodegroup_search(self, context):
utils.label_multiline(layout, text=props.report)
def draw_common_filters(layout, ui_props):
"""Draw common filter elements shared by multiple asset type panels.
Args:
layout: The UI layout to draw in
ui_props: The UI properties containing filter settings
"""
layout.separator()
row = layout.row()
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
row.prop(ui_props, "own_only", icon="USER")
layout.prop(ui_props, "free_only")
layout.prop(ui_props, "quality_limit", slider=True)
layout.prop(ui_props, "search_license")
layout.prop(ui_props, "search_order_by")
def draw_thumbnail_upload_panel(layout, props):
tex = autothumb.get_texture_ui(props.thumbnail, ".upload_preview")
if not tex or not tex.image:
@@ -276,7 +294,16 @@ def draw_panel_model_upload(self, context):
draw_upload_common(layout, props, asset_type, context)
# Add the photo thumbnail field only for printable assets
if asset_type == "PRINTABLE":
layout.prop(props, "photo_thumbnail_will_upload_on_website")
if not props.photo_thumbnail_will_upload_on_website:
col = layout.column()
prop_needed(col, props, "photo_thumbnail", props.photo_thumbnail)
col = layout.column()
if props.is_generating_thumbnail:
col.enabled = False
@@ -470,9 +497,8 @@ class VIEW3D_PT_blenderkit_model_properties(Panel):
def poll(cls, context):
if bpy.context.view_layer.objects.active is None:
return False
# if bpy.context.view_layer.objects.get('asset_data') is None:
# return False
return True
preferences = bpy.context.preferences.addons[__package__].preferences
return not preferences.sidebar_panels
def draw(self, context):
draw_model_context_menu(self, context)
@@ -639,7 +665,8 @@ class VIEW3D_PT_blenderkit_profile(Panel):
@classmethod
def poll(cls, context):
return True
preferences = bpy.context.preferences.addons[__package__].preferences
return not preferences.sidebar_panels
def draw_header(self, context):
layout = self.layout
@@ -1233,6 +1260,13 @@ def draw_panel_brush_search(self, context):
row.prop(ui_props, "search_keywords", text="", icon="VIEWZOOM")
draw_assetbar_show_hide(row, props)
if not context.sculpt_object and not context.image_paint_object:
utils.label_multiline(
layout,
text="Switch to paint or sculpt mode.",
width=context.region.width,
)
utils.label_multiline(layout, text=props.report)
@@ -1362,6 +1396,9 @@ class VIEW3D_PT_blenderkit_advanced_model_search(Panel):
# NSFW filter
layout.prop(preferences, "nsfw_filter")
# ORDER
layout.prop(ui_props, "search_order_by")
def draw(self, context):
self.draw_layout(self.layout)
@@ -1421,6 +1458,9 @@ class VIEW3D_PT_blenderkit_advanced_material_search(Panel):
layout.prop(ui_props, "quality_limit", slider=True)
layout.prop(ui_props, "search_license")
# ORDER
layout.prop(ui_props, "search_order_by")
def draw(self, context):
self.draw_layout(self.layout)
@@ -1441,19 +1481,8 @@ class VIEW3D_PT_blenderkit_advanced_scene_search(Panel):
return ui_props.down_up == "SEARCH" and ui_props.asset_type == "SCENE"
def draw_layout(self, layout):
wm = bpy.context.window_manager
props = wm.blenderkit_scene
ui_props = wm.blenderkitUI
layout.separator()
row = layout.row()
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
row.prop(ui_props, "own_only", icon="USER")
layout.prop(ui_props, "free_only")
layout.prop(ui_props, "quality_limit", slider=True)
layout.prop(ui_props, "search_license")
ui_props = bpy.context.window_manager.blenderkitUI
draw_common_filters(layout, ui_props)
def draw(self, context):
self.draw_layout(self.layout)
@@ -1481,20 +1510,14 @@ class VIEW3D_PT_blenderkit_advanced_HDR_search(Panel):
ui_props = wm.blenderkitUI
layout = self.layout
layout.separator()
draw_common_filters(layout, ui_props)
row = layout.row()
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
row.prop(ui_props, "own_only", icon="USER")
layout.prop(ui_props, "free_only")
layout.prop(props, "true_hdr")
layout.prop(props, "search_texture_resolution", text="Texture Resolutions")
if props.search_texture_resolution:
row = layout.row(align=True)
row.prop(props, "search_texture_resolution_min", text="Min")
row.prop(props, "search_texture_resolution_max", text="Max")
layout.prop(ui_props, "quality_limit", slider=True)
layout.prop(ui_props, "search_license")
class VIEW3D_PT_blenderkit_advanced_brush_search(Panel):
@@ -1512,17 +1535,53 @@ class VIEW3D_PT_blenderkit_advanced_brush_search(Panel):
return ui_props.down_up == "SEARCH" and ui_props.asset_type == "BRUSH"
def draw_layout(self, layout):
wm = bpy.context.window_manager
ui_props = wm.blenderkitUI
ui_props = bpy.context.window_manager.blenderkitUI
draw_common_filters(layout, ui_props)
layout.separator()
def draw(self, context):
self.draw_layout(self.layout)
row = layout.row()
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
row.prop(ui_props, "own_only", icon="USER")
layout.prop(ui_props, "free_only")
layout.prop(ui_props, "quality_limit", slider=True)
layout.prop(ui_props, "search_license")
class VIEW3D_PT_blenderkit_advanced_nodegroup_search(Panel):
bl_category = "BlenderKit"
bl_idname = "VIEW3D_PT_blenderkit_advanced_nodegroup_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 == "NODEGROUP"
def draw(self, context):
ui_props = bpy.context.window_manager.blenderkitUI
draw_common_filters(self.layout, ui_props)
class VIEW3D_PT_blenderkit_advanced_printable_search(Panel):
bl_category = "BlenderKit"
bl_idname = "VIEW3D_PT_blenderkit_advanced_printable_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 == "PRINTABLE"
def draw_layout(self, layout):
ui_props = bpy.context.window_manager.blenderkitUI
draw_common_filters(layout, ui_props)
def draw(self, context):
self.draw_layout(self.layout)
@@ -1547,8 +1606,8 @@ class VIEW3D_PT_blenderkit_categories(Panel):
def draw(self, context):
# measure time since last dropdown activation/ mouse hover e.t.c.
# this is then used in asset_bar_op.py to cancel asset drag drop if the time is too small and thus means double clicking.
global last_time_dropdown_active
last_time_dropdown_active = time.time()
global last_time_overlay_panel_active
last_time_overlay_panel_active = time.time()
draw_panel_categories(self.layout, context)
@@ -1604,7 +1663,7 @@ class VIEW3D_PT_blenderkit_import_settings(Panel):
if ui_props.asset_type == "MATERIAL":
props = wm.blenderkit_mat
layout.prop(props, "automap")
layout.prop(preferences, "material_import_automap")
layout.label(text="Import method:")
row = layout.row()
@@ -1621,6 +1680,25 @@ class VIEW3D_PT_blenderkit_import_settings(Panel):
# layout.prop(props, 'unpack_files')
def deferred_set_name(props, expected_obj_name):
"""Deferred timer to set empty name of uploaded asset to active Object's name.
We check if the names of active_now object and expected object are the same, because active object could have changed.
This is one-shot timer = return None.
"""
active_now = utils.get_active_asset()
if props.name != "":
return None
if not active_now:
return None
if active_now.name != expected_obj_name:
return None # active object is different from the one on which we have called the timer
props.name_old = (
expected_obj_name # prevents utils.name_update() from running twice
)
props.name = expected_obj_name # this ultimately triggers utils.name_update()
return None
class VIEW3D_PT_blenderkit_unified(Panel):
bl_category = "BlenderKit"
bl_idname = "VIEW3D_PT_blenderkit_unified"
@@ -1631,6 +1709,11 @@ class VIEW3D_PT_blenderkit_unified(Panel):
}
bl_label = ""
@classmethod
def poll(cls, context):
user_preferences = bpy.context.preferences.addons[__package__].preferences
return not user_preferences.sidebar_panels
def draw_header(self, context):
layout = self.layout
ui_props = bpy.context.window_manager.blenderkitUI
@@ -1718,7 +1801,7 @@ class VIEW3D_PT_blenderkit_unified(Panel):
layout.prop(search_props, "unrated_quality_only")
layout.prop(search_props, "unrated_wh_only")
if ui_props.asset_type == "MODEL":
if ui_props.asset_type == "MODEL" or ui_props.asset_type == "PRINTABLE":
return draw_panel_model_search(self, context)
if ui_props.asset_type == "SCENE":
@@ -1731,19 +1814,19 @@ class VIEW3D_PT_blenderkit_unified(Panel):
return draw_panel_material_search(self, context)
if ui_props.asset_type == "BRUSH":
if context.sculpt_object or context.image_paint_object:
return draw_panel_brush_search(self, context)
utils.label_multiline(
layout,
text="Switch to paint or sculpt mode.",
width=context.region.width,
)
return
return draw_panel_brush_search(self, context)
if ui_props.asset_type == "NODEGROUP":
return draw_panel_nodegroup_search(self, context)
def draw_upload(self, context, layout, ui_props):
obj = utils.get_active_asset()
props = getattr(obj, "blenderkit", None)
if props and not props.name:
bpy.app.timers.register(
lambda p=props, n=obj.name: deferred_set_name(p, n), first_interval=0.0
)
if ui_props.asset_type == "MODEL" or ui_props.asset_type == "PRINTABLE":
if bpy.context.view_layer.objects.active is not None:
return draw_panel_model_upload(self, context)
@@ -2235,13 +2318,15 @@ def label_or_url_or_operator(
tooltip="",
url="",
operator=None,
operator_kwargs={},
operator_kwargs=None,
icon_value=None,
icon=None,
emboss=False,
):
"""automatically switch between different layout options for linking or tooltips"""
layout.emboss = "NORMAL" if emboss else "NONE"
if operator_kwargs is None:
operator_kwargs = {}
if operator is not None:
if icon:
@@ -2326,7 +2411,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
url="",
tooltip="",
operator=None,
operator_kwargs={},
operator_kwargs=None,
emboss=False,
):
right = str(right)
@@ -2340,6 +2425,8 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
# if url != "" and not emboss:
split = split.split(factor=0.9)
split.alignment = "LEFT"
if operator_kwargs is None:
operator_kwargs = {}
label_or_url_or_operator(
split,
text=right,
@@ -2794,6 +2881,19 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
)
self.img.gl_touch()
# Display photo thumbnail for printable objects
if (
self.asset_data.get("assetType") == "printable"
and hasattr(self, "full_photo_thumbnail")
and self.full_photo_thumbnail
):
box_thumbnail.scale_y = 0.4
box_thumbnail.template_icon(
icon_value=self.full_photo_thumbnail.preview.icon_id,
scale=width * 0.12,
)
self.full_photo_thumbnail.gl_touch()
# op = row.operator('view3d.asset_drag_drop', text='Drag & Drop from here', depress=True)
# From here on, only ratings are drawn, which won't be displayed for private assets from now on.
@@ -3108,6 +3208,9 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
# box.label(text=str(comment['flags']))
def draw(self, context):
global last_time_overlay_panel_active
last_time_overlay_panel_active = time.time()
layout = self.layout
# top draggable bar with name of the asset
top_row = layout.row()
@@ -3118,6 +3221,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
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):
# Draw ratings, but not for owners of assets - doesn't make sense.
ratings_box = left_column.box()
@@ -3161,6 +3265,11 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
self.img = ui.get_large_thumbnail_image(asset_data)
utils.img_to_preview(self.img, copy_original=True)
if asset_data["assetType"] == "printable":
self.full_photo_thumbnail = ui.get_full_photo_thumbnail(asset_data)
if self.full_photo_thumbnail:
utils.img_to_preview(self.full_photo_thumbnail, copy_original=True)
self.asset_type = asset_data["assetType"]
self.asset_id = asset_data["id"]
# self.tex = utils.get_hidden_texture(self.img)
@@ -3358,7 +3467,7 @@ class PopupDialog(bpy.types.Operator):
class UrlPopupDialog(bpy.types.Operator):
"""Generate Cycles thumbnail for model assets"""
"""Show a popup asking the user to subscribe or log in to access the locked asset"""
bl_idname = "wm.blenderkit_url_dialog"
bl_label = "BlenderKit message:"
@@ -3372,14 +3481,12 @@ class UrlPopupDialog(bpy.types.Operator):
message: bpy.props.StringProperty(name="Text", description="text", default="") # type: ignore[valid-type]
# @classmethod
# def poll(cls, context):
# return bpy.context.view_layer.objects.active is not None
width: bpy.props.IntProperty(name="width", description="width", default=300) # type: ignore[valid-type]
def draw(self, context):
layout = self.layout
row = layout.row()
row.label(text=self.message)
utils.label_multiline(layout, text=self.message, width=300)
row.operator("view3d.close_popup_button", text="", icon="CANCEL")
layout.active_default = True
@@ -3387,19 +3494,19 @@ class UrlPopupDialog(bpy.types.Operator):
if not utils.user_logged_in():
utils.label_multiline(
layout,
text="Already subscribed? You need to login to access your Full Plan.",
text="Already subscribed? Log in to access your account.",
width=300,
)
layout.operator_context = "EXEC_DEFAULT"
layout.operator("wm.blenderkit_login", text="Login", icon="URL").signup = (
False
)
layout.operator(
"wm.blenderkit_login", text="Welcome Home", icon="URL"
).signup = False
op.url = self.url
def execute(self, context):
wm = bpy.context.window_manager
return wm.invoke_popup(self, width=300)
return wm.invoke_popup(self, width=self.width)
class LoginPopupDialog(bpy.types.Operator):
@@ -3691,7 +3798,7 @@ def header_search_draw(self, context):
icon_value=icon_id,
)
# FILTER ICON
# FILTER ICON: filters are default or modified
if props.use_filters:
icon_id = pcoll["filter_active"].icon_id
else:
@@ -3732,6 +3839,18 @@ def header_search_draw(self, context):
text="",
icon_value=icon_id,
)
elif ui_props.asset_type == "NODEGROUP":
layout.popover(
panel="VIEW3D_PT_blenderkit_advanced_nodegroup_search",
text="",
icon_value=icon_id,
)
elif ui_props.asset_type == "PRINTABLE":
layout.popover(
panel="VIEW3D_PT_blenderkit_advanced_printable_search",
text="",
icon_value=icon_id,
)
# NSFW filter shield badge - only for models right now
if preferences.nsfw_filter and ui_props.asset_type == "MODEL":
@@ -3770,6 +3889,180 @@ def ui_message(title, message):
bpy.context.window_manager.popup_menu(draw_message, title=title, icon="INFO")
class NodegroupDropDialog(bpy.types.Operator):
"""Dialog for choosing how to add a nodegroup when dropped on an object or in node editor"""
bl_idname = "wm.blenderkit_nodegroup_drop_dialog"
bl_label = "Add Nodegroup"
bl_options = {"REGISTER", "INTERNAL"}
# Store the parameters needed for the download
asset_search_index: bpy.props.IntProperty(default=-1) # type: ignore[valid-type]
target_object_name: bpy.props.StringProperty(default="") # type: ignore[valid-type]
snapped_location: bpy.props.FloatVectorProperty(size=3) # type: ignore[valid-type]
snapped_rotation: bpy.props.FloatVectorProperty(size=3) # type: ignore[valid-type]
# Node editor positioning (when dropped in node editor)
node_x: bpy.props.FloatProperty(default=0.0) # type: ignore[valid-type]
node_y: bpy.props.FloatProperty(default=0.0) # type: ignore[valid-type]
# Option for how to add the nodegroup
add_mode: bpy.props.EnumProperty( # type: ignore[valid-type]
name="Add Mode",
description="How to add the nodegroup",
items=[
(
"MODIFIER",
"As Modifier",
"Add the nodegroup as a new modifier on the object",
),
("NODE", "As Node", "Add the nodegroup as a node in an existing node tree"),
],
default="MODIFIER",
)
# Option for overwriting existing geometry nodes modifier
overwrite_modifier: bpy.props.BoolProperty( # type: ignore[valid-type]
name="Overwrite Last Geometry Nodes Modifier",
description="Replace the last geometry nodes modifier instead of creating a new one (recommended to avoid recursion)",
default=True,
)
def get_existing_geometry_modifiers(self, target_obj):
"""Get list of existing geometry nodes modifiers on target object"""
if not target_obj:
return []
return [mod for mod in target_obj.modifiers if mod.type == "NODES"]
def draw(self, context):
layout = self.layout
# Get asset data for display
sr = search.get_search_results()
if self.asset_search_index >= 0 and self.asset_search_index < len(sr):
asset_data = sr[self.asset_search_index]
col = layout.column(align=True)
col.label(text=f"Adding nodegroup: {asset_data['displayName']}")
# Get target object and check for existing geometry nodes modifiers
target_obj = None
existing_geo_modifiers = []
if self.target_object_name:
target_obj = bpy.data.objects.get(self.target_object_name)
existing_geo_modifiers = self.get_existing_geometry_modifiers(
target_obj
)
col.label(text=f"To object: {self.target_object_name}")
else:
col.label(text="A new target object will be created")
col.separator()
col.prop(self, "add_mode", expand=True)
# Show overwrite option only for MODIFIER mode when there are existing geometry nodes modifiers
if self.add_mode == "MODIFIER" and existing_geo_modifiers:
col.separator()
# Show info about existing modifiers
if len(existing_geo_modifiers) == 1:
col.label(text=f"Found 1 geometry nodes modifier:", icon="INFO")
else:
col.label(
text=f"Found {len(existing_geo_modifiers)} geometry nodes modifiers:",
icon="INFO",
)
# Show the last modifier name
last_modifier = existing_geo_modifiers[-1]
col.label(text=f"{last_modifier.name} (will be affected)")
col.separator()
col.prop(self, "overwrite_modifier")
col.separator()
# Add description based on selected mode
if self.add_mode == "MODIFIER":
if self.target_object_name:
if existing_geo_modifiers and self.overwrite_modifier:
col.label(text="The last geometry nodes modifier will be")
col.label(text="replaced with the new nodegroup.")
col.label(
text="(Recommended to avoid recursion)", icon="CHECKMARK"
)
else:
col.label(text="The nodegroup will be added as a new")
col.label(text="geometry nodes modifier on the object.")
if existing_geo_modifiers:
col.label(text="⚠ May cause recursion issues", icon="ERROR")
else:
col.label(text="A new cube will be created and the")
col.label(text="nodegroup added as a modifier.")
else:
if self.target_object_name:
col.label(text="The nodegroup will be added as a node")
col.label(text="in the geometry nodes editor.")
else:
col.label(text="A new cube will be created and the")
col.label(text="nodegroup added as a node.")
# Show node position if we have it
if self.node_x != 0.0 or self.node_y != 0.0:
col.label(
text=f"Position: ({self.node_x:.1f}, {self.node_y:.1f})",
icon="NODE",
)
def execute(self, context):
# Download the nodegroup with the specified mode
target_object = ""
if self.target_object_name:
target_object = self.target_object_name
# Handle modifier overwrite if requested
if self.add_mode == "MODIFIER" and self.overwrite_modifier and target_object:
target_obj = bpy.data.objects.get(target_object)
if target_obj:
existing_geo_modifiers = self.get_existing_geometry_modifiers(
target_obj
)
if existing_geo_modifiers:
# Remove the last geometry nodes modifier
last_modifier = existing_geo_modifiers[-1]
bk_logger.info(
f"Removed geometry nodes modifier '{last_modifier.name}' before adding new nodegroup"
)
target_obj.modifiers.remove(last_modifier)
# 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,
asset_index=self.asset_search_index,
node_x=self.node_x,
node_y=self.node_y,
target_object=target_object,
nodegroup_mode=self.add_mode,
model_location=self.snapped_location,
model_rotation=self.snapped_rotation,
)
else: # MODIFIER mode
bpy.ops.scene.blenderkit_download(
True,
asset_index=self.asset_search_index,
model_location=self.snapped_location,
model_rotation=self.snapped_rotation,
target_object=target_object,
nodegroup_mode=self.add_mode,
)
return {"FINISHED"}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=400)
classes = (
SetCategoryOperatorOrigin,
SetCategoryOperator,
@@ -3786,6 +4079,8 @@ classes = (
VIEW3D_PT_blenderkit_advanced_scene_search,
VIEW3D_PT_blenderkit_advanced_HDR_search,
VIEW3D_PT_blenderkit_advanced_brush_search,
VIEW3D_PT_blenderkit_advanced_nodegroup_search,
VIEW3D_PT_blenderkit_advanced_printable_search,
VIEW3D_PT_blenderkit_categories,
VIEW3D_PT_blenderkit_import_settings,
VIEW3D_PT_blenderkit_model_properties,
@@ -3818,6 +4113,7 @@ classes = (
NotificationOpenTarget,
MarkAllNotificationsRead,
LoginPopupDialog,
NodegroupDropDialog,
)
@@ -20,6 +20,7 @@
import json
import os
import sys
import traceback
import bpy
@@ -83,7 +84,8 @@ def unpack_asset(data):
try:
os.mkdir(tex_dir_abs)
except Exception as e:
print(e)
traceback.print_exc()
bpy.data.use_autopack = False
for image in bpy.data.images:
if image.name == "Render Result":
@@ -145,7 +147,8 @@ def unpack_asset(data):
try:
os.remove(bpy.data.filepath + "1")
except Exception as e:
print(e)
traceback.print_exc()
bpy.ops.wm.quit_blender()
sys.exit()
@@ -76,16 +76,28 @@ def write_to_report(props, text):
def prevalidate_model(props):
"""Check model for possible problems:
- check if all objects have not asymetrical scaling."""
- check if all objects does not have asymmetrical scaling. Asymmetrical scaling is a big problem.
Anything scaled away from (1,1,1) is a smaller problem. We do not check for that.
- round minor drifts from 1.0
"""
TOLERANCE = 1e-5
ob = utils.get_active_model()
obs = utils.get_hierarchy(ob)
# check scaling of objects, if anything is assymetrical, it's a big problem,
# if anything is scaled away from (1,1,1), it's a smaller problem.
for ob in obs:
if ob.scale[0] == ob.scale[1] == ob.scale[2]:
continue # all totally good
if all(abs(scalar - 1.0) <= TOLERANCE for scalar in ob.scale):
bk_logger.info(
f"Snapped minor float drift on '{ob}': "
+ f"{ob.scale[0], ob.scale[1], ob.scale[2]} → (1.0, 1.0, 1.0)"
)
ob.scale = (1.0, 1.0, 1.0)
if ob.scale[0] != ob.scale[1] or ob.scale[1] != ob.scale[2]:
write_to_report(
props,
"Assymetrical scaling in the object - please apply scale on all models",
f"Asymmetrical scaling in the object {ob.name} - please apply scale on all models",
)
@@ -106,12 +118,11 @@ def prevalidate_scene(props):
"""Check scene for possible problems:
- check if user is author of all assets in scene"""
problematic_assets = []
# Check if user is author of all assets in scene.
for ob in bpy.context.scene.objects:
if not ob.get("asset_data"):
continue
if utils.user_is_owner(ob["asset_data"]):
pass # continue
continue
asset_name = ob["asset_data"].get("name")
author_name = ob["asset_data"].get("author", {}).get("fullName")
problematic_assets.append(f" - {asset_name} by {author_name}\n")
@@ -153,7 +164,7 @@ def check_missing_data_brush(props):
autothumb.update_upload_brush_preview(None, None)
def check_missing_data(asset_type, props, upload_thumbnail=True):
def check_missing_data(asset_type, props, upload_set):
"""Check if all required data is present and fills in the upload props with error messages."""
props.report = ""
@@ -175,47 +186,6 @@ def check_missing_data(asset_type, props, upload_thumbnail=True):
f" Please provide a name with at most {NAME_MAXIMUM} characters.",
)
# Tags check
if props.tags == "":
write_to_report(
props,
"At least 3 tags are required.\n"
" Please provide tags for your asset.\n"
" Tags help users find your asset.",
)
else:
tags_list = utils.string2list(props.tags)
if len(tags_list) < TAGS_MINIMUM:
write_to_report(
props,
f"At least {TAGS_MINIMUM} tags are required.\n"
" Please provide more tags for your asset.\n"
" Tags help users find your asset.",
)
elif len(tags_list) > TAGS_MAXIMUM:
write_to_report(
props,
f"Maximum {TAGS_MAXIMUM} tags are allowed.\n"
" Please remove some tags from your asset.",
)
else:
check_tags_format(props.tags)
# Description check
if props.description == "":
write_to_report(
props,
"A description is required.\n"
" Please provide a description for your asset.\n"
" Description helps users understand your asset.",
)
elif len(props.description) < DESCRIPTION_MINIMUM:
write_to_report(
props,
f"Description is too short.\n"
f" Please provide a description with at least {DESCRIPTION_MINIMUM} characters.",
)
if props.is_private == "PUBLIC":
category_ok = props.category == "NONE"
subcategory_ok = props.subcategory != "EMPTY" and props.subcategory == "NONE"
@@ -228,7 +198,7 @@ def check_missing_data(asset_type, props, upload_thumbnail=True):
" Proper categorization significantly improves your asset's discoverability.",
)
if upload_thumbnail:
if "THUMBNAIL" in upload_set:
if asset_type in ("MODEL", "SCENE", "MATERIAL", "PRINTABLE"):
thumb_path = bpy.path.abspath(props.thumbnail)
if props.thumbnail == "":
@@ -260,6 +230,26 @@ def check_missing_data(asset_type, props, upload_thumbnail=True):
"Brush Icon Filepath does not exist on the disk.\n"
" Please check the filepath and try again.",
)
if "PHOTO_THUMBNAIL" in upload_set: # for printable assets
# Add validation for the photo thumbnail for printable assets
# only if it's in the upload set
if props.photo_thumbnail_will_upload_on_website:
pass
else:
foto_thumb_path = bpy.path.abspath(props.photo_thumbnail)
if props.photo_thumbnail == "":
write_to_report(
props,
"A photo thumbnail image has not been provided.\n"
" Please add a photo of the 3D printed object in JPG or PNG format, ensuring at least 1024x1024 pixels.",
)
elif not os.path.exists(Path(foto_thumb_path)):
write_to_report(
props,
"Photo thumbnail filepath does not exist on the disk.\n"
" Please check the filepath and try again.",
)
if props.is_private == "PUBLIC":
check_public_requirements(props)
@@ -380,6 +370,12 @@ def get_upload_data(caller=None, context=None, asset_type=None):
export_data["models"] = obnames
export_data["thumbnail_path"] = bpy.path.abspath(props.thumbnail)
# Add photo thumbnail path to export_data for printable assets
if asset_type == "PRINTABLE" and props.photo_thumbnail:
export_data["photo_thumbnail_path"] = bpy.path.abspath(
props.photo_thumbnail
)
eval_path_computing = (
"bpy.data.objects['%s'].blenderkit.uploading" % mainmodel.name
)
@@ -396,6 +392,15 @@ def get_upload_data(caller=None, context=None, asset_type=None):
upload_params = {
"faceCount": props.face_count,
"modifiers": utils.string2list(props.modifiers),
"dimensionX": round(props.dimensions[0], 4),
"dimensionY": round(props.dimensions[1], 4),
"dimensionZ": round(props.dimensions[2], 4),
"boundBoxMinX": round(props.bbox_min[0], 4),
"boundBoxMinY": round(props.bbox_min[1], 4),
"boundBoxMinZ": round(props.bbox_min[2], 4),
"boundBoxMaxX": round(props.bbox_max[0], 4),
"boundBoxMaxY": round(props.bbox_max[1], 4),
"boundBoxMaxZ": round(props.bbox_max[2], 4),
}
# Additional parameters only for MODEL type
@@ -420,15 +425,6 @@ def get_upload_data(caller=None, context=None, asset_type=None):
"materials": utils.string2list(props.materials),
"shaders": utils.string2list(props.shaders),
"uv": props.uv,
"dimensionX": round(props.dimensions[0], 4),
"dimensionY": round(props.dimensions[1], 4),
"dimensionZ": round(props.dimensions[2], 4),
"boundBoxMinX": round(props.bbox_min[0], 4),
"boundBoxMinY": round(props.bbox_min[1], 4),
"boundBoxMinZ": round(props.bbox_min[2], 4),
"boundBoxMaxX": round(props.bbox_max[0], 4),
"boundBoxMaxY": round(props.bbox_max[1], 4),
"boundBoxMaxZ": round(props.bbox_max[2], 4),
"animated": props.animated,
"rig": props.rig,
"simulation": props.simulation,
@@ -1034,8 +1030,7 @@ def storage_quota_available(props) -> bool:
if props.is_private == "PUBLIC":
return True
quota = profile.remainingPrivateQuota
if quota > 0:
if profile.remainingPrivateQuota is not None and profile.remainingPrivateQuota > 0:
return True
props.report = "Private storage quota exceeded."
@@ -1066,8 +1061,8 @@ def prepare_asset_data(self, context, asset_type, reupload, upload_set):
props.tags = props.tags[:]
# check for missing metadata
upload_thumbnail = "THUMBNAIL" in upload_set
check_missing_data(asset_type, props, upload_thumbnail=upload_thumbnail)
check_missing_data(asset_type, props, upload_set=upload_set)
# if previous check did find any problems then
if props.report != "":
return False, None, None
@@ -1094,6 +1089,14 @@ def prepare_asset_data(self, context, asset_type, reupload, upload_set):
props.uploading = False
return False, None, None
# Check if photo thumbnail exists for printable assets when it's included in upload_set
if "photo_thumbnail" in upload_set:
if asset_type == "PRINTABLE" and "photo_thumbnail_path" in export_data:
if not os.path.exists(export_data["photo_thumbnail_path"]):
props.upload_state = "0% - photo thumbnail not found"
props.uploading = False
return False, None, None
# save a copy of the file for processing. Only for blend files
_, ext = os.path.splitext(bpy.data.filepath)
if not ext:
@@ -1119,13 +1122,13 @@ def prepare_asset_data(self, context, asset_type, reupload, upload_set):
asset_types = (
("MODEL", "Model", "Set of objects"),
("PRINTABLE", "Printable", "3D printable model"),
("SCENE", "Scene", "Scene"),
("HDR", "HDR", "HDR image"),
("MATERIAL", "Material", "Any .blend Material"),
("TEXTURE", "Texture", "A texture, or texture set"),
("BRUSH", "Brush", "Brush, can be any type of blender brush"),
("NODEGROUP", "Tool", "Geometry nodes tool"),
("PRINTABLE", "Printable", "3D printable model"),
("ADDON", "Addon", "Addon"),
)
@@ -1158,6 +1161,9 @@ class UploadOperator(Operator):
thumbnail: BoolProperty(name="thumbnail", default=False, options={"SKIP_SAVE"}) # type: ignore[valid-type]
# Add new property for photo thumbnail
photo_thumbnail: BoolProperty(name="photo thumbnail", default=False, options={"SKIP_SAVE"}) # type: ignore[valid-type]
main_file: BoolProperty(name="main file", default=False, options={"SKIP_SAVE"}) # type: ignore[valid-type]
@classmethod
@@ -1171,11 +1177,16 @@ class UploadOperator(Operator):
upload_set = []
if not self.reupload:
upload_set = ["METADATA", "THUMBNAIL", "MAINFILE"]
# Add photo_thumbnail to the upload set for printable assets
if self.asset_type == "PRINTABLE" and props.photo_thumbnail:
upload_set.append("photo_thumbnail")
else:
if self.metadata:
upload_set.append("METADATA")
if self.thumbnail:
upload_set.append("THUMBNAIL")
if self.photo_thumbnail:
upload_set.append("photo_thumbnail")
if self.main_file:
upload_set.append("MAINFILE")
@@ -1212,6 +1223,10 @@ class UploadOperator(Operator):
layout.prop(self, "main_file")
layout.prop(self, "thumbnail")
# Show photo_thumbnail option only for printable assets
if self.asset_type == "PRINTABLE":
layout.prop(self, "photo_thumbnail")
if props.asset_base_id != "" and not self.reupload:
utils.label_multiline(
layout,
@@ -19,11 +19,114 @@
import json
import os
import sys
import shutil
import zipfile
import addon_utils # type: ignore[import-not-found]
import bpy
# Map dependencies into a single subdirectory inside the zip and rewrite paths to relative
def _zip_arc_for(p: str, deps_dirs: set[str]) -> str:
base = os.path.basename(p)
return os.path.join("caches", base)
def _arc_for_path(p: str, deps_dirs: set[str]) -> str:
pn = os.path.normpath(bpy.path.abspath(p))
best_root = ""
for d in deps_dirs:
dn = os.path.normpath(d)
if pn.startswith(dn) and len(dn) > len(best_root):
best_root = dn
if best_root:
rel = os.path.relpath(pn, best_root)
return os.path.join("caches", os.path.basename(best_root), rel)
return os.path.join("caches", os.path.basename(pn))
def get_deps_files_and_dirs():
"""Get all dependencies files and directories."""
deps_files: set[str] = set()
deps_dirs: set[str] = set()
# Alembic/USD and similar cache files
for cf in bpy.data.cache_files: # type: ignore[attr-defined]
fp = bpy.path.abspath(cf.filepath)
if fp and os.path.isfile(fp):
deps_files.add(fp)
# Volumes (OpenVDB). Include file; for sequences include containing directory
for v in getattr(bpy.data, "volumes", []):
fp = bpy.path.abspath(getattr(v, "filepath", ""))
if not fp:
continue
if os.path.isdir(fp):
deps_dirs.add(fp)
elif os.path.isfile(fp):
# Heuristic: sequence often resides in the directory of the file
if getattr(v, "is_sequence", False):
deps_dirs.add(os.path.dirname(fp))
else:
deps_files.add(fp)
# Movie clips
for clip in bpy.data.movieclips: # type: ignore[attr-defined]
fp = bpy.path.abspath(clip.filepath)
if fp and os.path.isfile(fp):
deps_files.add(fp)
# Fluid domain caches (directories)
for ob in bpy.data.objects:
for mod in ob.modifiers:
if (
getattr(mod, "type", "") == "FLUID"
and getattr(mod, "fluid_type", "") == "DOMAIN"
):
domain = getattr(mod, "domain_settings", None)
if domain is not None:
cache_dir = getattr(domain, "cache_directory", "")
if cache_dir:
cdir = bpy.path.abspath(cache_dir)
if os.path.isdir(cdir):
deps_dirs.add(cdir)
# Rewrite datablock paths to relative locations
for cf in bpy.data.cache_files: # type: ignore[attr-defined]
fp = bpy.path.abspath(cf.filepath)
if fp and os.path.isfile(fp):
cf.filepath = "//" + _arc_for_path(fp, deps_dirs).replace(os.sep, "/")
for v in getattr(bpy.data, "volumes", []):
fp = bpy.path.abspath(getattr(v, "filepath", ""))
if fp:
if os.path.isdir(fp):
target = os.path.join("caches", os.path.basename(fp))
else:
target = _arc_for_path(fp, deps_dirs)
v.filepath = "//" + target.replace(os.sep, "/")
for clip in bpy.data.movieclips: # type: ignore[attr-defined]
fp = bpy.path.abspath(clip.filepath)
if fp and os.path.isfile(fp):
clip.filepath = "//" + _arc_for_path(fp, deps_dirs).replace(os.sep, "/")
for ob in bpy.data.objects:
for mod in ob.modifiers:
if (
getattr(mod, "type", "") == "FLUID"
and getattr(mod, "fluid_type", "") == "DOMAIN"
):
domain = getattr(mod, "domain_settings", None)
if domain is not None:
cache_dir = getattr(domain, "cache_directory", "")
if cache_dir:
domain.cache_directory = "//" + _zip_arc_for(
cache_dir, deps_dirs
).replace(os.sep, "/")
return deps_files, deps_dirs
def patch_imports(addon_module_name: str):
"""Patch the python configuration, so the relative imports work as expected. There are few problems to fix:
1. Script is not recognized as module which would break at relative import. We need to set __package__ = "blenderkit" for legacy addon.
@@ -110,7 +213,7 @@ if __name__ == "__main__":
)
elif upload_data["assetType"] == "nodegroup":
toolname = export_data["nodegroup"]
main_source = append_link.append_nodegroup(
main_source, _ = append_link.append_nodegroup(
file_name=export_data["source_filepath"], nodegroupname=toolname
)
if main_source.asset_data is None:
@@ -145,7 +248,49 @@ if __name__ == "__main__":
bpy.ops.wm.save_as_mainfile(filepath=fpath, compress=True, copy=False)
except Exception as e:
print(f"Exception {type(e)} during save_as_mainfile(): {e}")
os.remove(export_data["source_filepath"])
# Remove temp source copy
try:
os.remove(export_data["source_filepath"])
except Exception as e:
print(f"Exception {type(e)} during source cleanup: {e}")
# Build a single zip containing the .blend and only dependencies referenced by the file
try:
deps_files, deps_dirs = get_deps_files_and_dirs()
# skip next steps if there are no dependencies
if not deps_files and not deps_dirs:
print("No dependencies found, skipping zip creation")
sys.exit(0)
# Re-save the .blend to include updated relative paths
try:
bpy.ops.wm.save_mainfile(filepath=fpath)
except Exception as e:
print(f"Exception {type(e)} during save_mainfile(): {e}")
# Create one zip with .blend and referenced caches/media
zip_path = os.path.join(
export_data["temp_dir"], upload_data["assetBaseId"] + ".zip"
)
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# Put .blend at root with stable name
zf.write(fpath, os.path.basename(fpath))
# Add files
for fp in sorted(deps_files):
if os.path.isfile(fp):
arc = _arc_for_path(fp, deps_dirs)
zf.write(fp, arc)
# Add directories recursively
for d in sorted(deps_dirs):
if os.path.isdir(d):
for r, _, fs in os.walk(d):
for fn in fs:
sp = os.path.join(r, fn)
arc = _arc_for_path(sp, deps_dirs)
zf.write(sp, arc)
except Exception as e:
print(f"Exception {type(e)} during building asset zip: {e}")
except Exception as e:
print(f"Exception {type(e)} in upload_bg.py: {e}")
sys.exit(1)
@@ -90,7 +90,15 @@ def get_process_flags():
def activate(ob):
bpy.ops.object.select_all(action="DESELECT")
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"utils.activate: {str(e)}",
3,
type="ERROR",
)
raise e
ob.select_set(True)
bpy.context.view_layer.objects.active = ob
@@ -102,17 +110,24 @@ def selection_get():
def selection_set(sel):
bpy.ops.object.select_all(action="DESELECT")
try:
bpy.ops.object.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"utils.selection_set: {str(e)}",
3,
type="ERROR",
)
raise e
try:
bpy.context.view_layer.objects.active = sel[0]
for ob in sel[1]:
ob.select_set(True)
except Exception as e:
print("Selectible objects not found")
print(e)
bk_logger.exception(f"failed to select objects: {str(e)}")
def get_active_model():
def get_active_model() -> Optional[bpy.types.Object]:
if bpy.context.view_layer.objects.active is not None:
ob = bpy.context.view_layer.objects.active
while ob.parent is not None:
@@ -434,7 +449,7 @@ def get_preferences_as_dict():
# GUI
"show_on_start": user_preferences.show_on_start,
"thumb_size": user_preferences.thumb_size,
"max_assetbar_rows": user_preferences.max_assetbar_rows,
"maximized_assetbar_rows": user_preferences.maximized_assetbar_rows,
"search_field_width": user_preferences.search_field_width,
"search_in_header": user_preferences.search_in_header,
"tips_on_start": user_preferences.tips_on_start,
@@ -485,7 +500,7 @@ def get_preferences() -> datas.Prefs:
# GUI
show_on_start=user_preferences.show_on_start, # type: ignore[union-attr]
thumb_size=user_preferences.thumb_size, # type: ignore[union-attr]
max_assetbar_rows=user_preferences.max_assetbar_rows, # type: ignore[union-attr]
maximized_assetbar_rows=user_preferences.maximized_assetbar_rows, # type: ignore[union-attr]
search_field_width=user_preferences.search_field_width, # type: ignore[union-attr]
search_in_header=user_preferences.search_in_header, # type: ignore[union-attr]
tips_on_start=user_preferences.tips_on_start, # type: ignore[union-attr]
@@ -504,6 +519,7 @@ def get_preferences() -> datas.Prefs:
updater_interval_days=user_preferences.updater_interval_days, # type: ignore[union-attr]
# IMPORT SETTINGS
resolution=user_preferences.resolution, # type: ignore[union-attr]
material_import_automap=user_preferences.material_import_automap, # type: ignore[union-attr]
)
return prefs
@@ -514,8 +530,8 @@ def save_prefs_without_save_userpref(user_preferences, context):
def save_prefs(user_preferences, context, **kwargs):
# first check context, so we don't do this on registration or blender startup
if bpy.app.background is True:
# first check context, so we don't do this on registration, blender startup, or blender factory startup
if bpy.app.background is True or bpy.app.factory_startup is True:
return
global_vars.PREFS = get_preferences_as_dict()
@@ -782,9 +798,9 @@ def pprint(data, data1=None, data2=None, data3=None, data4=None):
p(json.dumps(data, indent=4, sort_keys=True))
def get_hierarchy(object) -> list:
def get_hierarchy(object) -> list[bpy.types.Object]:
"""Get all objects in a hierarchy tree."""
obs = []
obs: list[bpy.types.Object] = []
doobs = [object]
while len(doobs) > 0:
o = doobs.pop()
@@ -820,6 +836,7 @@ def get_bounds_snappable(obs, use_modifiers=False):
obcount = 0 # calculates the mesh obs. Good for non-mesh objects
matrix_parent = parent.matrix_world
depsgraph = bpy.context.evaluated_depsgraph_get()
for ob in obs:
# bb=ob.bound_box
mw = ob.matrix_world
@@ -830,7 +847,6 @@ def get_bounds_snappable(obs, use_modifiers=False):
if ob.type == "MESH" or ob.type == "CURVE":
# If to_mesh() works we can use it on curves and any other ob type almost.
# disabled to_mesh for 2.8 by now, not wanting to use dependency graph yet.
depsgraph = bpy.context.evaluated_depsgraph_get()
object_eval = ob.evaluated_get(depsgraph)
if ob.type == "CURVE":
@@ -857,6 +873,36 @@ def get_bounds_snappable(obs, use_modifiers=False):
# bpy.data.meshes.remove(mesh)
if ob.type == "CURVE":
object_eval.to_mesh_clear()
elif ob.type == "VOLUME":
# Ensure evaluated bound box (so grids/sequences are loaded)
object_eval = ob.evaluated_get(depsgraph)
bb = object_eval.bound_box
obcount += 1
for c in bb:
coord = c
parent_coord = (
matrix_parent.inverted()
@ mw
@ Vector((coord[0], coord[1], coord[2]))
)
minx = min(minx, parent_coord.x)
miny = min(miny, parent_coord.y)
minz = min(minz, parent_coord.z)
maxx = max(maxx, parent_coord.x)
maxy = max(maxy, parent_coord.y)
maxz = max(maxz, parent_coord.z)
elif ob.type in ["LIGHT", "CAMERA"]:
# From these we only need center point for bounds
coord = ob.location
parent_coord = (
matrix_parent.inverted() @ mw @ Vector((coord[0], coord[1], coord[2]))
)
minx = min(minx, parent_coord.x)
miny = min(miny, parent_coord.y)
minz = min(minz, parent_coord.z)
maxx = max(maxx, parent_coord.x)
maxy = max(maxy, parent_coord.y)
maxz = max(maxz, parent_coord.z)
if obcount == 0:
minx, miny, minz, maxx, maxy, maxz = 0, 0, 0, 0, 0, 0
@@ -941,7 +987,6 @@ def scale_uvs(ob, scale=1.0, pivot=Vector((0.5, 0.5))):
uv.data[uvindex].uv = scale_2d(uv.data[uvindex].uv, scale, pivot)
# map uv cubic and switch of auto tex space and set it to 1,1,1
def automap(
target_object=None,
target_slot=None,
@@ -949,72 +994,96 @@ def automap(
bg_exception=False,
just_scale=False,
):
wm = bpy.context.window_manager
mat_props = wm.blenderkit_mat
if mat_props.automap:
tob = bpy.data.objects[target_object]
# only automap mesh models
if tob.type == "MESH" and len(tob.data.polygons) > 0:
# check polycount for a rare case where no polys are in editmesh
actob = bpy.context.active_object
bpy.context.view_layer.objects.active = tob
"""
Map uv cubic and switch off auto tex space and set it to 1,1,1.
Only automap mesh models and if enabled in material import preferences.
"""
preferences = bpy.context.preferences.addons[__package__].preferences
if not preferences.material_import_automap:
return
# auto tex space
if tob.data.use_auto_texspace:
tob.data.use_auto_texspace = False
tob = bpy.data.objects[target_object]
if not just_scale:
tob.data.texspace_size = (1, 1, 1)
# Only automap mesh models
if tob.type != "MESH" or len(tob.data.polygons) <= 0:
return
if "automap" not in tob.data.uv_layers:
bpy.ops.mesh.uv_texture_add()
uvl = tob.data.uv_layers[-1]
uvl.name = "automap"
# check polycount for a rare case where no polys are in editmesh
actob = bpy.context.active_object
bpy.context.view_layer.objects.active = tob
tob.data.uv_layers.active = tob.data.uv_layers["automap"]
tob.data.uv_layers["automap"].active_render = True
# auto tex space
if tob.data.use_auto_texspace:
tob.data.use_auto_texspace = False
# TODO limit this to active material
# tob.data.uv_textures['automap'].active = True
if not just_scale:
tob.data.texspace_size = (1, 1, 1)
scale = tob.scale.copy()
if "automap" not in tob.data.uv_layers:
bpy.ops.mesh.uv_texture_add()
uvl = tob.data.uv_layers[-1]
uvl.name = "automap"
if target_slot is not None:
tob.active_material_index = target_slot
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="DESELECT")
tob.data.uv_layers.active = tob.data.uv_layers["automap"]
tob.data.uv_layers["automap"].active_render = True
# this exception is just for a 2.8 background thunmbnailer crash, can be removed when material slot select works...
if bg_exception or len(tob.material_slots) == 0:
bpy.ops.mesh.select_all(action="SELECT")
else:
bpy.ops.object.material_slot_select()
# TODO limit this to active material
# tob.data.uv_textures['automap'].active = True
scale = (scale.x + scale.y + scale.z) / 3.0
scale = tob.scale.copy()
if (
tex_size == 0
): # prevent division by zero, it's possible to have 0 in tex size by unskilled uploaders
tex_size = 1
if target_slot is not None:
tob.active_material_index = target_slot
bpy.ops.object.mode_set(mode="EDIT")
try:
bpy.ops.mesh.select_all(action="DESELECT")
except Exception as e:
reports.add_report(
f"automap.1: {str(e)}",
3,
type="ERROR",
)
raise e
if not just_scale:
# compensate for the undocumented operator change in blender 3.2
if bpy.app.version >= (3, 2, 0):
cube_size = (tex_size) / scale
else:
cube_size = (
scale * 2.0 / (tex_size)
) # it's * 2.0 because blender can't tell size of a unit cube :)
# this exception is just for a 2.8 background thunmbnailer crash, can be removed when material slot select works...
if bg_exception or len(tob.material_slots) == 0:
try:
bpy.ops.mesh.select_all(action="SELECT")
except Exception as e:
reports.add_report(
f"automap.2: {str(e)}",
3,
type="ERROR",
)
raise e
else:
bpy.ops.object.material_slot_select()
bpy.ops.uv.cube_project(cube_size=cube_size, correct_aspect=False)
scale = (scale.x + scale.y + scale.z) / 3.0
bpy.ops.object.editmode_toggle()
# this by now works only for thumbnail preview, but should be extended to work on arbitrary objects.
# by now, it takes the basic uv map = 1 meter. also, it now doeasn't respect more materials on one object,
# it just scales whole UV.
if just_scale:
scale_uvs(tob, scale=Vector((1 / tex_size, 1 / tex_size)))
bpy.context.view_layer.objects.active = actob
if (
tex_size == 0
): # prevent division by zero, it's possible to have 0 in tex size by unskilled uploaders
tex_size = 1
if not just_scale:
# compensate for the undocumented operator change in blender 3.2
if bpy.app.version >= (3, 2, 0):
cube_size = (tex_size) / scale
else:
cube_size = (
scale * 2.0 / (tex_size)
) # it's * 2.0 because blender can't tell size of a unit cube :)
bpy.ops.uv.cube_project(cube_size=cube_size, correct_aspect=False)
bpy.ops.object.editmode_toggle()
# this by now works only for thumbnail preview, but should be extended to work on arbitrary objects.
# by now, it takes the basic uv map = 1 meter. also, it now doeasn't respect more materials on one object,
# it just scales whole UV.
if just_scale:
scale_uvs(tob, scale=Vector((1 / tex_size, 1 / tex_size)))
bpy.context.view_layer.objects.active = actob
def name_update(props, context=None):
@@ -1023,9 +1092,7 @@ def name_update(props, context=None):
Checks for name change, because it decides if whole asset has to be re-uploaded. Name is stored in the blend file
and that's the reason.
"""
scene = bpy.context.scene
ui_props = bpy.context.window_manager.blenderkitUI
# props = get_upload_props()
if props.name_old != props.name:
props.name_changed = True
@@ -1035,16 +1102,23 @@ def name_update(props, context=None):
if nname.isupper():
nname = nname.lower()
nname = nname[0].upper() + nname[1:]
props.name = nname
if nname != "":
nname = nname[0].upper() + nname[1:]
props.name = (
nname # this recursively triggers the name_update() again, so we return
)
return
# here we need to fix the name for blender data = ' or " give problems in path evaluation down the road.
fname = props.name
fname = fname.replace("'", "")
fname = fname.replace('"', "")
asset = get_active_asset()
if ui_props.asset_type != "HDR":
# Here we actually rename assets datablocks, but don't do that with HDR's and possibly with others
asset.name = fname
if ui_props.asset_type == "HDR" or fname == "":
bk_logger.info(f"Skiping the rename")
return # don't rename HDR's or with empty name
else:
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
def fmt_dimensions(p):
@@ -1172,9 +1246,8 @@ def asset_from_newer_blender_version(asset_data, blender_version=None):
return False, ""
def asset_version_as_tuple(asset_data) -> tuple[int, int, int]:
def asset_version_as_tuple(asset_data) -> tuple[int, ...]:
"""Convert a version string to a tuple of integers. This way it can be compared to the blender version tuple."""
version = asset_data["sourceAppVersion"]
return tuple(map(int, asset_data["sourceAppVersion"].split(".")))
@@ -1477,3 +1550,21 @@ def get_project_name() -> str:
if filename == "":
filename = "Untitled.blend"
return filename
class BlenderkitException(Exception):
"""Base class for all BlenderKit exceptions."""
pass
class BlenderkitDownloadException(BlenderkitException):
"""Exception raised when a download fails."""
pass
class BlenderkitAppendException(BlenderkitException):
"""Exception raised when an append or link of the asset fails."""
pass
@@ -1,6 +1,6 @@
{
"last_check": "2025-06-23 09:38:03.256719",
"backup_date": "June-23-2025",
"last_check": "2025-12-01 11:02:25.858363",
"backup_date": "December-1-2025",
"update_ready": false,
"ignore": false,
"just_restored": false,
@@ -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()
@@ -35,9 +40,9 @@ class BL_UI_Widget:
@bg_color.setter
def bg_color(self, value):
if value != self._bg_color:
bpy.context.region.tag_redraw()
self._bg_color = value
if bpy.context.region is not None:
bpy.context.region.tag_redraw()
@property
def visible(self):
@@ -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.16.1-250612" # 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()
+13 -2
View File
@@ -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)
+1 -1
View File
@@ -27,7 +27,7 @@ class Prefs:
unpack_files: bool
show_on_start: bool
thumb_size: int
max_assetbar_rows: int
maximized_assetbar_rows: int
search_field_width: int
search_in_header: bool
tips_on_start: bool
@@ -99,6 +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)
bk_logger.info("%s", self.message)
pix_size = get_text_size(
font_id=1,
text=self.message,
@@ -241,6 +243,7 @@ class BlenderKitDisclaimerOperator(BL_UI_OT_draw_operator):
def run_disclaimer_task(message: str, url: str, tip: bool):
message = " ".join(message.split())
fake_context = utils.get_fake_context(bpy.context)
if bpy.app.version < (4, 0, 0):
bpy.ops.view3d.blenderkit_disclaimer_widget( # type: ignore[attr-defined]
File diff suppressed because it is too large Load Diff
@@ -16,16 +16,15 @@
#
# ##### END GPL LICENSE BLOCK #####
from collections import deque
from logging import INFO, WARN
from os import environ
from subprocess import Popen
from typing import Optional
from typing import Any, Optional
from . import datas
CLIENT_VERSION = "v1.5.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
@@ -42,7 +41,7 @@ DATA: dict = { # TODO: move these
"asset comments": {},
}
TABS = {
TABS: dict[str, Any] = {
"active_tab": 0, # Index of currently active tab
"tabs": [ # List of all tabs
{
@@ -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
@@ -8,21 +8,21 @@ 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
import bpy
import bl_pkg.bl_extension_ui as exui
from . import icons
from bl_ui.space_userpref import (
USERPREF_PT_addons,
USERPREF_PT_extensions,
USERPREF_MT_extensions_active_repo,
)
from bpy.props import EnumProperty
from bpy.props import StringProperty, IntProperty
from bpy.props import IntProperty, StringProperty
from bpy.types import Operator
import time
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):
@@ -59,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?
@@ -75,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):
@@ -87,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"}
@@ -107,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
@@ -137,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,
*,
@@ -166,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
@@ -330,6 +354,7 @@ def extension_draw_item_blenderkit(
if show:
import os
from bpy.app.translations import pgettext_iface as iface_
col = layout.column()
@@ -432,7 +457,6 @@ def extension_draw_item_override(
extensions_warnings, # `dict[str, list[str]]`
show_developer_ui=False, # `bool`
):
print("BlenderKit Debug: ENTERING extension_draw_item_override")
# filter by verification state, only for blenderkit repository
if repo_item.remote_url == EXTENSIONS_API_URL:
extension_draw_item_blenderkit(
@@ -513,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.
@@ -528,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
@@ -544,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
@@ -604,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
@@ -612,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]
+9 -1
View File
@@ -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 = []
@@ -273,6 +275,7 @@ def get_res_file(asset_data, resolution, find_closest_with_url=False):
resolution, so that other processess can pass correctly which resolution is downloaded.
"""
orig = None
zipf = None
res = None
closest = None
target_resolution = resolutions.get(resolution)
@@ -284,6 +287,8 @@ def get_res_file(asset_data, resolution, find_closest_with_url=False):
if resolution == "blend":
# orig file found, return.
return orig, "blend"
if f.get("fileType") == "zip_file":
zipf = f
if f["fileType"] == resolution:
# exact match found, return.
@@ -296,7 +301,10 @@ def get_res_file(asset_data, resolution, find_closest_with_url=False):
closest = f
mindist = rdiff
if not res and not closest:
return orig, "blend"
if orig is not None:
return orig, "blend"
if zipf is not None:
return zipf, "zip_file"
return closest, closest["fileType"]
+281 -58
View File
@@ -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]]
@@ -106,8 +106,8 @@ def load_preferences_from_JSON():
"show_on_start", user_preferences.show_on_start
)
user_preferences.thumb_size = prefs.get("thumb_size", user_preferences.thumb_size)
user_preferences.max_assetbar_rows = prefs.get(
"max_assetbar_rows", user_preferences.max_assetbar_rows
user_preferences.maximized_assetbar_rows = prefs.get(
"maximized_assetbar_rows", user_preferences.maximized_assetbar_rows
)
user_preferences.search_field_width = prefs.get(
"search_field_width", user_preferences.search_field_width
@@ -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"
@@ -17,6 +17,7 @@
# ##### END GPL LICENSE BLOCK #####
import logging
import time
import bpy
from bpy.props import StringProperty
@@ -186,6 +187,11 @@ class FastRateMenu(Operator, ratings_utils.RatingProperties):
ratings_utils.ensure_rating(self.asset_id)
self.prefill_ratings()
# Update last popup activity time to prevent shortcut interference
from . import ui_panels
ui_panels.last_time_dropdown_active = time.time()
if self.asset_type in ("model", "scene"):
# spawn a wider one for validators for the enum buttons
return wm.invoke_popup(self, width=400)
@@ -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}"
+268 -61
View File
@@ -29,10 +29,7 @@ from typing import Optional, Union
import bpy
from bpy.app.handlers import persistent
from bpy.props import ( # TODO only keep the ones actually used when cleaning
BoolProperty,
StringProperty,
)
from bpy.props import BoolProperty, StringProperty
from bpy.types import Operator
from . import (
@@ -41,10 +38,10 @@ from . import (
client_tasks,
comments_utils,
datas,
download,
global_vars,
image_utils,
paths,
ratings_utils,
reports,
resolutions,
tasks_queue,
@@ -170,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
@@ -181,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
@@ -196,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)
@@ -393,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
@@ -709,25 +719,16 @@ def query_to_url(
query = {}
url = f"{paths.BLENDERKIT_API}/search/"
if query is None:
query = {}
requeststring = "?query="
if query.get("query") not in ("", None):
requeststring += urllib.parse.quote_plus(query["query"]) # .lower()
requeststring += urllib.parse.quote_plus(query["query"])
for q in query:
if q != "query" and q != "free_first":
requeststring += (
f"+{q}:{urllib.parse.quote_plus(str(query[q]))}" # .lower()
)
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
# result ordering: _score - relevance, score - BlenderKit score
order = []
if query.get("free_first", False):
order = [
"-is_free",
]
# query with category_subtree:model etc gives irrelevant results
if query.get("category_subtree") in (
@@ -741,24 +742,7 @@ def query_to_url(
):
query["category_subtree"] = None
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:
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")
order = decide_ordering(query)
if requeststring.find("+order:") == -1:
requeststring += "+order:" + ",".join(order)
requeststring += "&dict_parameters=1"
@@ -774,6 +758,50 @@ def query_to_url(
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.
@@ -919,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
):
@@ -1096,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:
@@ -1113,7 +1209,6 @@ def search(get_next=False, query=None, author_id=""):
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
@@ -1122,10 +1217,11 @@ def search(get_next=False, query=None, author_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.max_assetbar_rows + 5)
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"):
@@ -1198,6 +1294,7 @@ def update_filters():
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
)
@@ -1221,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
@@ -1269,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
@@ -1300,20 +1402,21 @@ def search_update(self, context):
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.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"
@@ -1345,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 = ""
@@ -1643,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()
@@ -1652,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
@@ -1695,15 +1809,18 @@ def update_tab_name(active_tab):
# Update tab name
active_tab["name"] = tab_name
# Update UI if asset bar exists
# 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):
asset_bar.tab_buttons[active_tab_index].text = tab_name
# Force redraw of the region
if asset_bar.area:
asset_bar.area.tag_redraw()
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
@@ -1752,6 +1869,96 @@ def create_history_step(active_tab):
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)
+2 -1
View File
@@ -25,6 +25,7 @@ import bpy
from . import (
addon_updater_ops,
asset_bar_op,
bg_blender,
bkit_oauth,
categories,
@@ -342,7 +343,7 @@ def handle_task(task: client_tasks.Task):
# BKCLIENTJS - Download from web
if task.task_type == "bkclientjs/get_asset":
return download.handle_bkclientjs_get_asset(task)
return asset_bar_op.handle_bkclientjs_get_asset(task)
# HANDLE MESSAGE FROM CLIENT
if (
+50
View File
@@ -0,0 +1,50 @@
.updater_install_popup
.updater_check_now
.updater_update_now
.updater_update_target
.updater_install_manually
.updater_update_successful
.updater_restore_backup
.updater_ignore
.end_background_check
view3d.asset_drag_drop
object.blenderkit_auto_tags
object.blenderkit_generate_thumbnail
object.blenderkit_regenerate_thumbnail
object.blenderkit_generate_material_thumbnail
object.blenderkit_regenerate_material_thumbnail
object.kill_bg_process
wm.blenderkit_login
wm.blenderkit_logout
wm.blenderkit_login_cancel
scene.blenderkit_addon_manager
scene.blenderkit_addon_choice
scene.blenderkit_download_kill
scene.blenderkit_download
wm.blenderkit_bookmark_asset
wm.blenderkit_mark_notification_read
wm.blenderkit_mark_notifications_read_all
wm.blenderkit_open_notification_target
wm.blenderkit_upvote_comment
wm.blenderkit_is_private_comment
wm.blenderkit_post_comment
wm.logo_status
wm.show_notifications
wm.blenderkit_join_discord
wm.blenderkit_welcome
wm.blenderkit_open_system_directory
wm.blenderkit_asset_popup
view3d.blenderkit_set_comment_reply_id
view3d.blenderkit_set_category_origin
view3d.blenderkit_clear_search_keywords
view3d.close_popup_button
wm.blenderkit_popup_dialog
wm.blenderkit_url_dialog
wm.blenderkit_login_dialog
wm.blenderkit_nodegroup_drop_dialog
object.blenderkit_particles_drop
object.blenderkit_data_trasnfer
wm.modal_timer_operator
view3d.run_assetbar_start_modal
view3d.run_assetbar_fix_context
wm.blenderkit_fast_metadata
+1 -1
View File
@@ -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."
+261 -26
View File
@@ -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)
+540 -111
View File
@@ -16,16 +16,14 @@
#
# ##### END GPL LICENSE BLOCK #####
import ctypes
import logging
import os
import platform
import random
import time
from webbrowser import open_new_tab
import bpy
from bpy.props import IntProperty, StringProperty
from bpy.props import IntProperty, StringProperty, FloatVectorProperty, EnumProperty
from bpy.types import Context, Menu, Panel, UILayout
from . import (
@@ -53,7 +51,7 @@ ACCEPTABLE_ENGINES = ("CYCLES", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT")
bk_logger = logging.getLogger(__name__)
last_time_dropdown_active = 0.0
last_time_overlay_panel_active = 0.0
def draw_not_logged_in(source, message="Please Login/Signup to use this feature"):
@@ -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
@@ -256,6 +270,24 @@ def draw_panel_nodegroup_search(self, context):
utils.label_multiline(layout, text=props.report)
def draw_common_filters(layout, ui_props):
"""Draw common filter elements shared by multiple asset type panels.
Args:
layout: The UI layout to draw in
ui_props: The UI properties containing filter settings
"""
layout.separator()
row = layout.row()
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
row.prop(ui_props, "own_only", icon="USER")
layout.prop(ui_props, "free_only")
layout.prop(ui_props, "quality_limit", slider=True)
layout.prop(ui_props, "search_license")
layout.prop(ui_props, "search_order_by")
def draw_thumbnail_upload_panel(layout, props):
tex = autothumb.get_texture_ui(props.thumbnail, ".upload_preview")
if not tex or not tex.image:
@@ -440,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,
@@ -479,9 +513,8 @@ class VIEW3D_PT_blenderkit_model_properties(Panel):
def poll(cls, context):
if bpy.context.view_layer.objects.active is None:
return False
# if bpy.context.view_layer.objects.get('asset_data') is None:
# return False
return True
preferences = bpy.context.preferences.addons[__package__].preferences
return not preferences.sidebar_panels
def draw(self, context):
draw_model_context_menu(self, context)
@@ -648,7 +681,8 @@ class VIEW3D_PT_blenderkit_profile(Panel):
@classmethod
def poll(cls, context):
return True
preferences = bpy.context.preferences.addons[__package__].preferences
return not preferences.sidebar_panels
def draw_header(self, context):
layout = self.layout
@@ -1378,6 +1412,9 @@ class VIEW3D_PT_blenderkit_advanced_model_search(Panel):
# NSFW filter
layout.prop(preferences, "nsfw_filter")
# ORDER
layout.prop(ui_props, "search_order_by")
def draw(self, context):
self.draw_layout(self.layout)
@@ -1437,6 +1474,9 @@ class VIEW3D_PT_blenderkit_advanced_material_search(Panel):
layout.prop(ui_props, "quality_limit", slider=True)
layout.prop(ui_props, "search_license")
# ORDER
layout.prop(ui_props, "search_order_by")
def draw(self, context):
self.draw_layout(self.layout)
@@ -1457,19 +1497,8 @@ class VIEW3D_PT_blenderkit_advanced_scene_search(Panel):
return ui_props.down_up == "SEARCH" and ui_props.asset_type == "SCENE"
def draw_layout(self, layout):
wm = bpy.context.window_manager
props = wm.blenderkit_scene
ui_props = wm.blenderkitUI
layout.separator()
row = layout.row()
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
row.prop(ui_props, "own_only", icon="USER")
layout.prop(ui_props, "free_only")
layout.prop(ui_props, "quality_limit", slider=True)
layout.prop(ui_props, "search_license")
ui_props = bpy.context.window_manager.blenderkitUI
draw_common_filters(layout, ui_props)
def draw(self, context):
self.draw_layout(self.layout)
@@ -1497,20 +1526,14 @@ class VIEW3D_PT_blenderkit_advanced_HDR_search(Panel):
ui_props = wm.blenderkitUI
layout = self.layout
layout.separator()
draw_common_filters(layout, ui_props)
row = layout.row()
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
row.prop(ui_props, "own_only", icon="USER")
layout.prop(ui_props, "free_only")
layout.prop(props, "true_hdr")
layout.prop(props, "search_texture_resolution", text="Texture Resolutions")
if props.search_texture_resolution:
row = layout.row(align=True)
row.prop(props, "search_texture_resolution_min", text="Min")
row.prop(props, "search_texture_resolution_max", text="Max")
layout.prop(ui_props, "quality_limit", slider=True)
layout.prop(ui_props, "search_license")
class VIEW3D_PT_blenderkit_advanced_brush_search(Panel):
@@ -1528,17 +1551,79 @@ class VIEW3D_PT_blenderkit_advanced_brush_search(Panel):
return ui_props.down_up == "SEARCH" and ui_props.asset_type == "BRUSH"
def draw_layout(self, layout):
wm = bpy.context.window_manager
ui_props = wm.blenderkitUI
ui_props = bpy.context.window_manager.blenderkitUI
draw_common_filters(layout, ui_props)
layout.separator()
def draw(self, context):
self.draw_layout(self.layout)
class VIEW3D_PT_blenderkit_advanced_nodegroup_search(Panel):
bl_category = "BlenderKit"
bl_idname = "VIEW3D_PT_blenderkit_advanced_nodegroup_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 == "NODEGROUP"
def draw(self, context):
ui_props = bpy.context.window_manager.blenderkitUI
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(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
row.prop(ui_props, "own_only", icon="USER")
layout.prop(ui_props, "free_only")
layout.prop(ui_props, "quality_limit", slider=True)
layout.prop(ui_props, "search_license")
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"
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 == "PRINTABLE"
def draw_layout(self, layout):
ui_props = bpy.context.window_manager.blenderkitUI
draw_common_filters(layout, ui_props)
def draw(self, context):
self.draw_layout(self.layout)
@@ -1563,8 +1648,8 @@ class VIEW3D_PT_blenderkit_categories(Panel):
def draw(self, context):
# measure time since last dropdown activation/ mouse hover e.t.c.
# this is then used in asset_bar_op.py to cancel asset drag drop if the time is too small and thus means double clicking.
global last_time_dropdown_active
last_time_dropdown_active = time.time()
global last_time_overlay_panel_active
last_time_overlay_panel_active = time.time()
draw_panel_categories(self.layout, context)
@@ -1637,6 +1722,25 @@ class VIEW3D_PT_blenderkit_import_settings(Panel):
# layout.prop(props, 'unpack_files')
def deferred_set_name(props, expected_obj_name):
"""Deferred timer to set empty name of uploaded asset to active Object's name.
We check if the names of active_now object and expected object are the same, because active object could have changed.
This is one-shot timer = return None.
"""
active_now = utils.get_active_asset()
if props.name != "":
return None
if not active_now:
return None
if active_now.name != expected_obj_name:
return None # active object is different from the one on which we have called the timer
props.name_old = (
expected_obj_name # prevents utils.name_update() from running twice
)
props.name = expected_obj_name # this ultimately triggers utils.name_update()
return None
class VIEW3D_PT_blenderkit_unified(Panel):
bl_category = "BlenderKit"
bl_idname = "VIEW3D_PT_blenderkit_unified"
@@ -1647,6 +1751,11 @@ class VIEW3D_PT_blenderkit_unified(Panel):
}
bl_label = ""
@classmethod
def poll(cls, context):
user_preferences = bpy.context.preferences.addons[__package__].preferences
return not user_preferences.sidebar_panels
def draw_header(self, context):
layout = self.layout
ui_props = bpy.context.window_manager.blenderkitUI
@@ -1752,7 +1861,17 @@ 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)
if props and not props.name:
bpy.app.timers.register(
lambda p=props, n=obj.name: deferred_set_name(p, n), first_interval=0.0
)
if ui_props.asset_type == "MODEL" or ui_props.asset_type == "PRINTABLE":
if bpy.context.view_layer.objects.active is not None:
return draw_panel_model_upload(self, context)
@@ -1787,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"""
@@ -2051,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
@@ -2364,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")
@@ -2438,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"
@@ -2540,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
@@ -2581,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]
@@ -2656,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")
@@ -3134,6 +3372,9 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
# box.label(text=str(comment['flags']))
def draw(self, context):
global last_time_overlay_panel_active
last_time_overlay_panel_active = time.time()
layout = self.layout
# top draggable bar with name of the asset
top_row = layout.row()
@@ -3143,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)
@@ -3335,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"}
@@ -3390,7 +3619,7 @@ class PopupDialog(bpy.types.Operator):
class UrlPopupDialog(bpy.types.Operator):
"""Generate Cycles thumbnail for model assets"""
"""Show a popup asking the user to subscribe or log in to access the locked asset"""
bl_idname = "wm.blenderkit_url_dialog"
bl_label = "BlenderKit message:"
@@ -3404,34 +3633,36 @@ class UrlPopupDialog(bpy.types.Operator):
message: bpy.props.StringProperty(name="Text", description="text", default="") # type: ignore[valid-type]
# @classmethod
# def poll(cls, context):
# return bpy.context.view_layer.objects.active is not None
width: bpy.props.IntProperty(name="width", description="width", default=300) # type: ignore[valid-type]
def draw(self, context):
layout = self.layout
row = layout.row()
row.label(text=self.message)
utils.label_multiline(layout, text=self.message, width=300)
row.operator("view3d.close_popup_button", text="", icon="CANCEL")
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? You need to login to access your Full Plan.",
text=f"Already {text}? Log in to access your account.",
width=300,
)
layout.operator_context = "EXEC_DEFAULT"
layout.operator("wm.blenderkit_login", text="Login", icon="URL").signup = (
False
)
layout.operator(
"wm.blenderkit_login", text="Welcome Home", icon="URL"
).signup = False
op.url = self.url
def execute(self, context):
wm = bpy.context.window_manager
return wm.invoke_popup(self, width=300)
return wm.invoke_popup(self, width=self.width)
class LoginPopupDialog(bpy.types.Operator):
@@ -3620,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"]
@@ -3633,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]
@@ -3723,7 +3956,7 @@ def header_search_draw(self, context):
icon_value=icon_id,
)
# FILTER ICON
# FILTER ICON: filters are default or modified
if props.use_filters:
icon_id = pcoll["filter_active"].icon_id
else:
@@ -3764,6 +3997,24 @@ def header_search_draw(self, context):
text="",
icon_value=icon_id,
)
elif ui_props.asset_type == "NODEGROUP":
layout.popover(
panel="VIEW3D_PT_blenderkit_advanced_nodegroup_search",
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",
text="",
icon_value=icon_id,
)
# NSFW filter shield badge - only for models right now
if preferences.nsfw_filter and ui_props.asset_type == "MODEL":
@@ -3802,6 +4053,180 @@ def ui_message(title, message):
bpy.context.window_manager.popup_menu(draw_message, title=title, icon="INFO")
class NodegroupDropDialog(bpy.types.Operator):
"""Dialog for choosing how to add a nodegroup when dropped on an object or in node editor"""
bl_idname = "wm.blenderkit_nodegroup_drop_dialog"
bl_label = "Add Nodegroup"
bl_options = {"REGISTER", "INTERNAL"}
# Store the parameters needed for the download
asset_search_index: bpy.props.IntProperty(default=-1) # type: ignore[valid-type]
target_object_name: bpy.props.StringProperty(default="") # type: ignore[valid-type]
snapped_location: bpy.props.FloatVectorProperty(size=3) # type: ignore[valid-type]
snapped_rotation: bpy.props.FloatVectorProperty(size=3) # type: ignore[valid-type]
# Node editor positioning (when dropped in node editor)
node_x: bpy.props.FloatProperty(default=0.0) # type: ignore[valid-type]
node_y: bpy.props.FloatProperty(default=0.0) # type: ignore[valid-type]
# Option for how to add the nodegroup
add_mode: bpy.props.EnumProperty( # type: ignore[valid-type]
name="Add Mode",
description="How to add the nodegroup",
items=[
(
"MODIFIER",
"As Modifier",
"Add the nodegroup as a new modifier on the object",
),
("NODE", "As Node", "Add the nodegroup as a node in an existing node tree"),
],
default="MODIFIER",
)
# Option for overwriting existing geometry nodes modifier
overwrite_modifier: bpy.props.BoolProperty( # type: ignore[valid-type]
name="Overwrite Last Geometry Nodes Modifier",
description="Replace the last geometry nodes modifier instead of creating a new one (recommended to avoid recursion)",
default=True,
)
def get_existing_geometry_modifiers(self, target_obj):
"""Get list of existing geometry nodes modifiers on target object"""
if not target_obj:
return []
return [mod for mod in target_obj.modifiers if mod.type == "NODES"]
def draw(self, context):
layout = self.layout
# Get asset data for display
sr = search.get_search_results()
if self.asset_search_index >= 0 and self.asset_search_index < len(sr):
asset_data = sr[self.asset_search_index]
col = layout.column(align=True)
col.label(text=f"Adding nodegroup: {asset_data['displayName']}")
# Get target object and check for existing geometry nodes modifiers
target_obj = None
existing_geo_modifiers = []
if self.target_object_name:
target_obj = bpy.data.objects.get(self.target_object_name)
existing_geo_modifiers = self.get_existing_geometry_modifiers(
target_obj
)
col.label(text=f"To object: {self.target_object_name}")
else:
col.label(text="A new target object will be created")
col.separator()
col.prop(self, "add_mode", expand=True)
# Show overwrite option only for MODIFIER mode when there are existing geometry nodes modifiers
if self.add_mode == "MODIFIER" and existing_geo_modifiers:
col.separator()
# Show info about existing modifiers
if len(existing_geo_modifiers) == 1:
col.label(text=f"Found 1 geometry nodes modifier:", icon="INFO")
else:
col.label(
text=f"Found {len(existing_geo_modifiers)} geometry nodes modifiers:",
icon="INFO",
)
# Show the last modifier name
last_modifier = existing_geo_modifiers[-1]
col.label(text=f"{last_modifier.name} (will be affected)")
col.separator()
col.prop(self, "overwrite_modifier")
col.separator()
# Add description based on selected mode
if self.add_mode == "MODIFIER":
if self.target_object_name:
if existing_geo_modifiers and self.overwrite_modifier:
col.label(text="The last geometry nodes modifier will be")
col.label(text="replaced with the new nodegroup.")
col.label(
text="(Recommended to avoid recursion)", icon="CHECKMARK"
)
else:
col.label(text="The nodegroup will be added as a new")
col.label(text="geometry nodes modifier on the object.")
if existing_geo_modifiers:
col.label(text="⚠ May cause recursion issues", icon="ERROR")
else:
col.label(text="A new cube will be created and the")
col.label(text="nodegroup added as a modifier.")
else:
if self.target_object_name:
col.label(text="The nodegroup will be added as a node")
col.label(text="in the geometry nodes editor.")
else:
col.label(text="A new cube will be created and the")
col.label(text="nodegroup added as a node.")
# Show node position if we have it
if self.node_x != 0.0 or self.node_y != 0.0:
col.label(
text=f"Position: ({self.node_x:.1f}, {self.node_y:.1f})",
icon="NODE",
)
def execute(self, context):
# Download the nodegroup with the specified mode
target_object = ""
if self.target_object_name:
target_object = self.target_object_name
# Handle modifier overwrite if requested
if self.add_mode == "MODIFIER" and self.overwrite_modifier and target_object:
target_obj = bpy.data.objects.get(target_object)
if target_obj:
existing_geo_modifiers = self.get_existing_geometry_modifiers(
target_obj
)
if existing_geo_modifiers:
# Remove the last geometry nodes modifier
last_modifier = existing_geo_modifiers[-1]
bk_logger.info(
f"Removed geometry nodes modifier '{last_modifier.name}' before adding new nodegroup"
)
target_obj.modifiers.remove(last_modifier)
# 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(
"EXEC_DEFAULT",
asset_index=self.asset_search_index,
node_x=self.node_x,
node_y=self.node_y,
target_object=target_object,
nodegroup_mode=self.add_mode,
model_location=self.snapped_location,
model_rotation=self.snapped_rotation,
)
else: # MODIFIER mode
bpy.ops.scene.blenderkit_download(
"EXEC_DEFAULT",
asset_index=self.asset_search_index,
model_location=self.snapped_location,
model_rotation=self.snapped_rotation,
target_object=target_object,
nodegroup_mode=self.add_mode,
)
return {"FINISHED"}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=400)
classes = (
SetCategoryOperatorOrigin,
SetCategoryOperator,
@@ -3818,6 +4243,9 @@ classes = (
VIEW3D_PT_blenderkit_advanced_scene_search,
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,
VIEW3D_PT_blenderkit_model_properties,
@@ -3850,6 +4278,7 @@ classes = (
NotificationOpenTarget,
MarkAllNotificationsRead,
LoginPopupDialog,
NodegroupDropDialog,
)
@@ -78,10 +78,22 @@ def prevalidate_model(props):
"""Check model for possible problems:
- check if all objects does not have asymmetrical scaling. Asymmetrical scaling is a big problem.
Anything scaled away from (1,1,1) is a smaller problem. We do not check for that.
- round minor drifts from 1.0
"""
TOLERANCE = 1e-5
ob = utils.get_active_model()
obs = utils.get_hierarchy(ob)
for ob in obs:
if ob.scale[0] == ob.scale[1] == ob.scale[2]:
continue # all totally good
if all(abs(scalar - 1.0) <= TOLERANCE for scalar in ob.scale):
bk_logger.info(
f"Snapped minor float drift on '{ob}': "
+ f"{ob.scale[0], ob.scale[1], ob.scale[2]} → (1.0, 1.0, 1.0)"
)
ob.scale = (1.0, 1.0, 1.0)
if ob.scale[0] != ob.scale[1] or ob.scale[1] != ob.scale[2]:
write_to_report(
props,
+146 -1
View File
@@ -19,11 +19,114 @@
import json
import os
import sys
import shutil
import zipfile
import addon_utils # type: ignore[import-not-found]
import bpy
# Map dependencies into a single subdirectory inside the zip and rewrite paths to relative
def _zip_arc_for(p: str, deps_dirs: set[str]) -> str:
base = os.path.basename(p)
return os.path.join("caches", base)
def _arc_for_path(p: str, deps_dirs: set[str]) -> str:
pn = os.path.normpath(bpy.path.abspath(p))
best_root = ""
for d in deps_dirs:
dn = os.path.normpath(d)
if pn.startswith(dn) and len(dn) > len(best_root):
best_root = dn
if best_root:
rel = os.path.relpath(pn, best_root)
return os.path.join("caches", os.path.basename(best_root), rel)
return os.path.join("caches", os.path.basename(pn))
def get_deps_files_and_dirs():
"""Get all dependencies files and directories."""
deps_files: set[str] = set()
deps_dirs: set[str] = set()
# Alembic/USD and similar cache files
for cf in bpy.data.cache_files: # type: ignore[attr-defined]
fp = bpy.path.abspath(cf.filepath)
if fp and os.path.isfile(fp):
deps_files.add(fp)
# Volumes (OpenVDB). Include file; for sequences include containing directory
for v in getattr(bpy.data, "volumes", []):
fp = bpy.path.abspath(getattr(v, "filepath", ""))
if not fp:
continue
if os.path.isdir(fp):
deps_dirs.add(fp)
elif os.path.isfile(fp):
# Heuristic: sequence often resides in the directory of the file
if getattr(v, "is_sequence", False):
deps_dirs.add(os.path.dirname(fp))
else:
deps_files.add(fp)
# Movie clips
for clip in bpy.data.movieclips: # type: ignore[attr-defined]
fp = bpy.path.abspath(clip.filepath)
if fp and os.path.isfile(fp):
deps_files.add(fp)
# Fluid domain caches (directories)
for ob in bpy.data.objects:
for mod in ob.modifiers:
if (
getattr(mod, "type", "") == "FLUID"
and getattr(mod, "fluid_type", "") == "DOMAIN"
):
domain = getattr(mod, "domain_settings", None)
if domain is not None:
cache_dir = getattr(domain, "cache_directory", "")
if cache_dir:
cdir = bpy.path.abspath(cache_dir)
if os.path.isdir(cdir):
deps_dirs.add(cdir)
# Rewrite datablock paths to relative locations
for cf in bpy.data.cache_files: # type: ignore[attr-defined]
fp = bpy.path.abspath(cf.filepath)
if fp and os.path.isfile(fp):
cf.filepath = "//" + _arc_for_path(fp, deps_dirs).replace(os.sep, "/")
for v in getattr(bpy.data, "volumes", []):
fp = bpy.path.abspath(getattr(v, "filepath", ""))
if fp:
if os.path.isdir(fp):
target = os.path.join("caches", os.path.basename(fp))
else:
target = _arc_for_path(fp, deps_dirs)
v.filepath = "//" + target.replace(os.sep, "/")
for clip in bpy.data.movieclips: # type: ignore[attr-defined]
fp = bpy.path.abspath(clip.filepath)
if fp and os.path.isfile(fp):
clip.filepath = "//" + _arc_for_path(fp, deps_dirs).replace(os.sep, "/")
for ob in bpy.data.objects:
for mod in ob.modifiers:
if (
getattr(mod, "type", "") == "FLUID"
and getattr(mod, "fluid_type", "") == "DOMAIN"
):
domain = getattr(mod, "domain_settings", None)
if domain is not None:
cache_dir = getattr(domain, "cache_directory", "")
if cache_dir:
domain.cache_directory = "//" + _zip_arc_for(
cache_dir, deps_dirs
).replace(os.sep, "/")
return deps_files, deps_dirs
def patch_imports(addon_module_name: str):
"""Patch the python configuration, so the relative imports work as expected. There are few problems to fix:
1. Script is not recognized as module which would break at relative import. We need to set __package__ = "blenderkit" for legacy addon.
@@ -145,7 +248,49 @@ if __name__ == "__main__":
bpy.ops.wm.save_as_mainfile(filepath=fpath, compress=True, copy=False)
except Exception as e:
print(f"Exception {type(e)} during save_as_mainfile(): {e}")
os.remove(export_data["source_filepath"])
# Remove temp source copy
try:
os.remove(export_data["source_filepath"])
except Exception as e:
print(f"Exception {type(e)} during source cleanup: {e}")
# Build a single zip containing the .blend and only dependencies referenced by the file
try:
deps_files, deps_dirs = get_deps_files_and_dirs()
# skip next steps if there are no dependencies
if not deps_files and not deps_dirs:
print("No dependencies found, skipping zip creation")
sys.exit(0)
# Re-save the .blend to include updated relative paths
try:
bpy.ops.wm.save_mainfile(filepath=fpath)
except Exception as e:
print(f"Exception {type(e)} during save_mainfile(): {e}")
# Create one zip with .blend and referenced caches/media
zip_path = os.path.join(
export_data["temp_dir"], upload_data["assetBaseId"] + ".zip"
)
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# Put .blend at root with stable name
zf.write(fpath, os.path.basename(fpath))
# Add files
for fp in sorted(deps_files):
if os.path.isfile(fp):
arc = _arc_for_path(fp, deps_dirs)
zf.write(fp, arc)
# Add directories recursively
for d in sorted(deps_dirs):
if os.path.isdir(d):
for r, _, fs in os.walk(d):
for fn in fs:
sp = os.path.join(r, fn)
arc = _arc_for_path(sp, deps_dirs)
zf.write(sp, arc)
except Exception as e:
print(f"Exception {type(e)} during building asset zip: {e}")
except Exception as e:
print(f"Exception {type(e)} in upload_bg.py: {e}")
sys.exit(1)
+88 -29
View File
@@ -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
@@ -449,7 +458,7 @@ def get_preferences_as_dict():
# GUI
"show_on_start": user_preferences.show_on_start,
"thumb_size": user_preferences.thumb_size,
"max_assetbar_rows": user_preferences.max_assetbar_rows,
"maximized_assetbar_rows": user_preferences.maximized_assetbar_rows,
"search_field_width": user_preferences.search_field_width,
"search_in_header": user_preferences.search_in_header,
"tips_on_start": user_preferences.tips_on_start,
@@ -500,7 +509,7 @@ def get_preferences() -> datas.Prefs:
# GUI
show_on_start=user_preferences.show_on_start, # type: ignore[union-attr]
thumb_size=user_preferences.thumb_size, # type: ignore[union-attr]
max_assetbar_rows=user_preferences.max_assetbar_rows, # type: ignore[union-attr]
maximized_assetbar_rows=user_preferences.maximized_assetbar_rows, # type: ignore[union-attr]
search_field_width=user_preferences.search_field_width, # type: ignore[union-attr]
search_in_header=user_preferences.search_in_header, # type: ignore[union-attr]
tips_on_start=user_preferences.tips_on_start, # type: ignore[union-attr]
@@ -530,8 +539,8 @@ def save_prefs_without_save_userpref(user_preferences, context):
def save_prefs(user_preferences, context, **kwargs):
# first check context, so we don't do this on registration or blender startup
if bpy.app.background is True:
# first check context, so we don't do this on registration, blender startup, or blender factory startup
if bpy.app.background is True or bpy.app.factory_startup is True:
return
global_vars.PREFS = get_preferences_as_dict()
@@ -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
@@ -836,6 +846,7 @@ def get_bounds_snappable(obs, use_modifiers=False):
obcount = 0 # calculates the mesh obs. Good for non-mesh objects
matrix_parent = parent.matrix_world
depsgraph = bpy.context.evaluated_depsgraph_get()
for ob in obs:
# bb=ob.bound_box
mw = ob.matrix_world
@@ -846,7 +857,6 @@ def get_bounds_snappable(obs, use_modifiers=False):
if ob.type == "MESH" or ob.type == "CURVE":
# If to_mesh() works we can use it on curves and any other ob type almost.
# disabled to_mesh for 2.8 by now, not wanting to use dependency graph yet.
depsgraph = bpy.context.evaluated_depsgraph_get()
object_eval = ob.evaluated_get(depsgraph)
if ob.type == "CURVE":
@@ -873,6 +883,36 @@ def get_bounds_snappable(obs, use_modifiers=False):
# bpy.data.meshes.remove(mesh)
if ob.type == "CURVE":
object_eval.to_mesh_clear()
elif ob.type == "VOLUME":
# Ensure evaluated bound box (so grids/sequences are loaded)
object_eval = ob.evaluated_get(depsgraph)
bb = object_eval.bound_box
obcount += 1
for c in bb:
coord = c
parent_coord = (
matrix_parent.inverted()
@ mw
@ Vector((coord[0], coord[1], coord[2]))
)
minx = min(minx, parent_coord.x)
miny = min(miny, parent_coord.y)
minz = min(minz, parent_coord.z)
maxx = max(maxx, parent_coord.x)
maxy = max(maxy, parent_coord.y)
maxz = max(maxz, parent_coord.z)
elif ob.type in ["LIGHT", "CAMERA"]:
# From these we only need center point for bounds
coord = ob.location
parent_coord = (
matrix_parent.inverted() @ mw @ Vector((coord[0], coord[1], coord[2]))
)
minx = min(minx, parent_coord.x)
miny = min(miny, parent_coord.y)
minz = min(minz, parent_coord.z)
maxx = max(maxx, parent_coord.x)
maxy = max(maxy, parent_coord.y)
maxz = max(maxz, parent_coord.z)
if obcount == 0:
minx, miny, minz, maxx, maxy, maxz = 0, 0, 0, 0, 0, 0
@@ -1062,9 +1102,7 @@ def name_update(props, context=None):
Checks for name change, because it decides if whole asset has to be re-uploaded. Name is stored in the blend file
and that's the reason.
"""
scene = bpy.context.scene
ui_props = bpy.context.window_manager.blenderkitUI
# props = get_upload_props()
if props.name_old != props.name:
props.name_changed = True
@@ -1074,16 +1112,23 @@ def name_update(props, context=None):
if nname.isupper():
nname = nname.lower()
nname = nname[0].upper() + nname[1:]
props.name = nname
if nname != "":
nname = nname[0].upper() + nname[1:]
props.name = (
nname # this recursively triggers the name_update() again, so we return
)
return
# here we need to fix the name for blender data = ' or " give problems in path evaluation down the road.
fname = props.name
fname = fname.replace("'", "")
fname = fname.replace('"', "")
asset = get_active_asset()
if ui_props.asset_type != "HDR":
# Here we actually rename assets datablocks, but don't do that with HDR's and possibly with others
asset.name = fname
if ui_props.asset_type == "HDR" or fname == "":
bk_logger.info(f"Skiping the rename")
return # don't rename HDR's or with empty name
else:
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
def fmt_dimensions(p):
@@ -1189,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
@@ -1231,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