2025-12-01
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
bl_info = {
|
||||
"name": "BlenderKit Online Asset Library",
|
||||
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik",
|
||||
"version": (3, 16, 1, 250612), # X.Y.Z.yymmdd
|
||||
"version": (3, 18, 0, 251121), # X.Y.Z.yymmdd
|
||||
"blender": (3, 0, 0),
|
||||
"location": "View3D > Properties > BlenderKit",
|
||||
"description": "Boost your workflow with drag&drop assets from the community driven library.",
|
||||
@@ -28,7 +28,7 @@ bl_info = {
|
||||
"tracker_url": "https://github.com/BlenderKit/blenderkit/issues",
|
||||
"category": "3D View",
|
||||
}
|
||||
VERSION = (3, 16, 1, 250612)
|
||||
VERSION = (3, 18, 0, 251121)
|
||||
|
||||
import logging
|
||||
import random
|
||||
@@ -297,6 +297,11 @@ def asset_type_callback(self, context):
|
||||
6,
|
||||
),
|
||||
]
|
||||
|
||||
# Only add addon option for Blender 4.2+
|
||||
|
||||
if bpy.app.version >= (4, 2, 0):
|
||||
items.append(("ADDON", "Add-ons", "Find add-ons", "PLUGIN", 7))
|
||||
else:
|
||||
items = [
|
||||
("MODEL", "Model", "Upload a model", "OBJECT_DATAMODE", 0),
|
||||
@@ -314,6 +319,11 @@ def asset_type_callback(self, context):
|
||||
),
|
||||
]
|
||||
|
||||
# Only add addon option for Blender 4.2+
|
||||
|
||||
if bpy.app.version >= (4, 2, 0):
|
||||
items.append(("ADDON", "Add-on", "Upload an addon", "PLUGIN", 7))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@@ -380,6 +390,61 @@ class BlenderKitUIProps(PropertyGroup):
|
||||
max=10,
|
||||
update=search.search_update_delayed,
|
||||
)
|
||||
search_order_by: EnumProperty(
|
||||
name="Order",
|
||||
description="Search result order",
|
||||
items=(
|
||||
(
|
||||
"default",
|
||||
"Default",
|
||||
"By default, the sorting algorithm changes dynamically based on search filters.",
|
||||
),
|
||||
("-created", "Newest", "Sort results from newest to oldest."),
|
||||
("created", "Oldest", "Sort results from oldest to newest."),
|
||||
(
|
||||
"-bookmarks",
|
||||
"▼ Bookmarks",
|
||||
"Sort results from most bookmarked to least.",
|
||||
),
|
||||
(
|
||||
"bookmarks",
|
||||
"▲ Bookmarks",
|
||||
"Sort results from least bookmarked to most.",
|
||||
),
|
||||
(
|
||||
"-score",
|
||||
"▼ Score",
|
||||
"Sort results from highest asset score to the lowest.",
|
||||
),
|
||||
(
|
||||
"score",
|
||||
"▲ Score",
|
||||
"Sort results from lowest asset score to the highest.",
|
||||
),
|
||||
(
|
||||
"-working_hours",
|
||||
"▼ Complexity",
|
||||
"Sort results from most complex to the least.",
|
||||
),
|
||||
(
|
||||
"working_hours",
|
||||
"▲ Complexity",
|
||||
"Sort results from least complex to the most.",
|
||||
),
|
||||
(
|
||||
"-quality",
|
||||
"▼ Quality",
|
||||
"Sort results from highest quality rating to the lowest.",
|
||||
),
|
||||
(
|
||||
"quality",
|
||||
"▲ Quality",
|
||||
"Sort results from lowest quality rating to the highest.",
|
||||
),
|
||||
),
|
||||
default="default",
|
||||
update=search.search_update,
|
||||
)
|
||||
search_license: EnumProperty(
|
||||
name="License",
|
||||
items=(
|
||||
@@ -408,7 +473,7 @@ class BlenderKitUIProps(PropertyGroup):
|
||||
)
|
||||
search_blender_version_max: StringProperty(
|
||||
name="Maximum version (excluding, lower than)",
|
||||
default="4.99",
|
||||
default="5.99",
|
||||
description="Limit the assets by maximum version of Blender in which they were created, exluding the specified version and all newer versions from the search results. "
|
||||
+ "Only assets created in LOWER THAN (< max) maximum version will be shown. Use semantic versioning format: X.Y.Z.\n\n"
|
||||
+ "E.g.: exclude all Blender 4 assets by specifying 4, 4.0, or 4.0.0. Assets created in 3.6 and lower will be shown",
|
||||
@@ -1109,6 +1174,19 @@ class BlenderKitGeoToolSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
|
||||
pass
|
||||
|
||||
|
||||
class BlenderKitAddonSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
|
||||
search_installed: BoolProperty(
|
||||
name="Installed Only",
|
||||
description="Show only addons that are already installed in Blender",
|
||||
default=False,
|
||||
update=lambda self, context: (
|
||||
search.refresh_search()
|
||||
if context.window_manager.blenderkitUI.asset_type == "ADDON"
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BlenderKitHDRUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
|
||||
texture_resolution_max: IntProperty(
|
||||
name="Texture Resolution Max",
|
||||
@@ -1927,7 +2005,11 @@ class BlenderKitAddonPreferences(AddonPreferences):
|
||||
|
||||
api_key: StringProperty(
|
||||
name="BlenderKit API Key",
|
||||
description="Your blenderkit API Key. Get it from your page on the website",
|
||||
description=(
|
||||
"Your unique API key authenticates downloads and requests inside the add-on. "
|
||||
"No manual setup is required, the API Key is auto-filled at login and cleared at logout. "
|
||||
"However, you can also paste the key from your profile settings on the BlenderKit website."
|
||||
),
|
||||
default="",
|
||||
subtype="PASSWORD",
|
||||
update=utils.api_key_property_updated,
|
||||
@@ -1980,6 +2062,13 @@ class BlenderKitAddonPreferences(AddonPreferences):
|
||||
update=utils.save_prefs,
|
||||
)
|
||||
|
||||
sidebar_panels: BoolProperty(
|
||||
name="Hide sidebar panels",
|
||||
description="Hide BlenderKit sidebar panels (search, upload, and selected model functionality). This prevents upload and it's also the only place for import settings. Reenable this to access these features.",
|
||||
default=False,
|
||||
update=utils.save_prefs,
|
||||
)
|
||||
|
||||
header_menu_fold: BoolProperty(
|
||||
name="Header menu fold", default=False, update=ui_panels.update_header_menu_fold
|
||||
)
|
||||
@@ -2209,15 +2298,21 @@ In this case you should also set path to your system CA bundle containing proxy'
|
||||
update=utils.save_prefs,
|
||||
)
|
||||
|
||||
max_assetbar_rows: IntProperty(
|
||||
name="Max Assetbar Rows",
|
||||
description="max rows of assetbar in the 3D view",
|
||||
default=1,
|
||||
min=1,
|
||||
maximized_assetbar_rows: IntProperty(
|
||||
name="Maximized Assetbar Rows",
|
||||
description="Maximum rows of assetbar in the 3D view when expanded",
|
||||
default=4,
|
||||
min=2,
|
||||
max=20,
|
||||
update=utils.save_prefs,
|
||||
)
|
||||
|
||||
assetbar_expanded: BoolProperty(
|
||||
name="Assetbar Expanded",
|
||||
description="Whether the assetbar is currently expanded to show maximum rows",
|
||||
default=False,
|
||||
)
|
||||
|
||||
thumb_size: IntProperty(
|
||||
name="Assetbar Thumbnail Size",
|
||||
default=96,
|
||||
@@ -2329,6 +2424,12 @@ In this case you should also set path to your system CA bundle containing proxy'
|
||||
update=search.search_update,
|
||||
) # In future we can subsets like sexualized, pornography or violence subset. And allow users choose what is part of NSFW.
|
||||
|
||||
temp_enabled_addons: StringProperty(
|
||||
name="Temporarily Enabled Addons",
|
||||
description="JSON string of temporarily enabled addon package IDs that should be disabled on next session",
|
||||
default="[]",
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
if self.api_key.strip() == "":
|
||||
@@ -2362,9 +2463,10 @@ In this case you should also set path to your system CA bundle containing proxy'
|
||||
gui_settings.label(text="GUI settings")
|
||||
gui_settings.prop(self, "show_on_start")
|
||||
gui_settings.prop(self, "thumb_size")
|
||||
gui_settings.prop(self, "max_assetbar_rows")
|
||||
gui_settings.prop(self, "maximized_assetbar_rows")
|
||||
gui_settings.prop(self, "search_field_width")
|
||||
gui_settings.prop(self, "search_in_header")
|
||||
gui_settings.prop(self, "sidebar_panels")
|
||||
gui_settings.prop(self, "show_VIEW3D_MT_blenderkit_model_properties")
|
||||
gui_settings.prop(self, "tips_on_start")
|
||||
gui_settings.prop(self, "announcements_on_start")
|
||||
@@ -2434,6 +2536,7 @@ classes = (
|
||||
BlenderKitBrushUploadProps,
|
||||
BlenderKitGeoToolSearchProps,
|
||||
BlenderKitNodeGroulUploadProps,
|
||||
BlenderKitAddonSearchProps,
|
||||
)
|
||||
|
||||
|
||||
@@ -2500,6 +2603,9 @@ def register():
|
||||
bpy.types.NodeTree.blenderkit = PointerProperty( # for uploads, not now...
|
||||
type=BlenderKitNodeGroulUploadProps
|
||||
)
|
||||
bpy.types.WindowManager.blenderkit_addon = PointerProperty(
|
||||
type=BlenderKitAddonSearchProps
|
||||
)
|
||||
if bpy.app.factory_startup is False:
|
||||
user_preferences = bpy.context.preferences.addons[__package__].preferences
|
||||
global_vars.PREFS = utils.get_preferences_as_dict()
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
include:
|
||||
- "**/*.py"
|
||||
|
||||
exclude_dirs:
|
||||
- "tests"
|
||||
- ".venv"
|
||||
- "__pycache__"
|
||||
|
||||
skips:
|
||||
- "B404" # https://bandit.readthedocs.io/en/1.7.10/blacklists/blacklist_imports.html#b404-import-subprocess
|
||||
- "B603" # https://bandit.readthedocs.io/en/1.7.10/plugins/b603_subprocess_without_shell_equals_true.html
|
||||
- "B608" # https://bandit.readthedocs.io/en/1.7.10/plugins/b608_hardcoded_sql_expressions.html
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import bpy
|
||||
|
||||
@@ -55,11 +56,34 @@ def append_brush(file_name, brushname=None, link=False, fake_user=True):
|
||||
|
||||
|
||||
def append_nodegroup(
|
||||
file_name, nodegroupname=None, link=False, fake_user=True, node_x=0, node_y=0
|
||||
file_name,
|
||||
nodegroupname=None,
|
||||
link=False,
|
||||
fake_user=True,
|
||||
node_x=0,
|
||||
node_y=0,
|
||||
target_object=None,
|
||||
nodegroup_mode="",
|
||||
model_location=(0, 0, 0),
|
||||
model_rotation=(0, 0, 0),
|
||||
**kwargs,
|
||||
):
|
||||
"""Append selected node group. If nodegroupname is None, first node group is appended.
|
||||
If node group with the same name is already in the scene, it is not appended again.
|
||||
Try to look for a suitable node editor and insert the node group there, in the middle of the area.
|
||||
Try to look for a suitable node editor and insert the node group there, or create/use modifier based on mode.
|
||||
For geometry nodegroups, if no target object is provided, a target object will be created automatically.
|
||||
|
||||
Args:
|
||||
file_name: Path to the .blend file containing the nodegroup
|
||||
nodegroupname: Name of the nodegroup to append
|
||||
link: Whether to link or append
|
||||
fake_user: Whether to set fake user
|
||||
node_x: X position for node placement in editor
|
||||
node_y: Y position for node placement in editor
|
||||
target_object: Target object for modifier mode (name string). If None and nodegroup is geometry type, a target object will be created
|
||||
nodegroup_mode: How to add the nodegroup - "MODIFIER" for new modifier, "NODE" for node in editor, "" for default behavior
|
||||
model_location: Location for the target object (used when creating new target)
|
||||
model_rotation: Rotation for the target object (used when creating new target)
|
||||
|
||||
Returns:
|
||||
tuple: (nodegroup, added_to_editor) - The nodegroup and whether it was added to an editor
|
||||
@@ -76,6 +100,22 @@ def append_nodegroup(
|
||||
nodegroup = bpy.data.node_groups[nodegroupname]
|
||||
nodegroup.use_fake_user = fake_user
|
||||
|
||||
# Create target object automatically for geometry nodegroups when no target is provided
|
||||
auto_created_target: Optional[bpy.types.Object] = None
|
||||
if nodegroup.bl_rna.identifier == "GeometryNodeTree" and not target_object:
|
||||
# Create a default mesh cube
|
||||
bpy.ops.mesh.primitive_cube_add(
|
||||
size=2, location=model_location, rotation=model_rotation
|
||||
)
|
||||
target_obj = bpy.context.active_object
|
||||
target_obj.name = "GeometryNodeTarget"
|
||||
target_object = target_obj.name
|
||||
auto_created_target = target_obj
|
||||
|
||||
# Make sure it's selected and active
|
||||
bpy.context.view_layer.objects.active = target_obj
|
||||
target_obj.select_set(True)
|
||||
|
||||
# Mapping dict for node editor tree types to node group node types
|
||||
sdict = {
|
||||
"GeometryNodeTree": "GeometryNodeGroup",
|
||||
@@ -86,25 +126,119 @@ def append_nodegroup(
|
||||
# Get the nodegroup type
|
||||
nodegroup_type = nodegroup.bl_rna.identifier
|
||||
|
||||
# Find a suitable node editor
|
||||
# If no explicit mode is set, try to detect if we should add to an existing editor first
|
||||
# This allows drag-drop into existing node editors to work properly
|
||||
if not nodegroup_mode:
|
||||
# Find a suitable node editor
|
||||
for area in bpy.context.screen.areas:
|
||||
if area.type != "NODE_EDITOR":
|
||||
continue
|
||||
|
||||
if area.spaces.active.tree_type == nodegroup_type:
|
||||
nt = area.spaces.active.edit_tree
|
||||
if nt is None:
|
||||
continue
|
||||
|
||||
# Add node to this editor
|
||||
for n in nt.nodes:
|
||||
n.select = False
|
||||
|
||||
node_type = sdict.get(nodegroup_type)
|
||||
if node_type:
|
||||
node = nt.nodes.new(node_type)
|
||||
node.node_tree = nodegroup
|
||||
node.location = (node_x, node_y)
|
||||
node.select = True
|
||||
nt.nodes.active = node
|
||||
return (nodegroup, True)
|
||||
|
||||
# Handle modifier mode for geometry nodegroups
|
||||
if nodegroup_mode == "MODIFIER" and target_object:
|
||||
target_obj = bpy.data.objects.get(target_object)
|
||||
if target_obj and nodegroup.bl_rna.identifier == "GeometryNodeTree":
|
||||
# Create a new geometry nodes modifier with this nodegroup
|
||||
gn_mod = target_obj.modifiers.new(name=nodegroup.name, type="NODES")
|
||||
gn_mod.node_group = nodegroup
|
||||
|
||||
# Select the target object to make the change visible
|
||||
bpy.context.view_layer.objects.active = target_obj
|
||||
if target_obj not in bpy.context.selected_objects:
|
||||
target_obj.select_set(True)
|
||||
|
||||
return (
|
||||
nodegroup,
|
||||
True,
|
||||
) # Return True as we "added" it successfully to the modifier
|
||||
|
||||
# Handle node mode for geometry nodegroups with target object
|
||||
# Create a modifier setup and then add the nodegroup as a node to the tree
|
||||
if (
|
||||
nodegroup_mode == "NODE"
|
||||
and target_object
|
||||
and nodegroup.bl_rna.identifier == "GeometryNodeTree"
|
||||
):
|
||||
target_obj = bpy.data.objects.get(target_object)
|
||||
if target_obj:
|
||||
# Select the target object to make it active
|
||||
bpy.context.view_layer.objects.active = target_obj
|
||||
if target_obj not in bpy.context.selected_objects:
|
||||
target_obj.select_set(True)
|
||||
# look for the geometry nodes modifier
|
||||
gn_mod = None
|
||||
for mod in target_obj.modifiers:
|
||||
if mod.type == "NODES" and mod.node_group:
|
||||
gn_mod = mod
|
||||
break
|
||||
if not gn_mod:
|
||||
# create a new geometry nodes modifier
|
||||
gn_mod = target_obj.modifiers.new(name="GeometryNodes", type="NODES")
|
||||
if not gn_mod.node_group:
|
||||
# create a new node group
|
||||
bpy.ops.node.new_geometry_node_group_assign()
|
||||
|
||||
node_tree = gn_mod.node_group
|
||||
|
||||
if node_tree:
|
||||
# Add the nodegroup as a node to the tree
|
||||
group_node = node_tree.nodes.new("GeometryNodeGroup")
|
||||
group_node.node_tree = nodegroup
|
||||
group_node.location = (node_x, node_y)
|
||||
group_node.select = True
|
||||
node_tree.nodes.active = group_node
|
||||
|
||||
return (nodegroup, True)
|
||||
|
||||
# If not added yet through modes or if no mode specified, try to find any compatible editor
|
||||
added_to_editor = False
|
||||
|
||||
# First try: exact match for tree type
|
||||
# Try any compatible editor
|
||||
for area in bpy.context.screen.areas:
|
||||
if area.type != "NODE_EDITOR":
|
||||
continue
|
||||
|
||||
if area.spaces.active.tree_type == nodegroup_type:
|
||||
nt = area.spaces.active.edit_tree
|
||||
if nt is None:
|
||||
continue
|
||||
nt = area.spaces.active.edit_tree
|
||||
if nt is None:
|
||||
continue
|
||||
|
||||
# Check if this editor type is compatible
|
||||
if area.spaces.active.tree_type in sdict:
|
||||
# Add node to this editor
|
||||
for n in nt.nodes:
|
||||
n.select = False
|
||||
|
||||
node_type = sdict.get(nodegroup_type)
|
||||
node_type = sdict.get(area.spaces.active.tree_type)
|
||||
if node_type:
|
||||
# Check if nodegroup is compatible with this editor
|
||||
# For example, don't add shader nodegroups to geometry node editor
|
||||
if (
|
||||
nodegroup_type == "ShaderNodeTree"
|
||||
and area.spaces.active.tree_type != "ShaderNodeTree"
|
||||
) or (
|
||||
nodegroup_type == "GeometryNodeTree"
|
||||
and area.spaces.active.tree_type != "GeometryNodeTree"
|
||||
):
|
||||
continue
|
||||
|
||||
node = nt.nodes.new(node_type)
|
||||
node.node_tree = nodegroup
|
||||
node.location = (node_x, node_y)
|
||||
@@ -113,42 +247,20 @@ def append_nodegroup(
|
||||
added_to_editor = True
|
||||
break
|
||||
|
||||
# If not added yet, try any compatible editor
|
||||
if not added_to_editor:
|
||||
for area in bpy.context.screen.areas:
|
||||
if area.type != "NODE_EDITOR":
|
||||
continue
|
||||
|
||||
nt = area.spaces.active.edit_tree
|
||||
if nt is None:
|
||||
continue
|
||||
|
||||
# Check if this editor type is compatible
|
||||
if area.spaces.active.tree_type in sdict:
|
||||
# Add node to this editor
|
||||
for n in nt.nodes:
|
||||
n.select = False
|
||||
|
||||
node_type = sdict.get(area.spaces.active.tree_type)
|
||||
if node_type:
|
||||
# Check if nodegroup is compatible with this editor
|
||||
# For example, don't add shader nodegroups to geometry node editor
|
||||
if (
|
||||
nodegroup_type == "ShaderNodeTree"
|
||||
and area.spaces.active.tree_type != "ShaderNodeTree"
|
||||
) or (
|
||||
nodegroup_type == "GeometryNodeTree"
|
||||
and area.spaces.active.tree_type != "GeometryNodeTree"
|
||||
):
|
||||
continue
|
||||
|
||||
node = nt.nodes.new(node_type)
|
||||
node.node_tree = nodegroup
|
||||
node.location = (node_x, node_y)
|
||||
node.select = True
|
||||
nt.nodes.active = node
|
||||
added_to_editor = True
|
||||
break
|
||||
# Ensure automatically created targets receive the nodegroup as modifier
|
||||
if auto_created_target:
|
||||
gn_mod = None
|
||||
for mod in auto_created_target.modifiers:
|
||||
if mod.type == "NODES":
|
||||
gn_mod = mod
|
||||
break
|
||||
if not gn_mod:
|
||||
gn_mod = auto_created_target.modifiers.new(
|
||||
name=nodegroup.name, type="NODES"
|
||||
)
|
||||
gn_mod.node_group = nodegroup
|
||||
auto_created_target.select_set(True)
|
||||
bpy.context.view_layer.objects.active = auto_created_target
|
||||
|
||||
return nodegroup, added_to_editor
|
||||
|
||||
@@ -246,15 +358,18 @@ def hdr_swap(name, hdr):
|
||||
:return: None
|
||||
"""
|
||||
w = bpy.context.scene.world
|
||||
if w:
|
||||
if not w:
|
||||
new_hdr_world(name, hdr)
|
||||
|
||||
if bpy.app.version < (5, 0, 0):
|
||||
w.use_nodes = True
|
||||
w.name = name
|
||||
nt = w.node_tree
|
||||
for n in nt.nodes:
|
||||
if "ShaderNodeTexEnvironment" == n.bl_rna.identifier:
|
||||
env_node = n
|
||||
env_node.image = hdr
|
||||
return
|
||||
w.name = name
|
||||
nt = w.node_tree
|
||||
for n in nt.nodes:
|
||||
if "ShaderNodeTexEnvironment" == n.bl_rna.identifier:
|
||||
env_node = n
|
||||
env_node.image = hdr
|
||||
return
|
||||
new_hdr_world(name, hdr)
|
||||
|
||||
|
||||
@@ -266,7 +381,8 @@ def new_hdr_world(name, hdr):
|
||||
:return: None
|
||||
"""
|
||||
w = bpy.data.worlds.new(name=name)
|
||||
w.use_nodes = True
|
||||
if bpy.app.version < (5, 0, 0):
|
||||
w.use_nodes = True
|
||||
bpy.context.scene.world = w
|
||||
|
||||
nt = w.node_tree
|
||||
@@ -302,11 +418,11 @@ def load_HDR(file_name, name):
|
||||
|
||||
def link_collection(
|
||||
file_name,
|
||||
obnames=None,
|
||||
obnames: Optional[list] = None,
|
||||
location=(0, 0, 0),
|
||||
link=False,
|
||||
parent=None,
|
||||
collection="",
|
||||
link: bool = False,
|
||||
parent: Optional[str] = None,
|
||||
collection: str = "",
|
||||
**kwargs,
|
||||
):
|
||||
"""link an instanced group - model type asset"""
|
||||
@@ -314,7 +430,7 @@ def link_collection(
|
||||
obnames = []
|
||||
sel = utils.selection_get()
|
||||
# Store the original active collection
|
||||
orig_active_collection = bpy.context.view_layer.active_layer_collection
|
||||
orig_active_collection = bpy.context.view_layer.active_layer_collection # type: ignore[union-attr]
|
||||
|
||||
# Activate target collection if specified
|
||||
if collection:
|
||||
@@ -322,10 +438,10 @@ def link_collection(
|
||||
if target_collection:
|
||||
# Find and activate the layer collection
|
||||
layer_collection = find_layer_collection(
|
||||
bpy.context.view_layer.layer_collection, collection
|
||||
bpy.context.view_layer.layer_collection, collection # type: ignore[union-attr]
|
||||
)
|
||||
if layer_collection:
|
||||
bpy.context.view_layer.active_layer_collection = layer_collection
|
||||
bpy.context.view_layer.active_layer_collection = layer_collection # type: ignore[union-attr]
|
||||
|
||||
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
|
||||
data_from,
|
||||
@@ -340,33 +456,33 @@ def link_collection(
|
||||
rotation = kwargs["rotation"]
|
||||
|
||||
bpy.ops.object.empty_add(type="PLAIN_AXES", location=location, rotation=rotation)
|
||||
main_object = bpy.context.view_layer.objects.active
|
||||
main_object.instance_type = "COLLECTION"
|
||||
main_object = bpy.context.view_layer.objects.active # type: ignore[union-attr]
|
||||
main_object.instance_type = "COLLECTION" # type: ignore[union-attr]
|
||||
|
||||
if parent is not None:
|
||||
main_object.parent = bpy.data.objects.get(parent)
|
||||
if parent is not None and parent != "":
|
||||
main_object.parent = bpy.data.objects.get(parent) # type: ignore[union-attr]
|
||||
|
||||
main_object.matrix_world.translation = location
|
||||
main_object.matrix_world.translation = location # type: ignore[union-attr]
|
||||
|
||||
for col in bpy.data.collections:
|
||||
if col.library is not None:
|
||||
fp = bpy.path.abspath(col.library.filepath)
|
||||
fp1 = bpy.path.abspath(file_name)
|
||||
if fp == fp1:
|
||||
main_object.instance_collection = col
|
||||
main_object.instance_collection = col # type: ignore[union-attr]
|
||||
break
|
||||
|
||||
# sometimes, the lib might already be without the actual link.
|
||||
if not main_object.instance_collection and kwargs["name"]:
|
||||
if not main_object.instance_collection and kwargs["name"]: # type: ignore[union-attr]
|
||||
col = bpy.data.collections.get(kwargs["name"])
|
||||
if col:
|
||||
main_object.instance_collection = col
|
||||
main_object.instance_collection = col # type: ignore[union-attr]
|
||||
|
||||
main_object.name = main_object.instance_collection.name
|
||||
main_object.name = main_object.instance_collection.name # type: ignore[union-attr]
|
||||
|
||||
# Restore original active collection
|
||||
if orig_active_collection:
|
||||
bpy.context.view_layer.active_layer_collection = orig_active_collection
|
||||
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr]
|
||||
|
||||
utils.selection_set(sel)
|
||||
return main_object, []
|
||||
@@ -449,7 +565,13 @@ def append_particle_system(
|
||||
|
||||
|
||||
def append_objects(
|
||||
file_name, obnames=None, location=(0, 0, 0), link=False, collection="", **kwargs
|
||||
file_name,
|
||||
obnames: Optional[list] = None,
|
||||
location=(0, 0, 0),
|
||||
link: bool = False,
|
||||
parent: Optional[str] = None,
|
||||
collection: str = "",
|
||||
**kwargs,
|
||||
):
|
||||
"""Append object into scene individually. 2 approaches based in definition of name argument.
|
||||
TODO: really split this function into 2 functions: kwargs.get('name')==None and else.
|
||||
@@ -461,7 +583,7 @@ def append_objects(
|
||||
scene = bpy.context.scene
|
||||
sel = utils.selection_get()
|
||||
# Store the original active collection
|
||||
orig_active_collection = bpy.context.view_layer.active_layer_collection
|
||||
orig_active_collection = bpy.context.view_layer.active_layer_collection # type: ignore[union-attr]
|
||||
|
||||
# Activate target collection if specified
|
||||
if collection:
|
||||
@@ -469,10 +591,10 @@ def append_objects(
|
||||
if target_collection:
|
||||
# Find and activate the layer collection
|
||||
layer_collection = find_layer_collection(
|
||||
bpy.context.view_layer.layer_collection, collection
|
||||
bpy.context.view_layer.layer_collection, collection # type: ignore[union-attr]
|
||||
)
|
||||
if layer_collection:
|
||||
bpy.context.view_layer.active_layer_collection = layer_collection
|
||||
bpy.context.view_layer.active_layer_collection = layer_collection # type: ignore[union-attr]
|
||||
|
||||
try:
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
@@ -486,6 +608,9 @@ def append_objects(
|
||||
|
||||
path = file_name + "/Collection"
|
||||
collection_name = kwargs.get("name")
|
||||
if collection_name is None:
|
||||
bk_logger.warning("collection_name is None")
|
||||
collection_name = ""
|
||||
bpy.ops.wm.append(filename=collection_name, directory=path)
|
||||
|
||||
# fc = utils.get_fake_context(bpy.context, area_type='VIEW_3D')
|
||||
@@ -496,13 +621,13 @@ def append_objects(
|
||||
appended_collection = None
|
||||
main_object = None
|
||||
# get first at least one parent for sure
|
||||
for ob in bpy.context.scene.objects:
|
||||
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
|
||||
if ob.select_get():
|
||||
if not ob.parent:
|
||||
main_object = ob
|
||||
ob.location = location
|
||||
# do once again to ensure hidden objects are hidden
|
||||
for ob in bpy.context.scene.objects:
|
||||
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
|
||||
if ob.select_get():
|
||||
return_obs.append(ob)
|
||||
# check for object that should be hidden
|
||||
@@ -521,14 +646,14 @@ def append_objects(
|
||||
if kwargs.get("rotation"):
|
||||
main_object.rotation_euler = kwargs["rotation"]
|
||||
|
||||
if kwargs.get("parent") is not None:
|
||||
main_object.parent = bpy.data.objects[kwargs["parent"]]
|
||||
if parent is not None and parent != "":
|
||||
main_object.parent = bpy.data.objects[parent]
|
||||
main_object.matrix_world.translation = location
|
||||
|
||||
# move objects that should be hidden to a sub collection
|
||||
if len(to_hidden_collection) > 0 and appended_collection is not None:
|
||||
hidden_collections = []
|
||||
scene_collection = bpy.context.scene.collection
|
||||
scene_collection = bpy.context.scene.collection # type: ignore[union-attr]
|
||||
for ob in to_hidden_collection:
|
||||
hide_collection = ob.users_collection[0]
|
||||
|
||||
@@ -577,7 +702,7 @@ def append_objects(
|
||||
|
||||
# Restore original active collection
|
||||
if orig_active_collection:
|
||||
bpy.context.view_layer.active_layer_collection = orig_active_collection
|
||||
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr]
|
||||
|
||||
utils.selection_set(sel)
|
||||
# let collection also store info that it was created by BlenderKit, for purging reasons
|
||||
@@ -618,7 +743,7 @@ def append_objects(
|
||||
for obj in data_to.objects:
|
||||
if obj is not None:
|
||||
# if obj.name not in scene.objects:
|
||||
scene.collection.objects.link(obj)
|
||||
scene.collection.objects.link(obj) # type: ignore[union-attr]
|
||||
if obj.parent is None:
|
||||
obj.location = location
|
||||
main_object = obj
|
||||
@@ -637,11 +762,11 @@ def append_objects(
|
||||
ob.hide_viewport = True
|
||||
|
||||
if kwargs.get("rotation") is not None:
|
||||
main_object.rotation_euler = kwargs["rotation"]
|
||||
main_object.rotation_euler = kwargs["rotation"] # type: ignore[union-attr]
|
||||
|
||||
if kwargs.get("parent") is not None:
|
||||
main_object.parent = bpy.data.objects[kwargs["parent"]]
|
||||
main_object.matrix_world.translation = location
|
||||
if parent is not None and parent != "":
|
||||
main_object.parent = bpy.data.objects[parent] # type: ignore[union-attr]
|
||||
main_object.matrix_world.translation = location # type: ignore[union-attr]
|
||||
|
||||
try:
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,7 @@ def get_texture_ui(tpath, iname):
|
||||
|
||||
|
||||
def check_thumbnail(props, imgpath):
|
||||
# TODO implement check if the file exists, if size is corect etc. needs some care
|
||||
# TODO implement check if the file exists, if size is correct etc. needs some care
|
||||
if imgpath == "":
|
||||
props.has_thumbnail = False
|
||||
return None
|
||||
|
||||
@@ -104,7 +104,7 @@ if __name__ == "__main__":
|
||||
asset_data["files"][0]["file_name"] = file_name
|
||||
if not has_url:
|
||||
bg_blender.progress(
|
||||
"couldn't download asset for thumnbail re-rendering"
|
||||
"couldn't download asset for thumbnail re-rendering"
|
||||
)
|
||||
exit()
|
||||
# download first, or rather make sure if it's already downloaded
|
||||
|
||||
@@ -163,7 +163,7 @@ if __name__ == "__main__":
|
||||
asset_data["files"][0]["file_name"] = file_name
|
||||
if has_url is not True:
|
||||
bg_blender.progress(
|
||||
"couldn't download asset for thumnbail re-rendering"
|
||||
"couldn't download asset for thumbnail re-rendering"
|
||||
)
|
||||
bg_blender.progress("downloading asset")
|
||||
fpath = bg_utils.download_asset_file(
|
||||
|
||||
@@ -31,6 +31,8 @@ from bpy.props import BoolProperty
|
||||
|
||||
from . import client_lib, client_tasks, datas, global_vars, reports, tasks_queue, utils
|
||||
|
||||
if bpy.app.version >= (4, 2, 0):
|
||||
from . import override_extension_draw
|
||||
|
||||
CLIENT_ID = "IdFRwa3SGA8eMpzhRVFMg5Ts8sPK93xBjif93x0F"
|
||||
REFRESH_RESERVE = 60 * 60 * 24 * 3 # 3 days
|
||||
@@ -103,10 +105,9 @@ def clean_login_data():
|
||||
preferences.api_key_timeout = 0
|
||||
global_vars.BKIT_PROFILE = datas.MineProfile()
|
||||
# Cleanup also the api key in the extensions repository setting and clean the cache
|
||||
from . import override_extension_draw
|
||||
|
||||
override_extension_draw.ensure_repository(api_key="")
|
||||
override_extension_draw.clear_repo_cache()
|
||||
if bpy.app.version >= (4, 2, 0):
|
||||
override_extension_draw.ensure_repository(api_key="")
|
||||
override_extension_draw.clear_repo_cache()
|
||||
|
||||
|
||||
def logout() -> None:
|
||||
@@ -158,8 +159,6 @@ def write_tokens(auth_token, refresh_token, oauth_response):
|
||||
preferences.api_key = auth_token # triggers api_key update function
|
||||
# write token also to extensions repository setting and clear the cache
|
||||
if bpy.app.version >= (4, 2, 0):
|
||||
from . import override_extension_draw
|
||||
|
||||
override_extension_draw.ensure_repository(api_key=auth_token)
|
||||
override_extension_draw.clear_repo_cache()
|
||||
|
||||
|
||||
+144
-58
@@ -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")
|
||||
|
||||
+323
-69
@@ -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
|
||||
|
||||
+202
-24
@@ -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]
|
||||
|
||||
|
||||
+1181
-123
File diff suppressed because it is too large
Load Diff
+52
-26
@@ -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)
|
||||
|
||||
|
||||
|
||||
+59
-5
@@ -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")
|
||||
|
||||
+7
-1
@@ -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()
|
||||
|
||||
#
|
||||
|
||||
|
||||
+6
-6
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -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()
|
||||
|
||||
+2
-2
@@ -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):
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -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:
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -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():
|
||||
|
||||
+8
-2
@@ -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 = ""
|
||||
|
||||
+4
-3
@@ -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()
|
||||
|
||||
|
||||
|
||||
+235
-141
@@ -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()
|
||||
|
||||
+11
-4
@@ -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()
|
||||
|
||||
|
||||
+282
-36
@@ -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
|
||||
|
||||
+19
-5
@@ -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)
|
||||
|
||||
+7
-1
@@ -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"]
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+201
-92
@@ -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)
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
-1
@@ -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 (
|
||||
|
||||
+35
@@ -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
|
||||
|
||||
+44
-3
@@ -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()
|
||||
|
||||
+356
-60
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+5
-2
@@ -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()
|
||||
|
||||
|
||||
+78
-63
@@ -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,
|
||||
|
||||
+147
-2
@@ -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)
|
||||
|
||||
+167
-76
@@ -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
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"last_check": "2025-06-23 09:38:03.256719",
|
||||
"backup_date": "June-23-2025",
|
||||
"last_check": "2025-12-01 11:02:25.858363",
|
||||
"backup_date": "December-1-2025",
|
||||
"update_ready": false,
|
||||
"ignore": false,
|
||||
"just_restored": false,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import blf
|
||||
import bpy
|
||||
@@ -6,9 +8,14 @@ import gpu
|
||||
|
||||
from .. import image_utils, ui_bgl
|
||||
from .bl_ui_widget import BL_UI_Widget
|
||||
from .bl_ui_image import BL_UI_Image
|
||||
|
||||
bk_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BL_UI_Button(BL_UI_Widget):
|
||||
"""Image Button for assets in asset bar."""
|
||||
|
||||
def __init__(self, x, y, width, height):
|
||||
super().__init__(x, y, width, height)
|
||||
self._text_color = (1.0, 1.0, 1.0, 1.0)
|
||||
@@ -89,7 +96,7 @@ class BL_UI_Button(BL_UI_Widget):
|
||||
except Exception as e:
|
||||
self.__image = None
|
||||
|
||||
def set_image_colorspace(self, colorspace):
|
||||
def set_image_colorspace(self, colorspace: str = ""):
|
||||
image_utils.set_colorspace(self.__image, colorspace)
|
||||
|
||||
def set_image(self, rel_filepath):
|
||||
@@ -98,22 +105,10 @@ class BL_UI_Button(BL_UI_Widget):
|
||||
try:
|
||||
if self.__image is None or self.__image.filepath != rel_filepath:
|
||||
imgname = f".{os.path.basename(rel_filepath)}"
|
||||
img = bpy.data.images.get(imgname)
|
||||
if img is not None:
|
||||
self.__image = img
|
||||
else:
|
||||
self.__image = bpy.data.images.load(
|
||||
rel_filepath, check_existing=True
|
||||
)
|
||||
self.__image.name = imgname
|
||||
self.__image = image_utils.IMG(name=imgname, filepath=rel_filepath)
|
||||
|
||||
self.__image.gl_load()
|
||||
|
||||
if self.__image and len(self.__image.pixels) == 0:
|
||||
self.__image.reload()
|
||||
self.__image.gl_load()
|
||||
except Exception as e:
|
||||
print(f"BL_UI_BUTTON set_image() error: {e}")
|
||||
except Exception:
|
||||
bk_logger.exception("BL_UI_BUTTON set_image() error:")
|
||||
self.__image = None
|
||||
|
||||
def get_image_path(self):
|
||||
@@ -185,7 +180,7 @@ class BL_UI_Button(BL_UI_Widget):
|
||||
y_screen_flip = self.get_area_height() - self.y_screen
|
||||
off_x, off_y = self.__image_position
|
||||
sx, sy = self.__image_size
|
||||
ui_bgl.draw_image(
|
||||
ui_bgl.draw_image_runtime(
|
||||
self.x_screen + off_x,
|
||||
y_screen_flip - off_y - sy,
|
||||
sx,
|
||||
@@ -206,10 +201,22 @@ class BL_UI_Button(BL_UI_Widget):
|
||||
self.__state = 1
|
||||
try:
|
||||
self.mouse_down_func(self)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
except Exception:
|
||||
bk_logger.exception("BL_UI_BUTTON mouse_down() error:")
|
||||
|
||||
traceback.print_exc()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_mouse_down_right(self, mouse_down_right_func):
|
||||
self.mouse_down_right_func = mouse_down_right_func
|
||||
|
||||
def mouse_down_right(self, x, y):
|
||||
if self.is_in_rect(x, y):
|
||||
try:
|
||||
self.mouse_down_right_func(self)
|
||||
except Exception:
|
||||
bk_logger.exception("BL_UI_BUTTON mouse_down_right() error:")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import traceback
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
|
||||
bk_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BL_UI_OT_draw_operator(Operator):
|
||||
bl_idname = "object.bl_ui_ot_draw_operator"
|
||||
@@ -23,7 +27,7 @@ class BL_UI_OT_draw_operator(Operator):
|
||||
for widget in self.widgets:
|
||||
widget.init(context)
|
||||
|
||||
def on_invoke(self, context, event):
|
||||
def on_invoke(self, context, event) -> Optional[bool]:
|
||||
pass
|
||||
|
||||
def on_finish(self, context):
|
||||
@@ -105,7 +109,7 @@ class BL_UI_OT_draw_operator(Operator):
|
||||
|
||||
|
||||
def draw_callback_px_separated(self, op, context):
|
||||
# separated only for puprpose of profiling
|
||||
# separated only for purpose of profiling
|
||||
try:
|
||||
# hide during animation playback, to improve performance
|
||||
if context.screen.is_animation_playing:
|
||||
@@ -113,5 +117,5 @@ def draw_callback_px_separated(self, op, context):
|
||||
if context.area.as_pointer() == self.active_area_pointer:
|
||||
for widget in self.widgets:
|
||||
widget.draw()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
except Exception:
|
||||
bk_logger.exception("Error in draw_callback_px_separated: ")
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
import bpy
|
||||
import gpu
|
||||
|
||||
from .. import image_utils, ui_bgl
|
||||
from .bl_ui_widget import BL_UI_Widget
|
||||
|
||||
bk_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BL_UI_Image(BL_UI_Widget):
|
||||
"""A simple image widget.
|
||||
|
||||
Used to display bigger thumbnail with additional info,
|
||||
while hover over a button.
|
||||
"""
|
||||
|
||||
def __init__(self, x, y, width, height):
|
||||
super().__init__(x, y, width, height)
|
||||
|
||||
@@ -36,25 +45,12 @@ class BL_UI_Image(BL_UI_Widget):
|
||||
try:
|
||||
if self.__image is None or self.__image.filepath != rel_filepath:
|
||||
imgname = f".{os.path.basename(rel_filepath)}"
|
||||
img = bpy.data.images.get(imgname)
|
||||
if img is not None:
|
||||
self.__image = img
|
||||
else:
|
||||
self.__image = bpy.data.images.load(
|
||||
rel_filepath, check_existing=True
|
||||
)
|
||||
self.__image.name = imgname
|
||||
|
||||
self.__image.gl_load()
|
||||
|
||||
if self.__image and len(self.__image.pixels) == 0:
|
||||
self.__image.reload()
|
||||
self.__image.gl_load()
|
||||
self.__image = image_utils.IMG(name=imgname, filepath=rel_filepath)
|
||||
except Exception as e:
|
||||
print(f"BL_UI_BUTTON: exception in set_image(): {e}")
|
||||
bk_logger.exception("BL_UI_BUTTON: exception in set_image(): %s", e)
|
||||
self.__image = None
|
||||
|
||||
def set_image_colorspace(self, colorspace):
|
||||
def set_image_colorspace(self, colorspace: str = ""):
|
||||
image_utils.set_colorspace(self.__image, colorspace)
|
||||
|
||||
def get_image_path(self):
|
||||
@@ -69,9 +65,9 @@ class BL_UI_Image(BL_UI_Widget):
|
||||
def draw(self):
|
||||
if not self._is_visible:
|
||||
return
|
||||
gpu.state.blend_set("ALPHA")
|
||||
|
||||
self.shader.bind()
|
||||
|
||||
self.batch_panel.draw(self.shader)
|
||||
|
||||
self.draw_image()
|
||||
@@ -81,7 +77,7 @@ class BL_UI_Image(BL_UI_Widget):
|
||||
y_screen_flip = self.get_area_height() - self.y_screen
|
||||
off_x, off_y = self.__image_position
|
||||
sx, sy = self.__image_size
|
||||
ui_bgl.draw_image(
|
||||
ui_bgl.draw_image_runtime(
|
||||
self.x_screen + off_x,
|
||||
y_screen_flip - off_y - sy,
|
||||
sx,
|
||||
|
||||
@@ -20,6 +20,11 @@ class BL_UI_Widget:
|
||||
self._is_visible = True
|
||||
self._is_active = True # if the widget needs to be disabled
|
||||
|
||||
if bpy.app.version < (4, 0, 0):
|
||||
self.shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR")
|
||||
else:
|
||||
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
|
||||
def set_location(self, x, y):
|
||||
# if self.x != x or self.y != y or self.x_screen != x or self.y_screen != y:
|
||||
# bpy.context.region.tag_redraw()
|
||||
@@ -35,9 +40,9 @@ class BL_UI_Widget:
|
||||
|
||||
@bg_color.setter
|
||||
def bg_color(self, value):
|
||||
if value != self._bg_color:
|
||||
bpy.context.region.tag_redraw()
|
||||
self._bg_color = value
|
||||
if bpy.context.region is not None:
|
||||
bpy.context.region.tag_redraw()
|
||||
|
||||
@property
|
||||
def visible(self):
|
||||
@@ -71,6 +76,8 @@ class BL_UI_Widget:
|
||||
if not self._is_visible:
|
||||
return
|
||||
|
||||
gpu.state.blend_set("ALPHA")
|
||||
|
||||
self.shader.bind()
|
||||
self.shader.uniform_float("color", self._bg_color)
|
||||
|
||||
@@ -97,11 +104,6 @@ class BL_UI_Widget:
|
||||
(self.x_screen + self.width, y_screen_flip),
|
||||
)
|
||||
|
||||
if bpy.app.version < (4, 0, 0):
|
||||
self.shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR")
|
||||
else:
|
||||
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
|
||||
self.batch_panel = batch_for_shader(
|
||||
self.shader, "TRIS", {"pos": vertices}, indices=indices
|
||||
)
|
||||
@@ -187,8 +189,8 @@ class BL_UI_Widget:
|
||||
):
|
||||
# print('is in rect!?')
|
||||
# print('area height', area_height)
|
||||
# print ('x sceen ',self.x_screen,'x ', x, 'width', self.width)
|
||||
# print ('widghet y', widget_y,'y', y, 'height',self.height)
|
||||
# print ('x screen ',self.x_screen,'x ', x, 'width', self.width)
|
||||
# print ('widget y', widget_y,'y', y, 'height',self.height)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
|
||||
|
||||
id = "blenderkit"
|
||||
type = "add-on"
|
||||
version = "3.16.1-250612" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
|
||||
version = "3.18.0-251121" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
|
||||
|
||||
name = "BlenderKit Online Asset Library"
|
||||
tagline = "Drag & drop of assets from the community driven library"
|
||||
|
||||
@@ -124,6 +124,7 @@ def handle_categories_task(task: client_tasks.Task):
|
||||
"BRUSH": ["brush"],
|
||||
"NODEGROUP": ["nodegroup"],
|
||||
"PRINTABLE": ["printable"],
|
||||
"ADDON": ["addon"],
|
||||
}
|
||||
|
||||
if task.status == "finished":
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -679,6 +679,8 @@ def start_blenderkit_client():
|
||||
|
||||
def decide_client_binary_name() -> str:
|
||||
"""Decide the name of the BlenderKit-Client binary based on the current operating system and architecture.
|
||||
We unify the OS and CPU architecture naming to make it more accessible for general public.
|
||||
Darwin is renamed to MacOS. The CPU architecture is aligned to x86_64 or arm64.
|
||||
Possible return values:
|
||||
- blenderkit-client-windows-x86_64.exe
|
||||
- blenderkit-client-windows-arm64.exe
|
||||
@@ -687,14 +689,17 @@ def decide_client_binary_name() -> str:
|
||||
- blenderkit-client-macos-x86_64
|
||||
- blenderkit-client-macos-arm64
|
||||
"""
|
||||
os_name = platform.system()
|
||||
architecture = platform.machine()
|
||||
if os_name == "Darwin": # more user-friendly name for macOS
|
||||
os_name = platform.system().lower()
|
||||
if os_name == "darwin": # more user-friendly name for macOS
|
||||
os_name = "macos"
|
||||
if architecture == "AMD64": # fix for windows
|
||||
architecture = "x86_64"
|
||||
|
||||
if os_name == "Windows":
|
||||
architecture = platform.machine().lower()
|
||||
if architecture == "amd64": # align Windows convention
|
||||
architecture = "x86_64"
|
||||
elif architecture == "aarch64": # align Linux convention
|
||||
architecture = "arm64"
|
||||
|
||||
if os_name == "windows":
|
||||
return f"blenderkit-client-{os_name}-{architecture}.exe".lower()
|
||||
|
||||
return f"blenderkit-client-{os_name}-{architecture}".lower()
|
||||
|
||||
@@ -24,7 +24,18 @@ WHITE = (1, 1, 1, 0.9)
|
||||
TEXT = (0.9, 0.9, 0.9, 0.6)
|
||||
GREEN = (0.9, 1, 0.9, 0.6)
|
||||
RED = (1, 0.5, 0.5, 0.8)
|
||||
|
||||
|
||||
BLUE = (0.8, 0.8, 1, 0.8)
|
||||
|
||||
PURPLE = (0.8, 0.4, 1.0, 1.0) # Full Plan purple
|
||||
GREEN_FREE = (0.4, 0.8, 0.4, 1.0) # Green for free addons
|
||||
"""Color for validator reports."""
|
||||
|
||||
GRAY = (0.7, 0.7, 0.7, 0.6)
|
||||
"""Default color for debug reports."""
|
||||
|
||||
# pure colors
|
||||
PURE_WHITE = (1, 1, 1, 1)
|
||||
PURE_BLACK = (0, 0, 0, 1)
|
||||
PURE_GREEN = (0, 1, 0, 1)
|
||||
PURE_RED = (1, 0, 0, 1)
|
||||
PURE_BLUE = (0, 0, 1, 1)
|
||||
|
||||
@@ -27,7 +27,7 @@ class Prefs:
|
||||
unpack_files: bool
|
||||
show_on_start: bool
|
||||
thumb_size: int
|
||||
max_assetbar_rows: int
|
||||
maximized_assetbar_rows: int
|
||||
search_field_width: int
|
||||
search_in_header: bool
|
||||
tips_on_start: bool
|
||||
|
||||
@@ -99,6 +99,8 @@ class BlenderKitDisclaimerOperator(BL_UI_OT_draw_operator):
|
||||
self.hover_bg_color = (0.127, 0.034, 1, 1.0)
|
||||
self.text_color = (0.9, 0.9, 0.9, 1)
|
||||
|
||||
bk_logger.info("%s", self.message)
|
||||
|
||||
pix_size = get_text_size(
|
||||
font_id=1,
|
||||
text=self.message,
|
||||
@@ -241,6 +243,7 @@ class BlenderKitDisclaimerOperator(BL_UI_OT_draw_operator):
|
||||
|
||||
|
||||
def run_disclaimer_task(message: str, url: str, tip: bool):
|
||||
message = " ".join(message.split())
|
||||
fake_context = utils.get_fake_context(bpy.context)
|
||||
if bpy.app.version < (4, 0, 0):
|
||||
bpy.ops.view3d.blenderkit_disclaimer_widget( # type: ignore[attr-defined]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,16 +16,15 @@
|
||||
#
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
from collections import deque
|
||||
from logging import INFO, WARN
|
||||
from os import environ
|
||||
from subprocess import Popen
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from . import datas
|
||||
|
||||
|
||||
CLIENT_VERSION = "v1.5.0"
|
||||
CLIENT_VERSION = "v1.7.0"
|
||||
CLIENT_ACCESSIBLE = False
|
||||
"""Is Client accessible? Can add-on access it and call stuff which uses it?"""
|
||||
CLIENT_RUNNING = False
|
||||
@@ -42,7 +41,7 @@ DATA: dict = { # TODO: move these
|
||||
"asset comments": {},
|
||||
}
|
||||
|
||||
TABS = {
|
||||
TABS: dict[str, Any] = {
|
||||
"active_tab": 0, # Index of currently active tab
|
||||
"tabs": [ # List of all tabs
|
||||
{
|
||||
|
||||
@@ -18,10 +18,26 @@
|
||||
|
||||
import os
|
||||
import time
|
||||
from functools import lru_cache
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
bk_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IMG:
|
||||
name: str
|
||||
filepath: str
|
||||
|
||||
def gl_load(self):
|
||||
"""Imitates bpy.types.Image.gl_load() behavior."""
|
||||
return None
|
||||
|
||||
|
||||
def get_orig_render_settings():
|
||||
rs = bpy.context.scene.render
|
||||
ims = rs.image_settings
|
||||
@@ -91,21 +107,68 @@ def set_colorspace(img, colorspace: str = ""):
|
||||
if colorspace == "":
|
||||
colorspace = guess_colorspace()
|
||||
|
||||
if colorspace == "Non-Color":
|
||||
img.colorspace_settings.is_data = True
|
||||
else:
|
||||
img.colorspace_settings.name = colorspace
|
||||
except Exception as e:
|
||||
print(f"Colorspace {colorspace} not found: {e}")
|
||||
if hasattr(img, "colorspace_settings") and colorspace:
|
||||
if colorspace == "Non-Color":
|
||||
img.colorspace_settings.is_data = True
|
||||
else:
|
||||
img.colorspace_settings.name = colorspace
|
||||
|
||||
except Exception:
|
||||
bk_logger.exception("Colorspace '%s' not found: ", colorspace)
|
||||
|
||||
|
||||
def guess_colorspace():
|
||||
@lru_cache(maxsize=1)
|
||||
def list_available_image_colorspaces():
|
||||
"""Lists available color spaces in blender by creating a temporary image if needed.
|
||||
|
||||
Returns:
|
||||
List of color space names.
|
||||
"""
|
||||
# Check if there are existing images
|
||||
temp_image = None
|
||||
if bpy.data.images:
|
||||
img = bpy.data.images[0]
|
||||
else:
|
||||
# Create temporary image
|
||||
temp_image = bpy.data.images.new(
|
||||
"TempImage_ForColorSpaceList", width=1, height=1
|
||||
)
|
||||
img = temp_image
|
||||
|
||||
# Get available color spaces
|
||||
color_spaces = [
|
||||
cs.identifier
|
||||
for cs in img.colorspace_settings.bl_rna.properties["name"].enum_items
|
||||
]
|
||||
|
||||
# Clean up temporary image if created
|
||||
if temp_image:
|
||||
bpy.data.images.remove(temp_image)
|
||||
|
||||
return color_spaces
|
||||
|
||||
|
||||
def guess_colorspace() -> str:
|
||||
"""Tries to guess the colorspace from the current display device and available color spaces."""
|
||||
display_device = bpy.context.scene.display_settings.display_device
|
||||
if display_device == "sRGB":
|
||||
return "sRGB"
|
||||
if display_device == "ACES":
|
||||
return "aces"
|
||||
|
||||
# detect available color spaces on image data
|
||||
all_clr_spaces = list_available_image_colorspaces()
|
||||
|
||||
# try to match display device with color space
|
||||
for cs in all_clr_spaces:
|
||||
if display_device.lower() in cs.lower():
|
||||
return cs
|
||||
|
||||
# fallback
|
||||
if "sRGB" in all_clr_spaces:
|
||||
return "sRGB"
|
||||
return ""
|
||||
|
||||
|
||||
def analyze_image_is_true_hdr(image):
|
||||
import numpy
|
||||
|
||||
@@ -8,21 +8,21 @@ The original method is then called from the new method, with the same arguments,
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
|
||||
from . import icons
|
||||
|
||||
import bpy
|
||||
import bl_pkg.bl_extension_ui as exui
|
||||
from . import icons
|
||||
from bl_ui.space_userpref import (
|
||||
USERPREF_PT_addons,
|
||||
USERPREF_PT_extensions,
|
||||
USERPREF_MT_extensions_active_repo,
|
||||
)
|
||||
from bpy.props import EnumProperty
|
||||
from bpy.props import StringProperty, IntProperty
|
||||
from bpy.props import IntProperty, StringProperty
|
||||
from bpy.types import Operator
|
||||
import time
|
||||
|
||||
|
||||
EXTENSIONS_API_URL = "https://www.blenderkit.com/api/v1/extensions/"
|
||||
|
||||
bk_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --- New Modal Operator ---
|
||||
class BK_OT_buy_extension_and_watch(Operator):
|
||||
@@ -59,7 +59,7 @@ class BK_OT_buy_extension_and_watch(Operator):
|
||||
# Open the URL
|
||||
try:
|
||||
bpy.ops.wm.url_open(url=self.url)
|
||||
print(f"BlenderKit: Opening buy URL: {self.url}")
|
||||
bk_logger.info("Opening buy URL: %s.", self.url)
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Could not open URL: {e}")
|
||||
# Don't cancel, maybe the user still wants the refresh?
|
||||
@@ -75,10 +75,11 @@ class BK_OT_buy_extension_and_watch(Operator):
|
||||
self._last_refresh_time = (
|
||||
self._start_time
|
||||
) # Initialize to avoid immediate refresh
|
||||
print(
|
||||
f"BlenderKit: Started watching repository index {self.repo_index} for updates."
|
||||
bk_logger.info(
|
||||
"Started watching repository index %s for updates.", self.repo_index
|
||||
)
|
||||
context.area.tag_redraw() # Update UI to show operator is running if needed
|
||||
if context and context.area:
|
||||
context.area.tag_redraw() # Update UI to show operator is running if needed
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def modal(self, context, event):
|
||||
@@ -87,19 +88,19 @@ class BK_OT_buy_extension_and_watch(Operator):
|
||||
# --- Exit Conditions ---
|
||||
# 1. User closed Preferences or changed area
|
||||
if context.area is None or context.area.type != "PREFERENCES":
|
||||
print("BlenderKit: Preferences window closed or changed, stopping watcher.")
|
||||
bk_logger.info("Preferences window closed or changed, stopping watcher.")
|
||||
self.cancel(context)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# 2. Timeout
|
||||
if current_time - self._start_time > self._max_duration:
|
||||
print("BlenderKit: Watcher timed out, stopping.")
|
||||
bk_logger.info("Watcher timed out, stopping.")
|
||||
self.cancel(context)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# 3. User cancellation
|
||||
if event.type in {"RIGHTMOUSE", "ESC"}:
|
||||
print("BlenderKit: Watcher cancelled by user.")
|
||||
bk_logger.info("Watcher cancelled by user.")
|
||||
self.cancel(context)
|
||||
return {"CANCELLED"}
|
||||
|
||||
@@ -107,24 +108,25 @@ class BK_OT_buy_extension_and_watch(Operator):
|
||||
if event.type == "TIMER":
|
||||
# Check if refresh interval has passed
|
||||
if current_time - self._last_refresh_time >= self._refresh_interval:
|
||||
print(
|
||||
f"BlenderKit: Refresh interval reached, attempting sync for repo index {self.repo_index}..."
|
||||
bk_logger.info(
|
||||
"Refresh interval reached, attempting sync for repo index %s...",
|
||||
self.repo_index,
|
||||
)
|
||||
try:
|
||||
# Check if repo still exists at that index
|
||||
if self.repo_index < len(context.preferences.extensions.repos):
|
||||
bpy.ops.extensions.repo_sync(repo_index=self.repo_index)
|
||||
print(
|
||||
f"BlenderKit: repo_sync called for index {self.repo_index}."
|
||||
bk_logger.info(
|
||||
"repo_sync called for index %s.", self.repo_index
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"BlenderKit: Repository index {self.repo_index} no longer valid."
|
||||
bk_logger.info(
|
||||
"Repository index %s no longer valid.", self.repo_index
|
||||
)
|
||||
# Optionally cancel here if repo is gone
|
||||
except Exception as e:
|
||||
except:
|
||||
# This might fail if another operation is in progress
|
||||
print(f"BlenderKit: extensions.repo_sync failed: {e}")
|
||||
bk_logger.exception("extensions.repo_sync failed.")
|
||||
finally:
|
||||
self._last_refresh_time = (
|
||||
current_time # Reset timer regardless of success
|
||||
@@ -137,13 +139,32 @@ class BK_OT_buy_extension_and_watch(Operator):
|
||||
wm = context.window_manager
|
||||
wm.event_timer_remove(self._timer)
|
||||
self._timer = None
|
||||
print("BlenderKit: Watcher timer removed.")
|
||||
context.area.tag_redraw() # Update UI
|
||||
bk_logger.info("Watcher timer removed.")
|
||||
if context and context.area:
|
||||
context.area.tag_redraw() # Update UI
|
||||
|
||||
|
||||
# --- End New Modal Operator ---
|
||||
|
||||
|
||||
def redraw_preferences_once():
|
||||
"""Tag the redraw on the Blender preferences.
|
||||
Meant to be registered as a timer, runs just once.
|
||||
"""
|
||||
for window in bpy.context.window_manager.windows:
|
||||
screen = window.screen
|
||||
if not screen:
|
||||
continue
|
||||
for area in screen.areas:
|
||||
if area.type != "PREFERENCES":
|
||||
continue
|
||||
for region in area.regions:
|
||||
if region.type in {"UI", "WINDOW"}:
|
||||
region.tag_redraw()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extension_draw_item_blenderkit(
|
||||
layout,
|
||||
*,
|
||||
@@ -166,14 +187,17 @@ def extension_draw_item_blenderkit(
|
||||
cache_reloaded = ensure_repo_cache()
|
||||
if cache_reloaded:
|
||||
# If cache was just reloaded, tag UI for redraw
|
||||
layout.tag_redraw()
|
||||
print("BlenderKit: Cache reloaded, tagging layout for redraw.")
|
||||
# as UILayout doesn't have tag_redraw we call a custom function
|
||||
if bpy.app.timers.is_registered(redraw_preferences_once):
|
||||
bpy.app.timers.unregister(redraw_preferences_once)
|
||||
bpy.app.timers.register(redraw_preferences_once, first_interval=0.01)
|
||||
bk_logger.info("Cache reloaded, tagging preferences for redraw.")
|
||||
|
||||
# check if the cache is already in the window manager
|
||||
if "blenderkit_extensions_repo_cache" not in bpy.context.window_manager:
|
||||
# Log if cache is missing after trying to ensure it
|
||||
print(
|
||||
"BlenderKit: Extension cache not available in window_manager after ensure_repo_cache call."
|
||||
bk_logger.info(
|
||||
"Extension cache not available in window_manager after ensure_repo_cache call."
|
||||
)
|
||||
# Optionally draw a minimal representation or return early to avoid errors
|
||||
# For now, just return to avoid potential errors accessing bk_ext_cache
|
||||
@@ -330,6 +354,7 @@ def extension_draw_item_blenderkit(
|
||||
|
||||
if show:
|
||||
import os
|
||||
|
||||
from bpy.app.translations import pgettext_iface as iface_
|
||||
|
||||
col = layout.column()
|
||||
@@ -432,7 +457,6 @@ def extension_draw_item_override(
|
||||
extensions_warnings, # `dict[str, list[str]]`
|
||||
show_developer_ui=False, # `bool`
|
||||
):
|
||||
print("BlenderKit Debug: ENTERING extension_draw_item_override")
|
||||
# filter by verification state, only for blenderkit repository
|
||||
if repo_item.remote_url == EXTENSIONS_API_URL:
|
||||
extension_draw_item_blenderkit(
|
||||
@@ -513,7 +537,7 @@ def clear_repo_cache():
|
||||
|
||||
|
||||
def ensure_repo_cache():
|
||||
"""
|
||||
r"""
|
||||
Reads the .json file blender stores in \extensions\www_blenderkit_com\.blender_ext
|
||||
and parses it to a dict from json, we can use it then for drawing purposes and have the extra data BlenderKit api provides.
|
||||
Checks the modification time of the cache file and reloads it if necessary.
|
||||
@@ -528,10 +552,10 @@ def ensure_repo_cache():
|
||||
# If repo doesn't exist, clear cache if it exists in window manager
|
||||
if cache_key in wm:
|
||||
del wm[cache_key]
|
||||
print(f"BlenderKit: Cleared stale extension cache for missing repository.")
|
||||
bk_logger.info("Cleared stale extension cache for missing repository.")
|
||||
if mtime_key in wm:
|
||||
del wm[mtime_key]
|
||||
print(f"BlenderKit Debug: Repository not found, exiting check.")
|
||||
bk_logger.debug("Repository not found, exiting check.")
|
||||
return False # No repo, nothing loaded
|
||||
|
||||
# get the path to the cache file which is in repository directory under /.blender_ext/index.json
|
||||
@@ -544,13 +568,11 @@ def ensure_repo_cache():
|
||||
if os.path.exists(cache_file):
|
||||
current_mtime = os.path.getmtime(cache_file)
|
||||
except OSError as e: # Handle potential race condition or permission issue
|
||||
print(
|
||||
f"BlenderKit: Warning - Could not get modification time for {cache_file}: {e}"
|
||||
)
|
||||
bk_logger.exception("Could not get modification time for %s.", cache_file)
|
||||
# Clear cache if we can't verify its freshness? Safer approach.
|
||||
if cache_key in wm:
|
||||
del wm[cache_key]
|
||||
print(f"BlenderKit: Cleared extension cache due to mtime access error.")
|
||||
bk_logger.info("Cleared extension cache due to mtime access error.")
|
||||
if mtime_key in wm:
|
||||
del wm[mtime_key]
|
||||
return False # Error, nothing loaded
|
||||
@@ -604,7 +626,7 @@ def ensure_repo_cache():
|
||||
): # Ensure pkg is a dict and 'id' key exists
|
||||
new_cache[pkg["id"][:32]] = pkg
|
||||
else:
|
||||
print(f"BlenderKit: Skipping invalid package entry in cache: {pkg}")
|
||||
bk_logger.info("Skipping invalid package entry in cache: %s.", pkg)
|
||||
|
||||
wm[cache_key] = new_cache
|
||||
wm[mtime_key] = current_mtime # Update mtime only on successful load
|
||||
@@ -612,21 +634,21 @@ def ensure_repo_cache():
|
||||
reloaded_flag = True # Mark that we reloaded successfully
|
||||
|
||||
except json.JSONDecodeError:
|
||||
print(
|
||||
f"BlenderKit: Error decoding JSON from {cache_file}. Cache not loaded/updated."
|
||||
bk_logger.warning(
|
||||
"Error decoding JSON from %s. Cache not loaded/updated.", cache_file
|
||||
)
|
||||
# Clear potentially corrupt cache? Or leave old one? Clearing is safer.
|
||||
if cache_key in wm:
|
||||
del wm[cache_key]
|
||||
print("BlenderKit: Cleared cache due to JSON error.")
|
||||
bk_logger.info("Cleared cache due to JSON error.")
|
||||
if mtime_key in wm:
|
||||
del wm[mtime_key]
|
||||
except Exception as e:
|
||||
print(f"BlenderKit: Error reading or processing cache file {cache_file}: {e}")
|
||||
except Exception:
|
||||
bk_logger.exception("Error reading or processing cache file %s.", cache_file)
|
||||
# Clear potentially corrupt cache?
|
||||
if cache_key in wm:
|
||||
del wm[cache_key]
|
||||
print("BlenderKit: Cleared cache due to file processing error.")
|
||||
bk_logger.info("Cleared cache due to file processing error.")
|
||||
if mtime_key in wm:
|
||||
del wm[mtime_key]
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ BLENDERKIT_MATERIAL_UPLOAD_INSTRUCTIONS_URL = (
|
||||
BLENDERKIT_BRUSH_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/uploading-brush/"
|
||||
BLENDERKIT_HDR_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/uploading-hdr/"
|
||||
BLENDERKIT_SCENE_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/uploading-scene/"
|
||||
BLENDERKIT_ADDON_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/add-ons-upload-beta/"
|
||||
BLENDERKIT_LOGIN_URL = f"{global_vars.SERVER}/accounts/login"
|
||||
BLENDERKIT_SIGNUP_URL = f"{global_vars.SERVER}/accounts/register"
|
||||
|
||||
@@ -152,6 +153,7 @@ def get_download_dirs(asset_type):
|
||||
"hdr": "hdrs",
|
||||
"nodegroup": "nodegroups",
|
||||
"printable": "printables",
|
||||
"addon": "addons",
|
||||
}
|
||||
|
||||
dirs = []
|
||||
@@ -273,6 +275,7 @@ def get_res_file(asset_data, resolution, find_closest_with_url=False):
|
||||
resolution, so that other processess can pass correctly which resolution is downloaded.
|
||||
"""
|
||||
orig = None
|
||||
zipf = None
|
||||
res = None
|
||||
closest = None
|
||||
target_resolution = resolutions.get(resolution)
|
||||
@@ -284,6 +287,8 @@ def get_res_file(asset_data, resolution, find_closest_with_url=False):
|
||||
if resolution == "blend":
|
||||
# orig file found, return.
|
||||
return orig, "blend"
|
||||
if f.get("fileType") == "zip_file":
|
||||
zipf = f
|
||||
|
||||
if f["fileType"] == resolution:
|
||||
# exact match found, return.
|
||||
@@ -296,7 +301,10 @@ def get_res_file(asset_data, resolution, find_closest_with_url=False):
|
||||
closest = f
|
||||
mindist = rdiff
|
||||
if not res and not closest:
|
||||
return orig, "blend"
|
||||
if orig is not None:
|
||||
return orig, "blend"
|
||||
if zipf is not None:
|
||||
return zipf, "zip_file"
|
||||
return closest, closest["fileType"]
|
||||
|
||||
|
||||
|
||||
+281
-58
@@ -2,44 +2,62 @@
|
||||
# It is not intended for manual editing.
|
||||
|
||||
[metadata]
|
||||
groups = ["default"]
|
||||
groups = ["default", "dev"]
|
||||
strategy = ["inherit_metadata"]
|
||||
lock_version = "4.5.0"
|
||||
content_hash = "sha256:c76a62dd151343ab8bade20c52ba9ab72a8c97afba4d30e648c98eb624af333e"
|
||||
content_hash = "sha256:e66407ebe96aea59816d07e7d141f2564da883f1073e87bb85c544e60644b85a"
|
||||
|
||||
[[metadata.targets]]
|
||||
requires_python = ">=3.10"
|
||||
requires_python = ">=3.10,<3.13"
|
||||
|
||||
[[package]]
|
||||
name = "bandit"
|
||||
version = "1.8.6"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Security oriented static analyser for python code."
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"PyYAML>=5.3.1",
|
||||
"colorama>=0.3.9; platform_system == \"Windows\"",
|
||||
"rich",
|
||||
"stevedore>=1.20.0",
|
||||
]
|
||||
files = [
|
||||
{file = "bandit-1.8.6-py3-none-any.whl", hash = "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0"},
|
||||
{file = "bandit-1.8.6.tar.gz", hash = "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "24.1.0"
|
||||
requires_python = ">=3.8"
|
||||
version = "25.9.0"
|
||||
requires_python = ">=3.9"
|
||||
summary = "The uncompromising code formatter."
|
||||
groups = ["default"]
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"click>=8.0.0",
|
||||
"mypy-extensions>=0.4.3",
|
||||
"packaging>=22.0",
|
||||
"pathspec>=0.9.0",
|
||||
"platformdirs>=2",
|
||||
"pytokens>=0.1.10",
|
||||
"tomli>=1.1.0; python_version < \"3.11\"",
|
||||
"typing-extensions>=4.0.1; python_version < \"3.11\"",
|
||||
]
|
||||
files = [
|
||||
{file = "black-24.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94d5280d020dadfafc75d7cae899609ed38653d3f5e82e7ce58f75e76387ed3d"},
|
||||
{file = "black-24.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aaf9aa85aaaa466bf969e7dd259547f4481b712fe7ee14befeecc152c403ee05"},
|
||||
{file = "black-24.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec489cae76eac3f7573629955573c3a0e913641cafb9e3bfc87d8ce155ebdb29"},
|
||||
{file = "black-24.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5a0100b4bdb3744dd68412c3789f472d822dc058bb3857743342f8d7f93a5a7"},
|
||||
{file = "black-24.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6cc5a6ba3e671cfea95a40030b16a98ee7dc2e22b6427a6f3389567ecf1b5262"},
|
||||
{file = "black-24.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0e367759062dcabcd9a426d12450c6d61faf1704a352a49055a04c9f9ce8f5a"},
|
||||
{file = "black-24.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be305563ff4a2dea813f699daaffac60b977935f3264f66922b1936a5e492ee4"},
|
||||
{file = "black-24.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a8977774929b5db90442729f131221e58cc5d8208023c6af9110f26f75b6b20"},
|
||||
{file = "black-24.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d74d4d0da276fbe3b95aa1f404182562c28a04402e4ece60cf373d0b902f33a0"},
|
||||
{file = "black-24.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39addf23f7070dbc0b5518cdb2018468ac249d7412a669b50ccca18427dba1f3"},
|
||||
{file = "black-24.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:827a7c0da520dd2f8e6d7d3595f4591aa62ccccce95b16c0e94bb4066374c4c2"},
|
||||
{file = "black-24.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:0cd59d01bf3306ff7e3076dd7f4435fcd2fafe5506a6111cae1138fc7de52382"},
|
||||
{file = "black-24.1.0-py3-none-any.whl", hash = "sha256:5134a6f6b683aa0a5592e3fd61dd3519d8acd953d93e2b8b76f9981245b65594"},
|
||||
{file = "black-24.1.0.tar.gz", hash = "sha256:30fbf768cd4f4576598b1db0202413fafea9a227ef808d1a12230c643cefe9fc"},
|
||||
{file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"},
|
||||
{file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"},
|
||||
{file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"},
|
||||
{file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"},
|
||||
{file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"},
|
||||
{file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"},
|
||||
{file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"},
|
||||
{file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"},
|
||||
{file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"},
|
||||
{file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"},
|
||||
{file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"},
|
||||
{file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"},
|
||||
{file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"},
|
||||
{file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -126,17 +144,16 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.7"
|
||||
requires_python = ">=3.7"
|
||||
version = "8.3.0"
|
||||
requires_python = ">=3.10"
|
||||
summary = "Composable command line interface toolkit"
|
||||
groups = ["default"]
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"colorama; platform_system == \"Windows\"",
|
||||
"importlib-metadata; python_version < \"3.8\"",
|
||||
]
|
||||
files = [
|
||||
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
|
||||
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
|
||||
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
|
||||
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -144,13 +161,24 @@ name = "colorama"
|
||||
version = "0.4.6"
|
||||
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
summary = "Cross-platform colored terminal text."
|
||||
groups = ["default"]
|
||||
groups = ["dev"]
|
||||
marker = "platform_system == \"Windows\""
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docstring-parser-fork"
|
||||
version = "0.0.14"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Parse Python docstrings in reST, Google and Numpydoc format"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "docstring_parser_fork-0.0.14-py3-none-any.whl", hash = "sha256:4c544f234ef2cc2749a3df32b70c437d77888b1099143a1ad5454452c574b9af"},
|
||||
{file = "docstring_parser_fork-0.0.14.tar.gz", hash = "sha256:a2743a63d8d36c09650594f7b4ab5b2758fee8629dcf794d1b221b23179baa5c"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
@@ -164,13 +192,38 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "5.13.2"
|
||||
requires_python = ">=3.8.0"
|
||||
version = "7.0.0"
|
||||
requires_python = ">=3.10.0"
|
||||
summary = "A Python utility / library to sort Python imports."
|
||||
groups = ["default"]
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
|
||||
{file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
|
||||
{file = "isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1"},
|
||||
{file = "isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
requires_python = ">=3.10"
|
||||
summary = "Python port of markdown-it. Markdown parsing, done right!"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"mdurl~=0.1",
|
||||
]
|
||||
files = [
|
||||
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
|
||||
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
requires_python = ">=3.7"
|
||||
summary = "Markdown URL utilities"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
|
||||
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -178,7 +231,7 @@ name = "mypy"
|
||||
version = "1.13.0"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Optional static typing for Python"
|
||||
groups = ["default"]
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"mypy-extensions>=1.0.0",
|
||||
"tomli>=1.1.0; python_version < \"3.11\"",
|
||||
@@ -211,13 +264,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.0.0"
|
||||
requires_python = ">=3.5"
|
||||
version = "1.1.0"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Type system extensions for programs checked with the mypy type checker."
|
||||
groups = ["default"]
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
|
||||
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
|
||||
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
|
||||
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -256,13 +309,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "24.2"
|
||||
version = "25.0"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Core utilities for Python packages"
|
||||
groups = ["default"]
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
|
||||
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
|
||||
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
||||
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -270,7 +323,7 @@ name = "pathspec"
|
||||
version = "0.12.1"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Utility library for gitignore style pattern matching of file paths."
|
||||
groups = ["default"]
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
|
||||
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
|
||||
@@ -278,13 +331,89 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.3.6"
|
||||
requires_python = ">=3.8"
|
||||
version = "4.5.0"
|
||||
requires_python = ">=3.10"
|
||||
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
groups = ["default"]
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
|
||||
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
|
||||
{file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"},
|
||||
{file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydoclint"
|
||||
version = "0.7.3"
|
||||
requires_python = ">=3.9"
|
||||
summary = "A Python docstring linter that checks arguments, returns, yields, and raises sections"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"click>=8.1.0",
|
||||
"docstring-parser-fork>=0.0.12",
|
||||
"tomli>=2.0.1; python_version < \"3.11\"",
|
||||
]
|
||||
files = [
|
||||
{file = "pydoclint-0.7.3-py3-none-any.whl", hash = "sha256:a656b0e863565644670ded19a4b506450364e4f1f5e8ff7705d6ba8bb5a82982"},
|
||||
{file = "pydoclint-0.7.3.tar.gz", hash = "sha256:3351d5eeb19f8831d992714f71f5ea1175af649503d39b9da0071445a4002138"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Pygments is a syntax highlighting package written in Python."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
||||
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytokens"
|
||||
version = "0.2.0"
|
||||
requires_python = ">=3.8"
|
||||
summary = "A Fast, spec compliant Python 3.13+ tokenizer that runs on older Pythons."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8"},
|
||||
{file = "pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
requires_python = ">=3.8"
|
||||
summary = "YAML parser and emitter for Python"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
|
||||
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -304,16 +433,110 @@ files = [
|
||||
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
requires_python = ">=3.8.0"
|
||||
summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"markdown-it-py>=2.2.0",
|
||||
"pygments<3.0.0,>=2.13.0",
|
||||
]
|
||||
files = [
|
||||
{file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
|
||||
{file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.1"
|
||||
requires_python = ">=3.7"
|
||||
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ruff-0.14.1-py3-none-linux_armv6l.whl", hash = "sha256:083bfc1f30f4a391ae09c6f4f99d83074416b471775b59288956f5bc18e82f8b"},
|
||||
{file = "ruff-0.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f6fa757cd717f791009f7669fefb09121cc5f7d9bd0ef211371fad68c2b8b224"},
|
||||
{file = "ruff-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6191903d39ac156921398e9c86b7354d15e3c93772e7dbf26c9fcae59ceccd5"},
|
||||
{file = "ruff-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed04f0e04f7a4587244e5c9d7df50e6b5bf2705d75059f409a6421c593a35896"},
|
||||
{file = "ruff-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9e6cf6cd4acae0febbce29497accd3632fe2025c0c583c8b87e8dbdeae5f61"},
|
||||
{file = "ruff-0.14.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fa2458527794ecdfbe45f654e42c61f2503a230545a91af839653a0a93dbc6"},
|
||||
{file = "ruff-0.14.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:39f1c392244e338b21d42ab29b8a6392a722c5090032eb49bb4d6defcdb34345"},
|
||||
{file = "ruff-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7382fa12a26cce1f95070ce450946bec357727aaa428983036362579eadcc5cf"},
|
||||
{file = "ruff-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd0bf2be3ae8521e1093a487c4aa3b455882f139787770698530d28ed3fbb37c"},
|
||||
{file = "ruff-0.14.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabcaa9ccf8089fb4fdb78d17cc0e28241520f50f4c2e88cb6261ed083d85151"},
|
||||
{file = "ruff-0.14.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:747d583400f6125ec11a4c14d1c8474bf75d8b419ad22a111a537ec1a952d192"},
|
||||
{file = "ruff-0.14.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5a6e74c0efd78515a1d13acbfe6c90f0f5bd822aa56b4a6d43a9ffb2ae6e56cd"},
|
||||
{file = "ruff-0.14.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0ea6a864d2fb41a4b6d5b456ed164302a0d96f4daac630aeba829abfb059d020"},
|
||||
{file = "ruff-0.14.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0826b8764f94229604fa255918d1cc45e583e38c21c203248b0bfc9a0e930be5"},
|
||||
{file = "ruff-0.14.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cbc52160465913a1a3f424c81c62ac8096b6a491468e7d872cb9444a860bc33d"},
|
||||
{file = "ruff-0.14.1-py3-none-win32.whl", hash = "sha256:e037ea374aaaff4103240ae79168c0945ae3d5ae8db190603de3b4012bd1def6"},
|
||||
{file = "ruff-0.14.1-py3-none-win_amd64.whl", hash = "sha256:59d599cdff9c7f925a017f6f2c256c908b094e55967f93f2821b1439928746a1"},
|
||||
{file = "ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44"},
|
||||
{file = "ruff-0.14.1.tar.gz", hash = "sha256:1dd86253060c4772867c61791588627320abcb6ed1577a90ef432ee319729b69"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stevedore"
|
||||
version = "5.5.0"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Manage dynamic plugins for Python applications"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf"},
|
||||
{file = "stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
requires_python = ">=3.8"
|
||||
summary = "A lil' TOML parser"
|
||||
groups = ["default"]
|
||||
groups = ["dev"]
|
||||
marker = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"},
|
||||
{file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"},
|
||||
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"},
|
||||
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -332,13 +555,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
groups = ["default"]
|
||||
version = "4.15.0"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
||||
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -106,8 +106,8 @@ def load_preferences_from_JSON():
|
||||
"show_on_start", user_preferences.show_on_start
|
||||
)
|
||||
user_preferences.thumb_size = prefs.get("thumb_size", user_preferences.thumb_size)
|
||||
user_preferences.max_assetbar_rows = prefs.get(
|
||||
"max_assetbar_rows", user_preferences.max_assetbar_rows
|
||||
user_preferences.maximized_assetbar_rows = prefs.get(
|
||||
"maximized_assetbar_rows", user_preferences.maximized_assetbar_rows
|
||||
)
|
||||
user_preferences.search_field_width = prefs.get(
|
||||
"search_field_width", user_preferences.search_field_width
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
[project]
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.10,<3.13" # in order to support numpy<2.0.0
|
||||
dependencies = [
|
||||
"requests>=2.18.4",
|
||||
"types-requests>=2.31.0.5",
|
||||
"numpy<2.0.0",
|
||||
"black==24.1.0",
|
||||
"isort==5.13.2",
|
||||
"numpy>=1.21.0,<2.0.0",
|
||||
]
|
||||
|
||||
# these will not be included in build distributions
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
## dev dependencies
|
||||
"black==25.9.0",
|
||||
"isort==7.0.0",
|
||||
"mypy==1.13.0",
|
||||
|
||||
## I would like to enable these in the future
|
||||
"bandit>=1.8.2", # code is currently invalid for bandit
|
||||
"ruff>=0.14.1", # code is currently invalid for ruff
|
||||
"pydoclint>=0.7.3", # code is currently invalid for pydoclint
|
||||
]
|
||||
|
||||
[tool.isort]
|
||||
@@ -23,11 +34,50 @@ exclude = '''
|
||||
'''
|
||||
|
||||
[tool.ruff]
|
||||
exclude = ["lib", "out", "addon_updater.py", "addon_updater_ops.py"]
|
||||
target-version = "py310"
|
||||
line-length = 120
|
||||
include = ["pyproject.toml",
|
||||
"**/*.py",
|
||||
"*.py"
|
||||
]
|
||||
exclude = [
|
||||
"lib",
|
||||
"out",
|
||||
"addon_updater.py",
|
||||
"addon_updater_ops.py",
|
||||
"_debug/**",
|
||||
"_bck/**",
|
||||
"*.tmp",
|
||||
"__pycache__/**",
|
||||
".venv/**",
|
||||
"*.pyi",
|
||||
"sentry_sdk",
|
||||
]
|
||||
ignore = [
|
||||
"E501", # Line too long
|
||||
]
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.format]
|
||||
line-ending = "auto"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["F", "E", "W", "C90", "I", "N", "D", "UP", "S1", "S2", "S3", "S5", "BLE", "FBT",
|
||||
"B", "A", "C4", "COM", "DTZ", "T10", "FA", "ISC", "ICN", "LOG", "G", "INP", "PIE", "T20", "Q", "RSE", "RET", "SLF",
|
||||
"SLOT", "SIM", "TID",
|
||||
"TC001", "TC004", "TC005", "TC010",
|
||||
"INT", "ARG", "FIX", "ERA", "PL", "TRY", "FLY", "PERF", "RUF"
|
||||
]
|
||||
|
||||
ignore = [
|
||||
"D105", # https://docs.astral.sh/ruff/rules/undocumented-magic-method/
|
||||
"D107", # https://docs.astral.sh/ruff/rules/undocumented-public-init/
|
||||
"PLC0415", # https://docs.astral.sh/ruff/rules/import-outside-top-level/
|
||||
"RET504", # https://docs.astral.sh/ruff/rules/unnecessary-assign/
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
|
||||
[tool.mypy]
|
||||
exclude = ['test_*', 'out', 'lib']
|
||||
@@ -46,3 +96,10 @@ module = [
|
||||
]
|
||||
ignore_missing_imports = true # Ignore missing type hints for bpy
|
||||
|
||||
[tool.pydoclint]
|
||||
style = "google"
|
||||
arg-type-hints-in-docstring = false
|
||||
check-return-types = false
|
||||
ignore-private-args = true
|
||||
ignore-underscore-args = true
|
||||
exclude = "\\.venv|\\sentry_sdk"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import bpy
|
||||
from bpy.props import StringProperty
|
||||
@@ -186,6 +187,11 @@ class FastRateMenu(Operator, ratings_utils.RatingProperties):
|
||||
ratings_utils.ensure_rating(self.asset_id)
|
||||
self.prefill_ratings()
|
||||
|
||||
# Update last popup activity time to prevent shortcut interference
|
||||
from . import ui_panels
|
||||
|
||||
ui_panels.last_time_dropdown_active = time.time()
|
||||
|
||||
if self.asset_type in ("model", "scene"):
|
||||
# spawn a wider one for validators for the enum buttons
|
||||
return wm.invoke_popup(self, width=400)
|
||||
|
||||
@@ -21,6 +21,7 @@ from logging import getLogger
|
||||
from os.path import basename
|
||||
from re import search
|
||||
from time import time
|
||||
from typing import Literal
|
||||
|
||||
import bpy
|
||||
|
||||
@@ -32,7 +33,12 @@ reports = []
|
||||
|
||||
|
||||
# check for same reports and just make them longer by the timeout.
|
||||
def add_report(text="", timeout=-1, type="INFO", details=""):
|
||||
def add_report(
|
||||
text: str = "",
|
||||
timeout: float = -1,
|
||||
type: Literal["INFO", "ERROR", "VALIDATOR"] = "INFO",
|
||||
details: str = "",
|
||||
) -> None:
|
||||
"""Add text report to GUI. Function checks for same reports and make them longer by the timeout.
|
||||
Also log the text and details into the console with levels: ERROR=RED, INFO=GREEN, VALIDATOR=BLUE.
|
||||
When timeout is not specified, default 15s will be used for ERROR, 5s for INFO/VALIDATOR.
|
||||
@@ -41,6 +47,7 @@ def add_report(text="", timeout=-1, type="INFO", details=""):
|
||||
text = text.strip()
|
||||
full_message = text
|
||||
details = details.strip()
|
||||
color = colors.GRAY
|
||||
if details != "":
|
||||
full_message = f"{text} {details}"
|
||||
|
||||
|
||||
@@ -29,10 +29,7 @@ from typing import Optional, Union
|
||||
|
||||
import bpy
|
||||
from bpy.app.handlers import persistent
|
||||
from bpy.props import ( # TODO only keep the ones actually used when cleaning
|
||||
BoolProperty,
|
||||
StringProperty,
|
||||
)
|
||||
from bpy.props import BoolProperty, StringProperty
|
||||
from bpy.types import Operator
|
||||
|
||||
from . import (
|
||||
@@ -41,10 +38,10 @@ from . import (
|
||||
client_tasks,
|
||||
comments_utils,
|
||||
datas,
|
||||
download,
|
||||
global_vars,
|
||||
image_utils,
|
||||
paths,
|
||||
ratings_utils,
|
||||
reports,
|
||||
resolutions,
|
||||
tasks_queue,
|
||||
@@ -170,6 +167,8 @@ def check_clipboard():
|
||||
target_asset_type = "PRINTABLE"
|
||||
elif asset_type_string.find("nodegroup") > -1:
|
||||
target_asset_type = "NODEGROUP"
|
||||
elif asset_type_string.find("addon") > -1 or asset_type_string.find("add-on") > -1:
|
||||
target_asset_type = "ADDON"
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
if ui_props.asset_type != target_asset_type:
|
||||
ui_props.asset_type = target_asset_type # switch asset type before placing keywords, so it does not search under wrong asset type
|
||||
@@ -181,7 +180,8 @@ def check_clipboard():
|
||||
# TODO: type annotate and check this crazy function!
|
||||
# Are we sure it behaves correctly on network issues, malfunctioning search etc?
|
||||
def parse_result(r) -> dict:
|
||||
"""Needed to generate some extra data in the result(by now)
|
||||
"""Parse search result into an asset_data by tweaking some of its parameters.
|
||||
We need to generate some extra data in the result (for now).
|
||||
Parameters
|
||||
----------
|
||||
r - search result, also called asset_data
|
||||
@@ -196,10 +196,6 @@ def parse_result(r) -> dict:
|
||||
utils.p("asset with no files-size")
|
||||
|
||||
asset_type = r["assetType"]
|
||||
# TODO remove this condition so all assets are parsed?
|
||||
if len(r["files"]) == 0:
|
||||
return {}
|
||||
|
||||
adata = r["author"]
|
||||
social_networks = datas.parse_social_networks(adata.pop("socialNetworks", []))
|
||||
author = datas.UserProfile(**adata, socialNetworks=social_networks)
|
||||
@@ -393,6 +389,20 @@ def handle_search_task(task: client_tasks.Task) -> bool:
|
||||
if comments is None:
|
||||
client_lib.get_comments(asset_data["assetBaseId"])
|
||||
|
||||
# Apply addon-specific status checking and filtering if needed
|
||||
if ui_props.asset_type == "ADDON":
|
||||
# Always process addon search results to store installation status
|
||||
result_field = filter_addon_search_results(
|
||||
result_field, filter_installed_only=False
|
||||
)
|
||||
|
||||
addon_props = bpy.context.window_manager.blenderkit_addon
|
||||
if addon_props.search_installed:
|
||||
# Filter to only show installed addons
|
||||
result_field = [
|
||||
asset for asset in result_field if asset.get("downloaded", 0) > 0
|
||||
]
|
||||
|
||||
# Store results in history step
|
||||
history_step["search_results"] = result_field
|
||||
history_step["search_results_orig"] = task.result
|
||||
@@ -709,25 +719,16 @@ def query_to_url(
|
||||
query = {}
|
||||
|
||||
url = f"{paths.BLENDERKIT_API}/search/"
|
||||
if query is None:
|
||||
query = {}
|
||||
|
||||
requeststring = "?query="
|
||||
if query.get("query") not in ("", None):
|
||||
requeststring += urllib.parse.quote_plus(query["query"]) # .lower()
|
||||
requeststring += urllib.parse.quote_plus(query["query"])
|
||||
for q in query:
|
||||
if q != "query" and q != "free_first":
|
||||
requeststring += (
|
||||
f"+{q}:{urllib.parse.quote_plus(str(query[q]))}" # .lower()
|
||||
)
|
||||
if q in ["query", "free_first", "search_order_by"]:
|
||||
continue
|
||||
requeststring += f"+{q}:{urllib.parse.quote_plus(str(query[q]))}"
|
||||
|
||||
# add dict_parameters to make results smaller
|
||||
# result ordering: _score - relevance, score - BlenderKit score
|
||||
order = []
|
||||
if query.get("free_first", False):
|
||||
order = [
|
||||
"-is_free",
|
||||
]
|
||||
|
||||
# query with category_subtree:model etc gives irrelevant results
|
||||
if query.get("category_subtree") in (
|
||||
@@ -741,24 +742,7 @@ def query_to_url(
|
||||
):
|
||||
query["category_subtree"] = None
|
||||
|
||||
if query.get("query") is None and query.get("category_subtree") == None:
|
||||
# assumes no keywords and no category, thus an empty search that is triggered on start.
|
||||
# orders by last core file upload
|
||||
if query.get("verification_status") == "uploaded":
|
||||
# for validators, sort uploaded from oldest
|
||||
order.append("last_blend_upload")
|
||||
else:
|
||||
order.append("-last_blend_upload")
|
||||
elif (
|
||||
query.get("author_id") is not None
|
||||
or query.get("query", "").find("+author_id:") > -1
|
||||
) and utils.profile_is_validator():
|
||||
order.append("-created")
|
||||
else:
|
||||
if query.get("category_subtree") is not None:
|
||||
order.append("-score,_score")
|
||||
else:
|
||||
order.append("_score")
|
||||
order = decide_ordering(query)
|
||||
if requeststring.find("+order:") == -1:
|
||||
requeststring += "+order:" + ",".join(order)
|
||||
requeststring += "&dict_parameters=1"
|
||||
@@ -774,6 +758,50 @@ def query_to_url(
|
||||
return urlquery
|
||||
|
||||
|
||||
def decide_ordering(query: dict) -> list:
|
||||
"""Decides which ordering should be used based on the search_order_by.
|
||||
If search_order_by is not default, its value is used for the sorting (quality, uploaded, etc.).
|
||||
Otherwise the 'legacy' mode is used which
|
||||
"""
|
||||
# result ordering: _score - relevance, score - BlenderKit score
|
||||
order = []
|
||||
if query.get("free_first", False):
|
||||
order = [
|
||||
"-is_free",
|
||||
]
|
||||
|
||||
search_order_by = query.get("search_order_by", "default")
|
||||
if search_order_by != "default":
|
||||
order.append(search_order_by)
|
||||
return order
|
||||
|
||||
# DEFAULT TRADITIONAL SMART ORDERING
|
||||
if query.get("query") is None and query.get("category_subtree") == None:
|
||||
# assumes no keywords and no category, thus an empty search that is triggered on start.
|
||||
# orders by last core file upload
|
||||
if query.get("verification_status") == "uploaded":
|
||||
# for validators, sort uploaded from oldest
|
||||
order.append("last_blend_upload")
|
||||
else:
|
||||
if query.get("asset_type") == "addon":
|
||||
# addons don't have athe blend so need to sort by created
|
||||
order.append("-created")
|
||||
else:
|
||||
order.append("-last_blend_upload")
|
||||
elif (
|
||||
query.get("author_id") is not None
|
||||
or query.get("query", "").find("+author_id:") > -1
|
||||
) and utils.profile_is_validator():
|
||||
order.append("-created")
|
||||
else:
|
||||
if query.get("category_subtree") is not None:
|
||||
order.append("-score,_score")
|
||||
else:
|
||||
order.append("_score")
|
||||
|
||||
return order
|
||||
|
||||
|
||||
def build_query_common(query: dict, props, ui_props) -> dict:
|
||||
"""Pure function to add shared parameters based on props to query dict.
|
||||
Returns the updated version of the query dict.
|
||||
@@ -919,6 +947,68 @@ def build_query_nodegroup(
|
||||
return build_query_common(query, props, ui_props)
|
||||
|
||||
|
||||
def build_query_addon(props, ui_props) -> dict:
|
||||
"""Pure function to construct search query dict for addons."""
|
||||
query = {"asset_type": "addon"}
|
||||
return build_query_common(query, props, ui_props)
|
||||
|
||||
|
||||
def filter_addon_search_results(search_results, filter_installed_only=False):
|
||||
"""
|
||||
Filter addon search results based on local installation status.
|
||||
This is called after search results arrive since installation info isn't stored on server.
|
||||
Also stores installation and enablement status in the search results data.
|
||||
|
||||
Args:
|
||||
search_results: List of addon asset data from search
|
||||
filter_installed_only: If True, only return installed addons
|
||||
|
||||
Returns:
|
||||
Filtered list of add-on assets with installation status stored
|
||||
"""
|
||||
|
||||
filtered_results = []
|
||||
|
||||
for asset in search_results:
|
||||
if asset.get("assetType") != "addon":
|
||||
# Skip non-addon assets (shouldn't happen in addon search but safety check)
|
||||
if not filter_installed_only:
|
||||
filtered_results.append(asset)
|
||||
continue
|
||||
|
||||
# Check installation and enablement status for addon
|
||||
try:
|
||||
status = download.get_addon_installation_status(asset)
|
||||
is_installed = status.get("installed", False)
|
||||
is_enabled = status.get("enabled", False)
|
||||
|
||||
# Store installation status in asset data using existing 'downloaded' field
|
||||
# Use 100 for installed, 0 for not installed (matching existing pattern)
|
||||
asset["downloaded"] = 100 if is_installed else 0
|
||||
|
||||
# Store enablement status in new 'enabled' field
|
||||
asset["enabled"] = is_enabled
|
||||
|
||||
if filter_installed_only:
|
||||
if is_installed:
|
||||
filtered_results.append(asset)
|
||||
else:
|
||||
filtered_results.append(asset)
|
||||
|
||||
except Exception as e:
|
||||
# If we can't determine status, mark as not installed/enabled
|
||||
bk_logger.warning(
|
||||
f"Could not determine installation status for addon {asset.get('name', 'Unknown')}: {e}"
|
||||
)
|
||||
asset["downloaded"] = 0
|
||||
asset["enabled"] = False
|
||||
|
||||
if not filter_installed_only:
|
||||
filtered_results.append(asset)
|
||||
|
||||
return filtered_results
|
||||
|
||||
|
||||
def add_search_process(
|
||||
query, get_next: bool, page_size: int, next_url: str, history_id: str
|
||||
):
|
||||
@@ -1096,6 +1186,12 @@ def search(get_next=False, query=None, author_id=""):
|
||||
ui_props=bpy.context.window_manager.blenderkitUI,
|
||||
)
|
||||
|
||||
if ui_props.asset_type == "ADDON":
|
||||
query = build_query_addon(
|
||||
props=bpy.context.window_manager.blenderkit_addon,
|
||||
ui_props=bpy.context.window_manager.blenderkitUI,
|
||||
)
|
||||
|
||||
# crop long searches
|
||||
if query.get("query"):
|
||||
if len(query["query"]) > 50:
|
||||
@@ -1113,7 +1209,6 @@ def search(get_next=False, query=None, author_id=""):
|
||||
|
||||
if author_id != "":
|
||||
query["author_id"] = author_id
|
||||
|
||||
elif ui_props.own_only:
|
||||
# if user searches for [another] author, 'only my assets' is invalid. that's why in elif.
|
||||
profile = global_vars.BKIT_PROFILE
|
||||
@@ -1122,10 +1217,11 @@ def search(get_next=False, query=None, author_id=""):
|
||||
|
||||
# free first has to by in query to be evaluated as changed as another search, otherwise the filter is not updated.
|
||||
query["free_first"] = ui_props.free_only
|
||||
query["search_order_by"] = ui_props.search_order_by
|
||||
|
||||
active_history_step["is_searching"] = True
|
||||
|
||||
page_size = min(40, ui_props.wcount * user_preferences.max_assetbar_rows + 5)
|
||||
page_size = min(40, ui_props.wcount * user_preferences.maximized_assetbar_rows + 5)
|
||||
|
||||
next_url = ""
|
||||
if get_next and active_history_step.get("search_results_orig"):
|
||||
@@ -1198,6 +1294,7 @@ def update_filters():
|
||||
or ui_props.search_bookmarks
|
||||
or ui_props.search_license != "ANY"
|
||||
or ui_props.search_blender_version
|
||||
or ui_props.search_order_by != "default"
|
||||
# NSFW filter is signaled in a special way and should not affect the filter icon
|
||||
)
|
||||
|
||||
@@ -1221,6 +1318,8 @@ def update_filters():
|
||||
sprops.use_filters = sprops.true_hdr
|
||||
elif ui_props.asset_type == "NODEGROUP":
|
||||
sprops.use_filters = fcommon
|
||||
elif ui_props.asset_type == "ADDON":
|
||||
sprops.use_filters = fcommon
|
||||
return True
|
||||
|
||||
|
||||
@@ -1269,6 +1368,9 @@ def detect_asset_type_from_keywords(keywords: str) -> tuple[str, str]:
|
||||
"nodegroup": "NODEGROUP",
|
||||
"node": "NODEGROUP",
|
||||
"printable": "PRINTABLE",
|
||||
"addon": "ADDON",
|
||||
"add-on": "ADDON",
|
||||
"extension": "ADDON",
|
||||
}
|
||||
|
||||
# Convert to lowercase for matching
|
||||
@@ -1300,20 +1402,21 @@ def search_update(self, context):
|
||||
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
|
||||
# Remove this feature for now, but leave the code here for future reference
|
||||
# Check if keywords contain asset type before processing clipboard
|
||||
if ui_props.search_keywords != "":
|
||||
detected_type, cleaned_keywords = detect_asset_type_from_keywords(
|
||||
ui_props.search_keywords
|
||||
)
|
||||
if detected_type and detected_type != ui_props.asset_type:
|
||||
# Store keywords before switching
|
||||
ui_props.search_lock = True
|
||||
ui_props.search_keywords = cleaned_keywords
|
||||
# Switch asset type
|
||||
ui_props.asset_type = detected_type
|
||||
ui_props.search_lock = False
|
||||
# Return since changing keywords will trigger this function again
|
||||
# not now - let's try it with lock
|
||||
# if ui_props.search_keywords != "":
|
||||
# detected_type, cleaned_keywords = detect_asset_type_from_keywords(
|
||||
# ui_props.search_keywords
|
||||
# )
|
||||
# if detected_type and detected_type != ui_props.asset_type:
|
||||
# # Store keywords before switching
|
||||
# ui_props.search_lock = True
|
||||
# ui_props.search_keywords = cleaned_keywords
|
||||
# # Switch asset type
|
||||
# ui_props.asset_type = detected_type
|
||||
# ui_props.search_lock = False
|
||||
# Return since changing keywords will trigger this function again
|
||||
# not now - let's try it with lock
|
||||
|
||||
# if ui_props.down_up != "SEARCH":
|
||||
# ui_props.down_up = "SEARCH"
|
||||
@@ -1345,6 +1448,8 @@ def search_update(self, context):
|
||||
target_asset_type = "NODEGROUP"
|
||||
elif asset_type_string.find("printable") > -1:
|
||||
target_asset_type = "PRINTABLE"
|
||||
elif asset_type_string.find("addon") > -1:
|
||||
target_asset_type = "ADDON"
|
||||
|
||||
if ui_props.asset_type != target_asset_type:
|
||||
ui_props.search_keywords = ""
|
||||
@@ -1643,6 +1748,8 @@ def get_ui_state():
|
||||
store_props = store_scene_props
|
||||
elif asset_type == "PRINTABLE":
|
||||
store_props = store_model_props
|
||||
elif asset_type == "ADDON":
|
||||
store_props = [] # Addons don't need to store specific props
|
||||
|
||||
search_props = utils.get_search_props()
|
||||
|
||||
@@ -1652,6 +1759,13 @@ def get_ui_state():
|
||||
if prop_name != "rna_type":
|
||||
ui_state["search_props"][prop_name] = getattr(search_props, prop_name)
|
||||
|
||||
# Store addon-specific search properties
|
||||
if ui_props.asset_type == "ADDON":
|
||||
addon_props = bpy.context.window_manager.blenderkit_addon
|
||||
ui_state["addon_props"] = {
|
||||
"search_installed": addon_props.search_installed,
|
||||
}
|
||||
|
||||
return ui_state
|
||||
|
||||
|
||||
@@ -1695,15 +1809,18 @@ def update_tab_name(active_tab):
|
||||
# Update tab name
|
||||
active_tab["name"] = tab_name
|
||||
|
||||
# Update UI if asset bar exists
|
||||
# Update UI if asset bar exists and is properly initialized
|
||||
asset_bar = asset_bar_op.asset_bar_operator
|
||||
if asset_bar and hasattr(asset_bar, "tab_buttons"):
|
||||
active_tab_index = global_vars.TABS["active_tab"]
|
||||
if 0 <= active_tab_index < len(asset_bar.tab_buttons):
|
||||
asset_bar.tab_buttons[active_tab_index].text = tab_name
|
||||
# Force redraw of the region
|
||||
if asset_bar.area:
|
||||
asset_bar.area.tag_redraw()
|
||||
try:
|
||||
asset_bar.tab_buttons[active_tab_index].text = tab_name
|
||||
# Only try to redraw if we have a valid region
|
||||
if asset_bar.area and asset_bar.area.region:
|
||||
asset_bar.area.tag_redraw()
|
||||
except Exception as e:
|
||||
bk_logger.debug(f"Could not update tab name in UI: {e}")
|
||||
|
||||
return history_step
|
||||
|
||||
@@ -1752,6 +1869,96 @@ def create_history_step(active_tab):
|
||||
return history_step
|
||||
|
||||
|
||||
def append_history_step(
|
||||
search_keywords,
|
||||
search_results,
|
||||
active_tab=None,
|
||||
asset_type=None,
|
||||
search_results_orig=None,
|
||||
) -> dict:
|
||||
"""Append a complete history step consisting of search keywords and results. No search is triggered.
|
||||
Use this function when you already have search results data and want to add them to the history step.
|
||||
Function also switches the asset type to the one provided, refreshes the UI and updates the tab name.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
search_keywords : str
|
||||
The search keywords to use for this history step
|
||||
search_results : list
|
||||
List of parsed search results to store in the history step
|
||||
active_tab : dict
|
||||
The active tab to add the history step to
|
||||
asset_type : str, optional
|
||||
The asset type to use. If None, current asset type will be used
|
||||
search_results_orig : dict, optional
|
||||
The original search results from the server. If None, will be constructed from search_results
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created history step
|
||||
"""
|
||||
if active_tab is None:
|
||||
active_tab = get_active_tab()
|
||||
|
||||
ui_state = get_ui_state()
|
||||
ui_state["ui_props"]["search_keywords"] = search_keywords
|
||||
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
ui_props.search_lock = True
|
||||
if asset_type:
|
||||
ui_state["ui_props"]["asset_type"] = asset_type
|
||||
ui_props.asset_type = asset_type
|
||||
|
||||
ui_props.search_keywords = search_keywords
|
||||
ui_props.search_lock = False
|
||||
|
||||
# Create the history step
|
||||
history_step = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"ui_state": ui_state,
|
||||
"scroll_offset": 0, # Reset scroll offset for new search
|
||||
"search_results": search_results,
|
||||
"is_searching": False,
|
||||
}
|
||||
|
||||
# Add original search results if provided, otherwise construct from search_results
|
||||
if search_results_orig:
|
||||
history_step["search_results_orig"] = search_results_orig
|
||||
else:
|
||||
history_step["search_results_orig"] = {
|
||||
"results": search_results,
|
||||
"count": len(search_results),
|
||||
}
|
||||
|
||||
# Delete any future history steps
|
||||
if active_tab["history_index"] < len(active_tab["history"]) - 1:
|
||||
# Remove future steps from global history steps dict first
|
||||
for step in active_tab["history"][active_tab["history_index"] + 1 :]:
|
||||
global_vars.DATA["history steps"].pop(step["id"], None)
|
||||
# Then truncate the tab's history list
|
||||
active_tab["history"] = active_tab["history"][: active_tab["history_index"] + 1]
|
||||
|
||||
# Add to tab history
|
||||
active_tab["history"].append(history_step)
|
||||
active_tab["history_index"] = len(active_tab["history"]) - 1
|
||||
|
||||
# Add to global history steps
|
||||
global_vars.DATA["history steps"][history_step["id"]] = history_step
|
||||
|
||||
# Update tab name
|
||||
update_tab_name(active_tab)
|
||||
|
||||
# Update history button visibility if asset bar exists
|
||||
asset_bar = asset_bar_op.asset_bar_operator
|
||||
if asset_bar and hasattr(asset_bar, "history_back_button"):
|
||||
asset_bar.history_back_button.visible = active_tab["history_index"] > 0
|
||||
asset_bar.history_forward_button.visible = False
|
||||
asset_bar.update_tab_icons()
|
||||
|
||||
return history_step
|
||||
|
||||
|
||||
def get_history_step(history_step_id):
|
||||
return global_vars.DATA["history steps"].get(history_step_id)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -25,6 +25,7 @@ import bpy
|
||||
|
||||
from . import (
|
||||
addon_updater_ops,
|
||||
asset_bar_op,
|
||||
bg_blender,
|
||||
bkit_oauth,
|
||||
categories,
|
||||
@@ -342,7 +343,7 @@ def handle_task(task: client_tasks.Task):
|
||||
|
||||
# BKCLIENTJS - Download from web
|
||||
if task.task_type == "bkclientjs/get_asset":
|
||||
return download.handle_bkclientjs_get_asset(task)
|
||||
return asset_bar_op.handle_bkclientjs_get_asset(task)
|
||||
|
||||
# HANDLE MESSAGE FROM CLIENT
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
.updater_install_popup
|
||||
.updater_check_now
|
||||
.updater_update_now
|
||||
.updater_update_target
|
||||
.updater_install_manually
|
||||
.updater_update_successful
|
||||
.updater_restore_backup
|
||||
.updater_ignore
|
||||
.end_background_check
|
||||
view3d.asset_drag_drop
|
||||
object.blenderkit_auto_tags
|
||||
object.blenderkit_generate_thumbnail
|
||||
object.blenderkit_regenerate_thumbnail
|
||||
object.blenderkit_generate_material_thumbnail
|
||||
object.blenderkit_regenerate_material_thumbnail
|
||||
object.kill_bg_process
|
||||
wm.blenderkit_login
|
||||
wm.blenderkit_logout
|
||||
wm.blenderkit_login_cancel
|
||||
scene.blenderkit_addon_manager
|
||||
scene.blenderkit_addon_choice
|
||||
scene.blenderkit_download_kill
|
||||
scene.blenderkit_download
|
||||
wm.blenderkit_bookmark_asset
|
||||
wm.blenderkit_mark_notification_read
|
||||
wm.blenderkit_mark_notifications_read_all
|
||||
wm.blenderkit_open_notification_target
|
||||
wm.blenderkit_upvote_comment
|
||||
wm.blenderkit_is_private_comment
|
||||
wm.blenderkit_post_comment
|
||||
wm.logo_status
|
||||
wm.show_notifications
|
||||
wm.blenderkit_join_discord
|
||||
wm.blenderkit_welcome
|
||||
wm.blenderkit_open_system_directory
|
||||
wm.blenderkit_asset_popup
|
||||
view3d.blenderkit_set_comment_reply_id
|
||||
view3d.blenderkit_set_category_origin
|
||||
view3d.blenderkit_clear_search_keywords
|
||||
view3d.close_popup_button
|
||||
wm.blenderkit_popup_dialog
|
||||
wm.blenderkit_url_dialog
|
||||
wm.blenderkit_login_dialog
|
||||
wm.blenderkit_nodegroup_drop_dialog
|
||||
object.blenderkit_particles_drop
|
||||
object.blenderkit_data_trasnfer
|
||||
wm.modal_timer_operator
|
||||
view3d.run_assetbar_start_modal
|
||||
view3d.run_assetbar_fix_context
|
||||
wm.blenderkit_fast_metadata
|
||||
@@ -225,7 +225,7 @@ class ParticlesDropDialog(bpy.types.Operator):
|
||||
layout = self.layout
|
||||
message = (
|
||||
"This asset is a particle setup. BlenderKit can apply particles to the active/drag-drop object."
|
||||
"The number of particles is caluclated automatically, but if there are too many particles,"
|
||||
"The number of particles is calculated automatically, but if there are too many particles,"
|
||||
" BlenderKit can do the following steps to make sure Blender continues to run:\n"
|
||||
"\n1.Switch to bounding box view of the particles."
|
||||
"\n2.Turn down number of particles that are shown in the view."
|
||||
|
||||
@@ -16,16 +16,161 @@
|
||||
#
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
import blf
|
||||
import gpu
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
import blf
|
||||
import bpy
|
||||
from bpy import app
|
||||
|
||||
import gpu
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
|
||||
from .image_utils import IMG
|
||||
|
||||
|
||||
bk_logger = logging.getLogger(__name__)
|
||||
|
||||
cached_images = {}
|
||||
|
||||
cached_gpu_textures = {}
|
||||
|
||||
_cached_image_shader: Optional[gpu.types.GPUShader] = None
|
||||
|
||||
|
||||
VERTEX_SHADER_LEGACY = """
|
||||
uniform mat4 ModelViewProjectionMatrix;
|
||||
in vec2 pos;
|
||||
in vec2 texCoord;
|
||||
out vec2 uv;
|
||||
|
||||
void main()
|
||||
{
|
||||
uv = texCoord;
|
||||
gl_Position = ModelViewProjectionMatrix * vec4(pos.xy, 0.0, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
FRAGMENT_SHADER_LEGACY = """
|
||||
in vec2 uv;
|
||||
out vec4 fragColor;
|
||||
uniform sampler2D image;
|
||||
uniform float transparency;
|
||||
uniform int color_space_mode;
|
||||
|
||||
vec3 linear_to_srgb(vec3 linear_color)
|
||||
{
|
||||
vec3 cutoff = vec3(0.0031308);
|
||||
vec3 lower = linear_color * 12.92;
|
||||
vec3 higher = 1.055 * pow(max(linear_color, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;
|
||||
return mix(lower, higher, step(cutoff, linear_color));
|
||||
}
|
||||
|
||||
void main()
|
||||
{
|
||||
vec4 color = texture(image, uv);
|
||||
if (color_space_mode == 1) {
|
||||
color.rgb = linear_to_srgb(color.rgb);
|
||||
}
|
||||
color.a *= transparency;
|
||||
fragColor = color;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def create_image_shader_info():
|
||||
"""Return GPU shader info for the runtime image shader."""
|
||||
shader_info = gpu.types.GPUShaderCreateInfo()
|
||||
shader_info.vertex_in(0, "VEC2", "pos")
|
||||
shader_info.vertex_in(1, "VEC2", "texCoord")
|
||||
|
||||
stage_iface = gpu.types.GPUStageInterfaceInfo("uv_iface")
|
||||
stage_iface.smooth("VEC2", "uv")
|
||||
shader_info.vertex_out(stage_iface)
|
||||
|
||||
shader_info.push_constant("MAT4", "ModelViewProjectionMatrix")
|
||||
shader_info.push_constant("FLOAT", "transparency")
|
||||
shader_info.push_constant("INT", "color_space_mode")
|
||||
shader_info.sampler(0, "FLOAT_2D", "image")
|
||||
|
||||
shader_info.fragment_out(0, "VEC4", "fragColor")
|
||||
shader_info.vertex_source(
|
||||
"""
|
||||
void main()
|
||||
{
|
||||
uv = texCoord;
|
||||
gl_Position = ModelViewProjectionMatrix * vec4(pos.xy, 0.0, 1.0);
|
||||
}
|
||||
"""
|
||||
)
|
||||
shader_info.fragment_source(
|
||||
"""
|
||||
void main()
|
||||
{
|
||||
vec4 color = texture(image, uv);
|
||||
if (color_space_mode == 1) {
|
||||
vec3 cutoff = vec3(0.0031308);
|
||||
vec3 lower = color.rgb * 12.92;
|
||||
vec3 higher = 1.055 * pow(max(color.rgb, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;
|
||||
color.rgb = mix(lower, higher, step(cutoff, color.rgb));
|
||||
}
|
||||
color.a *= transparency;
|
||||
fragColor = color;
|
||||
}
|
||||
"""
|
||||
)
|
||||
return shader_info
|
||||
|
||||
|
||||
def create_image_shader():
|
||||
"""Return a cached shader that supports transparency across Blender versions.
|
||||
Features:
|
||||
- sRGB conversion for UI overlays
|
||||
- transparency
|
||||
"""
|
||||
global _cached_image_shader
|
||||
|
||||
if _cached_image_shader is not None:
|
||||
return _cached_image_shader
|
||||
|
||||
shader = None
|
||||
|
||||
create_info_supported = (
|
||||
hasattr(gpu, "shader")
|
||||
and hasattr(gpu.shader, "create_from_info")
|
||||
and hasattr(gpu.types, "GPUShaderCreateInfo")
|
||||
)
|
||||
|
||||
if create_info_supported:
|
||||
try:
|
||||
shader_info = create_image_shader_info()
|
||||
shader = gpu.shader.create_from_info(shader_info)
|
||||
except Exception: # noqa: BLE001
|
||||
bk_logger.exception("Failed to create image shader")
|
||||
shader = None
|
||||
|
||||
if shader is None:
|
||||
try:
|
||||
shader = gpu.types.GPUShader(VERTEX_SHADER_LEGACY, FRAGMENT_SHADER_LEGACY)
|
||||
except Exception: # noqa: BLE001
|
||||
bk_logger.exception("Failed to create image shader")
|
||||
|
||||
if shader is None:
|
||||
# fallback to builtin shader
|
||||
# mainly for MacOS builds that have issues with custom shaders
|
||||
if app.version < (4, 0, 0):
|
||||
shader = gpu.shader.from_builtin("2D_IMAGE")
|
||||
else:
|
||||
shader = gpu.shader.from_builtin("IMAGE")
|
||||
|
||||
_cached_image_shader = shader
|
||||
return shader
|
||||
|
||||
|
||||
def draw_rect(x, y, width, height, color):
|
||||
"""Used for drawing 2D rectangle backgrounds."""
|
||||
xmax = x + width
|
||||
ymax = y + height
|
||||
points = (
|
||||
@@ -48,6 +193,31 @@ def draw_rect(x, y, width, height, color):
|
||||
batch.draw(shader)
|
||||
|
||||
|
||||
def draw_rect_outline(x, y, width, height, color, line_width=1.0):
|
||||
"""Used for drawing 2D rectangle outlines."""
|
||||
xmax = x + width
|
||||
ymax = y + height
|
||||
coords = (
|
||||
(x, y), # (x, y)
|
||||
(x, ymax), # (x, y)
|
||||
(xmax, ymax), # (x, y)
|
||||
(xmax, y), # (x, y)
|
||||
)
|
||||
indices = ((0, 1), (1, 2), (2, 3), (3, 0))
|
||||
|
||||
if app.version < (4, 0, 0):
|
||||
shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR")
|
||||
else:
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": coords}, indices=indices)
|
||||
|
||||
gpu.state.blend_set("ALPHA")
|
||||
gpu.state.line_width_set(line_width)
|
||||
shader.bind()
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
|
||||
def draw_line2d(x1, y1, x2, y2, width, color):
|
||||
"""Used for drawing line from dragged thumbnail to the 3D bounding box."""
|
||||
coords = ((x1, y1), (x2, y2))
|
||||
@@ -115,30 +285,59 @@ def draw_lines(vertices, indices, color):
|
||||
|
||||
|
||||
def draw_rect_3d(coords, color):
|
||||
"""Used for drawing 3D rectangle backgrounds."""
|
||||
indices = [(0, 1, 2), (2, 3, 0)]
|
||||
if app.version < (4, 0, 0):
|
||||
shader = gpu.shader.from_builtin("3D_UNIFORM_COLOR")
|
||||
else:
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
batch = batch_for_shader(shader, "TRIS", {"pos": coords}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
|
||||
gpu.state.blend_set("ALPHA")
|
||||
shader.bind()
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
|
||||
cached_images = {}
|
||||
def _resolve_color_space_mode() -> int:
|
||||
"""Return shader color conversion mode for the current drawing context.
|
||||
|
||||
area over non-3D means UI overlay, so we need to apply sRGB conversion."""
|
||||
area = getattr(bpy.context, "area", None)
|
||||
if area is None:
|
||||
return 0
|
||||
|
||||
# Blender 5.0+ node editors already expect linear data, so avoid extra conversion there
|
||||
node_editor_types = {"NODE_EDITOR", "VIEW_3D"}
|
||||
|
||||
if area.type in node_editor_types:
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def draw_image(x, y, width, height, image, transparency, crop=(0, 0, 1, 1), batch=None):
|
||||
# draw_rect(x,y, width, height, (.5,0,0,.5))
|
||||
def draw_image_runtime(
|
||||
x: float,
|
||||
y: float,
|
||||
width: float,
|
||||
height: float,
|
||||
image: Union[bpy.types.Image, IMG],
|
||||
transparency: Optional[float] = 1.0,
|
||||
crop: Tuple[float, float, float, float] = (0, 0, 1, 1),
|
||||
batch: Optional[gpu.types.GPUBatch] = None,
|
||||
) -> Optional[gpu.types.GPUBatch]:
|
||||
"""Draws an image at given location with given size.
|
||||
|
||||
try:
|
||||
image.name
|
||||
except:
|
||||
print("Image is invalid- draw function")
|
||||
return
|
||||
Returns:
|
||||
The batch object if successful, or None if the image is invalid.
|
||||
"""
|
||||
if not image.name or not image.filepath:
|
||||
return None
|
||||
|
||||
ci = cached_images.get(image.filepath)
|
||||
image_shader = create_image_shader()
|
||||
|
||||
texture = None
|
||||
ci = cached_images.get(image.filepath + "GPU_TEXTURE")
|
||||
if ci is not None:
|
||||
if (
|
||||
ci["x"] == x
|
||||
@@ -149,6 +348,7 @@ def draw_image(x, y, width, height, image, transparency, crop=(0, 0, 1, 1), batc
|
||||
batch = ci["batch"]
|
||||
image_shader = ci["image_shader"]
|
||||
texture = ci["texture"]
|
||||
|
||||
if not batch:
|
||||
coords = [(x, y), (x + width, y), (x, y + height), (x + width, y + height)]
|
||||
|
||||
@@ -161,16 +361,14 @@ def draw_image(x, y, width, height, image, transparency, crop=(0, 0, 1, 1), batc
|
||||
|
||||
indices = [(0, 1, 2), (2, 1, 3)]
|
||||
|
||||
if app.version < (4, 0, 0):
|
||||
image_shader = gpu.shader.from_builtin("2D_IMAGE")
|
||||
else:
|
||||
image_shader = gpu.shader.from_builtin("IMAGE")
|
||||
batch = batch_for_shader(
|
||||
image_shader, "TRIS", {"pos": coords, "texCoord": uvs}, indices=indices
|
||||
)
|
||||
texture = gpu.texture.from_image(image)
|
||||
|
||||
texture = path_to_gpu_texture(image.filepath)
|
||||
|
||||
# tell shader to use the image that is bound to image unit 0
|
||||
cached_images[image.filepath] = {
|
||||
cached_images[image.filepath + "GPU_TEXTURE"] = {
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
@@ -179,19 +377,56 @@ def draw_image(x, y, width, height, image, transparency, crop=(0, 0, 1, 1), batc
|
||||
"image_shader": image_shader,
|
||||
"texture": texture,
|
||||
}
|
||||
# send image to gpu if it isn't there already
|
||||
if image.gl_load():
|
||||
raise Exception()
|
||||
|
||||
# texture = gpu.texture.from_image(image)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
image_shader.bind()
|
||||
image_shader.uniform_sampler("image", texture)
|
||||
batch.draw(image_shader)
|
||||
if batch is None:
|
||||
return None
|
||||
|
||||
if image_shader and texture:
|
||||
color_space_mode = _resolve_color_space_mode()
|
||||
|
||||
gpu.state.blend_set("ALPHA")
|
||||
|
||||
image_shader.bind()
|
||||
image_shader.uniform_sampler("image", texture)
|
||||
|
||||
# may not be available in simple shader
|
||||
try:
|
||||
# set floats
|
||||
image_shader.uniform_float("transparency", transparency)
|
||||
|
||||
# set color space mode
|
||||
image_shader.uniform_int("color_space_mode", color_space_mode)
|
||||
batch.draw(image_shader)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return batch
|
||||
|
||||
|
||||
def path_to_gpu_texture(path: str) -> Optional[gpu.types.GPUTexture]:
|
||||
"""Convert a Blender image to a GPU texture.
|
||||
|
||||
Returns:
|
||||
The GPU texture if successful, or None if the image is invalid.
|
||||
"""
|
||||
# check if exists and is file [prevent exception for missing files]
|
||||
if path in cached_gpu_textures:
|
||||
return cached_gpu_textures[path]
|
||||
|
||||
if not os.path.exists(path) or not os.path.isfile(path):
|
||||
# do not spam log with warnings, just return None
|
||||
return None
|
||||
img = bpy.data.images.load(path, check_existing=False)
|
||||
img.gl_load()
|
||||
|
||||
tex = gpu.texture.from_image(img)
|
||||
cached_gpu_textures[path] = tex
|
||||
|
||||
# # Clean up Blender image
|
||||
bpy.data.images.remove(img)
|
||||
return tex
|
||||
|
||||
|
||||
def get_text_size(font_id=0, text="", text_size=16, dpi=72):
|
||||
if app.version < (4, 0, 0):
|
||||
blf.size(font_id, text_size, dpi)
|
||||
|
||||
@@ -16,16 +16,14 @@
|
||||
#
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import time
|
||||
from webbrowser import open_new_tab
|
||||
|
||||
import bpy
|
||||
from bpy.props import IntProperty, StringProperty
|
||||
from bpy.props import IntProperty, StringProperty, FloatVectorProperty, EnumProperty
|
||||
from bpy.types import Context, Menu, Panel, UILayout
|
||||
|
||||
from . import (
|
||||
@@ -53,7 +51,7 @@ ACCEPTABLE_ENGINES = ("CYCLES", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT")
|
||||
|
||||
bk_logger = logging.getLogger(__name__)
|
||||
|
||||
last_time_dropdown_active = 0.0
|
||||
last_time_overlay_panel_active = 0.0
|
||||
|
||||
|
||||
def draw_not_logged_in(source, message="Please Login/Signup to use this feature"):
|
||||
@@ -87,6 +85,9 @@ def draw_upload_common(layout, props, asset_type, context):
|
||||
url = (
|
||||
paths.BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL
|
||||
) # Reuse model instructions since prints are similar
|
||||
if asset_type == "ADDON":
|
||||
asset_type_text = asset_type
|
||||
url = paths.BLENDERKIT_ADDON_UPLOAD_INSTRUCTIONS_URL
|
||||
op = layout.operator(
|
||||
"wm.url_open", text=f"Read {asset_type} upload instructions", icon="QUESTION"
|
||||
)
|
||||
@@ -225,6 +226,19 @@ def draw_panel_hdr_search(self, context):
|
||||
utils.label_multiline(layout, text=props.report)
|
||||
|
||||
|
||||
def draw_panel_addon_search(self, context):
|
||||
wm = context.window_manager
|
||||
ui_props = wm.blenderkitUI
|
||||
addon_props = wm.blenderkit_addon
|
||||
|
||||
layout = self.layout
|
||||
row = layout.row()
|
||||
row.prop(ui_props, "search_keywords", text="", icon="VIEWZOOM")
|
||||
draw_assetbar_show_hide(row, addon_props)
|
||||
|
||||
utils.label_multiline(layout, text=addon_props.report)
|
||||
|
||||
|
||||
def draw_panel_nodegroup_upload(self, context):
|
||||
layout = self.layout
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
@@ -256,6 +270,24 @@ def draw_panel_nodegroup_search(self, context):
|
||||
utils.label_multiline(layout, text=props.report)
|
||||
|
||||
|
||||
def draw_common_filters(layout, ui_props):
|
||||
"""Draw common filter elements shared by multiple asset type panels.
|
||||
|
||||
Args:
|
||||
layout: The UI layout to draw in
|
||||
ui_props: The UI properties containing filter settings
|
||||
"""
|
||||
layout.separator()
|
||||
|
||||
row = layout.row()
|
||||
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
|
||||
row.prop(ui_props, "own_only", icon="USER")
|
||||
layout.prop(ui_props, "free_only")
|
||||
layout.prop(ui_props, "quality_limit", slider=True)
|
||||
layout.prop(ui_props, "search_license")
|
||||
layout.prop(ui_props, "search_order_by")
|
||||
|
||||
|
||||
def draw_thumbnail_upload_panel(layout, props):
|
||||
tex = autothumb.get_texture_ui(props.thumbnail, ".upload_preview")
|
||||
if not tex or not tex.image:
|
||||
@@ -440,6 +472,8 @@ def draw_model_context_menu(self, context):
|
||||
layout = self.layout
|
||||
|
||||
o = utils.get_active_model()
|
||||
if not o:
|
||||
return
|
||||
if o.get("asset_data") is None:
|
||||
utils.label_multiline(
|
||||
layout,
|
||||
@@ -479,9 +513,8 @@ class VIEW3D_PT_blenderkit_model_properties(Panel):
|
||||
def poll(cls, context):
|
||||
if bpy.context.view_layer.objects.active is None:
|
||||
return False
|
||||
# if bpy.context.view_layer.objects.get('asset_data') is None:
|
||||
# return False
|
||||
return True
|
||||
preferences = bpy.context.preferences.addons[__package__].preferences
|
||||
return not preferences.sidebar_panels
|
||||
|
||||
def draw(self, context):
|
||||
draw_model_context_menu(self, context)
|
||||
@@ -648,7 +681,8 @@ class VIEW3D_PT_blenderkit_profile(Panel):
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return True
|
||||
preferences = bpy.context.preferences.addons[__package__].preferences
|
||||
return not preferences.sidebar_panels
|
||||
|
||||
def draw_header(self, context):
|
||||
layout = self.layout
|
||||
@@ -1378,6 +1412,9 @@ class VIEW3D_PT_blenderkit_advanced_model_search(Panel):
|
||||
# NSFW filter
|
||||
layout.prop(preferences, "nsfw_filter")
|
||||
|
||||
# ORDER
|
||||
layout.prop(ui_props, "search_order_by")
|
||||
|
||||
def draw(self, context):
|
||||
self.draw_layout(self.layout)
|
||||
|
||||
@@ -1437,6 +1474,9 @@ class VIEW3D_PT_blenderkit_advanced_material_search(Panel):
|
||||
layout.prop(ui_props, "quality_limit", slider=True)
|
||||
layout.prop(ui_props, "search_license")
|
||||
|
||||
# ORDER
|
||||
layout.prop(ui_props, "search_order_by")
|
||||
|
||||
def draw(self, context):
|
||||
self.draw_layout(self.layout)
|
||||
|
||||
@@ -1457,19 +1497,8 @@ class VIEW3D_PT_blenderkit_advanced_scene_search(Panel):
|
||||
return ui_props.down_up == "SEARCH" and ui_props.asset_type == "SCENE"
|
||||
|
||||
def draw_layout(self, layout):
|
||||
wm = bpy.context.window_manager
|
||||
props = wm.blenderkit_scene
|
||||
ui_props = wm.blenderkitUI
|
||||
|
||||
layout.separator()
|
||||
|
||||
row = layout.row()
|
||||
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
|
||||
row.prop(ui_props, "own_only", icon="USER")
|
||||
|
||||
layout.prop(ui_props, "free_only")
|
||||
layout.prop(ui_props, "quality_limit", slider=True)
|
||||
layout.prop(ui_props, "search_license")
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
draw_common_filters(layout, ui_props)
|
||||
|
||||
def draw(self, context):
|
||||
self.draw_layout(self.layout)
|
||||
@@ -1497,20 +1526,14 @@ class VIEW3D_PT_blenderkit_advanced_HDR_search(Panel):
|
||||
ui_props = wm.blenderkitUI
|
||||
|
||||
layout = self.layout
|
||||
layout.separator()
|
||||
draw_common_filters(layout, ui_props)
|
||||
|
||||
row = layout.row()
|
||||
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
|
||||
row.prop(ui_props, "own_only", icon="USER")
|
||||
layout.prop(ui_props, "free_only")
|
||||
layout.prop(props, "true_hdr")
|
||||
layout.prop(props, "search_texture_resolution", text="Texture Resolutions")
|
||||
if props.search_texture_resolution:
|
||||
row = layout.row(align=True)
|
||||
row.prop(props, "search_texture_resolution_min", text="Min")
|
||||
row.prop(props, "search_texture_resolution_max", text="Max")
|
||||
layout.prop(ui_props, "quality_limit", slider=True)
|
||||
layout.prop(ui_props, "search_license")
|
||||
|
||||
|
||||
class VIEW3D_PT_blenderkit_advanced_brush_search(Panel):
|
||||
@@ -1528,17 +1551,79 @@ class VIEW3D_PT_blenderkit_advanced_brush_search(Panel):
|
||||
return ui_props.down_up == "SEARCH" and ui_props.asset_type == "BRUSH"
|
||||
|
||||
def draw_layout(self, layout):
|
||||
wm = bpy.context.window_manager
|
||||
ui_props = wm.blenderkitUI
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
draw_common_filters(layout, ui_props)
|
||||
|
||||
layout.separator()
|
||||
def draw(self, context):
|
||||
self.draw_layout(self.layout)
|
||||
|
||||
|
||||
class VIEW3D_PT_blenderkit_advanced_nodegroup_search(Panel):
|
||||
bl_category = "BlenderKit"
|
||||
bl_idname = "VIEW3D_PT_blenderkit_advanced_nodegroup_search"
|
||||
bl_parent_id = "VIEW3D_PT_blenderkit_unified"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "UI"
|
||||
bl_label = "Search filters"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
if not global_vars.CLIENT_RUNNING:
|
||||
return False
|
||||
return ui_props.down_up == "SEARCH" and ui_props.asset_type == "NODEGROUP"
|
||||
|
||||
def draw(self, context):
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
draw_common_filters(self.layout, ui_props)
|
||||
|
||||
|
||||
class VIEW3D_PT_blenderkit_advanced_addon_search(Panel):
|
||||
bl_category = "BlenderKit"
|
||||
bl_idname = "VIEW3D_PT_blenderkit_advanced_addon_search"
|
||||
bl_parent_id = "VIEW3D_PT_blenderkit_unified"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "UI"
|
||||
bl_label = "Search filters"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
if not global_vars.CLIENT_RUNNING:
|
||||
return False
|
||||
return ui_props.down_up == "SEARCH" and ui_props.asset_type == "ADDON"
|
||||
|
||||
def draw(self, context):
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
draw_common_filters(self.layout, ui_props)
|
||||
layout = self.layout
|
||||
addon_props = bpy.context.window_manager.blenderkit_addon
|
||||
# Add installed filter for addons
|
||||
row = layout.row()
|
||||
row.prop(ui_props, "search_bookmarks", text="Bookmarks", icon="BOOKMARKS")
|
||||
row.prop(ui_props, "own_only", icon="USER")
|
||||
layout.prop(ui_props, "free_only")
|
||||
layout.prop(ui_props, "quality_limit", slider=True)
|
||||
layout.prop(ui_props, "search_license")
|
||||
row.prop(addon_props, "search_installed", text="Installed Only")
|
||||
|
||||
|
||||
class VIEW3D_PT_blenderkit_advanced_printable_search(Panel):
|
||||
bl_category = "BlenderKit"
|
||||
bl_idname = "VIEW3D_PT_blenderkit_advanced_printable_search"
|
||||
bl_parent_id = "VIEW3D_PT_blenderkit_unified"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "UI"
|
||||
bl_label = "Search filters"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
if not global_vars.CLIENT_RUNNING:
|
||||
return False
|
||||
return ui_props.down_up == "SEARCH" and ui_props.asset_type == "PRINTABLE"
|
||||
|
||||
def draw_layout(self, layout):
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
draw_common_filters(layout, ui_props)
|
||||
|
||||
def draw(self, context):
|
||||
self.draw_layout(self.layout)
|
||||
@@ -1563,8 +1648,8 @@ class VIEW3D_PT_blenderkit_categories(Panel):
|
||||
def draw(self, context):
|
||||
# measure time since last dropdown activation/ mouse hover e.t.c.
|
||||
# this is then used in asset_bar_op.py to cancel asset drag drop if the time is too small and thus means double clicking.
|
||||
global last_time_dropdown_active
|
||||
last_time_dropdown_active = time.time()
|
||||
global last_time_overlay_panel_active
|
||||
last_time_overlay_panel_active = time.time()
|
||||
draw_panel_categories(self.layout, context)
|
||||
|
||||
|
||||
@@ -1637,6 +1722,25 @@ class VIEW3D_PT_blenderkit_import_settings(Panel):
|
||||
# layout.prop(props, 'unpack_files')
|
||||
|
||||
|
||||
def deferred_set_name(props, expected_obj_name):
|
||||
"""Deferred timer to set empty name of uploaded asset to active Object's name.
|
||||
We check if the names of active_now object and expected object are the same, because active object could have changed.
|
||||
This is one-shot timer = return None.
|
||||
"""
|
||||
active_now = utils.get_active_asset()
|
||||
if props.name != "":
|
||||
return None
|
||||
if not active_now:
|
||||
return None
|
||||
if active_now.name != expected_obj_name:
|
||||
return None # active object is different from the one on which we have called the timer
|
||||
props.name_old = (
|
||||
expected_obj_name # prevents utils.name_update() from running twice
|
||||
)
|
||||
props.name = expected_obj_name # this ultimately triggers utils.name_update()
|
||||
return None
|
||||
|
||||
|
||||
class VIEW3D_PT_blenderkit_unified(Panel):
|
||||
bl_category = "BlenderKit"
|
||||
bl_idname = "VIEW3D_PT_blenderkit_unified"
|
||||
@@ -1647,6 +1751,11 @@ class VIEW3D_PT_blenderkit_unified(Panel):
|
||||
}
|
||||
bl_label = ""
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
user_preferences = bpy.context.preferences.addons[__package__].preferences
|
||||
return not user_preferences.sidebar_panels
|
||||
|
||||
def draw_header(self, context):
|
||||
layout = self.layout
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
@@ -1752,7 +1861,17 @@ class VIEW3D_PT_blenderkit_unified(Panel):
|
||||
if ui_props.asset_type == "NODEGROUP":
|
||||
return draw_panel_nodegroup_search(self, context)
|
||||
|
||||
if ui_props.asset_type == "ADDON":
|
||||
return draw_panel_addon_search(self, context)
|
||||
|
||||
def draw_upload(self, context, layout, ui_props):
|
||||
obj = utils.get_active_asset()
|
||||
props = getattr(obj, "blenderkit", None)
|
||||
if props and not props.name:
|
||||
bpy.app.timers.register(
|
||||
lambda p=props, n=obj.name: deferred_set_name(p, n), first_interval=0.0
|
||||
)
|
||||
|
||||
if ui_props.asset_type == "MODEL" or ui_props.asset_type == "PRINTABLE":
|
||||
if bpy.context.view_layer.objects.active is not None:
|
||||
return draw_panel_model_upload(self, context)
|
||||
@@ -1787,6 +1906,15 @@ class VIEW3D_PT_blenderkit_unified(Panel):
|
||||
if ui_props.asset_type == "NODEGROUP":
|
||||
return draw_panel_nodegroup_upload(self, context)
|
||||
|
||||
if ui_props.asset_type == "ADDON":
|
||||
layout.label(text="Add-on uploads are managed through")
|
||||
layout.label(text="the BlenderKit website.")
|
||||
op = layout.operator(
|
||||
"wm.url_open", text="Go to BlenderKit Website", icon="URL"
|
||||
)
|
||||
op.url = paths.BLENDERKIT_ADDON_UPLOAD_INSTRUCTIONS_URL
|
||||
return
|
||||
|
||||
|
||||
class BlenderKitWelcomeOperator(bpy.types.Operator):
|
||||
"""Login online on BlenderKit webpage"""
|
||||
@@ -2051,10 +2179,11 @@ def draw_asset_context_menu(
|
||||
op.asset_base_id = asset_data["assetBaseId"]
|
||||
if asset_data["assetType"] == "model":
|
||||
o = utils.get_active_model()
|
||||
op.model_location = o.location
|
||||
op.model_rotation = o.rotation_euler
|
||||
op.target_object = o.name
|
||||
op.material_target_slot = o.active_material_index
|
||||
if o is not None:
|
||||
op.model_location = o.location
|
||||
op.model_rotation = o.rotation_euler
|
||||
op.target_object = o.name
|
||||
op.material_target_slot = o.active_material_index
|
||||
|
||||
elif asset_data["assetType"] == "material":
|
||||
aob = bpy.context.active_object
|
||||
@@ -2364,8 +2493,8 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
|
||||
icon=icon,
|
||||
emboss=emboss,
|
||||
)
|
||||
# additional questionmark icon where it's important?
|
||||
# Embossed elements are visibly clickable, so we don't need the questionmark icon
|
||||
# additional 'question mark' icon where it's important?
|
||||
# Embossed elements are visibly clickable, so we don't need the 'question mark' icon
|
||||
if url != "" and not emboss:
|
||||
split = split.split()
|
||||
op = split.operator("wm.blenderkit_url", text="", icon="QUESTION")
|
||||
@@ -2438,15 +2567,15 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
|
||||
box.separator()
|
||||
|
||||
if self.asset_data.get("license") == "cc_zero":
|
||||
t = "CC Zero "
|
||||
text = "CC Zero "
|
||||
icon = pcoll["cc0"]
|
||||
else:
|
||||
t = "Royalty free"
|
||||
text = "Royalty free"
|
||||
icon = pcoll["royalty_free"]
|
||||
self.draw_property(
|
||||
box,
|
||||
"License",
|
||||
t,
|
||||
text,
|
||||
# icon_value=icon.icon_id,
|
||||
url=f"{global_vars.SERVER}/docs/licenses/",
|
||||
tooltip="All BlenderKit assets are available for commercial use. \n"
|
||||
@@ -2540,8 +2669,8 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
|
||||
self.draw_asset_parameter(box, key="modelStyle", pretext="Style")
|
||||
|
||||
if utils.get_param(self.asset_data, "dimensionX"):
|
||||
t = utils.fmt_dimensions(mparams)
|
||||
self.draw_property(box, "Size", t)
|
||||
text = utils.fmt_dimensions(mparams)
|
||||
self.draw_property(box, "Size", text)
|
||||
if self.asset_data.get("filesSize"):
|
||||
fs = self.asset_data["filesSize"] * 1024
|
||||
# multiply because the number is reduced when search is done to avoind C intiger limit with large files
|
||||
@@ -2581,38 +2710,147 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
|
||||
|
||||
# self.draw_property(box, 'Tags', self.asset_data['tags']) #TODO make them clickable!
|
||||
|
||||
# Free/Full plan or private Access
|
||||
# Free/Full plan or private Access - with special handling for addons
|
||||
plans_tooltip = (
|
||||
"BlenderKit has 2 plans:\n"
|
||||
" * Free plan - more than 50% of all assets\n"
|
||||
" * Full plan - unlimited access to everything\n"
|
||||
"Click to go to subscriptions page"
|
||||
)
|
||||
if self.asset_data["isPrivate"]:
|
||||
t = "Private"
|
||||
self.draw_property(box, "Access", t, icon="LOCKED")
|
||||
elif self.asset_data["isFree"]:
|
||||
t = "Free plan"
|
||||
icon = pcoll["free"]
|
||||
self.draw_property(
|
||||
box,
|
||||
"Access",
|
||||
t,
|
||||
icon_value=icon.icon_id,
|
||||
tooltip=plans_tooltip,
|
||||
url=paths.BLENDERKIT_PLANS_URL,
|
||||
)
|
||||
|
||||
# Special pricing display for addons
|
||||
if self.asset_data.get("assetType") == "addon":
|
||||
|
||||
can_download = self.asset_data.get("canDownload")
|
||||
is_free = self.asset_data.get("isFree")
|
||||
|
||||
# Get pricing info from extensions cache
|
||||
base_price = self.asset_data.get("basePrice")
|
||||
is_for_sale = self.asset_data.get("isForSale")
|
||||
|
||||
if self.asset_data["isPrivate"]:
|
||||
text = "Private"
|
||||
self.draw_property(box, "Access", text, icon="LOCKED")
|
||||
elif is_for_sale and not can_download and base_price:
|
||||
text = f"${base_price} (Not purchased)"
|
||||
icon = pcoll["full"]
|
||||
self.draw_property(
|
||||
box,
|
||||
"Price",
|
||||
text,
|
||||
icon_value=icon.icon_id,
|
||||
tooltip="This addon is for sale but you haven't purchased it yet",
|
||||
)
|
||||
elif is_for_sale and can_download and base_price:
|
||||
text = f"${base_price} (Purchased)"
|
||||
icon = pcoll["full"]
|
||||
self.draw_property(
|
||||
box,
|
||||
"Price",
|
||||
text,
|
||||
icon_value=icon.icon_id,
|
||||
tooltip="You have purchased this addon",
|
||||
)
|
||||
elif not is_free and not is_for_sale:
|
||||
text = "Full plan required"
|
||||
icon = pcoll["full"]
|
||||
self.draw_property(
|
||||
box,
|
||||
"Access",
|
||||
text,
|
||||
icon_value=icon.icon_id,
|
||||
tooltip=plans_tooltip,
|
||||
url=paths.BLENDERKIT_PLANS_URL,
|
||||
)
|
||||
else:
|
||||
text = "Free"
|
||||
icon = pcoll["free"]
|
||||
self.draw_property(
|
||||
box,
|
||||
"Access",
|
||||
text,
|
||||
icon_value=icon.icon_id,
|
||||
tooltip="This addon is free to use",
|
||||
)
|
||||
|
||||
# Display Blender version requirements for addons
|
||||
dict_params = self.asset_data.get("dictParameters", {})
|
||||
min_version = dict_params.get("blenderVersionMin")
|
||||
max_version = dict_params.get("blenderVersionMax")
|
||||
if min_version:
|
||||
min_version_tuple = tuple(map(int, min_version.split(".")))
|
||||
if max_version:
|
||||
max_version_tuple = tuple(map(int, max_version.split(".")))
|
||||
|
||||
if min_version or max_version:
|
||||
version_text = ""
|
||||
if min_version and max_version:
|
||||
version_text = f"{min_version} - {max_version}"
|
||||
elif min_version:
|
||||
version_text = f"{min_version}+"
|
||||
elif max_version:
|
||||
version_text = f"≤ {max_version}"
|
||||
|
||||
# Check if current Blender version is compatible
|
||||
current_version = (
|
||||
f"{bpy.app.version[0]}.{bpy.app.version[1]}.{bpy.app.version[2]}"
|
||||
)
|
||||
is_compatible = True
|
||||
|
||||
if min_version:
|
||||
if bpy.app.version < min_version_tuple:
|
||||
is_compatible = False
|
||||
|
||||
if max_version and is_compatible:
|
||||
if bpy.app.version > max_version_tuple:
|
||||
is_compatible = False
|
||||
|
||||
# Display version requirement with appropriate warning
|
||||
if not is_compatible:
|
||||
box.alert = True
|
||||
self.draw_property(
|
||||
box,
|
||||
"Blender versions",
|
||||
f"{version_text} (Incompatible!)",
|
||||
icon="ERROR",
|
||||
tooltip=f"This addon requires Blender {version_text}, but you're using {current_version}",
|
||||
)
|
||||
box.alert = False
|
||||
else:
|
||||
self.draw_property(
|
||||
box,
|
||||
"Blender versions",
|
||||
version_text,
|
||||
icon="CHECKMARK",
|
||||
tooltip=f"This addon is compatible with your Blender version ({current_version})",
|
||||
)
|
||||
else:
|
||||
t = "Full plan"
|
||||
icon = pcoll["full"]
|
||||
self.draw_property(
|
||||
box,
|
||||
"Access",
|
||||
t,
|
||||
icon_value=icon.icon_id,
|
||||
tooltip=plans_tooltip,
|
||||
url=paths.BLENDERKIT_PLANS_URL,
|
||||
)
|
||||
# Regular asset access display
|
||||
if self.asset_data["isPrivate"]:
|
||||
text = "Private"
|
||||
self.draw_property(box, "Access", text, icon="LOCKED")
|
||||
elif self.asset_data["isFree"]:
|
||||
text = "Free plan"
|
||||
icon = pcoll["free"]
|
||||
self.draw_property(
|
||||
box,
|
||||
"Access",
|
||||
text,
|
||||
icon_value=icon.icon_id,
|
||||
tooltip=plans_tooltip,
|
||||
url=paths.BLENDERKIT_PLANS_URL,
|
||||
)
|
||||
else:
|
||||
text = "Full plan"
|
||||
icon = pcoll["full"]
|
||||
self.draw_property(
|
||||
box,
|
||||
"Access",
|
||||
text,
|
||||
icon_value=icon.icon_id,
|
||||
tooltip=plans_tooltip,
|
||||
url=paths.BLENDERKIT_PLANS_URL,
|
||||
)
|
||||
|
||||
if utils.profile_is_validator():
|
||||
date = self.asset_data["created"][:10]
|
||||
@@ -2656,7 +2894,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
|
||||
)
|
||||
|
||||
# Add TwinBru specific parameters for material assets
|
||||
# only if they have twinbruReference in the dictparameters
|
||||
# only if they have 'twinbruReference' in the 'dictParameters'
|
||||
if self.asset_data.get("dictParameters").get("twinbruReference"):
|
||||
box.separator()
|
||||
box.label(text="TwinBru physical material categories")
|
||||
@@ -3134,6 +3372,9 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
|
||||
# box.label(text=str(comment['flags']))
|
||||
|
||||
def draw(self, context):
|
||||
global last_time_overlay_panel_active
|
||||
last_time_overlay_panel_active = time.time()
|
||||
|
||||
layout = self.layout
|
||||
# top draggable bar with name of the asset
|
||||
top_row = layout.row()
|
||||
@@ -3143,10 +3384,15 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
|
||||
split_ratio = 0.45
|
||||
split_left = row.split(factor=split_ratio)
|
||||
left_column = split_left.column()
|
||||
|
||||
self.draw_thumbnail_box(left_column, width=int(self.width * split_ratio))
|
||||
|
||||
if not utils.user_is_owner(asset_data=self.asset_data):
|
||||
if (
|
||||
not utils.user_is_owner(asset_data=self.asset_data)
|
||||
and self.asset_data.get("assetType") != "addon"
|
||||
):
|
||||
# Draw ratings, but not for owners of assets - doesn't make sense.
|
||||
# also addons are now disabled until we figure out how to handle them.
|
||||
ratings_box = left_column.box()
|
||||
self.prefill_ratings()
|
||||
ratings.draw_ratings_menu(self, context, ratings_box)
|
||||
@@ -3335,35 +3581,18 @@ class ClearSearchKeywords(bpy.types.Operator):
|
||||
|
||||
|
||||
class ClosePopupButton(bpy.types.Operator):
|
||||
"""Close popup window"""
|
||||
"""Close the popup window. It can also be closed by pressing Esc or clicking outside it."""
|
||||
|
||||
bl_idname = "view3d.close_popup_button"
|
||||
bl_label = "Close popup"
|
||||
bl_label = "Close Popup"
|
||||
bl_options = {"REGISTER", "INTERNAL"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return True
|
||||
|
||||
def win_close(self):
|
||||
VK_ESCAPE = 0x1B
|
||||
ctypes.windll.user32.keybd_event(VK_ESCAPE)
|
||||
return True
|
||||
|
||||
def mouse_trick(self, context, x, y):
|
||||
# import time
|
||||
context.area.tag_redraw()
|
||||
w = context.window
|
||||
w.cursor_warp(w.x + 15, w.y + w.height - 15)
|
||||
# time.sleep(.12)
|
||||
w.cursor_warp(x, y)
|
||||
context.area.tag_redraw()
|
||||
|
||||
def invoke(self, context, event):
|
||||
if platform.system() == "Windows":
|
||||
self.win_close()
|
||||
else:
|
||||
self.mouse_trick(context, event.mouse_x, event.mouse_y)
|
||||
"""Force the (containing, parent) popup to close.
|
||||
This was done by emulating Esc or hacking mouse, but stopped working in B5.
|
||||
But can be effectively done by just tweaking screen: https://blender.stackexchange.com/a/329900
|
||||
"""
|
||||
bpy.context.window.screen = bpy.context.window.screen
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -3390,7 +3619,7 @@ class PopupDialog(bpy.types.Operator):
|
||||
|
||||
|
||||
class UrlPopupDialog(bpy.types.Operator):
|
||||
"""Generate Cycles thumbnail for model assets"""
|
||||
"""Show a popup asking the user to subscribe or log in to access the locked asset"""
|
||||
|
||||
bl_idname = "wm.blenderkit_url_dialog"
|
||||
bl_label = "BlenderKit message:"
|
||||
@@ -3404,34 +3633,36 @@ class UrlPopupDialog(bpy.types.Operator):
|
||||
|
||||
message: bpy.props.StringProperty(name="Text", description="text", default="") # type: ignore[valid-type]
|
||||
|
||||
# @classmethod
|
||||
# def poll(cls, context):
|
||||
# return bpy.context.view_layer.objects.active is not None
|
||||
width: bpy.props.IntProperty(name="width", description="width", default=300) # type: ignore[valid-type]
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
row = layout.row()
|
||||
row.label(text=self.message)
|
||||
utils.label_multiline(layout, text=self.message, width=300)
|
||||
row.operator("view3d.close_popup_button", text="", icon="CANCEL")
|
||||
|
||||
layout.active_default = True
|
||||
op = layout.operator("wm.url_open", text=self.link_text, icon="QUESTION")
|
||||
if not utils.user_logged_in():
|
||||
if self.message.find("purchased") != -1:
|
||||
text = "purchased"
|
||||
else:
|
||||
text = "subscribed"
|
||||
utils.label_multiline(
|
||||
layout,
|
||||
text="Already subscribed? You need to login to access your Full Plan.",
|
||||
text=f"Already {text}? Log in to access your account.",
|
||||
width=300,
|
||||
)
|
||||
|
||||
layout.operator_context = "EXEC_DEFAULT"
|
||||
layout.operator("wm.blenderkit_login", text="Login", icon="URL").signup = (
|
||||
False
|
||||
)
|
||||
layout.operator(
|
||||
"wm.blenderkit_login", text="Welcome Home", icon="URL"
|
||||
).signup = False
|
||||
op.url = self.url
|
||||
|
||||
def execute(self, context):
|
||||
wm = bpy.context.window_manager
|
||||
return wm.invoke_popup(self, width=300)
|
||||
return wm.invoke_popup(self, width=self.width)
|
||||
|
||||
|
||||
class LoginPopupDialog(bpy.types.Operator):
|
||||
@@ -3620,6 +3851,7 @@ def header_search_draw(self, context):
|
||||
"HDR": wm.blenderkit_HDR,
|
||||
"SCENE": wm.blenderkit_scene,
|
||||
"NODEGROUP": wm.blenderkit_nodegroup,
|
||||
"ADDON": wm.blenderkit_addon,
|
||||
}
|
||||
props = props_dict[ui_props.asset_type]
|
||||
pcoll = icons.icon_collections["main"]
|
||||
@@ -3633,6 +3865,7 @@ def header_search_draw(self, context):
|
||||
"HDR": "WORLD",
|
||||
"SCENE": "SCENE_DATA",
|
||||
"NODEGROUP": "NODETREE",
|
||||
"ADDON": "PLUGIN",
|
||||
}
|
||||
|
||||
asset_type_icon = icons_dict[ui_props.asset_type]
|
||||
@@ -3723,7 +3956,7 @@ def header_search_draw(self, context):
|
||||
icon_value=icon_id,
|
||||
)
|
||||
|
||||
# FILTER ICON
|
||||
# FILTER ICON: filters are default or modified
|
||||
if props.use_filters:
|
||||
icon_id = pcoll["filter_active"].icon_id
|
||||
else:
|
||||
@@ -3764,6 +3997,24 @@ def header_search_draw(self, context):
|
||||
text="",
|
||||
icon_value=icon_id,
|
||||
)
|
||||
elif ui_props.asset_type == "NODEGROUP":
|
||||
layout.popover(
|
||||
panel="VIEW3D_PT_blenderkit_advanced_nodegroup_search",
|
||||
text="",
|
||||
icon_value=icon_id,
|
||||
)
|
||||
elif ui_props.asset_type == "ADDON":
|
||||
layout.popover(
|
||||
panel="VIEW3D_PT_blenderkit_advanced_addon_search",
|
||||
text="",
|
||||
icon_value=icon_id,
|
||||
)
|
||||
elif ui_props.asset_type == "PRINTABLE":
|
||||
layout.popover(
|
||||
panel="VIEW3D_PT_blenderkit_advanced_printable_search",
|
||||
text="",
|
||||
icon_value=icon_id,
|
||||
)
|
||||
|
||||
# NSFW filter shield badge - only for models right now
|
||||
if preferences.nsfw_filter and ui_props.asset_type == "MODEL":
|
||||
@@ -3802,6 +4053,180 @@ def ui_message(title, message):
|
||||
bpy.context.window_manager.popup_menu(draw_message, title=title, icon="INFO")
|
||||
|
||||
|
||||
class NodegroupDropDialog(bpy.types.Operator):
|
||||
"""Dialog for choosing how to add a nodegroup when dropped on an object or in node editor"""
|
||||
|
||||
bl_idname = "wm.blenderkit_nodegroup_drop_dialog"
|
||||
bl_label = "Add Nodegroup"
|
||||
bl_options = {"REGISTER", "INTERNAL"}
|
||||
|
||||
# Store the parameters needed for the download
|
||||
asset_search_index: bpy.props.IntProperty(default=-1) # type: ignore[valid-type]
|
||||
target_object_name: bpy.props.StringProperty(default="") # type: ignore[valid-type]
|
||||
snapped_location: bpy.props.FloatVectorProperty(size=3) # type: ignore[valid-type]
|
||||
snapped_rotation: bpy.props.FloatVectorProperty(size=3) # type: ignore[valid-type]
|
||||
|
||||
# Node editor positioning (when dropped in node editor)
|
||||
node_x: bpy.props.FloatProperty(default=0.0) # type: ignore[valid-type]
|
||||
node_y: bpy.props.FloatProperty(default=0.0) # type: ignore[valid-type]
|
||||
|
||||
# Option for how to add the nodegroup
|
||||
add_mode: bpy.props.EnumProperty( # type: ignore[valid-type]
|
||||
name="Add Mode",
|
||||
description="How to add the nodegroup",
|
||||
items=[
|
||||
(
|
||||
"MODIFIER",
|
||||
"As Modifier",
|
||||
"Add the nodegroup as a new modifier on the object",
|
||||
),
|
||||
("NODE", "As Node", "Add the nodegroup as a node in an existing node tree"),
|
||||
],
|
||||
default="MODIFIER",
|
||||
)
|
||||
|
||||
# Option for overwriting existing geometry nodes modifier
|
||||
overwrite_modifier: bpy.props.BoolProperty( # type: ignore[valid-type]
|
||||
name="Overwrite Last Geometry Nodes Modifier",
|
||||
description="Replace the last geometry nodes modifier instead of creating a new one (recommended to avoid recursion)",
|
||||
default=True,
|
||||
)
|
||||
|
||||
def get_existing_geometry_modifiers(self, target_obj):
|
||||
"""Get list of existing geometry nodes modifiers on target object"""
|
||||
if not target_obj:
|
||||
return []
|
||||
return [mod for mod in target_obj.modifiers if mod.type == "NODES"]
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Get asset data for display
|
||||
sr = search.get_search_results()
|
||||
if self.asset_search_index >= 0 and self.asset_search_index < len(sr):
|
||||
asset_data = sr[self.asset_search_index]
|
||||
|
||||
col = layout.column(align=True)
|
||||
col.label(text=f"Adding nodegroup: {asset_data['displayName']}")
|
||||
|
||||
# Get target object and check for existing geometry nodes modifiers
|
||||
target_obj = None
|
||||
existing_geo_modifiers = []
|
||||
if self.target_object_name:
|
||||
target_obj = bpy.data.objects.get(self.target_object_name)
|
||||
existing_geo_modifiers = self.get_existing_geometry_modifiers(
|
||||
target_obj
|
||||
)
|
||||
col.label(text=f"To object: {self.target_object_name}")
|
||||
else:
|
||||
col.label(text="A new target object will be created")
|
||||
|
||||
col.separator()
|
||||
|
||||
col.prop(self, "add_mode", expand=True)
|
||||
|
||||
# Show overwrite option only for MODIFIER mode when there are existing geometry nodes modifiers
|
||||
if self.add_mode == "MODIFIER" and existing_geo_modifiers:
|
||||
col.separator()
|
||||
|
||||
# Show info about existing modifiers
|
||||
if len(existing_geo_modifiers) == 1:
|
||||
col.label(text=f"Found 1 geometry nodes modifier:", icon="INFO")
|
||||
else:
|
||||
col.label(
|
||||
text=f"Found {len(existing_geo_modifiers)} geometry nodes modifiers:",
|
||||
icon="INFO",
|
||||
)
|
||||
|
||||
# Show the last modifier name
|
||||
last_modifier = existing_geo_modifiers[-1]
|
||||
col.label(text=f" • {last_modifier.name} (will be affected)")
|
||||
|
||||
col.separator()
|
||||
col.prop(self, "overwrite_modifier")
|
||||
|
||||
col.separator()
|
||||
|
||||
# Add description based on selected mode
|
||||
if self.add_mode == "MODIFIER":
|
||||
if self.target_object_name:
|
||||
if existing_geo_modifiers and self.overwrite_modifier:
|
||||
col.label(text="The last geometry nodes modifier will be")
|
||||
col.label(text="replaced with the new nodegroup.")
|
||||
col.label(
|
||||
text="(Recommended to avoid recursion)", icon="CHECKMARK"
|
||||
)
|
||||
else:
|
||||
col.label(text="The nodegroup will be added as a new")
|
||||
col.label(text="geometry nodes modifier on the object.")
|
||||
if existing_geo_modifiers:
|
||||
col.label(text="⚠ May cause recursion issues", icon="ERROR")
|
||||
else:
|
||||
col.label(text="A new cube will be created and the")
|
||||
col.label(text="nodegroup added as a modifier.")
|
||||
else:
|
||||
if self.target_object_name:
|
||||
col.label(text="The nodegroup will be added as a node")
|
||||
col.label(text="in the geometry nodes editor.")
|
||||
else:
|
||||
col.label(text="A new cube will be created and the")
|
||||
col.label(text="nodegroup added as a node.")
|
||||
# Show node position if we have it
|
||||
if self.node_x != 0.0 or self.node_y != 0.0:
|
||||
col.label(
|
||||
text=f"Position: ({self.node_x:.1f}, {self.node_y:.1f})",
|
||||
icon="NODE",
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
# Download the nodegroup with the specified mode
|
||||
target_object = ""
|
||||
if self.target_object_name:
|
||||
target_object = self.target_object_name
|
||||
|
||||
# Handle modifier overwrite if requested
|
||||
if self.add_mode == "MODIFIER" and self.overwrite_modifier and target_object:
|
||||
|
||||
target_obj = bpy.data.objects.get(target_object)
|
||||
if target_obj:
|
||||
existing_geo_modifiers = self.get_existing_geometry_modifiers(
|
||||
target_obj
|
||||
)
|
||||
if existing_geo_modifiers:
|
||||
# Remove the last geometry nodes modifier
|
||||
last_modifier = existing_geo_modifiers[-1]
|
||||
bk_logger.info(
|
||||
f"Removed geometry nodes modifier '{last_modifier.name}' before adding new nodegroup"
|
||||
)
|
||||
target_obj.modifiers.remove(last_modifier)
|
||||
|
||||
# When adding as a node, use node positioning; when adding as modifier, use 3D positioning
|
||||
if self.add_mode == "NODE":
|
||||
bpy.ops.scene.blenderkit_download(
|
||||
"EXEC_DEFAULT",
|
||||
asset_index=self.asset_search_index,
|
||||
node_x=self.node_x,
|
||||
node_y=self.node_y,
|
||||
target_object=target_object,
|
||||
nodegroup_mode=self.add_mode,
|
||||
model_location=self.snapped_location,
|
||||
model_rotation=self.snapped_rotation,
|
||||
)
|
||||
else: # MODIFIER mode
|
||||
bpy.ops.scene.blenderkit_download(
|
||||
"EXEC_DEFAULT",
|
||||
asset_index=self.asset_search_index,
|
||||
model_location=self.snapped_location,
|
||||
model_rotation=self.snapped_rotation,
|
||||
target_object=target_object,
|
||||
nodegroup_mode=self.add_mode,
|
||||
)
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self, width=400)
|
||||
|
||||
|
||||
classes = (
|
||||
SetCategoryOperatorOrigin,
|
||||
SetCategoryOperator,
|
||||
@@ -3818,6 +4243,9 @@ classes = (
|
||||
VIEW3D_PT_blenderkit_advanced_scene_search,
|
||||
VIEW3D_PT_blenderkit_advanced_HDR_search,
|
||||
VIEW3D_PT_blenderkit_advanced_brush_search,
|
||||
VIEW3D_PT_blenderkit_advanced_nodegroup_search,
|
||||
VIEW3D_PT_blenderkit_advanced_addon_search,
|
||||
VIEW3D_PT_blenderkit_advanced_printable_search,
|
||||
VIEW3D_PT_blenderkit_categories,
|
||||
VIEW3D_PT_blenderkit_import_settings,
|
||||
VIEW3D_PT_blenderkit_model_properties,
|
||||
@@ -3850,6 +4278,7 @@ classes = (
|
||||
NotificationOpenTarget,
|
||||
MarkAllNotificationsRead,
|
||||
LoginPopupDialog,
|
||||
NodegroupDropDialog,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -78,10 +78,22 @@ def prevalidate_model(props):
|
||||
"""Check model for possible problems:
|
||||
- check if all objects does not have asymmetrical scaling. Asymmetrical scaling is a big problem.
|
||||
Anything scaled away from (1,1,1) is a smaller problem. We do not check for that.
|
||||
- round minor drifts from 1.0
|
||||
"""
|
||||
TOLERANCE = 1e-5
|
||||
ob = utils.get_active_model()
|
||||
obs = utils.get_hierarchy(ob)
|
||||
for ob in obs:
|
||||
if ob.scale[0] == ob.scale[1] == ob.scale[2]:
|
||||
continue # all totally good
|
||||
|
||||
if all(abs(scalar - 1.0) <= TOLERANCE for scalar in ob.scale):
|
||||
bk_logger.info(
|
||||
f"Snapped minor float drift on '{ob}': "
|
||||
+ f"{ob.scale[0], ob.scale[1], ob.scale[2]} → (1.0, 1.0, 1.0)"
|
||||
)
|
||||
ob.scale = (1.0, 1.0, 1.0)
|
||||
|
||||
if ob.scale[0] != ob.scale[1] or ob.scale[1] != ob.scale[2]:
|
||||
write_to_report(
|
||||
props,
|
||||
|
||||
@@ -19,11 +19,114 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
import addon_utils # type: ignore[import-not-found]
|
||||
import bpy
|
||||
|
||||
|
||||
# Map dependencies into a single subdirectory inside the zip and rewrite paths to relative
|
||||
def _zip_arc_for(p: str, deps_dirs: set[str]) -> str:
|
||||
base = os.path.basename(p)
|
||||
return os.path.join("caches", base)
|
||||
|
||||
|
||||
def _arc_for_path(p: str, deps_dirs: set[str]) -> str:
|
||||
pn = os.path.normpath(bpy.path.abspath(p))
|
||||
best_root = ""
|
||||
for d in deps_dirs:
|
||||
dn = os.path.normpath(d)
|
||||
if pn.startswith(dn) and len(dn) > len(best_root):
|
||||
best_root = dn
|
||||
if best_root:
|
||||
rel = os.path.relpath(pn, best_root)
|
||||
return os.path.join("caches", os.path.basename(best_root), rel)
|
||||
return os.path.join("caches", os.path.basename(pn))
|
||||
|
||||
|
||||
def get_deps_files_and_dirs():
|
||||
"""Get all dependencies files and directories."""
|
||||
deps_files: set[str] = set()
|
||||
deps_dirs: set[str] = set()
|
||||
|
||||
# Alembic/USD and similar cache files
|
||||
for cf in bpy.data.cache_files: # type: ignore[attr-defined]
|
||||
fp = bpy.path.abspath(cf.filepath)
|
||||
if fp and os.path.isfile(fp):
|
||||
deps_files.add(fp)
|
||||
|
||||
# Volumes (OpenVDB). Include file; for sequences include containing directory
|
||||
for v in getattr(bpy.data, "volumes", []):
|
||||
fp = bpy.path.abspath(getattr(v, "filepath", ""))
|
||||
if not fp:
|
||||
continue
|
||||
if os.path.isdir(fp):
|
||||
deps_dirs.add(fp)
|
||||
elif os.path.isfile(fp):
|
||||
# Heuristic: sequence often resides in the directory of the file
|
||||
if getattr(v, "is_sequence", False):
|
||||
deps_dirs.add(os.path.dirname(fp))
|
||||
else:
|
||||
deps_files.add(fp)
|
||||
|
||||
# Movie clips
|
||||
for clip in bpy.data.movieclips: # type: ignore[attr-defined]
|
||||
fp = bpy.path.abspath(clip.filepath)
|
||||
if fp and os.path.isfile(fp):
|
||||
deps_files.add(fp)
|
||||
|
||||
# Fluid domain caches (directories)
|
||||
for ob in bpy.data.objects:
|
||||
for mod in ob.modifiers:
|
||||
if (
|
||||
getattr(mod, "type", "") == "FLUID"
|
||||
and getattr(mod, "fluid_type", "") == "DOMAIN"
|
||||
):
|
||||
domain = getattr(mod, "domain_settings", None)
|
||||
if domain is not None:
|
||||
cache_dir = getattr(domain, "cache_directory", "")
|
||||
if cache_dir:
|
||||
cdir = bpy.path.abspath(cache_dir)
|
||||
if os.path.isdir(cdir):
|
||||
deps_dirs.add(cdir)
|
||||
|
||||
# Rewrite datablock paths to relative locations
|
||||
for cf in bpy.data.cache_files: # type: ignore[attr-defined]
|
||||
fp = bpy.path.abspath(cf.filepath)
|
||||
if fp and os.path.isfile(fp):
|
||||
cf.filepath = "//" + _arc_for_path(fp, deps_dirs).replace(os.sep, "/")
|
||||
|
||||
for v in getattr(bpy.data, "volumes", []):
|
||||
fp = bpy.path.abspath(getattr(v, "filepath", ""))
|
||||
if fp:
|
||||
if os.path.isdir(fp):
|
||||
target = os.path.join("caches", os.path.basename(fp))
|
||||
else:
|
||||
target = _arc_for_path(fp, deps_dirs)
|
||||
v.filepath = "//" + target.replace(os.sep, "/")
|
||||
|
||||
for clip in bpy.data.movieclips: # type: ignore[attr-defined]
|
||||
fp = bpy.path.abspath(clip.filepath)
|
||||
if fp and os.path.isfile(fp):
|
||||
clip.filepath = "//" + _arc_for_path(fp, deps_dirs).replace(os.sep, "/")
|
||||
|
||||
for ob in bpy.data.objects:
|
||||
for mod in ob.modifiers:
|
||||
if (
|
||||
getattr(mod, "type", "") == "FLUID"
|
||||
and getattr(mod, "fluid_type", "") == "DOMAIN"
|
||||
):
|
||||
domain = getattr(mod, "domain_settings", None)
|
||||
if domain is not None:
|
||||
cache_dir = getattr(domain, "cache_directory", "")
|
||||
if cache_dir:
|
||||
domain.cache_directory = "//" + _zip_arc_for(
|
||||
cache_dir, deps_dirs
|
||||
).replace(os.sep, "/")
|
||||
return deps_files, deps_dirs
|
||||
|
||||
|
||||
def patch_imports(addon_module_name: str):
|
||||
"""Patch the python configuration, so the relative imports work as expected. There are few problems to fix:
|
||||
1. Script is not recognized as module which would break at relative import. We need to set __package__ = "blenderkit" for legacy addon.
|
||||
@@ -145,7 +248,49 @@ if __name__ == "__main__":
|
||||
bpy.ops.wm.save_as_mainfile(filepath=fpath, compress=True, copy=False)
|
||||
except Exception as e:
|
||||
print(f"Exception {type(e)} during save_as_mainfile(): {e}")
|
||||
os.remove(export_data["source_filepath"])
|
||||
# Remove temp source copy
|
||||
try:
|
||||
os.remove(export_data["source_filepath"])
|
||||
except Exception as e:
|
||||
print(f"Exception {type(e)} during source cleanup: {e}")
|
||||
|
||||
# Build a single zip containing the .blend and only dependencies referenced by the file
|
||||
try:
|
||||
deps_files, deps_dirs = get_deps_files_and_dirs()
|
||||
|
||||
# skip next steps if there are no dependencies
|
||||
if not deps_files and not deps_dirs:
|
||||
print("No dependencies found, skipping zip creation")
|
||||
sys.exit(0)
|
||||
|
||||
# Re-save the .blend to include updated relative paths
|
||||
try:
|
||||
bpy.ops.wm.save_mainfile(filepath=fpath)
|
||||
except Exception as e:
|
||||
print(f"Exception {type(e)} during save_mainfile(): {e}")
|
||||
|
||||
# Create one zip with .blend and referenced caches/media
|
||||
zip_path = os.path.join(
|
||||
export_data["temp_dir"], upload_data["assetBaseId"] + ".zip"
|
||||
)
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# Put .blend at root with stable name
|
||||
zf.write(fpath, os.path.basename(fpath))
|
||||
# Add files
|
||||
for fp in sorted(deps_files):
|
||||
if os.path.isfile(fp):
|
||||
arc = _arc_for_path(fp, deps_dirs)
|
||||
zf.write(fp, arc)
|
||||
# Add directories recursively
|
||||
for d in sorted(deps_dirs):
|
||||
if os.path.isdir(d):
|
||||
for r, _, fs in os.walk(d):
|
||||
for fn in fs:
|
||||
sp = os.path.join(r, fn)
|
||||
arc = _arc_for_path(sp, deps_dirs)
|
||||
zf.write(sp, arc)
|
||||
except Exception as e:
|
||||
print(f"Exception {type(e)} during building asset zip: {e}")
|
||||
except Exception as e:
|
||||
print(f"Exception {type(e)} in upload_bg.py: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -304,6 +304,11 @@ def get_search_props():
|
||||
if not hasattr(wm, "blenderkit_nodegroup"):
|
||||
return
|
||||
props = wm.blenderkit_nodegroup
|
||||
|
||||
if uiprops.asset_type == "ADDON":
|
||||
if not hasattr(wm, "blenderkit_addon"):
|
||||
return
|
||||
props = wm.blenderkit_addon
|
||||
return props
|
||||
|
||||
|
||||
@@ -357,6 +362,8 @@ def get_active_asset():
|
||||
return get_active_brush()
|
||||
elif ui_props.asset_type == "NODEGROUP":
|
||||
return get_active_nodegroup()
|
||||
elif ui_props.asset_type == "ADDON":
|
||||
return None # Addons don't have an active asset concept
|
||||
|
||||
return None
|
||||
|
||||
@@ -394,6 +401,8 @@ def get_upload_props():
|
||||
b = get_active_nodegroup()
|
||||
if b is not None:
|
||||
return b.blenderkit
|
||||
elif ui_props.asset_type == "ADDON":
|
||||
return None # Addons don't have upload props
|
||||
return None
|
||||
|
||||
|
||||
@@ -449,7 +458,7 @@ def get_preferences_as_dict():
|
||||
# GUI
|
||||
"show_on_start": user_preferences.show_on_start,
|
||||
"thumb_size": user_preferences.thumb_size,
|
||||
"max_assetbar_rows": user_preferences.max_assetbar_rows,
|
||||
"maximized_assetbar_rows": user_preferences.maximized_assetbar_rows,
|
||||
"search_field_width": user_preferences.search_field_width,
|
||||
"search_in_header": user_preferences.search_in_header,
|
||||
"tips_on_start": user_preferences.tips_on_start,
|
||||
@@ -500,7 +509,7 @@ def get_preferences() -> datas.Prefs:
|
||||
# GUI
|
||||
show_on_start=user_preferences.show_on_start, # type: ignore[union-attr]
|
||||
thumb_size=user_preferences.thumb_size, # type: ignore[union-attr]
|
||||
max_assetbar_rows=user_preferences.max_assetbar_rows, # type: ignore[union-attr]
|
||||
maximized_assetbar_rows=user_preferences.maximized_assetbar_rows, # type: ignore[union-attr]
|
||||
search_field_width=user_preferences.search_field_width, # type: ignore[union-attr]
|
||||
search_in_header=user_preferences.search_in_header, # type: ignore[union-attr]
|
||||
tips_on_start=user_preferences.tips_on_start, # type: ignore[union-attr]
|
||||
@@ -530,8 +539,8 @@ def save_prefs_without_save_userpref(user_preferences, context):
|
||||
|
||||
|
||||
def save_prefs(user_preferences, context, **kwargs):
|
||||
# first check context, so we don't do this on registration or blender startup
|
||||
if bpy.app.background is True:
|
||||
# first check context, so we don't do this on registration, blender startup, or blender factory startup
|
||||
if bpy.app.background is True or bpy.app.factory_startup is True:
|
||||
return
|
||||
|
||||
global_vars.PREFS = get_preferences_as_dict()
|
||||
@@ -647,10 +656,11 @@ def img_to_preview(img, copy_original=False):
|
||||
def get_hidden_image(
|
||||
tpath, bdata_name, force_reload: bool = False, colorspace: str = ""
|
||||
):
|
||||
"""Get hidden image by name. If not found, load it from tpath."""
|
||||
if bdata_name[0] == ".":
|
||||
hidden_name = bdata_name
|
||||
else:
|
||||
hidden_name = ".%s" % bdata_name
|
||||
hidden_name = f".{bdata_name}"
|
||||
img = bpy.data.images.get(hidden_name) # type: ignore[union-attr]
|
||||
|
||||
if tpath.startswith("//"):
|
||||
@@ -687,14 +697,14 @@ def get_hidden_image(
|
||||
|
||||
|
||||
def get_thumbnail(name):
|
||||
"""Get addon thumbnail image by name."""
|
||||
p = paths.get_addon_thumbnail_path(name)
|
||||
name = ".%s" % name
|
||||
name = f".{name}"
|
||||
img = bpy.data.images.get(name)
|
||||
if img == None:
|
||||
if img is None:
|
||||
img = bpy.data.images.load(p, check_existing=True)
|
||||
image_utils.set_colorspace(img)
|
||||
img.name = name
|
||||
img.name = name
|
||||
|
||||
return img
|
||||
|
||||
@@ -836,6 +846,7 @@ def get_bounds_snappable(obs, use_modifiers=False):
|
||||
|
||||
obcount = 0 # calculates the mesh obs. Good for non-mesh objects
|
||||
matrix_parent = parent.matrix_world
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
for ob in obs:
|
||||
# bb=ob.bound_box
|
||||
mw = ob.matrix_world
|
||||
@@ -846,7 +857,6 @@ def get_bounds_snappable(obs, use_modifiers=False):
|
||||
if ob.type == "MESH" or ob.type == "CURVE":
|
||||
# If to_mesh() works we can use it on curves and any other ob type almost.
|
||||
# disabled to_mesh for 2.8 by now, not wanting to use dependency graph yet.
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
|
||||
object_eval = ob.evaluated_get(depsgraph)
|
||||
if ob.type == "CURVE":
|
||||
@@ -873,6 +883,36 @@ def get_bounds_snappable(obs, use_modifiers=False):
|
||||
# bpy.data.meshes.remove(mesh)
|
||||
if ob.type == "CURVE":
|
||||
object_eval.to_mesh_clear()
|
||||
elif ob.type == "VOLUME":
|
||||
# Ensure evaluated bound box (so grids/sequences are loaded)
|
||||
object_eval = ob.evaluated_get(depsgraph)
|
||||
bb = object_eval.bound_box
|
||||
obcount += 1
|
||||
for c in bb:
|
||||
coord = c
|
||||
parent_coord = (
|
||||
matrix_parent.inverted()
|
||||
@ mw
|
||||
@ Vector((coord[0], coord[1], coord[2]))
|
||||
)
|
||||
minx = min(minx, parent_coord.x)
|
||||
miny = min(miny, parent_coord.y)
|
||||
minz = min(minz, parent_coord.z)
|
||||
maxx = max(maxx, parent_coord.x)
|
||||
maxy = max(maxy, parent_coord.y)
|
||||
maxz = max(maxz, parent_coord.z)
|
||||
elif ob.type in ["LIGHT", "CAMERA"]:
|
||||
# From these we only need center point for bounds
|
||||
coord = ob.location
|
||||
parent_coord = (
|
||||
matrix_parent.inverted() @ mw @ Vector((coord[0], coord[1], coord[2]))
|
||||
)
|
||||
minx = min(minx, parent_coord.x)
|
||||
miny = min(miny, parent_coord.y)
|
||||
minz = min(minz, parent_coord.z)
|
||||
maxx = max(maxx, parent_coord.x)
|
||||
maxy = max(maxy, parent_coord.y)
|
||||
maxz = max(maxz, parent_coord.z)
|
||||
|
||||
if obcount == 0:
|
||||
minx, miny, minz, maxx, maxy, maxz = 0, 0, 0, 0, 0, 0
|
||||
@@ -1062,9 +1102,7 @@ def name_update(props, context=None):
|
||||
Checks for name change, because it decides if whole asset has to be re-uploaded. Name is stored in the blend file
|
||||
and that's the reason.
|
||||
"""
|
||||
scene = bpy.context.scene
|
||||
ui_props = bpy.context.window_manager.blenderkitUI
|
||||
|
||||
# props = get_upload_props()
|
||||
if props.name_old != props.name:
|
||||
props.name_changed = True
|
||||
@@ -1074,16 +1112,23 @@ def name_update(props, context=None):
|
||||
|
||||
if nname.isupper():
|
||||
nname = nname.lower()
|
||||
nname = nname[0].upper() + nname[1:]
|
||||
props.name = nname
|
||||
if nname != "":
|
||||
nname = nname[0].upper() + nname[1:]
|
||||
props.name = (
|
||||
nname # this recursively triggers the name_update() again, so we return
|
||||
)
|
||||
return
|
||||
# here we need to fix the name for blender data = ' or " give problems in path evaluation down the road.
|
||||
fname = props.name
|
||||
fname = fname.replace("'", "")
|
||||
fname = fname.replace('"', "")
|
||||
asset = get_active_asset()
|
||||
if ui_props.asset_type != "HDR":
|
||||
# Here we actually rename assets datablocks, but don't do that with HDR's and possibly with others
|
||||
asset.name = fname
|
||||
if ui_props.asset_type == "HDR" or fname == "":
|
||||
bk_logger.info(f"Skiping the rename")
|
||||
return # don't rename HDR's or with empty name
|
||||
else:
|
||||
asset = get_active_asset()
|
||||
if asset.name != fname: # Here we actually rename assets datablocks
|
||||
asset.name = fname # change name of active object to upload Name
|
||||
|
||||
|
||||
def fmt_dimensions(p):
|
||||
@@ -1189,6 +1234,9 @@ def user_is_owner(asset_data: Optional[dict] = None) -> bool:
|
||||
|
||||
def asset_from_newer_blender_version(asset_data, blender_version=None):
|
||||
"""Check if asset is from a newer blender version, to avoid incompatibility. Give info if difference is in major, minor or patch version."""
|
||||
# addons don't have a blender version, so we return False
|
||||
if asset_data["assetType"] == "addon":
|
||||
return False, ""
|
||||
asset_ver = asset_data["sourceAppVersion"].split(".")
|
||||
if blender_version is None:
|
||||
blender_version = bpy.app.version
|
||||
@@ -1231,27 +1279,38 @@ def guard_from_crash():
|
||||
|
||||
|
||||
def get_largest_area(context=None, area_type="VIEW_3D"):
|
||||
"""Get the largest area of the given type."""
|
||||
maxsurf = 0
|
||||
maxa = None
|
||||
maxw = None
|
||||
region = None
|
||||
if context is None:
|
||||
windows = bpy.data.window_managers[0].windows
|
||||
if bpy.context.window is not None:
|
||||
windows = [bpy.context.window]
|
||||
else:
|
||||
windows = bpy.data.window_managers.windows
|
||||
else:
|
||||
windows = context.window_manager.windows
|
||||
for w in windows:
|
||||
for a in w.screen.areas:
|
||||
if a.type == area_type:
|
||||
asurf = a.width * a.height
|
||||
if asurf > maxsurf:
|
||||
maxa = a
|
||||
maxw = w
|
||||
maxsurf = asurf
|
||||
if bpy.context.area is not None and bpy.context.area.type == area_type:
|
||||
maxa = bpy.context.area
|
||||
maxw = bpy.context.window
|
||||
maxsurf = maxa.width * maxa.height
|
||||
region = maxa.regions[-1]
|
||||
else:
|
||||
areas = w.screen.areas
|
||||
for a in w.screen.areas:
|
||||
if a.type == area_type:
|
||||
asurf = a.width * a.height
|
||||
if asurf > maxsurf:
|
||||
maxa = a
|
||||
maxw = w
|
||||
maxsurf = asurf
|
||||
|
||||
region = a.regions[-1]
|
||||
# for r in a.regions:
|
||||
# if r.type == 'WINDOW':
|
||||
# region = r
|
||||
region = a.regions[-1]
|
||||
# for r in a.regions:
|
||||
# if r.type == 'WINDOW':
|
||||
# region = r
|
||||
|
||||
if maxw is None or maxa is None:
|
||||
return None, None, None
|
||||
|
||||
Reference in New Issue
Block a user