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
@@ -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,