2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
+339
View File
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,657 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import uuid
import bpy
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 (
data_from,
data_to,
):
for m in data_from.brushes:
if m == brushname or brushname is None:
data_to.brushes = [m]
brushname = m
brush = bpy.data.brushes[brushname]
brush.use_fake_user = fake_user
return brush
def append_nodegroup(
file_name, nodegroupname=None, link=False, fake_user=True, node_x=0, node_y=0
):
"""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.
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,
data_to,
):
for g in data_from.node_groups:
print(g)
if g == nodegroupname or nodegroupname is None:
data_to.node_groups = [g]
nodegroupname = g
nodegroup = bpy.data.node_groups[nodegroupname]
nodegroup.use_fake_user = fake_user
# Mapping dict for node editor tree types to node group node types
sdict = {
"GeometryNodeTree": "GeometryNodeGroup",
"ShaderNodeTree": "ShaderNodeGroup",
"CompositorNodeTree": "CompositorNodeGroup",
}
# Get the nodegroup type
nodegroup_type = nodegroup.bl_rna.identifier
# Find a suitable node editor
added_to_editor = False
# First try: exact match for tree type
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
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
return nodegroup, added_to_editor
def append_material(file_name, matname=None, link=False, fake_user=True):
"""append a material type asset
first, we have to check if there is a material with same name
in previous step there's check if the imported material
is already in the scene, so we know same name != same material
"""
mats_before = bpy.data.materials[:]
try:
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
found = False
for m in data_from.materials:
if m == matname or matname is None:
data_to.materials = [m]
matname = m
found = True
break
# not found yet? probably some name inconsistency then.
if not found and len(data_from.materials) > 0:
data_to.materials = [data_from.materials[0]]
matname = data_from.materials[0]
bk_logger.warning(
f"the material wasn't found under the exact name, appended another one: {matname}"
)
except Exception as e:
bk_logger.error(f"{e} - failed to open the asset file")
# we have to find the new material , due to possible name changes
mat = None
for m in bpy.data.materials:
if m not in mats_before:
mat = m
break
# still not found?
if mat is None:
mat = bpy.data.materials.get(matname)
if fake_user:
mat.use_fake_user = True
return mat
def append_scene(file_name, scenename=None, link=False, fake_user=False):
"""append a scene type asset"""
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for s in data_from.scenes:
if s == scenename or scenename is None:
data_to.scenes = [s]
scenename = s
scene = bpy.data.scenes[scenename]
if fake_user:
scene.use_fake_user = True
# scene has to have a new uuid, so user reports aren't screwed.
scene["uuid"] = str(uuid.uuid4())
# reset ui_props of the scene to defaults:
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.down_up = "SEARCH"
return scene
def get_node_sure(node_tree, ntype=""):
"""
Gets a node of certain type, but creates a new one if not pre
"""
node = None
for n in node_tree.nodes:
if ntype == n.bl_rna.identifier:
node = n
return node
if not node:
node = node_tree.nodes.new(type=ntype)
return node
def hdr_swap(name, hdr):
"""
Try to replace the hdr in current world setup. If this fails, create a new world.
:param name: Name of the resulting world (renamse the current one if swap is successfull)
:param hdr: Image type
:return: None
"""
w = bpy.context.scene.world
if w:
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
new_hdr_world(name, hdr)
def new_hdr_world(name, hdr):
"""
creates a new world, links in the hdr with mapping node, and links the world to scene
:param name: Name of the world datablock
:param hdr: Image type
:return: None
"""
w = bpy.data.worlds.new(name=name)
w.use_nodes = True
bpy.context.scene.world = w
nt = w.node_tree
env_node = nt.nodes.new(type="ShaderNodeTexEnvironment")
env_node.image = hdr
background = get_node_sure(nt, "ShaderNodeBackground")
tex_coord = get_node_sure(nt, "ShaderNodeTexCoord")
mapping = get_node_sure(nt, "ShaderNodeMapping")
nt.links.new(env_node.outputs["Color"], background.inputs["Color"])
nt.links.new(tex_coord.outputs["Generated"], mapping.inputs["Vector"])
nt.links.new(mapping.outputs["Vector"], env_node.inputs["Vector"])
env_node.location.x = -400
mapping.location.x = -600
tex_coord.location.x = -800
def load_HDR(file_name, name):
"""Load a HDR into file and link it to scene world."""
already_linked = False
for i in bpy.data.images:
if i.filepath == file_name:
hdr = i
already_linked = True
break
if not already_linked:
hdr = bpy.data.images.load(file_name)
hdr_swap(name, hdr)
return hdr
def link_collection(
file_name,
obnames=None,
location=(0, 0, 0),
link=False,
parent=None,
collection="",
**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
# 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
)
if layer_collection:
bpy.context.view_layer.active_layer_collection = layer_collection
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for col in data_from.collections:
if col == kwargs["name"]:
data_to.collections = [col]
rotation = (0, 0, 0)
if kwargs.get("rotation") is not None:
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"
if parent is not None:
main_object.parent = bpy.data.objects.get(parent)
main_object.matrix_world.translation = location
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
break
# sometimes, the lib might already be without the actual link.
if not main_object.instance_collection and kwargs["name"]:
col = bpy.data.collections.get(kwargs["name"])
if col:
main_object.instance_collection = col
main_object.name = main_object.instance_collection.name
# Restore original active collection
if orig_active_collection:
bpy.context.view_layer.active_layer_collection = orig_active_collection
utils.selection_set(sel)
return main_object, []
def append_particle_system(
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,
data_to,
):
for ps in data_from.particles:
pss.append(ps)
data_to.particles = pss
s = bpy.context.scene
sel = utils.selection_get()
target_object = bpy.context.scene.objects.get(kwargs["target_object"])
if target_object is not None and target_object.type == "MESH":
target_object.select_set(True)
bpy.context.view_layer.objects.active = target_object
for ps in pss:
# now let's tune this ps to the particular objects area:
totarea = 0
for p in target_object.data.polygons:
totarea += p.area
count = int(ps.count * totarea)
if ps.child_type in ("INTERPOLATED", "SIMPLE"):
total_count = count * ps.rendered_child_count
disp_count = count * ps.child_nbr
else:
total_count = count
bbox_threshold = 25000
display_threshold = 200000
total_max_threshold = 2000000
# emitting too many parent particles just kills blender now.
# this part tuned child count, we'll leave children to artists only.
# if count > total_max_threshold:
# ratio = round(count / total_max_threshold)
#
# if ps.child_type in ('INTERPOLATED', 'SIMPLE'):
# ps.rendered_child_count *= ratio
# else:
# ps.child_type = 'INTERPOLATED'
# ps.rendered_child_count = ratio
# count = max(2, int(count / ratio))
# 1st level of optimizaton - switch t bounding boxes.
if total_count > bbox_threshold:
target_object.display_type = "BOUNDS"
# 2nd level of optimization - reduce percentage of displayed particles.
ps.display_percentage = min(
ps.display_percentage,
max(1, int(100 * display_threshold / total_count)),
)
# here we can also tune down number of children displayed.
# set the count
ps.count = count
# add the modifier
bpy.ops.object.particle_system_add()
# 3rd level - hide particle system from viewport - is done on the modifier..
if total_count > total_max_threshold:
target_object.modifiers[-1].show_viewport = False
target_object.particle_systems[-1].settings = ps
target_object.select_set(False)
utils.selection_set(sel)
return target_object, []
def append_objects(
file_name, obnames=None, location=(0, 0, 0), link=False, collection="", **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()
# Store the original active collection
orig_active_collection = bpy.context.view_layer.active_layer_collection
# 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
)
if layer_collection:
bpy.context.view_layer.active_layer_collection = layer_collection
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")
bpy.ops.wm.append(filename=collection_name, directory=path)
# fc = utils.get_fake_context(bpy.context, area_type='VIEW_3D')
# bpy.ops.wm.append(fc, filename=collection_name, directory=path)
return_obs = []
to_hidden_collection = []
appended_collection = None
main_object = None
# get first at least one parent for sure
for ob in bpy.context.scene.objects:
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:
if ob.select_get():
return_obs.append(ob)
# check for object that should be hidden
if ob.users_collection[0].name == collection_name:
appended_collection = ob.users_collection[0]
appended_collection["is_blenderkit_asset"] = True
if not ob.parent:
main_object = ob
ob.location = location
else:
to_hidden_collection.append(ob)
assert (
main_object != None
), f"asset {kwargs['name']} not found in scene after appending"
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"]]
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
for ob in to_hidden_collection:
hide_collection = ob.users_collection[0]
# objects from scene collection (like rigify widgets go to a new collection
if (
hide_collection == scene_collection
or hide_collection.name in scene_collection.children
):
hidden_collection_name = collection_name + "_hidden"
h_col = bpy.data.collections.get(hidden_collection_name)
if h_col is None:
h_col = bpy.data.collections.new(name=hidden_collection_name)
# 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)
h_col.objects.link(ob)
continue
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}, {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)
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
utils.selection_set(sel)
# let collection also store info that it was created by BlenderKit, for purging reasons
return main_object, return_obs
# this is used for uploads:
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
sobs = []
# for col in data_from.collections:
# if col == kwargs.get('name'):
for ob in data_from.objects:
if ob in obnames or obnames == []:
sobs.append(ob)
data_to.objects = sobs
# data_to.objects = data_from.objects#[name for name in data_from.objects if name.startswith("house")]
# link them to scene
scene = bpy.context.scene
sel = utils.selection_get()
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
hidden_objects = []
for obj in data_to.objects:
if obj is not None:
# if obj.name not in scene.objects:
scene.collection.objects.link(obj)
if obj.parent is None:
obj.location = location
main_object = obj
obj.select_set(True)
# we need to unhide object so make_local op can use those too.
if link == True:
if obj.hide_viewport:
hidden_objects.append(obj)
obj.hide_viewport = False
return_obs.append(obj)
# Only after all objects are in scene! Otherwise gets broken relationships
if link == True:
bpy.ops.object.make_local(type="SELECT_OBJECT")
for ob in hidden_objects:
ob.hide_viewport = True
if kwargs.get("rotation") is not None:
main_object.rotation_euler = kwargs["rotation"]
if kwargs.get("parent") is not None:
main_object.parent = bpy.data.objects[kwargs["parent"]]
main_object.matrix_world.translation = location
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,447 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# This module is for analyzing the asset and filling the tags automatically.
# 1 part of the module effectively fills tags for the assets,
# the 2nd part finds possible problems in the asset.
import bpy
from . import utils
RENDER_OBTYPES = ["MESH", "CURVE", "SURFACE", "METABALL", "TEXT"]
def check_material(props, mat):
e = bpy.context.scene.render.engine
shaders = []
textures = []
props.texture_count = 0
props.node_count = 0
props.total_megapixels = 0
total_pixels = 0
props.is_procedural = True
if e == "CYCLES":
if mat.node_tree is not None:
checknodes = mat.node_tree.nodes[:]
while len(checknodes) > 0:
n = checknodes.pop()
props.node_count += 1
if n.type == "GROUP": # dive deeper here.
checknodes.extend(n.node_tree.nodes)
if (
len(n.outputs) == 1
and n.outputs[0].type == "SHADER"
and n.type != "GROUP"
):
if n.type not in shaders:
shaders.append(n.type)
if n.type == "TEX_IMAGE":
if n.image is not None:
mattype = "image based"
props.is_procedural = False
if n.image not in textures:
textures.append(n.image)
props.texture_count += 1
total_pixels += n.image.size[0] * n.image.size[1]
maxres = max(n.image.size[0], n.image.size[1])
props.texture_resolution_max = max(
props.texture_resolution_max, maxres
)
minres = min(n.image.size[0], n.image.size[1])
if props.texture_resolution_min == 0:
props.texture_resolution_min = minres
else:
props.texture_resolution_min = min(
props.texture_resolution_min, minres
)
props.total_megapixels = round(total_pixels / (1024 * 1024))
props.shaders = ""
for s in shaders:
if s.startswith("BSDF_"):
s = s[5:]
s = s.lower().replace("_", " ")
props.shaders += s + ", "
def check_render_engine(props, obs):
ob = obs[0]
m = None
e = bpy.context.scene.render.engine
mattype = None
materials = []
shaders = []
textures = []
props.uv = False
props.texture_count = 0
props.total_megapixels = 0
total_pixels = 0
props.node_count = 0
for ob in obs:
# TODO , this is duplicated here for other engines, otherwise this should be more clever.
for ms in ob.material_slots:
if ms.material is not None:
m = ms.material
if m.name not in materials:
materials.append(m.name)
if ob.type == "MESH" and len(ob.data.uv_layers) > 0:
props.uv = True
if e == "BLENDER_RENDER":
props.engine = "BLENDER_INTERNAL"
elif e == "CYCLES":
props.engine = "CYCLES"
# TODO: Clean this up, it's a mess.
for mname in materials:
m = bpy.data.materials[mname]
if m is not None and m.node_tree is not None:
checknodes = m.node_tree.nodes[:]
while len(checknodes) > 0:
n = checknodes.pop()
props.node_count += 1
if n.type == "GROUP": # dive deeper here.
if n.node_tree is not None:
checknodes.extend(n.node_tree.nodes)
if (
len(n.outputs) == 1
and n.outputs[0].type == "SHADER"
and n.type != "GROUP"
):
if n.type not in shaders:
shaders.append(n.type)
if n.type == "TEX_IMAGE":
if n.image is not None and n.image not in textures:
props.is_procedural = False
mattype = "image based"
textures.append(n.image)
props.texture_count += 1
total_pixels += n.image.size[0] * n.image.size[1]
maxres = max(n.image.size[0], n.image.size[1])
props.texture_resolution_max = max(
props.texture_resolution_max, maxres
)
minres = min(n.image.size[0], n.image.size[1])
if props.texture_resolution_min == 0:
props.texture_resolution_min = minres
else:
props.texture_resolution_min = min(
props.texture_resolution_min, minres
)
props.total_megapixels = round(total_pixels / (1024 * 1024))
# if mattype == None:
# mattype = 'procedural'
# tags['material type'] = mattype
elif e == "BLENDER_GAME":
props.engine = "BLENDER_GAME"
# write to object properties.
props.materials = ""
props.shaders = ""
for m in materials:
props.materials += m + ", "
for s in shaders:
if s.startswith("BSDF_"):
s = s[5:]
s = s.lower()
s = s.replace("_", " ")
props.shaders += s + ", "
""" ISSUE:https://github.com/BlenderKit/BlenderKit/issues/1251 #1258
Commenting this function out, some user has reported this function got executed and failed due to missing add-on in Blender 4.2.
Even though it is not called from anywhere, Python somehow went in here. So we are just commenting it out. In order to enable the func:
1. add-on object_print3d_utils had some bug in it, needs to be checked if it was fixed (are there any other better add-on for it?)
2. add-on object_print3d_utils is no longer preinstalled in Blender 4.2+, needs to be installed from extensions.blender.org -> "3D-Print Toolbox"
def check_printable(props, obs):
if len(obs) != 1:
return
addon_name = "object_print3d_utils"
was_enabled, _ = addon_utils.check(addon_name)
addon_utils.enable(addon_name)
from object_print3d_utils import operators as ops
check_cls = (
ops.MESH_OT_print3d_check_solid, # ops.Print3DCheckSolid,
ops.MESH_OT_print3d_check_intersections, # ops.Print3DCheckIntersections,
ops.MESH_OT_print3d_check_degenerate, # ops.Print3DCheckDegenerate,
ops.MESH_OT_print3d_check_distorted, # ops.Print3DCheckDistorted,
ops.MESH_OT_print3d_check_thick, # ops.Print3DCheckThick,
ops.MESH_OT_print3d_check_sharp, # ops.Print3DCheckSharp,
)
info = []
for cls in check_cls:
cls.main_check(obs[0], info)
printable = True
for item in info:
passed = item[0].endswith(" 0")
if not passed:
printable = False
props.printable_3d = printable
if not was_enabled:
addon_utils.disable(addon_name)
"""
def check_rig(props, obs):
for ob in obs:
if ob.type == "ARMATURE":
props.rig = True
def check_anim(props, obs):
animated = False
for ob in obs:
if ob.animation_data is not None:
a = ob.animation_data.action
if a is not None:
for c in a.fcurves:
if len(c.keyframe_points) > 1:
animated = True
# c.keyframe_points.remove(c.keyframe_points[0])
if animated:
props.animated = True
def check_meshprops(props, obs):
"""checks polycount, manifold, mesh parts (not implemented)"""
face_count = 0
face_count_render = 0
tris = 0
quads = 0
ngons = 0
vertices_count = 0
edges_counts = {}
manifold = True
for ob in obs:
if ob.type != "MESH" and ob.type != "CURVE":
continue
ob_eval = None
if ob.type == "CURVE":
# depsgraph = bpy.context.evaluated_depsgraph_get()
# object_eval = ob.evaluated_get(depsgraph)
mesh = ob.to_mesh()
else:
mesh = ob.data
if mesh == None: # One-point CURVE, can happen sometimes #1318
continue
fco = len(mesh.polygons)
face_count += fco
vertices_count += len(mesh.vertices)
fcor = fco
for f in mesh.polygons:
# face sides counter
if len(f.vertices) == 3:
tris += 1
elif len(f.vertices) == 4:
quads += 1
elif len(f.vertices) > 4:
ngons += 1
# manifold counter
for i, v in enumerate(f.vertices):
v1 = f.vertices[i - 1]
e = (min(v, v1), max(v, v1))
edges_counts[e] = edges_counts.get(e, 0) + 1
# all meshes have to be manifold for this to work.
manifold = manifold and not any(
i in edges_counts.values() for i in [0, 1, 3, 4]
)
for m in ob.modifiers:
if m.type == "SUBSURF" or m.type == "MULTIRES":
fcor *= 4**m.render_levels
if (
m.type == "SOLIDIFY"
): # this is rough estimate, not to waste time with evaluating all nonmanifold edges
fcor *= 2
if m.type == "ARRAY":
fcor *= m.count
if m.type == "MIRROR":
fcor *= 2
if m.type == "DECIMATE":
fcor *= m.ratio
face_count_render += fcor
if ob_eval:
ob_eval.to_mesh_clear()
# write out props
props.face_count = int(face_count)
props.face_count_render = int(face_count_render)
if quads > 0 and tris == 0 and ngons == 0:
props.mesh_poly_type = "QUAD"
elif quads > tris and quads > ngons:
props.mesh_poly_type = "QUAD_DOMINANT"
elif tris > quads and tris > quads:
props.mesh_poly_type = "TRI_DOMINANT"
elif quads == 0 and tris > 0 and ngons == 0:
props.mesh_poly_type = "TRI"
elif ngons > quads and ngons > tris:
props.mesh_poly_type = "NGON"
else:
props.mesh_poly_type = "OTHER"
props.manifold = manifold
def countObs(props, obs):
ob_types = {}
count = len(obs)
for ob in obs:
otype = ob.type.lower()
ob_types[otype] = ob_types.get(otype, 0) + 1
props.object_count = count
def check_modifiers(props, obs):
# modif_mapping = {
# }
modifiers = []
for ob in obs:
for m in ob.modifiers:
mtype = m.type
mtype = mtype.replace("_", " ")
mtype = mtype.lower()
# mtype = mtype.capitalize()
if mtype not in modifiers:
modifiers.append(mtype)
if m.type == "SMOKE":
if m.smoke_type == "FLOW":
smt = m.flow_settings.smoke_flow_type
if smt == "BOTH" or smt == "FIRE":
modifiers.append("fire")
# for mt in modifiers:
effectmodifiers = [
"soft body",
"fluid simulation",
"particle system",
"collision",
"smoke",
"cloth",
"dynamic paint",
]
for m in modifiers:
if m in effectmodifiers:
props.simulation = True
if ob.rigid_body is not None:
props.simulation = True
modifiers.append("rigid body")
finalstr = ""
for m in modifiers:
finalstr += m
finalstr += ","
props.modifiers = finalstr
def get_autotags():
"""call all analysis functions"""
ui = bpy.context.window_manager.blenderkitUI
if ui.asset_type == "MODEL" or ui.asset_type == "PRINTABLE":
ob = utils.get_active_model()
obs = utils.get_hierarchy(ob)
props = ob.blenderkit
if props.name == "":
props.name = ob.name
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore.
props.texture_resolution_max = 0
props.texture_resolution_min = 0
# disabled printing checking, some 3d print addon bug.
# bug fixed, could be enabled in the future
# also disable because add-on is not installed in Blender 4.2+, has to be installed from extensions.blender.org
# check the commented out function for more details
# check_printable( props, obs)
check_render_engine(props, obs)
dim, bbox_min, bbox_max = utils.get_dimensions(obs)
props.dimensions = dim
props.bbox_min = bbox_min
props.bbox_max = bbox_max
check_rig(props, obs)
check_anim(props, obs)
check_meshprops(props, obs)
check_modifiers(props, obs)
countObs(props, obs)
elif ui.asset_type == "MATERIAL":
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore.
mat = utils.get_active_asset()
props = mat.blenderkit
props.texture_resolution_max = 0
props.texture_resolution_min = 0
check_material(props, mat)
elif ui.asset_type == "HDR":
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore.
hdr = utils.get_active_asset()
props = hdr.blenderkit
props.texture_resolution_max = max(hdr.size[0], hdr.size[1])
class AutoFillTags(bpy.types.Operator):
"""Fill tags for asset. Now run before upload, no need to interact from user side"""
bl_idname = "object.blenderkit_auto_tags"
bl_label = "Generate Auto Tags for BlenderKit"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
return utils.uploadable_asset_poll()
def execute(self, context):
get_autotags()
return {"FINISHED"}
def register_asset_inspector():
bpy.utils.register_class(AutoFillTags)
def unregister_asset_inspector():
bpy.utils.unregister_class(AutoFillTags)
if __name__ == "__main__":
register() # type: ignore
# TODO: fix call to missing function
@@ -0,0 +1,885 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# import blenderkit
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,
FloatVectorProperty,
)
from . import bg_blender, global_vars, paths, tasks_queue, utils, upload, search
bk_logger = logging.getLogger(__name__)
BLENDERKIT_EXPORT_DATA_FILE = "data.json"
thumbnail_resolutions = (
("256", "256", ""),
("512", "512", ""),
("1024", "1024 - minimum for public", ""),
("2048", "2048", ""),
)
thumbnail_angles = (
("ANGLE_1", "Angle 1", "Lower hanging camera angle"),
("ANGLE_2", "Angle 2", "Higher hanging camera angle"),
("FRONT", "front", ""),
("SIDE", "side", ""),
("TOP", "top", ""),
)
thumbnail_snap = (
("GROUND", "ground", ""),
("WALL", "wall", ""),
("CEILING", "ceiling", ""),
("FLOAT", "floating", ""),
)
def get_texture_ui(tpath, iname):
img = bpy.data.images.get(iname)
tex = bpy.data.textures.get(iname)
if tpath.startswith("//"):
tpath = bpy.path.abspath(tpath)
if not tex or not tex.image or not tex.image.filepath == tpath:
if img is None:
tasks_queue.add_task(
(utils.get_hidden_image, (tpath, iname)), only_last=True
)
tasks_queue.add_task((utils.get_hidden_texture, (iname, False)), only_last=True)
return None
return tex
def check_thumbnail(props, imgpath):
# TODO implement check if the file exists, if size is corect etc. needs some care
if imgpath == "":
props.has_thumbnail = False
return None
img = utils.get_hidden_image(imgpath, "upload_preview", force_reload=True)
if img is not None: # and img.size[0] == img.size[1] and img.size[0] >= 512 and (
# img.file_format == 'JPEG' or img.file_format == 'PNG'):
props.has_thumbnail = True
props.thumbnail_generating_state = ""
utils.get_hidden_texture(img.name)
# pcoll = icons.icon_collections["previews"]
# pcoll.load(img.name, img.filepath, 'IMAGE')
return img
else:
props.has_thumbnail = False
output = ""
if (
img is None
or img.size[0] == 0
or img.filepath.find("thumbnail_notready.jpg") > -1
):
output += "No thumbnail or wrong file path\n"
else:
pass
# this is causing problems on some platforms, don't know why..
# if img.size[0] != img.size[1]:
# output += 'image not a square\n'
# if img.size[0] < 512:
# output += 'image too small, should be at least 512x512\n'
# if img.file_format != 'JPEG' or img.file_format != 'PNG':
# output += 'image has to be a jpeg or png'
props.thumbnail_generating_state = output
def update_upload_model_preview(self, context):
ob = utils.get_active_model()
if ob is not None:
props = ob.blenderkit
imgpath = props.thumbnail
check_thumbnail(props, imgpath)
def update_upload_scene_preview(self, context):
s = bpy.context.scene
props = s.blenderkit
imgpath = props.thumbnail
check_thumbnail(props, imgpath)
def update_upload_material_preview(self, context):
if (
hasattr(bpy.context, "active_object")
and bpy.context.view_layer.objects.active is not None
and bpy.context.active_object.active_material is not None
):
mat = bpy.context.active_object.active_material
props = mat.blenderkit
imgpath = props.thumbnail
check_thumbnail(props, imgpath)
def update_upload_brush_preview(self, context):
brush = utils.get_active_brush()
if brush is not None:
props = brush.blenderkit
imgpath = bpy.path.abspath(brush.icon_filepath)
check_thumbnail(props, imgpath)
def get_thumbnailer_args(script_name, thumbnailer_filepath, datafile, api_key):
"""Get the arguments to start Blender in background to render model or material thumbnails.
Watch out: the ending arguments must match order of those in: autothumb_model_bg.py and autothumb_material_bg.py.
"""
script_path = os.path.dirname(os.path.realpath(__file__))
script_path = os.path.join(script_path, script_name)
args = [
bpy.app.binary_path,
"--background",
"--factory-startup",
"--addons",
__package__,
"-noaudio",
thumbnailer_filepath,
"--python",
script_path,
"--",
datafile,
api_key,
__package__, # Legacy has it as "blenderkit", extensions have it like bl_ext.user_default.blenderkit or anything else
]
return args
def start_model_thumbnailer(
self=None, json_args=None, props=None, wait=False, add_bg_process=True
):
"""Start Blender in background and render the thumbnail."""
SCRIPT_NAME = "autothumb_model_bg.py"
if props:
props.is_generating_thumbnail = True
props.thumbnail_generating_state = "Saving .blend file"
datafile = os.path.join(json_args["tempdir"], BLENDERKIT_EXPORT_DATA_FILE)
user_preferences = bpy.context.preferences.addons[__package__].preferences
json_args["thumbnail_use_gpu"] = user_preferences.thumbnail_use_gpu
if user_preferences.thumbnail_use_gpu is True:
json_args["cycles_compute_device_type"] = bpy.context.preferences.addons[
"cycles"
].preferences.compute_device_type
try:
with open(datafile, "w", encoding="utf-8") as s:
json.dump(json_args, s, ensure_ascii=False, indent=4)
except Exception as e:
self.report({"WARNING"}, f"Error while exporting file: {e}")
return {"FINISHED"}
args = get_thumbnailer_args(
SCRIPT_NAME,
paths.get_thumbnailer_filepath(),
datafile,
user_preferences.api_key,
)
blender_user_scripts_dir = (
Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ)
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
creationflags=utils.get_process_flags(),
env=env,
)
bk_logger.info(f"Started Blender executing {SCRIPT_NAME} on file {datafile}")
eval_path_computing = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
eval_path = f"bpy.data.objects['{json_args['asset_name']}']"
name = f"{json_args['asset_name']} thumbnailer"
bg_blender.add_bg_process(
name=name,
eval_path_computing=eval_path_computing,
eval_path_state=eval_path_state,
eval_path=eval_path,
process_type="THUMBNAILER",
process=proc,
)
if props:
props.thumbnail_generating_state = "Started Blender instance"
if wait:
while proc.poll() is None:
stdout_data, stderr_data = proc.communicate()
bk_logger.info(stdout_data, stderr_data)
def start_material_thumbnailer(
self=None, json_args=None, props=None, wait=False, add_bg_process=True
):
"""Start Blender in background and render the thumbnail.
Parameters
----------
self
json_args - all arguments:
props - blenderkit upload props with thumbnail settings, to communicate back, if not present, not used.
wait - wait for the rendering to finish
Returns
-------
"""
SCRIPT_NAME = "autothumb_material_bg.py"
if props:
props.is_generating_thumbnail = True
props.thumbnail_generating_state = "Saving .blend file"
datafile = os.path.join(json_args["tempdir"], BLENDERKIT_EXPORT_DATA_FILE)
user_preferences = bpy.context.preferences.addons[__package__].preferences
json_args["thumbnail_use_gpu"] = user_preferences.thumbnail_use_gpu
if user_preferences.thumbnail_use_gpu is True:
json_args["cycles_compute_device_type"] = bpy.context.preferences.addons[
"cycles"
].preferences.compute_device_type
try:
with open(datafile, "w", encoding="utf-8") as s:
json.dump(json_args, s, ensure_ascii=False, indent=4)
except Exception as e:
self.report({"WARNING"}, f"Error while exporting file: {e}")
return {"FINISHED"}
args = get_thumbnailer_args(
SCRIPT_NAME,
paths.get_material_thumbnailer_filepath(),
datafile,
user_preferences.api_key,
)
blender_user_scripts_dir = (
Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ)
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
creationflags=utils.get_process_flags(),
env=env,
)
bk_logger.info(f"Started Blender executing {SCRIPT_NAME} on file {datafile}")
eval_path_computing = f"bpy.data.materials['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.materials['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
eval_path = f"bpy.data.materials['{json_args['asset_name']}']"
name = f"{json_args['asset_name']} thumbnailer"
bg_blender.add_bg_process(
name=name,
eval_path_computing=eval_path_computing,
eval_path_state=eval_path_state,
eval_path=eval_path,
process_type="THUMBNAILER",
process=proc,
)
if props:
props.thumbnail_generating_state = "Started Blender instance"
if wait:
while proc.poll() is None:
stdout_data, stderr_data = proc.communicate()
bk_logger.info(stdout_data, stderr_data)
class GenerateThumbnailOperator(bpy.types.Operator):
"""Generate Cycles thumbnail for model assets"""
bl_idname = "object.blenderkit_generate_thumbnail"
bl_label = "BlenderKit Thumbnail Generator"
bl_options = {"REGISTER", "INTERNAL"}
@classmethod
def poll(cls, context):
return bpy.context.view_layer.objects.active is not None
def draw(self, context):
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")
layout.prop(props, "thumbnail_resolution")
layout.prop(props, "thumbnail_denoising")
preferences = bpy.context.preferences.addons[__package__].preferences
layout.prop(preferences, "thumbnail_use_gpu")
def execute(self, context):
asset = utils.get_active_model()
asset.blenderkit.is_generating_thumbnail = True
asset.blenderkit.thumbnail_generating_state = "starting blender instance"
tempdir = tempfile.mkdtemp()
ext = ".blend"
filepath = os.path.join(tempdir, "thumbnailer_blenderkit" + ext)
path_can_be_relative = True
thumb_dir = os.path.dirname(bpy.data.filepath)
if thumb_dir == "":
thumb_dir = tempdir
path_can_be_relative = False
an_slug = paths.slugify(asset.name)
thumb_path = os.path.join(thumb_dir, an_slug)
if path_can_be_relative:
rel_thumb_path = f"//{an_slug}"
else:
rel_thumb_path = thumb_path
i = 0
while os.path.isfile(thumb_path + ".jpg"):
thumb_name = f"{an_slug}_{str(i).zfill(4)}"
thumb_path = os.path.join(thumb_dir, thumb_name)
if path_can_be_relative:
rel_thumb_path = f"//{thumb_name}"
i += 1
bkit = asset.blenderkit
bkit.thumbnail = rel_thumb_path + ".jpg"
bkit.thumbnail_generating_state = "Saving .blend file"
# if this isn't here, blender crashes.
if bpy.app.version >= (3, 0, 0):
bpy.context.preferences.filepaths.file_preview_type = "NONE"
# save a copy of actual scene but don't interfere with the users models
bpy.ops.wm.save_as_mainfile(filepath=filepath, compress=False, copy=True)
# get all included objects
obs = utils.get_hierarchy(asset)
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": asset_type,
"asset_name": asset.name,
"filepath": filepath,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
}
thumbnail_args = {
"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,
}
args_dict.update(thumbnail_args)
start_model_thumbnailer(
self, json_args=args_dict, props=asset.blenderkit, wait=False
)
return {"FINISHED"}
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=400)
class ReGenerateThumbnailOperator(bpy.types.Operator):
"""
Generate default thumbnail with Cycles renderer and upload it.
Works also for assets from search results, without being downloaded before.
By default marks the asset for server-side thumbnail regeneration.
"""
bl_idname = "object.blenderkit_regenerate_thumbnail"
bl_label = "BlenderKit Thumbnail Re-generate"
bl_options = {"REGISTER", "INTERNAL"}
asset_index: IntProperty( # type: ignore[valid-type]
name="Asset Index", description="asset index in search results", default=-1
)
render_locally: BoolProperty( # type: ignore[valid-type]
name="Render Locally",
description="Render thumbnail locally instead of using server-side rendering",
default=False,
)
thumbnail_background_lightness: FloatProperty( # type: ignore[valid-type]
name="Thumbnail Background Lightness",
description="Set to make your asset stand out",
default=1.0,
min=0.01,
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="ANGLE_1",
description="thumbnailer angle",
)
thumbnail_snap_to: EnumProperty( # type: ignore[valid-type]
name="Model Snaps To",
items=thumbnail_snap,
default="GROUND",
description="typical placing of the interior. Leave on ground for most objects that respect gravity",
)
thumbnail_resolution: EnumProperty( # type: ignore[valid-type]
name="Resolution",
items=thumbnail_resolutions,
description="Thumbnail resolution",
default="1024",
)
thumbnail_samples: IntProperty( # type: ignore[valid-type]
name="Cycles Samples",
description="cycles samples setting",
default=100,
min=5,
max=5000,
)
thumbnail_denoising: BoolProperty( # type: ignore[valid-type]
name="Use Denoising", description="Use denoising", default=True
)
@classmethod
def poll(cls, context):
return True # bpy.context.view_layer.objects.active is not None
def draw(self, context):
props = self
layout = self.layout
layout.prop(props, "render_locally")
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")
layout.prop(props, "thumbnail_resolution")
layout.prop(props, "thumbnail_denoising")
preferences = bpy.context.preferences.addons[__package__].preferences
layout.prop(preferences, "thumbnail_use_gpu")
def execute(self, context):
if not self.asset_index > -1:
return {"CANCELLED"}
preferences = bpy.context.preferences.addons[__package__].preferences
if not self.render_locally:
# Use server-side thumbnail regeneration
success = upload.mark_for_thumbnail(
asset_id=self.asset_data["id"],
api_key=preferences.api_key,
use_gpu=preferences.thumbnail_use_gpu,
samples=self.thumbnail_samples,
resolution=int(self.thumbnail_resolution),
denoising=self.thumbnail_denoising,
background_lightness=self.thumbnail_background_lightness,
angle=self.thumbnail_angle,
snap_to=self.thumbnail_snap_to,
)
if success:
self.report(
{"INFO"}, "Asset marked for server-side thumbnail regeneration"
)
else:
self.report(
{"ERROR"}, "Failed to mark asset for thumbnail regeneration"
)
return {"FINISHED"}
# Local thumbnail generation (original functionality)
tempdir = tempfile.mkdtemp()
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": self.asset_type,
"asset_name": self.asset_data["name"],
"asset_data": self.asset_data,
# "filepath": filepath,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
"do_download": True,
"upload_after_render": True,
}
thumbnail_args = {
"type": self.asset_type,
"thumbnail_angle": self.thumbnail_angle,
"thumbnail_snap_to": self.thumbnail_snap_to,
"thumbnail_background_lightness": self.thumbnail_background_lightness,
"thumbnail_resolution": self.thumbnail_resolution,
"thumbnail_samples": self.thumbnail_samples,
"thumbnail_denoising": self.thumbnail_denoising,
}
args_dict.update(thumbnail_args)
start_model_thumbnailer(self, json_args=args_dict, wait=False)
return {"FINISHED"}
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)
class GenerateMaterialThumbnailOperator(bpy.types.Operator):
"""Generate default thumbnail with Cycles renderer"""
bl_idname = "object.blenderkit_generate_material_thumbnail"
bl_label = "BlenderKit Material Thumbnail Generator"
bl_options = {"REGISTER", "INTERNAL"}
@classmethod
def poll(cls, context):
return bpy.context.view_layer.objects.active is not None
def check(self, context):
return True
def draw(self, context):
layout = self.layout
props = bpy.context.active_object.active_material.blenderkit
layout.prop(props, "thumbnail_generator_type")
layout.prop(props, "thumbnail_scale")
layout.prop(props, "thumbnail_background")
if props.thumbnail_background:
layout.prop(props, "thumbnail_background_lightness")
layout.prop(props, "thumbnail_resolution")
layout.prop(props, "thumbnail_samples")
layout.prop(props, "thumbnail_denoising")
layout.prop(props, "adaptive_subdivision")
preferences = bpy.context.preferences.addons[__package__].preferences
layout.prop(preferences, "thumbnail_use_gpu")
def execute(self, context):
asset = bpy.context.active_object.active_material
tempdir = tempfile.mkdtemp()
filepath = os.path.join(tempdir, "material_thumbnailer_cycles.blend")
# if this isn't here, blender crashes.
if bpy.app.version >= (3, 0, 0):
bpy.context.preferences.filepaths.file_preview_type = "NONE"
# save a copy of actual scene but don't interfere with the users models
bpy.ops.wm.save_as_mainfile(filepath=filepath, compress=False, copy=True)
path_can_be_relative = True
thumb_dir = os.path.dirname(bpy.data.filepath)
if thumb_dir == "": # file not saved
thumb_dir = tempdir
path_can_be_relative = False
an_slug = paths.slugify(asset.name)
thumb_path = os.path.join(thumb_dir, an_slug)
if path_can_be_relative:
rel_thumb_path = os.path.join("//", an_slug)
else:
rel_thumb_path = thumb_path
# auto increase number of the generated thumbnail.
i = 0
while os.path.isfile(thumb_path + ".png"):
thumb_path = os.path.join(thumb_dir, an_slug + "_" + str(i).zfill(4))
rel_thumb_path = os.path.join("//", an_slug + "_" + str(i).zfill(4))
i += 1
asset.blenderkit.thumbnail = rel_thumb_path + ".png"
bkit = asset.blenderkit
args_dict = {
"type": "material",
"asset_name": asset.name,
"filepath": filepath,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
}
thumbnail_args = {
"thumbnail_type": bkit.thumbnail_generator_type,
"thumbnail_scale": bkit.thumbnail_scale,
"thumbnail_background": bkit.thumbnail_background,
"thumbnail_background_lightness": bkit.thumbnail_background_lightness,
"thumbnail_resolution": bkit.thumbnail_resolution,
"thumbnail_samples": bkit.thumbnail_samples,
"thumbnail_denoising": bkit.thumbnail_denoising,
"adaptive_subdivision": bkit.adaptive_subdivision,
"texture_size_meters": bkit.texture_size_meters,
}
args_dict.update(thumbnail_args)
start_material_thumbnailer(
self, json_args=args_dict, props=asset.blenderkit, wait=False
)
return {"FINISHED"}
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=400)
class ReGenerateMaterialThumbnailOperator(bpy.types.Operator):
"""
Generate default thumbnail with Cycles renderer and upload it.
Works also for assets from search results, without being downloaded before.
By default marks the asset for server-side thumbnail regeneration.
"""
bl_idname = "object.blenderkit_regenerate_material_thumbnail"
bl_label = "BlenderKit Material Thumbnail Re-Generator"
bl_options = {"REGISTER", "INTERNAL"}
asset_index: IntProperty( # type: ignore[valid-type]
name="Asset Index", description="asset index in search results", default=-1
)
render_locally: BoolProperty( # type: ignore[valid-type]
name="Render Locally",
description="Render thumbnail locally instead of using server-side rendering",
default=False,
)
thumbnail_scale: FloatProperty( # type: ignore[valid-type]
name="Thumbnail Object Size",
description="Size of material preview object in meters."
"Change for materials that look better at sizes different than 1m",
default=1,
min=0.00001,
max=10,
)
thumbnail_background: BoolProperty( # type: ignore[valid-type]
name="Thumbnail Background (for Glass only)",
description="For refractive materials, you might need a background.\n"
"Don't use for other types of materials.\n"
"Transparent background is preferred",
default=False,
)
thumbnail_background_lightness: FloatProperty( # type: ignore[valid-type]
name="Thumbnail Background Lightness",
description="Set to make your material stand out with enough contrast",
default=0.9,
min=0.00001,
max=1,
)
thumbnail_samples: IntProperty( # type: ignore[valid-type]
name="Cycles Samples",
description="Cycles samples",
default=100,
min=5,
max=5000,
)
thumbnail_denoising: BoolProperty( # type: ignore[valid-type]
name="Use Denoising", description="Use denoising", default=True
)
adaptive_subdivision: BoolProperty( # type: ignore[valid-type]
name="Adaptive Subdivide",
description="Use adaptive displacement subdivision",
default=False,
)
thumbnail_resolution: EnumProperty( # type: ignore[valid-type]
name="Resolution",
items=thumbnail_resolutions,
description="Thumbnail resolution",
default="1024",
)
thumbnail_generator_type: EnumProperty( # type: ignore[valid-type]
name="Thumbnail Style",
items=(
("BALL", "Ball", ""),
(
"BALL_COMPLEX",
"Ball complex",
"Complex ball to highlight edgewear or material thickness",
),
("FLUID", "Fluid", "Fluid"),
("CLOTH", "Cloth", "Cloth"),
("HAIR", "Hair", "Hair "),
),
description="Style of asset",
default="BALL",
)
@classmethod
def poll(cls, context):
return True # bpy.context.view_layer.objects.active is not None
def check(self, context):
return True
def draw(self, context):
layout = self.layout
props = self
layout.prop(props, "render_locally")
layout.label(text="Server-side rendering may take several hours", icon="INFO")
layout.prop(props, "thumbnail_generator_type")
layout.prop(props, "thumbnail_scale")
layout.prop(props, "thumbnail_background")
if props.thumbnail_background:
layout.prop(props, "thumbnail_background_lightness")
layout.prop(props, "thumbnail_resolution")
layout.prop(props, "thumbnail_samples")
layout.prop(props, "thumbnail_denoising")
layout.prop(props, "adaptive_subdivision")
preferences = bpy.context.preferences.addons[__package__].preferences
layout.prop(preferences, "thumbnail_use_gpu")
def execute(self, context):
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"],
api_key=preferences.api_key,
use_gpu=preferences.thumbnail_use_gpu,
samples=self.thumbnail_samples,
resolution=int(self.thumbnail_resolution),
denoising=self.thumbnail_denoising,
background_lightness=self.thumbnail_background_lightness,
thumbnail_type=self.thumbnail_generator_type,
scale=self.thumbnail_scale,
background=self.thumbnail_background,
adaptive_subdivision=self.adaptive_subdivision,
)
if success:
self.report(
{"INFO"}, "Asset marked for server-side thumbnail regeneration"
)
else:
self.report(
{"ERROR"}, "Failed to mark asset for thumbnail regeneration"
)
return {"FINISHED"}
# Local thumbnail generation (original functionality)
an_slug = paths.slugify(asset_data["name"])
tempdir = tempfile.mkdtemp()
thumb_path = os.path.join(tempdir, an_slug)
args_dict = {
"type": "material",
"asset_name": asset_data["name"],
"asset_data": asset_data,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
"do_download": True,
"upload_after_render": True,
}
thumbnail_args = {
"thumbnail_type": self.thumbnail_generator_type,
"thumbnail_scale": self.thumbnail_scale,
"thumbnail_background": self.thumbnail_background,
"thumbnail_background_lightness": self.thumbnail_background_lightness,
"thumbnail_resolution": self.thumbnail_resolution,
"thumbnail_samples": self.thumbnail_samples,
"thumbnail_denoising": self.thumbnail_denoising,
"adaptive_subdivision": self.adaptive_subdivision,
"texture_size_meters": utils.get_param(
asset_data, "textureSizeMeters", 1.0
),
}
args_dict.update(thumbnail_args)
start_material_thumbnailer(self, json_args=args_dict, wait=False)
return {"FINISHED"}
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=400)
def register_thumbnailer():
bpy.utils.register_class(GenerateThumbnailOperator)
bpy.utils.register_class(ReGenerateThumbnailOperator)
bpy.utils.register_class(GenerateMaterialThumbnailOperator)
bpy.utils.register_class(ReGenerateMaterialThumbnailOperator)
def unregister_thumbnailer():
bpy.utils.unregister_class(GenerateThumbnailOperator)
bpy.utils.unregister_class(ReGenerateThumbnailOperator)
bpy.utils.unregister_class(GenerateMaterialThumbnailOperator)
bpy.utils.unregister_class(ReGenerateMaterialThumbnailOperator)
@@ -0,0 +1,245 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# type: ignore
import json
import os
import sys
from pathlib import Path
from traceback import print_exc
import bpy
def render_thumbnails():
bpy.ops.render.render(write_still=True, animation=False)
def unhide_collection(cname):
collection = bpy.context.scene.collection.children[cname]
collection.hide_viewport = False
collection.hide_render = False
collection.hide_select = False
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.
Or __package__ = "bl_ext.user_default.blenderkit"/"bl_ext.blenderkit_com.blenderkit_com". Otherwise we would see:
from . import paths
ImportError: attempted relative import with no known parent package
2. External repository (e.g. blenderkit_com) is not available as we start with --factory-startup, we need to enable it.
We can add it as LOCAL repo as the add-on is installed and we do not care about updates or anything in this BG script. Otherwise we would see:
from . import paths
ModuleNotFoundError: No module named 'bl_ext.blenderkit_com'; 'bl_ext' is not a package
"""
print(f"- Setting __package__ = '{addon_module_name}'")
global __package__
__package__ = addon_module_name
if bpy.app.version < (4, 2, 0):
print(
f"- Skipping, Blender version {bpy.app.version} < (4,2,0), no need to handle repositories"
)
return
parts = addon_module_name.split(".")
if len(parts) != 3:
print("- Skipping, addon_module_name does not contain 3 parts")
return
bpy.ops.preferences.extension_repo_add(
name=parts[1], type="LOCAL"
) # Local is enough
print(f"- Local repository {parts[1]} added")
if __name__ == "__main__":
try:
# args order must match the order in blenderkit/autothumb.py:get_thumbnailer_args()!
BLENDERKIT_EXPORT_DATA = sys.argv[-3]
BLENDERKIT_EXPORT_API_KEY = sys.argv[-2]
patch_imports(sys.argv[-1])
bpy.ops.preferences.addon_enable(module=sys.argv[-1])
from . import append_link, bg_blender, bg_utils, client_lib, utils
bg_blender.progress("preparing thumbnail scene")
with open(BLENDERKIT_EXPORT_DATA, "r", encoding="utf-8") as s:
data = json.load(s)
# append_material(file_name, matname = None, link = False, fake_user = True)
thumbnail_use_gpu = data.get("thumbnail_use_gpu")
if data.get("do_download"):
# need to save the file, so that asset doesn't get downloaded into addon directory
temp_blend_path = os.path.join(data["tempdir"], "temp.blend")
# if this isn't here, blender crashes.
if bpy.app.version >= (3, 0, 0):
bpy.context.preferences.filepaths.file_preview_type = "NONE"
bpy.ops.wm.save_as_mainfile(filepath=temp_blend_path)
asset_data = data["asset_data"]
has_url, download_url, file_name = client_lib.get_download_url(
asset_data, utils.get_scene_id(), BLENDERKIT_EXPORT_API_KEY
)
asset_data["files"][0]["url"] = download_url
asset_data["files"][0]["file_name"] = file_name
if not has_url:
bg_blender.progress(
"couldn't download asset for thumnbail re-rendering"
)
exit()
# download first, or rather make sure if it's already downloaded
bg_blender.progress("downloading asset")
fpath = bg_utils.download_asset_file(
asset_data, api_key=BLENDERKIT_EXPORT_API_KEY
)
data["filepath"] = fpath
mat = append_link.append_material(
file_name=data["filepath"],
matname=data["asset_name"],
link=True,
fake_user=False,
)
s = bpy.context.scene
colmapdict = {
"BALL": "Ball",
"BALL_COMPLEX": "Ball complex",
"FLUID": "Fluid",
"CLOTH": "Cloth",
"HAIR": "Hair",
}
unhide_collection(colmapdict[data["thumbnail_type"]])
if data["thumbnail_background"]:
unhide_collection("Background")
bpy.data.materials["bg checker colorable"].node_tree.nodes[
"input_level"
].outputs["Value"].default_value = data["thumbnail_background_lightness"]
tscale = data["thumbnail_scale"]
scaler = bpy.context.view_layer.objects["scaler"]
scaler.scale = (tscale, tscale, tscale)
utils.activate(scaler)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
bpy.context.view_layer.update()
for ob in bpy.context.visible_objects:
if ob.name[:15] == "MaterialPreview":
utils.activate(ob)
if bpy.app.version >= (3, 3, 0):
bpy.ops.object.transform_apply(
location=False, rotation=False, scale=True, isolate_users=True
)
else:
bpy.ops.object.transform_apply(
location=False, rotation=False, scale=True
)
bpy.ops.object.transform_apply(
location=False, rotation=False, scale=True
)
ob.material_slots[0].material = mat
ob.data.use_auto_texspace = False
ob.data.texspace_size.x = 1 # / tscale
ob.data.texspace_size.y = 1 # / tscale
ob.data.texspace_size.z = 1 # / tscale
if data["adaptive_subdivision"] == True:
ob.cycles.use_adaptive_subdivision = True
else:
ob.cycles.use_adaptive_subdivision = False
ts = data["texture_size_meters"]
if data["thumbnail_type"] in ["BALL", "BALL_COMPLEX", "CLOTH"]:
utils.automap(
ob.name,
tex_size=ts / tscale,
just_scale=True,
bg_exception=True,
)
bpy.context.view_layer.update()
s.cycles.volume_step_size = tscale * 0.1
if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU"
compute_device_type = data.get("cycles_compute_device_type")
if compute_device_type is not None:
# DOCS:https://github.com/dfelinto/blender/blob/master/intern/cycles/blender/addon/properties.py
bpy.context.preferences.addons[
"cycles"
].preferences.compute_device_type = compute_device_type
bpy.context.preferences.addons["cycles"].preferences.refresh_devices()
s.cycles.samples = data["thumbnail_samples"]
bpy.context.view_layer.cycles.use_denoising = data["thumbnail_denoising"]
# import blender's HDR here
hdr_path = Path("datafiles/studiolights/world/interior.exr")
bpath = Path(bpy.utils.resource_path("LOCAL"))
ipath = bpath / hdr_path
ipath = str(ipath)
# this stuff is for mac and possibly linux. For blender // means relative path.
# for Mac, // means start of absolute path
if ipath.startswith("//"):
ipath = ipath[1:]
img = bpy.data.images["interior.exr"]
img.filepath = ipath
img.reload()
bpy.context.scene.render.resolution_x = int(data["thumbnail_resolution"])
bpy.context.scene.render.resolution_y = int(data["thumbnail_resolution"])
bpy.context.scene.render.filepath = data["thumbnail_path"]
bg_blender.progress("rendering thumbnail")
# bpy.ops.wm.save_as_mainfile(filepath='C:/tmp/test.blend')
# fal
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")
ok = client_lib.complete_upload_file_blocking(
api_key=BLENDERKIT_EXPORT_API_KEY,
asset_id=data["asset_data"]["id"],
filepath=f"{data['thumbnail_path']}.png",
filetype=f"thumbnail",
fileindex=0,
)
if not ok:
bg_blender.progress("thumbnail upload failed, exiting")
sys.exit(1)
bg_blender.progress(
"background autothumbnailer finished successfully (with upload)"
)
except Exception as e:
print(f"background autothumbnailer failed: {e}")
print_exc()
sys.exit(1)
@@ -0,0 +1,321 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# type: ignore
import json
import math
import os
import random
import colorsys
import sys
from traceback import print_exc
import bpy
def get_obnames(BLENDERKIT_EXPORT_DATA: str):
with open(BLENDERKIT_EXPORT_DATA, "r", encoding="utf-8") as s:
data = json.load(s)
obnames = eval(data["models"])
return obnames
def center_obs_for_thumbnail(obs):
s = bpy.context.scene
# obs = bpy.context.selected_objects
parent = obs[0]
if parent.type == "EMPTY" and parent.instance_collection is not None:
obs = parent.instance_collection.objects[:]
while parent.parent is not None:
parent = parent.parent
# reset parent rotation, so we see how it really snaps.
parent.rotation_euler = (0, 0, 0)
parent.location = (0, 0, 0)
bpy.context.view_layer.update()
minx, miny, minz, maxx, maxy, maxz = utils.get_bounds_worldspace(obs)
cx = (maxx - minx) / 2 + minx
cy = (maxy - miny) / 2 + miny
for ob in s.collection.objects:
ob.select_set(False)
bpy.context.view_layer.objects.active = parent
# parent.location += mathutils.Vector((-cx, -cy, -minz))
parent.location = (-cx, -cy, 0)
camZ = s.camera.parent.parent
# camZ.location.z = (maxz - minz) / 2
camZ.location.z = (maxz) / 2
dx = maxx - minx
dy = maxy - miny
dz = maxz - minz
r = math.sqrt(dx * dx + dy * dy + dz * dz)
scaler = bpy.context.view_layer.objects["scaler"]
scaler.scale = (r, r, r)
coef = 0.7
r *= coef
camZ.scale = (r, r, r)
bpy.context.view_layer.update()
def render_thumbnails():
bpy.ops.render.render(write_still=True, animation=False)
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.
Or __package__ = "bl_ext.user_default.blenderkit"/"bl_ext.blenderkit_com.blenderkit_com". Otherwise we would see:
from . import paths
ImportError: attempted relative import with no known parent package
2. External repository (e.g. blenderkit_com) is not available as we start with --factory-startup, we need to enable it.
We can add it as LOCAL repo as the add-on is installed and we do not care about updates or anything in this BG script. Otherwise we would see:
from . import paths
ModuleNotFoundError: No module named 'bl_ext.blenderkit_com'; 'bl_ext' is not a package
"""
print(f"- Setting __package__ = '{addon_module_name}'")
global __package__
__package__ = addon_module_name
if bpy.app.version < (4, 2, 0):
print(
f"- Skipping, Blender version {bpy.app.version} < (4,2,0), no need to handle repositories"
)
return
parts = addon_module_name.split(".")
if len(parts) != 3:
print("- Skipping, addon_module_name does not contain 3 parts")
return
bpy.ops.preferences.extension_repo_add(
name=parts[1], type="LOCAL"
) # Local is enough
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()!
BLENDERKIT_EXPORT_DATA = sys.argv[-3]
BLENDERKIT_EXPORT_API_KEY = sys.argv[-2]
patch_imports(sys.argv[-1])
bpy.ops.preferences.addon_enable(module=sys.argv[-1])
from . import append_link, bg_blender, bg_utils, client_lib, utils
with open(BLENDERKIT_EXPORT_DATA, "r", encoding="utf-8") as s:
data = json.load(s)
thumbnail_use_gpu = data.get("thumbnail_use_gpu")
if data.get("do_download"):
# if this isn't here, blender crashes.
if bpy.app.version >= (3, 0, 0):
bpy.context.preferences.filepaths.file_preview_type = "NONE"
# need to save the file, so that asset doesn't get downloaded into addon directory
temp_blend_path = os.path.join(data["tempdir"], "temp.blend")
bpy.ops.wm.save_as_mainfile(filepath=temp_blend_path)
bg_blender.progress("Downloading asset")
asset_data = data["asset_data"]
has_url, download_url, file_name = client_lib.get_download_url(
asset_data, utils.get_scene_id(), BLENDERKIT_EXPORT_API_KEY
)
asset_data["files"][0]["url"] = download_url
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"
)
bg_blender.progress("downloading asset")
fpath = bg_utils.download_asset_file(
asset_data, api_key=BLENDERKIT_EXPORT_API_KEY
)
data["filepath"] = fpath
main_object, allobs = append_link.link_collection(
fpath,
location=(0, 0, 0),
rotation=(0, 0, 0),
link=True,
name=asset_data["name"],
parent=None,
)
allobs = [main_object]
else:
bg_blender.progress("preparing thumbnail scene")
obnames = get_obnames(BLENDERKIT_EXPORT_DATA)
main_object, allobs = append_link.append_objects(
file_name=data["filepath"], obnames=obnames, link=True
)
bpy.context.view_layer.update()
camdict = {
"GROUND": "camera ground",
"WALL": "camera wall",
"CEILING": "camera ceiling",
"FLOAT": "camera float",
}
bpy.context.scene.camera = bpy.data.objects[camdict[data["thumbnail_snap_to"]]]
center_obs_for_thumbnail(allobs)
bpy.context.scene.render.filepath = data["thumbnail_path"]
if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU"
compute_device_type = data.get("cycles_compute_device_type")
if compute_device_type is not None:
# DOCS:https://github.com/dfelinto/blender/blob/master/intern/cycles/blender/addon/properties.py
bpy.context.preferences.addons[
"cycles"
].preferences.compute_device_type = compute_device_type
bpy.context.preferences.addons["cycles"].preferences.refresh_devices()
fdict = {
"ANGLE_1": 1,
"ANGLE_2": 2,
"FRONT": 3,
"SIDE": 4,
"TOP": 5,
}
s = bpy.context.scene
s.frame_set(fdict[data["thumbnail_angle"]])
snapdict = {
"GROUND": "Ground",
"WALL": "Wall",
"CEILING": "Ceiling",
"FLOAT": "Float",
}
collection = bpy.context.scene.collection.children[
snapdict[data["thumbnail_snap_to"]]
]
collection.hide_viewport = False
collection.hide_render = False
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()
# import blender's HDR here
# hdr_path = Path('datafiles/studiolights/world/interior.exr')
# bpath = Path(bpy.utils.resource_path('LOCAL'))
# ipath = bpath / hdr_path
# ipath = str(ipath)
# this stuff is for mac and possibly linux. For blender // means relative path.
# for Mac, // means start of absolute path
# if ipath.startswith('//'):
# ipath = ipath[1:]
#
# img = bpy.data.images['interior.exr']
# img.filepath = ipath
# img.reload()
bpy.context.scene.render.resolution_x = int(data["thumbnail_resolution"])
bpy.context.scene.render.resolution_y = int(data["thumbnail_resolution"])
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")
fpath = data["thumbnail_path"] + ".jpg"
ok = client_lib.complete_upload_file_blocking(
api_key=BLENDERKIT_EXPORT_API_KEY,
asset_id=data["asset_data"]["id"],
filepath=fpath,
filetype=f"thumbnail",
fileindex=0,
)
if not ok:
bg_blender.progress("thumbnail upload failed, exiting")
sys.exit(1)
bg_blender.progress(
"background autothumbnailer finished successfully (with upload)"
)
except Exception as e:
print(f"background autothumbnailer failed: {e}")
print_exc()
sys.exit(1)
@@ -0,0 +1,320 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import re
import sys
import threading
import bpy
from bpy.props import EnumProperty
from . import utils
bk_logger = logging.getLogger(__name__)
bg_processes = []
class ThreadCom: # object passed to threads to read background process stdout info
"""Object to pass data between thread and"""
def __init__(
self,
eval_path_computing,
eval_path_state,
eval_path,
process_type,
proc,
location=None,
name="",
):
# self.obname=ob.name
self.name = name
self.eval_path_computing = eval_path_computing # property that gets written to.
self.eval_path_state = eval_path_state # property that gets written to.
self.eval_path = eval_path # property that gets written to.
self.process_type = process_type
self.outtext = ""
self.proc = proc
self.lasttext = ""
self.message = "" # the message to be sent.
self.progress = 0.0
self.location = location
self.error = False
self.log = ""
def threadread(tcom: ThreadCom):
"""reads stdout of background process.
this threads basically waits for a stdout line to come in,
fills the data, dies."""
found = False
while not found:
if tcom.proc.poll() is not None:
return # process terminated
inline = tcom.proc.stdout.readline()
inline = inline.decode("utf-8")
bk_logger.info(inline.strip())
progress = re.findall(r"progress\{(.*?)\}", inline)
if len(progress) > 0:
if type(progress[0]) == int or type(progress[0]) == float:
tcom.progress = progress
return
tcom.outtext = f"{progress[0]}"
return
s = inline.find("progress{")
if s > -1:
e = inline.find("}")
tcom.outtext = inline[s + 9 : e]
found = True
if tcom.outtext.find("%") > -1:
tcom.progress = float(re.findall(r"\d+\.\d+|\d+", tcom.outtext)[0])
return
if s == -1:
s = inline.find("Remaining")
if s > -1:
# e=inline.find('}')
tcom.outtext = inline[s : s + 18]
found = True
return
def progress(text, n=None):
"""function for reporting during the script, works for background operations in the header."""
# for i in range(n+1):
# sys.stdout.flush()
text = str(text)
if n is None:
n = ""
else:
n = " " + " " + str(int(n * 1000) / 1000) + "% "
try:
output = "progress{%s%s}\n" % (text, n)
sys.stdout.write(output)
sys.stdout.flush()
except Exception as e:
print("background progress reporting race condition")
print(e)
# @bpy.app.handlers.persistent
def bg_update():
"""monitoring of background process"""
text = ""
# utils.p('timer search')
# utils.p('start bg_blender timer bg_update')
global bg_processes
if len(bg_processes) == 0:
# utils.p('end bg_blender timer bg_update')
return 2
# cleanup dead processes first
remove_processes = []
for p in bg_processes:
if p[1].proc.poll() is not None:
remove_processes.append(p)
for p in remove_processes:
bk_logger.info(str(p[1].outtext))
estring = p[1].eval_path_computing + " = False"
try:
exec(estring)
except Exception as e:
bk_logger.error(f"Exception executing eval_path_computing: {e}")
bg_processes.remove(p)
# Parse process output
for p in bg_processes:
# proc=p[1].proc
readthread = p[0]
tcom = p[1]
if not readthread.is_alive():
readthread.join()
# readthread.
estring = None
if tcom.error:
estring = tcom.eval_path_computing + " = False"
tcom.lasttext = tcom.outtext
if tcom.outtext != "":
tcom.outtext = ""
text = tcom.lasttext.replace("'", "") # noqa: F841 needed in exec()
estring = tcom.eval_path_state + " = text"
# print(tcom.lasttext)
if "finished successfully" in tcom.lasttext:
bk_logger.info(str(tcom.lasttext))
bg_processes.remove(p)
estring = tcom.eval_path_computing + " = False"
else:
readthread = threading.Thread(
target=threadread, args=([tcom]), daemon=True
)
readthread.start()
p[0] = readthread
if estring:
try:
exec(estring)
except Exception as e:
print(f"Exception while reading from background process: {e}")
# if len(bg_processes) == 0:
# bpy.app.timers.unregister(bg_update)
if len(bg_processes) > 0:
# utils.p('end bg_blender timer bg_update')
return 0.3
# utils.p('end bg_blender timer bg_update')
return 1.0
process_types = (
("UPLOAD", "Upload", ""),
("THUMBNAILER", "Thumbnailer", ""),
)
process_sources = (
("MODEL", "Model", "set of objects"),
("SCENE", "Scene", "set of scenes"),
("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", "Node Group", "node group, can be any type of blender node group"),
("PRINTABLE", "Printable", "3D printable model"),
)
class KillBgProcess(bpy.types.Operator):
"""Remove processes in background"""
bl_idname = "object.kill_bg_process"
bl_label = "Kill Background Process"
bl_options = {"REGISTER"}
process_type: EnumProperty( # type: ignore[valid-type]
name="Type",
items=process_types,
description="Type of process",
default="UPLOAD",
)
process_source: EnumProperty( # type: ignore[valid-type]
name="Source",
items=process_sources,
description="Source of process",
default="MODEL",
)
def execute(self, context):
# first do the easy stuff...TODO all cases.
props = utils.get_upload_props()
if self.process_type == "UPLOAD":
props.uploading = False
if self.process_type == "THUMBNAILER":
props.is_generating_thumbnail = False
# print('killing', self.process_source, self.process_type)
# then go kill the process. this wasn't working for unsetting props and that was the reason for changing to the method above.
global bg_processes
processes = bg_processes
for p in processes:
tcom = p[1]
# print(tcom.process_type, self.process_type)
if tcom.process_type == self.process_type:
source = eval(tcom.eval_path)
kill = False
# TODO HDR - add killing of process
if source.bl_rna.name == "Object" and self.process_source == "MODEL":
if source.name == bpy.context.active_object.name:
kill = True
if source.bl_rna.name == "Scene" and self.process_source == "SCENE":
if source.name == bpy.context.scene.name:
kill = True
if source.bl_rna.name == "Image" and self.process_source == "HDR":
ui_props = bpy.context.window_manager.blenderkitUI
if source.name == ui_props.hdr_upload_image.name:
kill = False
if (
source.bl_rna.name == "Material"
and self.process_source == "MATERIAL"
):
if source.name == bpy.context.active_object.active_material.name:
kill = True
if source.bl_rna.name == "Brush" and self.process_source == "BRUSH":
brush = utils.get_active_brush()
if brush is not None and source.name == brush.name:
kill = True
if (
source.bl_rna.name == "Object"
and self.process_source == "PRINTABLE"
):
if source.name == bpy.context.active_object.name:
kill = True
if kill:
estring = tcom.eval_path_computing + " = False"
exec(estring)
processes.remove(p)
tcom.proc.kill()
return {"FINISHED"}
def add_bg_process(
location=None,
name=None,
eval_path_computing="",
eval_path_state="",
eval_path="",
process_type="",
process=None,
):
"""adds process for monitoring"""
global bg_processes
tcom = ThreadCom(
eval_path_computing,
eval_path_state,
eval_path,
process_type,
process,
location,
name,
)
readthread = threading.Thread(target=threadread, args=([tcom]), daemon=True)
readthread.start()
bg_processes.append([readthread, tcom])
# if not bpy.app.timers.is_registered(bg_update):
# bpy.app.timers.register(bg_update, persistent=True)
def register():
bpy.utils.register_class(KillBgProcess)
if not bpy.app.background:
bpy.app.timers.register(bg_update)
def unregister():
bpy.utils.unregister_class(KillBgProcess)
if bpy.app.timers.is_registered(bg_update):
bpy.app.timers.unregister(bg_update)
@@ -0,0 +1,67 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
"""Functions for background processes.
Not used directly in BlenderKit addon, but in BlenderKit background processes.
"""
import logging
import os
import addon_utils # type: ignore
from . import client_lib, download, paths
bk_logger = logging.getLogger(__name__)
def download_asset_file(asset_data, resolution="blend", api_key=""):
"""This is a simple non-threaded way to download files for background thumbnail rerender and others."""
# make sure BlenderKit is enabled, needed for downloading.
addon_utils.enable(
"blenderkit", default_set=True, persistent=True, handle_error=None
)
file_names = paths.get_download_filepaths(asset_data, resolution)
if len(file_names) == 0:
return None
file_name = file_names[0]
if download.check_existing(asset_data, resolution=resolution):
# this sends the thread for processing, where another check should occur, since the file might be corrupted.
bk_logger.debug("not downloading, already in db")
return file_name
res_file_info, resolution = paths.get_res_file(asset_data, resolution)
response = client_lib.blocking_file_download(
str(res_file_info["url"]), filepath=file_name, api_key=api_key
)
return file_name
def delete_unfinished_file(file_name):
"""Deletes download if it wasn't finished. If the directory it's containing is empty, it also removes the directory."""
try:
os.remove(file_name)
except Exception as e:
bk_logger.error(f"{e}")
asset_dir = os.path.dirname(file_name)
if len(os.listdir(asset_dir)) == 0:
os.rmdir(asset_dir)
return
@@ -0,0 +1,279 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import base64
import hashlib
import logging
import random
import secrets
import string
import time
from urllib.parse import quote as urlquote
from webbrowser import open_new_tab
import bpy
from bpy.props import BoolProperty
from . import client_lib, client_tasks, datas, global_vars, reports, tasks_queue, utils
CLIENT_ID = "IdFRwa3SGA8eMpzhRVFMg5Ts8sPK93xBjif93x0F"
REFRESH_RESERVE = 60 * 60 * 24 * 3 # 3 days
active_authenticator = None
bk_logger = logging.getLogger(__name__)
def handle_login_task(task: client_tasks.Task):
"""Handles incoming task of type Login. Writes tokens if it finished successfully, logouts the user on error."""
if task.status == "finished":
tasks_queue.add_task(
(
write_tokens,
(
task.result["access_token"],
task.result["refresh_token"],
task.result,
),
)
)
elif task.status == "error":
logout()
reports.add_report(task.message, type="ERROR", details=task.message_detailed)
# TODO: probably not needed anymore, check if true and remove
def handle_token_refresh_task(task: client_tasks.Task):
"""Handle incoming task of type token_refresh. If the new token is meant for the current user, calls handle_login_task.
Otherwise it ignores the incoming task.
"""
preferences = bpy.context.preferences.addons[__package__].preferences # type: ignore
if task.data.get("old_api_key") != preferences.api_key: # type: ignore
bk_logger.info("Refreshed token is not meant for current user. Ignoring.")
return
if task.status == "finished":
reports.add_report(task.message)
tasks_queue.add_task(
(
write_tokens,
(
task.result["access_token"],
task.result["refresh_token"],
task.result,
),
)
)
elif task.status == "error":
logout()
reports.add_report(task.message, details=task.message_detailed)
def handle_logout_task(task: client_tasks.Task):
"""Handles incoming task of type oauth2/logout. This could be triggered from another add-on also.
Shows messages depending on result of tokens revocation.
Regardless of revocation results, it also cleans login data."""
if task.status == "finished":
reports.add_report(task.message, timeout=3)
elif task.status == "error":
reports.add_report(task.message, type="ERROR")
clean_login_data()
def clean_login_data():
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.login_attempt = False
preferences.api_key_refresh = ""
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:
"""Logs out user from add-on. Also calls BlenderKit-client to revoke the tokens."""
bk_logger.info("Logging out.")
client_lib.oauth2_logout()
clean_login_data()
def login(signup: bool) -> None:
"""Logs user into the addon.
Opens a browser with login page. Once user is logged it redirects browser to Client handling access code via URL querry parameter.
Using the access_code Client then requests api_token and handles the results as a task with status finished/error.
This is handled by function handle_login_task which saves tokens, or shows error message.
"""
# We use redirect_URI without /vX.Y/ API path prefix to not complicate stuff on the server side.
redirect_URI = f"http://localhost:{client_lib.get_port()}/consumer/exchange/"
code_verifier, code_challenge = generate_pkce_pair()
state = secrets.token_urlsafe()
client_lib.send_oauth_verification_data(code_verifier, state)
authorize_url = f"/o/authorize?client_id={CLIENT_ID}&response_type=code&state={state}&redirect_uri={redirect_URI}&code_challenge={code_challenge}&code_challenge_method=S256"
if signup:
authorize_url = urlquote(authorize_url)
authorize_url = f"{global_vars.SERVER}/accounts/register/?next={authorize_url}"
else:
authorize_url = f"{global_vars.SERVER}{authorize_url}"
ok = open_new_tab(authorize_url)
bk_logger.info(f"Login page in browser opened ({ok})")
def generate_pkce_pair() -> tuple[str, str]:
"""Generate PKCE pair - a code verifier and code challange.
The challange should be sent first to the server, the verifier is used in next steps to verify identity (handles Client).
"""
rand = random.SystemRandom()
code_verifier = "".join(rand.choices(string.ascii_letters + string.digits, k=128))
code_sha_256 = hashlib.sha256(code_verifier.encode("utf-8")).digest()
b64 = base64.urlsafe_b64encode(code_sha_256)
code_challenge = b64.decode("utf-8").replace("=", "")
return code_verifier, code_challenge
def write_tokens(auth_token, refresh_token, oauth_response):
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.api_key_timeout = int(time.time() + oauth_response["expires_in"])
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 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()
#
def ensure_token_refresh() -> bool:
"""Check if API token needs refresh, call refresh and return True if so.
Otherwise do nothing and return False.
"""
preferences = bpy.context.preferences.addons[__package__].preferences # type: ignore
if preferences.api_key == "": # type: ignore
return False # not logged in
if preferences.api_key_refresh == "": # type: ignore
# Using manually inserted permanent token
return False
if time.time() + REFRESH_RESERVE < preferences.api_key_timeout: # type: ignore
# Token is not old
return False
# Token is at the end of life, refresh token exists, it is time to refresh
client_lib.refresh_token(preferences.api_key_refresh, preferences.api_key) # type: ignore
return True
class LoginOnline(bpy.types.Operator):
"""Login or register online on BlenderKit webpage"""
bl_idname = "wm.blenderkit_login"
bl_label = "BlenderKit login/signup"
bl_options = {"REGISTER", "UNDO"}
signup: BoolProperty( # type: ignore
name="create a new account",
description="True for register, otherwise login",
default=False,
options={"SKIP_SAVE"},
)
message: bpy.props.StringProperty( # type: ignore
name="Message",
description="",
default="You were logged out from BlenderKit.\n Clicking OK takes you to web login. ",
)
@classmethod
def poll(cls, context):
return True
def draw(self, context):
layout = self.layout
utils.label_multiline(layout, text=self.message, width=300)
def execute(self, context):
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.login_attempt = True
login(self.signup)
return {"FINISHED"}
def invoke(self, context, event):
wm = bpy.context.window_manager
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.api_key_refresh = ""
preferences.api_key = ""
return wm.invoke_props_dialog(self)
class Logout(bpy.types.Operator):
"""Logout from BlenderKit immediately"""
bl_idname = "wm.blenderkit_logout"
bl_label = "BlenderKit logout"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
logout()
return {"FINISHED"}
class CancelLoginOnline(bpy.types.Operator):
"""Cancel login attempt"""
bl_idname = "wm.blenderkit_login_cancel"
bl_label = "BlenderKit login cancel"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.login_attempt = False
return {"FINISHED"}
classes = (
LoginOnline,
CancelLoginOnline,
Logout,
)
def register():
for c in classes:
bpy.utils.register_class(c)
def unregister():
for c in classes:
bpy.utils.unregister_class(c)
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
@@ -0,0 +1,511 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import uuid
import bpy
from . import utils
bk_logger = logging.getLogger(__name__)
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 (
data_from,
data_to,
):
for m in data_from.brushes:
if m == brushname or brushname is None:
data_to.brushes = [m]
brushname = m
brush = bpy.data.brushes[brushname]
brush.use_fake_user = fake_user
return brush
def append_nodegroup(file_name, nodegroupname=None, link=False, fake_user=True):
"""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.
"""
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for g in data_from.node_groups:
print(g)
if g == nodegroupname or nodegroupname is None:
data_to.node_groups = [g]
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
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.
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
def append_material(file_name, matname=None, link=False, fake_user=True):
"""append a material type asset
first, we have to check if there is a material with same name
in previous step there's check if the imported material
is already in the scene, so we know same name != same material
"""
mats_before = bpy.data.materials[:]
try:
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
found = False
for m in data_from.materials:
if m == matname or matname is None:
data_to.materials = [m]
matname = m
found = True
break
# not found yet? probably some name inconsistency then.
if not found and len(data_from.materials) > 0:
data_to.materials = [data_from.materials[0]]
matname = data_from.materials[0]
bk_logger.warning(
f"the material wasn't found under the exact name, appended another one: {matname}"
)
except Exception as e:
bk_logger.error(f"{e} - failed to open the asset file")
# we have to find the new material , due to possible name changes
mat = None
for m in bpy.data.materials:
if m not in mats_before:
mat = m
break
# still not found?
if mat is None:
mat = bpy.data.materials.get(matname)
if fake_user:
mat.use_fake_user = True
return mat
def append_scene(file_name, scenename=None, link=False, fake_user=False):
"""append a scene type asset"""
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for s in data_from.scenes:
if s == scenename or scenename is None:
data_to.scenes = [s]
scenename = s
scene = bpy.data.scenes[scenename]
if fake_user:
scene.use_fake_user = True
# scene has to have a new uuid, so user reports aren't screwed.
scene["uuid"] = str(uuid.uuid4())
# reset ui_props of the scene to defaults:
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.down_up = "SEARCH"
return scene
def get_node_sure(node_tree, ntype=""):
"""
Gets a node of certain type, but creates a new one if not pre
"""
node = None
for n in node_tree.nodes:
if ntype == n.bl_rna.identifier:
node = n
return node
if not node:
node = node_tree.nodes.new(type=ntype)
return node
def hdr_swap(name, hdr):
"""
Try to replace the hdr in current world setup. If this fails, create a new world.
:param name: Name of the resulting world (renamse the current one if swap is successfull)
:param hdr: Image type
:return: None
"""
w = bpy.context.scene.world
if w:
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
new_hdr_world(name, hdr)
def new_hdr_world(name, hdr):
"""
creates a new world, links in the hdr with mapping node, and links the world to scene
:param name: Name of the world datablock
:param hdr: Image type
:return: None
"""
w = bpy.data.worlds.new(name=name)
w.use_nodes = True
bpy.context.scene.world = w
nt = w.node_tree
env_node = nt.nodes.new(type="ShaderNodeTexEnvironment")
env_node.image = hdr
background = get_node_sure(nt, "ShaderNodeBackground")
tex_coord = get_node_sure(nt, "ShaderNodeTexCoord")
mapping = get_node_sure(nt, "ShaderNodeMapping")
nt.links.new(env_node.outputs["Color"], background.inputs["Color"])
nt.links.new(tex_coord.outputs["Generated"], mapping.inputs["Vector"])
nt.links.new(mapping.outputs["Vector"], env_node.inputs["Vector"])
env_node.location.x = -400
mapping.location.x = -600
tex_coord.location.x = -800
def load_HDR(file_name, name):
"""Load a HDR into file and link it to scene world."""
already_linked = False
for i in bpy.data.images:
if i.filepath == file_name:
hdr = i
already_linked = True
break
if not already_linked:
hdr = bpy.data.images.load(file_name)
hdr_swap(name, hdr)
return hdr
def link_collection(
file_name, obnames=[], location=(0, 0, 0), link=False, parent=None, **kwargs
):
"""link an instanced group - model type asset"""
sel = utils.selection_get()
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]
rotation = (0, 0, 0)
if kwargs.get("rotation") is not None:
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"
if parent is not None:
main_object.parent = bpy.data.objects.get(parent)
main_object.matrix_world.translation = location
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
break
# sometimes, the lib might already be without the actual link.
if not main_object.instance_collection and kwargs["name"]:
col = bpy.data.collections.get(kwargs["name"])
if col:
main_object.instance_collection = col
main_object.name = main_object.instance_collection.name
# 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
utils.selection_set(sel)
return main_object, []
def append_particle_system(
file_name, obnames=[], location=(0, 0, 0), link=False, **kwargs
):
"""link an instanced group - model type asset"""
pss = []
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for ps in data_from.particles:
pss.append(ps)
data_to.particles = pss
s = bpy.context.scene
sel = utils.selection_get()
target_object = bpy.context.scene.objects.get(kwargs["target_object"])
if target_object is not None and target_object.type == "MESH":
target_object.select_set(True)
bpy.context.view_layer.objects.active = target_object
for ps in pss:
# now let's tune this ps to the particular objects area:
totarea = 0
for p in target_object.data.polygons:
totarea += p.area
count = int(ps.count * totarea)
if ps.child_type in ("INTERPOLATED", "SIMPLE"):
total_count = count * ps.rendered_child_count
disp_count = count * ps.child_nbr
else:
total_count = count
bbox_threshold = 25000
display_threshold = 200000
total_max_threshold = 2000000
# emitting too many parent particles just kills blender now.
# this part tuned child count, we'll leave children to artists only.
# if count > total_max_threshold:
# ratio = round(count / total_max_threshold)
#
# if ps.child_type in ('INTERPOLATED', 'SIMPLE'):
# ps.rendered_child_count *= ratio
# else:
# ps.child_type = 'INTERPOLATED'
# ps.rendered_child_count = ratio
# count = max(2, int(count / ratio))
# 1st level of optimizaton - switch t bounding boxes.
if total_count > bbox_threshold:
target_object.display_type = "BOUNDS"
# 2nd level of optimization - reduce percentage of displayed particles.
ps.display_percentage = min(
ps.display_percentage,
max(1, int(100 * display_threshold / total_count)),
)
# here we can also tune down number of children displayed.
# set the count
ps.count = count
# add the modifier
bpy.ops.object.particle_system_add()
# 3rd level - hide particle system from viewport - is done on the modifier..
if total_count > total_max_threshold:
target_object.modifiers[-1].show_viewport = False
target_object.particle_systems[-1].settings = ps
target_object.select_set(False)
utils.selection_set(sel)
return target_object, []
def append_objects(file_name, obnames=[], location=(0, 0, 0), link=False, **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.
"""
# simplified version of append
if kwargs.get("name"):
scene = bpy.context.scene
sel = utils.selection_get()
bpy.ops.object.select_all(action="DESELECT")
path = file_name + "/Collection"
collection_name = kwargs.get("name")
bpy.ops.wm.append(filename=collection_name, directory=path)
# fc = utils.get_fake_context(bpy.context, area_type='VIEW_3D')
# bpy.ops.wm.append(fc, filename=collection_name, directory=path)
return_obs = []
to_hidden_collection = []
collection = None
main_object = None
# get first at least one parent for sure
for ob in bpy.context.scene.objects:
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:
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
if not ob.parent:
main_object = ob
ob.location = location
else:
to_hidden_collection.append(ob)
assert (
main_object != None
), f"asset {kwargs['name']} not found in scene after appending"
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"]]
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:
hidden_collections = []
scene_collection = bpy.context.scene.collection
for ob in to_hidden_collection:
hide_collection = ob.users_collection[0]
# objects from scene collection (like rigify widgets go to a new collection
if (
hide_collection == scene_collection
or hide_collection.name in scene_collection.children
):
hidden_collection_name = collection_name + "_hidden"
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)
utils.exclude_collection(hidden_collection_name)
ob.users_collection[0].objects.unlink(ob)
h_col.objects.link(ob)
continue
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)
utils.exclude_collection(hide_collection.name)
hidden_collections.append(hide_collection)
bpy.ops.object.select_all(action="DESELECT")
utils.selection_set(sel)
# let collection also store info that it was created by BlenderKit, for purging reasons
return main_object, return_obs
# this is used for uploads:
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
sobs = []
# for col in data_from.collections:
# if col == kwargs.get('name'):
for ob in data_from.objects:
if ob in obnames or obnames == []:
sobs.append(ob)
data_to.objects = sobs
# data_to.objects = data_from.objects#[name for name in data_from.objects if name.startswith("house")]
# link them to scene
scene = bpy.context.scene
sel = utils.selection_get()
bpy.ops.object.select_all(action="DESELECT")
return_obs = [] # this might not be needed, but better be sure to rewrite the list.
main_object = None
hidden_objects = []
for obj in data_to.objects:
if obj is not None:
# if obj.name not in scene.objects:
scene.collection.objects.link(obj)
if obj.parent is None:
obj.location = location
main_object = obj
obj.select_set(True)
# we need to unhide object so make_local op can use those too.
if link == True:
if obj.hide_viewport:
hidden_objects.append(obj)
obj.hide_viewport = False
return_obs.append(obj)
# Only after all objects are in scene! Otherwise gets broken relationships
if link == True:
bpy.ops.object.make_local(type="SELECT_OBJECT")
for ob in hidden_objects:
ob.hide_viewport = True
if kwargs.get("rotation") is not None:
main_object.rotation_euler = kwargs["rotation"]
if kwargs.get("parent") is not None:
main_object.parent = bpy.data.objects[kwargs["parent"]]
main_object.matrix_world.translation = location
bpy.ops.object.select_all(action="DESELECT")
utils.selection_set(sel)
return main_object, return_obs
@@ -0,0 +1,447 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# This module is for analyzing the asset and filling the tags automatically.
# 1 part of the module effectively fills tags for the assets,
# the 2nd part finds possible problems in the asset.
import bpy
from . import utils
RENDER_OBTYPES = ["MESH", "CURVE", "SURFACE", "METABALL", "TEXT"]
def check_material(props, mat):
e = bpy.context.scene.render.engine
shaders = []
textures = []
props.texture_count = 0
props.node_count = 0
props.total_megapixels = 0
total_pixels = 0
props.is_procedural = True
if e == "CYCLES":
if mat.node_tree is not None:
checknodes = mat.node_tree.nodes[:]
while len(checknodes) > 0:
n = checknodes.pop()
props.node_count += 1
if n.type == "GROUP": # dive deeper here.
checknodes.extend(n.node_tree.nodes)
if (
len(n.outputs) == 1
and n.outputs[0].type == "SHADER"
and n.type != "GROUP"
):
if n.type not in shaders:
shaders.append(n.type)
if n.type == "TEX_IMAGE":
if n.image is not None:
mattype = "image based"
props.is_procedural = False
if n.image not in textures:
textures.append(n.image)
props.texture_count += 1
total_pixels += n.image.size[0] * n.image.size[1]
maxres = max(n.image.size[0], n.image.size[1])
props.texture_resolution_max = max(
props.texture_resolution_max, maxres
)
minres = min(n.image.size[0], n.image.size[1])
if props.texture_resolution_min == 0:
props.texture_resolution_min = minres
else:
props.texture_resolution_min = min(
props.texture_resolution_min, minres
)
props.total_megapixels = round(total_pixels / (1024 * 1024))
props.shaders = ""
for s in shaders:
if s.startswith("BSDF_"):
s = s[5:]
s = s.lower().replace("_", " ")
props.shaders += s + ", "
def check_render_engine(props, obs):
ob = obs[0]
m = None
e = bpy.context.scene.render.engine
mattype = None
materials = []
shaders = []
textures = []
props.uv = False
props.texture_count = 0
props.total_megapixels = 0
total_pixels = 0
props.node_count = 0
for ob in obs:
# TODO , this is duplicated here for other engines, otherwise this should be more clever.
for ms in ob.material_slots:
if ms.material is not None:
m = ms.material
if m.name not in materials:
materials.append(m.name)
if ob.type == "MESH" and len(ob.data.uv_layers) > 0:
props.uv = True
if e == "BLENDER_RENDER":
props.engine = "BLENDER_INTERNAL"
elif e == "CYCLES":
props.engine = "CYCLES"
# TODO: Clean this up, it's a mess.
for mname in materials:
m = bpy.data.materials[mname]
if m is not None and m.node_tree is not None:
checknodes = m.node_tree.nodes[:]
while len(checknodes) > 0:
n = checknodes.pop()
props.node_count += 1
if n.type == "GROUP": # dive deeper here.
if n.node_tree is not None:
checknodes.extend(n.node_tree.nodes)
if (
len(n.outputs) == 1
and n.outputs[0].type == "SHADER"
and n.type != "GROUP"
):
if n.type not in shaders:
shaders.append(n.type)
if n.type == "TEX_IMAGE":
if n.image is not None and n.image not in textures:
props.is_procedural = False
mattype = "image based"
textures.append(n.image)
props.texture_count += 1
total_pixels += n.image.size[0] * n.image.size[1]
maxres = max(n.image.size[0], n.image.size[1])
props.texture_resolution_max = max(
props.texture_resolution_max, maxres
)
minres = min(n.image.size[0], n.image.size[1])
if props.texture_resolution_min == 0:
props.texture_resolution_min = minres
else:
props.texture_resolution_min = min(
props.texture_resolution_min, minres
)
props.total_megapixels = round(total_pixels / (1024 * 1024))
# if mattype == None:
# mattype = 'procedural'
# tags['material type'] = mattype
elif e == "BLENDER_GAME":
props.engine = "BLENDER_GAME"
# write to object properties.
props.materials = ""
props.shaders = ""
for m in materials:
props.materials += m + ", "
for s in shaders:
if s.startswith("BSDF_"):
s = s[5:]
s = s.lower()
s = s.replace("_", " ")
props.shaders += s + ", "
""" ISSUE:https://github.com/BlenderKit/BlenderKit/issues/1251 #1258
Commenting this function out, some user has reported this function got executed and failed due to missing add-on in Blender 4.2.
Even though it is not called from anywhere, Python somehow went in here. So we are just commenting it out. In order to enable the func:
1. add-on object_print3d_utils had some bug in it, needs to be checked if it was fixed (are there any other better add-on for it?)
2. add-on object_print3d_utils is no longer preinstalled in Blender 4.2+, needs to be installed from extensions.blender.org -> "3D-Print Toolbox"
def check_printable(props, obs):
if len(obs) != 1:
return
addon_name = "object_print3d_utils"
was_enabled, _ = addon_utils.check(addon_name)
addon_utils.enable(addon_name)
from object_print3d_utils import operators as ops
check_cls = (
ops.MESH_OT_print3d_check_solid, # ops.Print3DCheckSolid,
ops.MESH_OT_print3d_check_intersections, # ops.Print3DCheckIntersections,
ops.MESH_OT_print3d_check_degenerate, # ops.Print3DCheckDegenerate,
ops.MESH_OT_print3d_check_distorted, # ops.Print3DCheckDistorted,
ops.MESH_OT_print3d_check_thick, # ops.Print3DCheckThick,
ops.MESH_OT_print3d_check_sharp, # ops.Print3DCheckSharp,
)
info = []
for cls in check_cls:
cls.main_check(obs[0], info)
printable = True
for item in info:
passed = item[0].endswith(" 0")
if not passed:
printable = False
props.printable_3d = printable
if not was_enabled:
addon_utils.disable(addon_name)
"""
def check_rig(props, obs):
for ob in obs:
if ob.type == "ARMATURE":
props.rig = True
def check_anim(props, obs):
animated = False
for ob in obs:
if ob.animation_data is not None:
a = ob.animation_data.action
if a is not None:
for c in a.fcurves:
if len(c.keyframe_points) > 1:
animated = True
# c.keyframe_points.remove(c.keyframe_points[0])
if animated:
props.animated = True
def check_meshprops(props, obs):
"""checks polycount, manifold, mesh parts (not implemented)"""
face_count = 0
face_count_render = 0
tris = 0
quads = 0
ngons = 0
vertices_count = 0
edges_counts = {}
manifold = True
for ob in obs:
if ob.type != "MESH" and ob.type != "CURVE":
continue
ob_eval = None
if ob.type == "CURVE":
# depsgraph = bpy.context.evaluated_depsgraph_get()
# object_eval = ob.evaluated_get(depsgraph)
mesh = ob.to_mesh()
else:
mesh = ob.data
if mesh == None: # One-point CURVE, can happen sometimes #1318
continue
fco = len(mesh.polygons)
face_count += fco
vertices_count += len(mesh.vertices)
fcor = fco
for f in mesh.polygons:
# face sides counter
if len(f.vertices) == 3:
tris += 1
elif len(f.vertices) == 4:
quads += 1
elif len(f.vertices) > 4:
ngons += 1
# manifold counter
for i, v in enumerate(f.vertices):
v1 = f.vertices[i - 1]
e = (min(v, v1), max(v, v1))
edges_counts[e] = edges_counts.get(e, 0) + 1
# all meshes have to be manifold for this to work.
manifold = manifold and not any(
i in edges_counts.values() for i in [0, 1, 3, 4]
)
for m in ob.modifiers:
if m.type == "SUBSURF" or m.type == "MULTIRES":
fcor *= 4**m.render_levels
if (
m.type == "SOLIDIFY"
): # this is rough estimate, not to waste time with evaluating all nonmanifold edges
fcor *= 2
if m.type == "ARRAY":
fcor *= m.count
if m.type == "MIRROR":
fcor *= 2
if m.type == "DECIMATE":
fcor *= m.ratio
face_count_render += fcor
if ob_eval:
ob_eval.to_mesh_clear()
# write out props
props.face_count = int(face_count)
props.face_count_render = int(face_count_render)
if quads > 0 and tris == 0 and ngons == 0:
props.mesh_poly_type = "QUAD"
elif quads > tris and quads > ngons:
props.mesh_poly_type = "QUAD_DOMINANT"
elif tris > quads and tris > quads:
props.mesh_poly_type = "TRI_DOMINANT"
elif quads == 0 and tris > 0 and ngons == 0:
props.mesh_poly_type = "TRI"
elif ngons > quads and ngons > tris:
props.mesh_poly_type = "NGON"
else:
props.mesh_poly_type = "OTHER"
props.manifold = manifold
def countObs(props, obs):
ob_types = {}
count = len(obs)
for ob in obs:
otype = ob.type.lower()
ob_types[otype] = ob_types.get(otype, 0) + 1
props.object_count = count
def check_modifiers(props, obs):
# modif_mapping = {
# }
modifiers = []
for ob in obs:
for m in ob.modifiers:
mtype = m.type
mtype = mtype.replace("_", " ")
mtype = mtype.lower()
# mtype = mtype.capitalize()
if mtype not in modifiers:
modifiers.append(mtype)
if m.type == "SMOKE":
if m.smoke_type == "FLOW":
smt = m.flow_settings.smoke_flow_type
if smt == "BOTH" or smt == "FIRE":
modifiers.append("fire")
# for mt in modifiers:
effectmodifiers = [
"soft body",
"fluid simulation",
"particle system",
"collision",
"smoke",
"cloth",
"dynamic paint",
]
for m in modifiers:
if m in effectmodifiers:
props.simulation = True
if ob.rigid_body is not None:
props.simulation = True
modifiers.append("rigid body")
finalstr = ""
for m in modifiers:
finalstr += m
finalstr += ","
props.modifiers = finalstr
def get_autotags():
"""call all analysis functions"""
ui = bpy.context.window_manager.blenderkitUI
if ui.asset_type == "MODEL" or ui.asset_type == "PRINTABLE":
ob = utils.get_active_model()
obs = utils.get_hierarchy(ob)
props = ob.blenderkit
if props.name == "":
props.name = ob.name
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore.
props.texture_resolution_max = 0
props.texture_resolution_min = 0
# disabled printing checking, some 3d print addon bug.
# bug fixed, could be enabled in the future
# also disable because add-on is not installed in Blender 4.2+, has to be installed from extensions.blender.org
# check the commented out function for more details
# check_printable( props, obs)
check_render_engine(props, obs)
dim, bbox_min, bbox_max = utils.get_dimensions(obs)
props.dimensions = dim
props.bbox_min = bbox_min
props.bbox_max = bbox_max
check_rig(props, obs)
check_anim(props, obs)
check_meshprops(props, obs)
check_modifiers(props, obs)
countObs(props, obs)
elif ui.asset_type == "MATERIAL":
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore.
mat = utils.get_active_asset()
props = mat.blenderkit
props.texture_resolution_max = 0
props.texture_resolution_min = 0
check_material(props, mat)
elif ui.asset_type == "HDR":
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore.
hdr = utils.get_active_asset()
props = hdr.blenderkit
props.texture_resolution_max = max(hdr.size[0], hdr.size[1])
class AutoFillTags(bpy.types.Operator):
"""Fill tags for asset. Now run before upload, no need to interact from user side"""
bl_idname = "object.blenderkit_auto_tags"
bl_label = "Generate Auto Tags for BlenderKit"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
return utils.uploadable_asset_poll()
def execute(self, context):
get_autotags()
return {"FINISHED"}
def register_asset_inspector():
bpy.utils.register_class(AutoFillTags)
def unregister_asset_inspector():
bpy.utils.unregister_class(AutoFillTags)
if __name__ == "__main__":
register() # type: ignore
# TODO: fix call to missing function
@@ -0,0 +1,859 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# import blenderkit
import json
import logging
import os
import subprocess
import tempfile
from pathlib import Path
import bpy
from bpy.props import BoolProperty, EnumProperty, FloatProperty, IntProperty
from . import bg_blender, global_vars, paths, tasks_queue, utils, upload, search
bk_logger = logging.getLogger(__name__)
BLENDERKIT_EXPORT_DATA_FILE = "data.json"
thumbnail_resolutions = (
("256", "256", ""),
("512", "512", ""),
("1024", "1024 - minimum for public", ""),
("2048", "2048", ""),
)
thumbnail_angles = (
("DEFAULT", "default", ""),
("FRONT", "front", ""),
("SIDE", "side", ""),
("TOP", "top", ""),
)
thumbnail_snap = (
("GROUND", "ground", ""),
("WALL", "wall", ""),
("CEILING", "ceiling", ""),
("FLOAT", "floating", ""),
)
def get_texture_ui(tpath, iname):
img = bpy.data.images.get(iname)
tex = bpy.data.textures.get(iname)
if tpath.startswith("//"):
tpath = bpy.path.abspath(tpath)
if not tex or not tex.image or not tex.image.filepath == tpath:
if img is None:
tasks_queue.add_task(
(utils.get_hidden_image, (tpath, iname)), only_last=True
)
tasks_queue.add_task((utils.get_hidden_texture, (iname, False)), only_last=True)
return None
return tex
def check_thumbnail(props, imgpath):
# TODO implement check if the file exists, if size is corect etc. needs some care
if imgpath == "":
props.has_thumbnail = False
return None
img = utils.get_hidden_image(imgpath, "upload_preview", force_reload=True)
if img is not None: # and img.size[0] == img.size[1] and img.size[0] >= 512 and (
# img.file_format == 'JPEG' or img.file_format == 'PNG'):
props.has_thumbnail = True
props.thumbnail_generating_state = ""
utils.get_hidden_texture(img.name)
# pcoll = icons.icon_collections["previews"]
# pcoll.load(img.name, img.filepath, 'IMAGE')
return img
else:
props.has_thumbnail = False
output = ""
if (
img is None
or img.size[0] == 0
or img.filepath.find("thumbnail_notready.jpg") > -1
):
output += "No thumbnail or wrong file path\n"
else:
pass
# this is causing problems on some platforms, don't know why..
# if img.size[0] != img.size[1]:
# output += 'image not a square\n'
# if img.size[0] < 512:
# output += 'image too small, should be at least 512x512\n'
# if img.file_format != 'JPEG' or img.file_format != 'PNG':
# output += 'image has to be a jpeg or png'
props.thumbnail_generating_state = output
def update_upload_model_preview(self, context):
ob = utils.get_active_model()
if ob is not None:
props = ob.blenderkit
imgpath = props.thumbnail
check_thumbnail(props, imgpath)
def update_upload_scene_preview(self, context):
s = bpy.context.scene
props = s.blenderkit
imgpath = props.thumbnail
check_thumbnail(props, imgpath)
def update_upload_material_preview(self, context):
if (
hasattr(bpy.context, "active_object")
and bpy.context.view_layer.objects.active is not None
and bpy.context.active_object.active_material is not None
):
mat = bpy.context.active_object.active_material
props = mat.blenderkit
imgpath = props.thumbnail
check_thumbnail(props, imgpath)
def update_upload_brush_preview(self, context):
brush = utils.get_active_brush()
if brush is not None:
props = brush.blenderkit
imgpath = bpy.path.abspath(brush.icon_filepath)
check_thumbnail(props, imgpath)
def get_thumbnailer_args(script_name, thumbnailer_filepath, datafile, api_key):
"""Get the arguments to start Blender in background to render model or material thumbnails.
Watch out: the ending arguments must match order of those in: autothumb_model_bg.py and autothumb_material_bg.py.
"""
script_path = os.path.dirname(os.path.realpath(__file__))
script_path = os.path.join(script_path, script_name)
args = [
bpy.app.binary_path,
"--background",
"--factory-startup",
"--addons",
__package__,
"-noaudio",
thumbnailer_filepath,
"--python",
script_path,
"--",
datafile,
api_key,
__package__, # Legacy has it as "blenderkit", extensions have it like bl_ext.user_default.blenderkit or anything else
]
return args
def start_model_thumbnailer(
self=None, json_args=None, props=None, wait=False, add_bg_process=True
):
"""Start Blender in background and render the thumbnail."""
SCRIPT_NAME = "autothumb_model_bg.py"
if props:
props.is_generating_thumbnail = True
props.thumbnail_generating_state = "Saving .blend file"
datafile = os.path.join(json_args["tempdir"], BLENDERKIT_EXPORT_DATA_FILE)
user_preferences = bpy.context.preferences.addons[__package__].preferences
json_args["thumbnail_use_gpu"] = user_preferences.thumbnail_use_gpu
if user_preferences.thumbnail_use_gpu is True:
json_args["cycles_compute_device_type"] = bpy.context.preferences.addons[
"cycles"
].preferences.compute_device_type
try:
with open(datafile, "w", encoding="utf-8") as s:
json.dump(json_args, s, ensure_ascii=False, indent=4)
except Exception as e:
self.report({"WARNING"}, f"Error while exporting file: {e}")
return {"FINISHED"}
args = get_thumbnailer_args(
SCRIPT_NAME,
paths.get_thumbnailer_filepath(),
datafile,
user_preferences.api_key,
)
blender_user_scripts_dir = (
Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ)
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
creationflags=utils.get_process_flags(),
env=env,
)
bk_logger.info(f"Started Blender executing {SCRIPT_NAME} on file {datafile}")
eval_path_computing = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
eval_path = f"bpy.data.objects['{json_args['asset_name']}']"
name = f"{json_args['asset_name']} thumbnailer"
bg_blender.add_bg_process(
name=name,
eval_path_computing=eval_path_computing,
eval_path_state=eval_path_state,
eval_path=eval_path,
process_type="THUMBNAILER",
process=proc,
)
if props:
props.thumbnail_generating_state = "Started Blender instance"
if wait:
while proc.poll() is None:
stdout_data, stderr_data = proc.communicate()
bk_logger.info(stdout_data, stderr_data)
def start_material_thumbnailer(
self=None, json_args=None, props=None, wait=False, add_bg_process=True
):
"""Start Blender in background and render the thumbnail.
Parameters
----------
self
json_args - all arguments:
props - blenderkit upload props with thumbnail settings, to communicate back, if not present, not used.
wait - wait for the rendering to finish
Returns
-------
"""
SCRIPT_NAME = "autothumb_material_bg.py"
if props:
props.is_generating_thumbnail = True
props.thumbnail_generating_state = "Saving .blend file"
datafile = os.path.join(json_args["tempdir"], BLENDERKIT_EXPORT_DATA_FILE)
user_preferences = bpy.context.preferences.addons[__package__].preferences
json_args["thumbnail_use_gpu"] = user_preferences.thumbnail_use_gpu
if user_preferences.thumbnail_use_gpu is True:
json_args["cycles_compute_device_type"] = bpy.context.preferences.addons[
"cycles"
].preferences.compute_device_type
try:
with open(datafile, "w", encoding="utf-8") as s:
json.dump(json_args, s, ensure_ascii=False, indent=4)
except Exception as e:
self.report({"WARNING"}, f"Error while exporting file: {e}")
return {"FINISHED"}
args = get_thumbnailer_args(
SCRIPT_NAME,
paths.get_material_thumbnailer_filepath(),
datafile,
user_preferences.api_key,
)
blender_user_scripts_dir = (
Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ)
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
creationflags=utils.get_process_flags(),
env=env,
)
bk_logger.info(f"Started Blender executing {SCRIPT_NAME} on file {datafile}")
eval_path_computing = f"bpy.data.materials['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.materials['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
eval_path = f"bpy.data.materials['{json_args['asset_name']}']"
name = f"{json_args['asset_name']} thumbnailer"
bg_blender.add_bg_process(
name=name,
eval_path_computing=eval_path_computing,
eval_path_state=eval_path_state,
eval_path=eval_path,
process_type="THUMBNAILER",
process=proc,
)
if props:
props.thumbnail_generating_state = "Started Blender instance"
if wait:
while proc.poll() is None:
stdout_data, stderr_data = proc.communicate()
bk_logger.info(stdout_data, stderr_data)
class GenerateThumbnailOperator(bpy.types.Operator):
"""Generate Cycles thumbnail for model assets"""
bl_idname = "object.blenderkit_generate_thumbnail"
bl_label = "BlenderKit Thumbnail Generator"
bl_options = {"REGISTER", "INTERNAL"}
@classmethod
def poll(cls, context):
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
props = ob.blenderkit
layout = self.layout
layout.label(text="thumbnailer settings")
layout.prop(props, "thumbnail_background_lightness")
layout.prop(props, "thumbnail_angle")
layout.prop(props, "thumbnail_snap_to")
layout.prop(props, "thumbnail_samples")
layout.prop(props, "thumbnail_resolution")
layout.prop(props, "thumbnail_denoising")
preferences = bpy.context.preferences.addons[__package__].preferences
layout.prop(preferences, "thumbnail_use_gpu")
def execute(self, context):
asset = utils.get_active_model()
asset.blenderkit.is_generating_thumbnail = True
asset.blenderkit.thumbnail_generating_state = "starting blender instance"
tempdir = tempfile.mkdtemp()
ext = ".blend"
filepath = os.path.join(tempdir, "thumbnailer_blenderkit" + ext)
path_can_be_relative = True
thumb_dir = os.path.dirname(bpy.data.filepath)
if thumb_dir == "":
thumb_dir = tempdir
path_can_be_relative = False
an_slug = paths.slugify(asset.name)
thumb_path = os.path.join(thumb_dir, an_slug)
if path_can_be_relative:
rel_thumb_path = f"//{an_slug}"
else:
rel_thumb_path = thumb_path
i = 0
while os.path.isfile(thumb_path + ".jpg"):
thumb_name = f"{an_slug}_{str(i).zfill(4)}"
thumb_path = os.path.join(thumb_dir, thumb_name)
if path_can_be_relative:
rel_thumb_path = f"//{thumb_name}"
i += 1
bkit = asset.blenderkit
bkit.thumbnail = rel_thumb_path + ".jpg"
bkit.thumbnail_generating_state = "Saving .blend file"
# if this isn't here, blender crashes.
if bpy.app.version >= (3, 0, 0):
bpy.context.preferences.filepaths.file_preview_type = "NONE"
# save a copy of actual scene but don't interfere with the users models
bpy.ops.wm.save_as_mainfile(filepath=filepath, compress=False, copy=True)
# get all included objects
obs = utils.get_hierarchy(asset)
obnames = []
for ob in obs:
obnames.append(ob.name)
args_dict = {
"type": "material",
"asset_name": asset.name,
"filepath": filepath,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
}
thumbnail_args = {
"type": "model",
"models": str(obnames),
"thumbnail_angle": bkit.thumbnail_angle,
"thumbnail_snap_to": bkit.thumbnail_snap_to,
"thumbnail_background_lightness": bkit.thumbnail_background_lightness,
"thumbnail_resolution": bkit.thumbnail_resolution,
"thumbnail_samples": bkit.thumbnail_samples,
"thumbnail_denoising": bkit.thumbnail_denoising,
}
args_dict.update(thumbnail_args)
start_model_thumbnailer(
self, json_args=args_dict, props=asset.blenderkit, wait=False
)
return {"FINISHED"}
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)
class ReGenerateThumbnailOperator(bpy.types.Operator):
"""
Generate default thumbnail with Cycles renderer and upload it.
Works also for assets from search results, without being downloaded before.
By default marks the asset for server-side thumbnail regeneration.
"""
bl_idname = "object.blenderkit_regenerate_thumbnail"
bl_label = "BlenderKit Thumbnail Re-generate"
bl_options = {"REGISTER", "INTERNAL"}
asset_index: IntProperty( # type: ignore[valid-type]
name="Asset Index", description="asset index in search results", default=-1
)
render_locally: BoolProperty( # type: ignore[valid-type]
name="Render Locally",
description="Render thumbnail locally instead of using server-side rendering",
default=False,
)
thumbnail_background_lightness: FloatProperty( # type: ignore[valid-type]
name="Thumbnail Background Lightness",
description="Set to make your asset stand out",
default=1.0,
min=0.01,
max=10,
)
thumbnail_angle: EnumProperty( # type: ignore[valid-type]
name="Thumbnail Angle",
items=thumbnail_angles,
default="DEFAULT",
description="thumbnailer angle",
)
thumbnail_snap_to: EnumProperty( # type: ignore[valid-type]
name="Model Snaps To",
items=thumbnail_snap,
default="GROUND",
description="typical placing of the interior. Leave on ground for most objects that respect gravity",
)
thumbnail_resolution: EnumProperty( # type: ignore[valid-type]
name="Resolution",
items=thumbnail_resolutions,
description="Thumbnail resolution",
default="1024",
)
thumbnail_samples: IntProperty( # type: ignore[valid-type]
name="Cycles Samples",
description="cycles samples setting",
default=100,
min=5,
max=5000,
)
thumbnail_denoising: BoolProperty( # type: ignore[valid-type]
name="Use Denoising", description="Use denoising", default=True
)
@classmethod
def poll(cls, context):
return True # bpy.context.view_layer.objects.active is not None
def draw(self, context):
props = self
layout = self.layout
layout.prop(props, "render_locally")
layout.label(text="Server-side rendering may take several hours", icon="INFO")
layout.label(text="thumbnailer settings")
layout.prop(props, "thumbnail_background_lightness")
layout.prop(props, "thumbnail_angle")
layout.prop(props, "thumbnail_snap_to")
layout.prop(props, "thumbnail_samples")
layout.prop(props, "thumbnail_resolution")
layout.prop(props, "thumbnail_denoising")
preferences = bpy.context.preferences.addons[__package__].preferences
layout.prop(preferences, "thumbnail_use_gpu")
def execute(self, context):
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"],
api_key=preferences.api_key,
use_gpu=preferences.thumbnail_use_gpu,
samples=self.thumbnail_samples,
resolution=int(self.thumbnail_resolution),
denoising=self.thumbnail_denoising,
background_lightness=self.thumbnail_background_lightness,
angle=self.thumbnail_angle,
snap_to=self.thumbnail_snap_to,
)
if success:
self.report(
{"INFO"}, "Asset marked for server-side thumbnail regeneration"
)
else:
self.report(
{"ERROR"}, "Failed to mark asset for thumbnail regeneration"
)
return {"FINISHED"}
# Local thumbnail generation (original functionality)
tempdir = tempfile.mkdtemp()
an_slug = paths.slugify(asset_data["name"])
thumb_path = os.path.join(tempdir, an_slug)
args_dict = {
"type": "material",
"asset_name": asset_data["name"],
"asset_data": asset_data,
# "filepath": filepath,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
"do_download": True,
"upload_after_render": True,
}
thumbnail_args = {
"type": "model",
"thumbnail_angle": self.thumbnail_angle,
"thumbnail_snap_to": self.thumbnail_snap_to,
"thumbnail_background_lightness": self.thumbnail_background_lightness,
"thumbnail_resolution": self.thumbnail_resolution,
"thumbnail_samples": self.thumbnail_samples,
"thumbnail_denoising": self.thumbnail_denoising,
}
args_dict.update(thumbnail_args)
start_model_thumbnailer(self, json_args=args_dict, wait=False)
return {"FINISHED"}
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=400)
class GenerateMaterialThumbnailOperator(bpy.types.Operator):
"""Generate default thumbnail with Cycles renderer"""
bl_idname = "object.blenderkit_generate_material_thumbnail"
bl_label = "BlenderKit Material Thumbnail Generator"
bl_options = {"REGISTER", "INTERNAL"}
@classmethod
def poll(cls, context):
return bpy.context.view_layer.objects.active is not None
def check(self, context):
return True
def draw(self, context):
layout = self.layout
props = bpy.context.active_object.active_material.blenderkit
layout.prop(props, "thumbnail_generator_type")
layout.prop(props, "thumbnail_scale")
layout.prop(props, "thumbnail_background")
if props.thumbnail_background:
layout.prop(props, "thumbnail_background_lightness")
layout.prop(props, "thumbnail_resolution")
layout.prop(props, "thumbnail_samples")
layout.prop(props, "thumbnail_denoising")
layout.prop(props, "adaptive_subdivision")
preferences = bpy.context.preferences.addons[__package__].preferences
layout.prop(preferences, "thumbnail_use_gpu")
def execute(self, context):
asset = bpy.context.active_object.active_material
tempdir = tempfile.mkdtemp()
filepath = os.path.join(tempdir, "material_thumbnailer_cycles.blend")
# if this isn't here, blender crashes.
if bpy.app.version >= (3, 0, 0):
bpy.context.preferences.filepaths.file_preview_type = "NONE"
# save a copy of actual scene but don't interfere with the users models
bpy.ops.wm.save_as_mainfile(filepath=filepath, compress=False, copy=True)
path_can_be_relative = True
thumb_dir = os.path.dirname(bpy.data.filepath)
if thumb_dir == "": # file not saved
thumb_dir = tempdir
path_can_be_relative = False
an_slug = paths.slugify(asset.name)
thumb_path = os.path.join(thumb_dir, an_slug)
if path_can_be_relative:
rel_thumb_path = os.path.join("//", an_slug)
else:
rel_thumb_path = thumb_path
# auto increase number of the generated thumbnail.
i = 0
while os.path.isfile(thumb_path + ".png"):
thumb_path = os.path.join(thumb_dir, an_slug + "_" + str(i).zfill(4))
rel_thumb_path = os.path.join("//", an_slug + "_" + str(i).zfill(4))
i += 1
asset.blenderkit.thumbnail = rel_thumb_path + ".png"
bkit = asset.blenderkit
args_dict = {
"type": "material",
"asset_name": asset.name,
"filepath": filepath,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
}
thumbnail_args = {
"thumbnail_type": bkit.thumbnail_generator_type,
"thumbnail_scale": bkit.thumbnail_scale,
"thumbnail_background": bkit.thumbnail_background,
"thumbnail_background_lightness": bkit.thumbnail_background_lightness,
"thumbnail_resolution": bkit.thumbnail_resolution,
"thumbnail_samples": bkit.thumbnail_samples,
"thumbnail_denoising": bkit.thumbnail_denoising,
"adaptive_subdivision": bkit.adaptive_subdivision,
"texture_size_meters": bkit.texture_size_meters,
}
args_dict.update(thumbnail_args)
start_material_thumbnailer(
self, json_args=args_dict, props=asset.blenderkit, wait=False
)
return {"FINISHED"}
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=400)
class ReGenerateMaterialThumbnailOperator(bpy.types.Operator):
"""
Generate default thumbnail with Cycles renderer and upload it.
Works also for assets from search results, without being downloaded before.
By default marks the asset for server-side thumbnail regeneration.
"""
bl_idname = "object.blenderkit_regenerate_material_thumbnail"
bl_label = "BlenderKit Material Thumbnail Re-Generator"
bl_options = {"REGISTER", "INTERNAL"}
asset_index: IntProperty( # type: ignore[valid-type]
name="Asset Index", description="asset index in search results", default=-1
)
render_locally: BoolProperty( # type: ignore[valid-type]
name="Render Locally",
description="Render thumbnail locally instead of using server-side rendering",
default=False,
)
thumbnail_scale: FloatProperty( # type: ignore[valid-type]
name="Thumbnail Object Size",
description="Size of material preview object in meters."
"Change for materials that look better at sizes different than 1m",
default=1,
min=0.00001,
max=10,
)
thumbnail_background: BoolProperty( # type: ignore[valid-type]
name="Thumbnail Background (for Glass only)",
description="For refractive materials, you might need a background.\n"
"Don't use for other types of materials.\n"
"Transparent background is preferred",
default=False,
)
thumbnail_background_lightness: FloatProperty( # type: ignore[valid-type]
name="Thumbnail Background Lightness",
description="Set to make your material stand out with enough contrast",
default=0.9,
min=0.00001,
max=1,
)
thumbnail_samples: IntProperty( # type: ignore[valid-type]
name="Cycles Samples",
description="Cycles samples",
default=100,
min=5,
max=5000,
)
thumbnail_denoising: BoolProperty( # type: ignore[valid-type]
name="Use Denoising", description="Use denoising", default=True
)
adaptive_subdivision: BoolProperty( # type: ignore[valid-type]
name="Adaptive Subdivide",
description="Use adaptive displacement subdivision",
default=False,
)
thumbnail_resolution: EnumProperty( # type: ignore[valid-type]
name="Resolution",
items=thumbnail_resolutions,
description="Thumbnail resolution",
default="1024",
)
thumbnail_generator_type: EnumProperty( # type: ignore[valid-type]
name="Thumbnail Style",
items=(
("BALL", "Ball", ""),
(
"BALL_COMPLEX",
"Ball complex",
"Complex ball to highlight edgewear or material thickness",
),
("FLUID", "Fluid", "Fluid"),
("CLOTH", "Cloth", "Cloth"),
("HAIR", "Hair", "Hair "),
),
description="Style of asset",
default="BALL",
)
@classmethod
def poll(cls, context):
return True # bpy.context.view_layer.objects.active is not None
def check(self, context):
return True
def draw(self, context):
layout = self.layout
props = self
layout.prop(props, "render_locally")
layout.label(text="Server-side rendering may take several hours", icon="INFO")
layout.prop(props, "thumbnail_generator_type")
layout.prop(props, "thumbnail_scale")
layout.prop(props, "thumbnail_background")
if props.thumbnail_background:
layout.prop(props, "thumbnail_background_lightness")
layout.prop(props, "thumbnail_resolution")
layout.prop(props, "thumbnail_samples")
layout.prop(props, "thumbnail_denoising")
layout.prop(props, "adaptive_subdivision")
preferences = bpy.context.preferences.addons[__package__].preferences
layout.prop(preferences, "thumbnail_use_gpu")
def execute(self, context):
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"],
api_key=preferences.api_key,
use_gpu=preferences.thumbnail_use_gpu,
samples=self.thumbnail_samples,
resolution=int(self.thumbnail_resolution),
denoising=self.thumbnail_denoising,
background_lightness=self.thumbnail_background_lightness,
thumbnail_type=self.thumbnail_generator_type,
scale=self.thumbnail_scale,
background=self.thumbnail_background,
adaptive_subdivision=self.adaptive_subdivision,
)
if success:
self.report(
{"INFO"}, "Asset marked for server-side thumbnail regeneration"
)
else:
self.report(
{"ERROR"}, "Failed to mark asset for thumbnail regeneration"
)
return {"FINISHED"}
# Local thumbnail generation (original functionality)
an_slug = paths.slugify(asset_data["name"])
tempdir = tempfile.mkdtemp()
thumb_path = os.path.join(tempdir, an_slug)
args_dict = {
"type": "material",
"asset_name": asset_data["name"],
"asset_data": asset_data,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
"do_download": True,
"upload_after_render": True,
}
thumbnail_args = {
"thumbnail_type": self.thumbnail_generator_type,
"thumbnail_scale": self.thumbnail_scale,
"thumbnail_background": self.thumbnail_background,
"thumbnail_background_lightness": self.thumbnail_background_lightness,
"thumbnail_resolution": self.thumbnail_resolution,
"thumbnail_samples": self.thumbnail_samples,
"thumbnail_denoising": self.thumbnail_denoising,
"adaptive_subdivision": self.adaptive_subdivision,
"texture_size_meters": utils.get_param(
asset_data, "textureSizeMeters", 1.0
),
}
args_dict.update(thumbnail_args)
start_material_thumbnailer(self, json_args=args_dict, wait=False)
return {"FINISHED"}
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=400)
def register_thumbnailer():
bpy.utils.register_class(GenerateThumbnailOperator)
bpy.utils.register_class(ReGenerateThumbnailOperator)
bpy.utils.register_class(GenerateMaterialThumbnailOperator)
bpy.utils.register_class(ReGenerateMaterialThumbnailOperator)
def unregister_thumbnailer():
bpy.utils.unregister_class(GenerateThumbnailOperator)
bpy.utils.unregister_class(ReGenerateThumbnailOperator)
bpy.utils.unregister_class(GenerateMaterialThumbnailOperator)
bpy.utils.unregister_class(ReGenerateMaterialThumbnailOperator)
@@ -0,0 +1,245 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# type: ignore
import json
import os
import sys
from pathlib import Path
from traceback import print_exc
import bpy
def render_thumbnails():
bpy.ops.render.render(write_still=True, animation=False)
def unhide_collection(cname):
collection = bpy.context.scene.collection.children[cname]
collection.hide_viewport = False
collection.hide_render = False
collection.hide_select = False
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.
Or __package__ = "bl_ext.user_default.blenderkit"/"bl_ext.blenderkit_com.blenderkit_com". Otherwise we would see:
from . import paths
ImportError: attempted relative import with no known parent package
2. External repository (e.g. blenderkit_com) is not available as we start with --factory-startup, we need to enable it.
We can add it as LOCAL repo as the add-on is installed and we do not care about updates or anything in this BG script. Otherwise we would see:
from . import paths
ModuleNotFoundError: No module named 'bl_ext.blenderkit_com'; 'bl_ext' is not a package
"""
print(f"- Setting __package__ = '{addon_module_name}'")
global __package__
__package__ = addon_module_name
if bpy.app.version < (4, 2, 0):
print(
f"- Skipping, Blender version {bpy.app.version} < (4,2,0), no need to handle repositories"
)
return
parts = addon_module_name.split(".")
if len(parts) != 3:
print("- Skipping, addon_module_name does not contain 3 parts")
return
bpy.ops.preferences.extension_repo_add(
name=parts[1], type="LOCAL"
) # Local is enough
print(f"- Local repository {parts[1]} added")
if __name__ == "__main__":
try:
# args order must match the order in blenderkit/autothumb.py:get_thumbnailer_args()!
BLENDERKIT_EXPORT_DATA = sys.argv[-3]
BLENDERKIT_EXPORT_API_KEY = sys.argv[-2]
patch_imports(sys.argv[-1])
bpy.ops.preferences.addon_enable(module=sys.argv[-1])
from . import append_link, bg_blender, bg_utils, client_lib, utils
bg_blender.progress("preparing thumbnail scene")
with open(BLENDERKIT_EXPORT_DATA, "r", encoding="utf-8") as s:
data = json.load(s)
# append_material(file_name, matname = None, link = False, fake_user = True)
thumbnail_use_gpu = data.get("thumbnail_use_gpu")
if data.get("do_download"):
# need to save the file, so that asset doesn't get downloaded into addon directory
temp_blend_path = os.path.join(data["tempdir"], "temp.blend")
# if this isn't here, blender crashes.
if bpy.app.version >= (3, 0, 0):
bpy.context.preferences.filepaths.file_preview_type = "NONE"
bpy.ops.wm.save_as_mainfile(filepath=temp_blend_path)
asset_data = data["asset_data"]
has_url, download_url, file_name = client_lib.get_download_url(
asset_data, utils.get_scene_id(), BLENDERKIT_EXPORT_API_KEY
)
asset_data["files"][0]["url"] = download_url
asset_data["files"][0]["file_name"] = file_name
if not has_url:
bg_blender.progress(
"couldn't download asset for thumnbail re-rendering"
)
exit()
# download first, or rather make sure if it's already downloaded
bg_blender.progress("downloading asset")
fpath = bg_utils.download_asset_file(
asset_data, api_key=BLENDERKIT_EXPORT_API_KEY
)
data["filepath"] = fpath
mat = append_link.append_material(
file_name=data["filepath"],
matname=data["asset_name"],
link=True,
fake_user=False,
)
s = bpy.context.scene
colmapdict = {
"BALL": "Ball",
"BALL_COMPLEX": "Ball complex",
"FLUID": "Fluid",
"CLOTH": "Cloth",
"HAIR": "Hair",
}
unhide_collection(colmapdict[data["thumbnail_type"]])
if data["thumbnail_background"]:
unhide_collection("Background")
bpy.data.materials["bg checker colorable"].node_tree.nodes[
"input_level"
].outputs["Value"].default_value = data["thumbnail_background_lightness"]
tscale = data["thumbnail_scale"]
scaler = bpy.context.view_layer.objects["scaler"]
scaler.scale = (tscale, tscale, tscale)
utils.activate(scaler)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
bpy.context.view_layer.update()
for ob in bpy.context.visible_objects:
if ob.name[:15] == "MaterialPreview":
utils.activate(ob)
if bpy.app.version >= (3, 3, 0):
bpy.ops.object.transform_apply(
location=False, rotation=False, scale=True, isolate_users=True
)
else:
bpy.ops.object.transform_apply(
location=False, rotation=False, scale=True
)
bpy.ops.object.transform_apply(
location=False, rotation=False, scale=True
)
ob.material_slots[0].material = mat
ob.data.use_auto_texspace = False
ob.data.texspace_size.x = 1 # / tscale
ob.data.texspace_size.y = 1 # / tscale
ob.data.texspace_size.z = 1 # / tscale
if data["adaptive_subdivision"] == True:
ob.cycles.use_adaptive_subdivision = True
else:
ob.cycles.use_adaptive_subdivision = False
ts = data["texture_size_meters"]
if data["thumbnail_type"] in ["BALL", "BALL_COMPLEX", "CLOTH"]:
utils.automap(
ob.name,
tex_size=ts / tscale,
just_scale=True,
bg_exception=True,
)
bpy.context.view_layer.update()
s.cycles.volume_step_size = tscale * 0.1
if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU"
compute_device_type = data.get("cycles_compute_device_type")
if compute_device_type is not None:
# DOCS:https://github.com/dfelinto/blender/blob/master/intern/cycles/blender/addon/properties.py
bpy.context.preferences.addons[
"cycles"
].preferences.compute_device_type = compute_device_type
bpy.context.preferences.addons["cycles"].preferences.refresh_devices()
s.cycles.samples = data["thumbnail_samples"]
bpy.context.view_layer.cycles.use_denoising = data["thumbnail_denoising"]
# import blender's HDR here
hdr_path = Path("datafiles/studiolights/world/interior.exr")
bpath = Path(bpy.utils.resource_path("LOCAL"))
ipath = bpath / hdr_path
ipath = str(ipath)
# this stuff is for mac and possibly linux. For blender // means relative path.
# for Mac, // means start of absolute path
if ipath.startswith("//"):
ipath = ipath[1:]
img = bpy.data.images["interior.exr"]
img.filepath = ipath
img.reload()
bpy.context.scene.render.resolution_x = int(data["thumbnail_resolution"])
bpy.context.scene.render.resolution_y = int(data["thumbnail_resolution"])
bpy.context.scene.render.filepath = data["thumbnail_path"]
bg_blender.progress("rendering thumbnail")
# bpy.ops.wm.save_as_mainfile(filepath='C:/tmp/test.blend')
# fal
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")
ok = client_lib.complete_upload_file_blocking(
api_key=BLENDERKIT_EXPORT_API_KEY,
asset_id=data["asset_data"]["id"],
filepath=f"{data['thumbnail_path']}.png",
filetype=f"thumbnail",
fileindex=0,
)
if not ok:
bg_blender.progress("thumbnail upload failed, exiting")
sys.exit(1)
bg_blender.progress(
"background autothumbnailer finished successfully (with upload)"
)
except Exception as e:
print(f"background autothumbnailer failed: {e}")
print_exc()
sys.exit(1)
@@ -0,0 +1,267 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# type: ignore
import json
import math
import os
import sys
from traceback import print_exc
import bpy
def get_obnames(BLENDERKIT_EXPORT_DATA: str):
with open(BLENDERKIT_EXPORT_DATA, "r", encoding="utf-8") as s:
data = json.load(s)
obnames = eval(data["models"])
return obnames
def center_obs_for_thumbnail(obs):
s = bpy.context.scene
# obs = bpy.context.selected_objects
parent = obs[0]
if parent.type == "EMPTY" and parent.instance_collection is not None:
obs = parent.instance_collection.objects[:]
while parent.parent is not None:
parent = parent.parent
# reset parent rotation, so we see how it really snaps.
parent.rotation_euler = (0, 0, 0)
parent.location = (0, 0, 0)
bpy.context.view_layer.update()
minx, miny, minz, maxx, maxy, maxz = utils.get_bounds_worldspace(obs)
cx = (maxx - minx) / 2 + minx
cy = (maxy - miny) / 2 + miny
for ob in s.collection.objects:
ob.select_set(False)
bpy.context.view_layer.objects.active = parent
# parent.location += mathutils.Vector((-cx, -cy, -minz))
parent.location = (-cx, -cy, 0)
camZ = s.camera.parent.parent
# camZ.location.z = (maxz - minz) / 2
camZ.location.z = (maxz) / 2
dx = maxx - minx
dy = maxy - miny
dz = maxz - minz
r = math.sqrt(dx * dx + dy * dy + dz * dz)
scaler = bpy.context.view_layer.objects["scaler"]
scaler.scale = (r, r, r)
coef = 0.7
r *= coef
camZ.scale = (r, r, r)
bpy.context.view_layer.update()
def render_thumbnails():
bpy.ops.render.render(write_still=True, animation=False)
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.
Or __package__ = "bl_ext.user_default.blenderkit"/"bl_ext.blenderkit_com.blenderkit_com". Otherwise we would see:
from . import paths
ImportError: attempted relative import with no known parent package
2. External repository (e.g. blenderkit_com) is not available as we start with --factory-startup, we need to enable it.
We can add it as LOCAL repo as the add-on is installed and we do not care about updates or anything in this BG script. Otherwise we would see:
from . import paths
ModuleNotFoundError: No module named 'bl_ext.blenderkit_com'; 'bl_ext' is not a package
"""
print(f"- Setting __package__ = '{addon_module_name}'")
global __package__
__package__ = addon_module_name
if bpy.app.version < (4, 2, 0):
print(
f"- Skipping, Blender version {bpy.app.version} < (4,2,0), no need to handle repositories"
)
return
parts = addon_module_name.split(".")
if len(parts) != 3:
print("- Skipping, addon_module_name does not contain 3 parts")
return
bpy.ops.preferences.extension_repo_add(
name=parts[1], type="LOCAL"
) # Local is enough
print(f"- Local repository {parts[1]} added")
if __name__ == "__main__":
try:
# args order must match the order in blenderkit/autothumb.py:get_thumbnailer_args()!
BLENDERKIT_EXPORT_DATA = sys.argv[-3]
BLENDERKIT_EXPORT_API_KEY = sys.argv[-2]
patch_imports(sys.argv[-1])
bpy.ops.preferences.addon_enable(module=sys.argv[-1])
from . import append_link, bg_blender, bg_utils, client_lib, utils
with open(BLENDERKIT_EXPORT_DATA, "r", encoding="utf-8") as s:
data = json.load(s)
thumbnail_use_gpu = data.get("thumbnail_use_gpu")
if data.get("do_download"):
# if this isn't here, blender crashes.
if bpy.app.version >= (3, 0, 0):
bpy.context.preferences.filepaths.file_preview_type = "NONE"
# need to save the file, so that asset doesn't get downloaded into addon directory
temp_blend_path = os.path.join(data["tempdir"], "temp.blend")
bpy.ops.wm.save_as_mainfile(filepath=temp_blend_path)
bg_blender.progress("Downloading asset")
asset_data = data["asset_data"]
has_url, download_url, file_name = client_lib.get_download_url(
asset_data, utils.get_scene_id(), BLENDERKIT_EXPORT_API_KEY
)
asset_data["files"][0]["url"] = download_url
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"
)
bg_blender.progress("downloading asset")
fpath = bg_utils.download_asset_file(
asset_data, api_key=BLENDERKIT_EXPORT_API_KEY
)
data["filepath"] = fpath
main_object, allobs = append_link.link_collection(
fpath,
location=(0, 0, 0),
rotation=(0, 0, 0),
link=True,
name=asset_data["name"],
parent=None,
)
allobs = [main_object]
else:
bg_blender.progress("preparing thumbnail scene")
obnames = get_obnames(BLENDERKIT_EXPORT_DATA)
main_object, allobs = append_link.append_objects(
file_name=data["filepath"], obnames=obnames, link=True
)
bpy.context.view_layer.update()
camdict = {
"GROUND": "camera ground",
"WALL": "camera wall",
"CEILING": "camera ceiling",
"FLOAT": "camera float",
}
bpy.context.scene.camera = bpy.data.objects[camdict[data["thumbnail_snap_to"]]]
center_obs_for_thumbnail(allobs)
bpy.context.scene.render.filepath = data["thumbnail_path"]
if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU"
compute_device_type = data.get("cycles_compute_device_type")
if compute_device_type is not None:
# DOCS:https://github.com/dfelinto/blender/blob/master/intern/cycles/blender/addon/properties.py
bpy.context.preferences.addons[
"cycles"
].preferences.compute_device_type = compute_device_type
bpy.context.preferences.addons["cycles"].preferences.refresh_devices()
fdict = {
"DEFAULT": 1,
"FRONT": 2,
"SIDE": 3,
"TOP": 4,
}
s = bpy.context.scene
s.frame_set(fdict[data["thumbnail_angle"]])
snapdict = {
"GROUND": "Ground",
"WALL": "Wall",
"CEILING": "Ceiling",
"FLOAT": "Float",
}
collection = bpy.context.scene.collection.children[
snapdict[data["thumbnail_snap_to"]]
]
collection.hide_viewport = False
collection.hide_render = False
collection.hide_select = False
main_object.rotation_euler = (0, 0, 0)
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()
# import blender's HDR here
# hdr_path = Path('datafiles/studiolights/world/interior.exr')
# bpath = Path(bpy.utils.resource_path('LOCAL'))
# ipath = bpath / hdr_path
# ipath = str(ipath)
# this stuff is for mac and possibly linux. For blender // means relative path.
# for Mac, // means start of absolute path
# if ipath.startswith('//'):
# ipath = ipath[1:]
#
# img = bpy.data.images['interior.exr']
# img.filepath = ipath
# img.reload()
bpy.context.scene.render.resolution_x = int(data["thumbnail_resolution"])
bpy.context.scene.render.resolution_y = int(data["thumbnail_resolution"])
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")
fpath = data["thumbnail_path"] + ".jpg"
ok = client_lib.complete_upload_file_blocking(
api_key=BLENDERKIT_EXPORT_API_KEY,
asset_id=data["asset_data"]["id"],
filepath=fpath,
filetype=f"thumbnail",
fileindex=0,
)
if not ok:
bg_blender.progress("thumbnail upload failed, exiting")
sys.exit(1)
bg_blender.progress(
"background autothumbnailer finished successfully (with upload)"
)
except Exception as e:
print(f"background autothumbnailer failed: {e}")
print_exc()
sys.exit(1)
@@ -0,0 +1,320 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import re
import sys
import threading
import bpy
from bpy.props import EnumProperty
from . import utils
bk_logger = logging.getLogger(__name__)
bg_processes = []
class ThreadCom: # object passed to threads to read background process stdout info
"""Object to pass data between thread and"""
def __init__(
self,
eval_path_computing,
eval_path_state,
eval_path,
process_type,
proc,
location=None,
name="",
):
# self.obname=ob.name
self.name = name
self.eval_path_computing = eval_path_computing # property that gets written to.
self.eval_path_state = eval_path_state # property that gets written to.
self.eval_path = eval_path # property that gets written to.
self.process_type = process_type
self.outtext = ""
self.proc = proc
self.lasttext = ""
self.message = "" # the message to be sent.
self.progress = 0.0
self.location = location
self.error = False
self.log = ""
def threadread(tcom: ThreadCom):
"""reads stdout of background process.
this threads basically waits for a stdout line to come in,
fills the data, dies."""
found = False
while not found:
if tcom.proc.poll() is not None:
return # process terminated
inline = tcom.proc.stdout.readline()
inline = inline.decode("utf-8")
bk_logger.info(inline.strip())
progress = re.findall(r"progress\{(.*?)\}", inline)
if len(progress) > 0:
if type(progress[0]) == int or type(progress[0]) == float:
tcom.progress = progress
return
tcom.outtext = f"{progress[0]}"
return
s = inline.find("progress{")
if s > -1:
e = inline.find("}")
tcom.outtext = inline[s + 9 : e]
found = True
if tcom.outtext.find("%") > -1:
tcom.progress = float(re.findall(r"\d+\.\d+|\d+", tcom.outtext)[0])
return
if s == -1:
s = inline.find("Remaining")
if s > -1:
# e=inline.find('}')
tcom.outtext = inline[s : s + 18]
found = True
return
def progress(text, n=None):
"""function for reporting during the script, works for background operations in the header."""
# for i in range(n+1):
# sys.stdout.flush()
text = str(text)
if n is None:
n = ""
else:
n = " " + " " + str(int(n * 1000) / 1000) + "% "
try:
output = "progress{%s%s}\n" % (text, n)
sys.stdout.write(output)
sys.stdout.flush()
except Exception as e:
print("background progress reporting race condition")
print(e)
# @bpy.app.handlers.persistent
def bg_update():
"""monitoring of background process"""
text = ""
# utils.p('timer search')
# utils.p('start bg_blender timer bg_update')
global bg_processes
if len(bg_processes) == 0:
# utils.p('end bg_blender timer bg_update')
return 2
# cleanup dead processes first
remove_processes = []
for p in bg_processes:
if p[1].proc.poll() is not None:
remove_processes.append(p)
for p in remove_processes:
bk_logger.info(str(p[1].outtext))
estring = p[1].eval_path_computing + " = False"
try:
exec(estring)
except Exception as e:
bk_logger.error(f"Exception executing eval_path_computing: {e}")
bg_processes.remove(p)
# Parse process output
for p in bg_processes:
# proc=p[1].proc
readthread = p[0]
tcom = p[1]
if not readthread.is_alive():
readthread.join()
# readthread.
estring = None
if tcom.error:
estring = tcom.eval_path_computing + " = False"
tcom.lasttext = tcom.outtext
if tcom.outtext != "":
tcom.outtext = ""
text = tcom.lasttext.replace("'", "") # noqa: F841 needed in exec()
estring = tcom.eval_path_state + " = text"
# print(tcom.lasttext)
if "finished successfully" in tcom.lasttext:
bk_logger.info(str(tcom.lasttext))
bg_processes.remove(p)
estring = tcom.eval_path_computing + " = False"
else:
readthread = threading.Thread(
target=threadread, args=([tcom]), daemon=True
)
readthread.start()
p[0] = readthread
if estring:
try:
exec(estring)
except Exception as e:
print(f"Exception while reading from background process: {e}")
# if len(bg_processes) == 0:
# bpy.app.timers.unregister(bg_update)
if len(bg_processes) > 0:
# utils.p('end bg_blender timer bg_update')
return 0.3
# utils.p('end bg_blender timer bg_update')
return 1.0
process_types = (
("UPLOAD", "Upload", ""),
("THUMBNAILER", "Thumbnailer", ""),
)
process_sources = (
("MODEL", "Model", "set of objects"),
("SCENE", "Scene", "set of scenes"),
("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", "Node Group", "node group, can be any type of blender node group"),
("PRINTABLE", "Printable", "3D printable model"),
)
class KillBgProcess(bpy.types.Operator):
"""Remove processes in background"""
bl_idname = "object.kill_bg_process"
bl_label = "Kill Background Process"
bl_options = {"REGISTER"}
process_type: EnumProperty( # type: ignore[valid-type]
name="Type",
items=process_types,
description="Type of process",
default="UPLOAD",
)
process_source: EnumProperty( # type: ignore[valid-type]
name="Source",
items=process_sources,
description="Source of process",
default="MODEL",
)
def execute(self, context):
# first do the easy stuff...TODO all cases.
props = utils.get_upload_props()
if self.process_type == "UPLOAD":
props.uploading = False
if self.process_type == "THUMBNAILER":
props.is_generating_thumbnail = False
# print('killing', self.process_source, self.process_type)
# then go kill the process. this wasn't working for unsetting props and that was the reason for changing to the method above.
global bg_processes
processes = bg_processes
for p in processes:
tcom = p[1]
# print(tcom.process_type, self.process_type)
if tcom.process_type == self.process_type:
source = eval(tcom.eval_path)
kill = False
# TODO HDR - add killing of process
if source.bl_rna.name == "Object" and self.process_source == "MODEL":
if source.name == bpy.context.active_object.name:
kill = True
if source.bl_rna.name == "Scene" and self.process_source == "SCENE":
if source.name == bpy.context.scene.name:
kill = True
if source.bl_rna.name == "Image" and self.process_source == "HDR":
ui_props = bpy.context.window_manager.blenderkitUI
if source.name == ui_props.hdr_upload_image.name:
kill = False
if (
source.bl_rna.name == "Material"
and self.process_source == "MATERIAL"
):
if source.name == bpy.context.active_object.active_material.name:
kill = True
if source.bl_rna.name == "Brush" and self.process_source == "BRUSH":
brush = utils.get_active_brush()
if brush is not None and source.name == brush.name:
kill = True
if (
source.bl_rna.name == "Object"
and self.process_source == "PRINTABLE"
):
if source.name == bpy.context.active_object.name:
kill = True
if kill:
estring = tcom.eval_path_computing + " = False"
exec(estring)
processes.remove(p)
tcom.proc.kill()
return {"FINISHED"}
def add_bg_process(
location=None,
name=None,
eval_path_computing="",
eval_path_state="",
eval_path="",
process_type="",
process=None,
):
"""adds process for monitoring"""
global bg_processes
tcom = ThreadCom(
eval_path_computing,
eval_path_state,
eval_path,
process_type,
process,
location,
name,
)
readthread = threading.Thread(target=threadread, args=([tcom]), daemon=True)
readthread.start()
bg_processes.append([readthread, tcom])
# if not bpy.app.timers.is_registered(bg_update):
# bpy.app.timers.register(bg_update, persistent=True)
def register():
bpy.utils.register_class(KillBgProcess)
if not bpy.app.background:
bpy.app.timers.register(bg_update)
def unregister():
bpy.utils.unregister_class(KillBgProcess)
if bpy.app.timers.is_registered(bg_update):
bpy.app.timers.unregister(bg_update)
@@ -0,0 +1,67 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
"""Functions for background processes.
Not used directly in BlenderKit addon, but in BlenderKit background processes.
"""
import logging
import os
import addon_utils # type: ignore
from . import client_lib, download, paths
bk_logger = logging.getLogger(__name__)
def download_asset_file(asset_data, resolution="blend", api_key=""):
"""This is a simple non-threaded way to download files for background thumbnail rerender and others."""
# make sure BlenderKit is enabled, needed for downloading.
addon_utils.enable(
"blenderkit", default_set=True, persistent=True, handle_error=None
)
file_names = paths.get_download_filepaths(asset_data, resolution)
if len(file_names) == 0:
return None
file_name = file_names[0]
if download.check_existing(asset_data, resolution=resolution):
# this sends the thread for processing, where another check should occur, since the file might be corrupted.
bk_logger.debug("not downloading, already in db")
return file_name
res_file_info, resolution = paths.get_res_file(asset_data, resolution)
response = client_lib.blocking_file_download(
str(res_file_info["url"]), filepath=file_name, api_key=api_key
)
return file_name
def delete_unfinished_file(file_name):
"""Deletes download if it wasn't finished. If the directory it's containing is empty, it also removes the directory."""
try:
os.remove(file_name)
except Exception as e:
bk_logger.error(f"{e}")
asset_dir = os.path.dirname(file_name)
if len(os.listdir(asset_dir)) == 0:
os.rmdir(asset_dir)
return
@@ -0,0 +1,273 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import base64
import hashlib
import logging
import random
import secrets
import string
import time
from urllib.parse import quote as urlquote
from webbrowser import open_new_tab
import bpy
from bpy.props import BoolProperty
from . import client_lib, client_tasks, datas, global_vars, reports, tasks_queue, utils
CLIENT_ID = "IdFRwa3SGA8eMpzhRVFMg5Ts8sPK93xBjif93x0F"
REFRESH_RESERVE = 60 * 60 * 24 * 3 # 3 days
active_authenticator = None
bk_logger = logging.getLogger(__name__)
def handle_login_task(task: client_tasks.Task):
"""Handles incoming task of type Login. Writes tokens if it finished successfully, logouts the user on error."""
if task.status == "finished":
tasks_queue.add_task(
(
write_tokens,
(
task.result["access_token"],
task.result["refresh_token"],
task.result,
),
)
)
elif task.status == "error":
logout()
reports.add_report(task.message, type="ERROR", details=task.message_detailed)
# TODO: probably not needed anymore, check if true and remove
def handle_token_refresh_task(task: client_tasks.Task):
"""Handle incoming task of type token_refresh. If the new token is meant for the current user, calls handle_login_task.
Otherwise it ignores the incoming task.
"""
preferences = bpy.context.preferences.addons[__package__].preferences # type: ignore
if task.data.get("old_api_key") != preferences.api_key: # type: ignore
bk_logger.info("Refreshed token is not meant for current user. Ignoring.")
return
if task.status == "finished":
reports.add_report(task.message)
tasks_queue.add_task(
(
write_tokens,
(
task.result["access_token"],
task.result["refresh_token"],
task.result,
),
)
)
elif task.status == "error":
logout()
reports.add_report(task.message, details=task.message_detailed)
def handle_logout_task(task: client_tasks.Task):
"""Handles incoming task of type oauth2/logout. This could be triggered from another add-on also.
Shows messages depending on result of tokens revocation.
Regardless of revocation results, it also cleans login data."""
if task.status == "finished":
reports.add_report(task.message, timeout=3)
elif task.status == "error":
reports.add_report(task.message, type="ERROR")
clean_login_data()
def clean_login_data():
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.login_attempt = False
preferences.api_key_refresh = ""
preferences.api_key = ""
preferences.api_key_timeout = 0
global_vars.BKIT_PROFILE = datas.MineProfile()
def logout() -> None:
"""Logs out user from add-on. Also calls BlenderKit-client to revoke the tokens."""
bk_logger.info("Logging out.")
client_lib.oauth2_logout()
clean_login_data()
def login(signup: bool) -> None:
"""Logs user into the addon.
Opens a browser with login page. Once user is logged it redirects browser to Client handling access code via URL querry parameter.
Using the access_code Client then requests api_token and handles the results as a task with status finished/error.
This is handled by function handle_login_task which saves tokens, or shows error message.
"""
# We use redirect_URI without /vX.Y/ API path prefix to not complicate stuff on the server side.
redirect_URI = f"http://localhost:{client_lib.get_port()}/consumer/exchange/"
code_verifier, code_challenge = generate_pkce_pair()
state = secrets.token_urlsafe()
client_lib.send_oauth_verification_data(code_verifier, state)
authorize_url = f"/o/authorize?client_id={CLIENT_ID}&response_type=code&state={state}&redirect_uri={redirect_URI}&code_challenge={code_challenge}&code_challenge_method=S256"
if signup:
authorize_url = urlquote(authorize_url)
authorize_url = f"{global_vars.SERVER}/accounts/register/?next={authorize_url}"
else:
authorize_url = f"{global_vars.SERVER}{authorize_url}"
ok = open_new_tab(authorize_url)
bk_logger.info(f"Login page in browser opened ({ok})")
def generate_pkce_pair() -> tuple[str, str]:
"""Generate PKCE pair - a code verifier and code challange.
The challange should be sent first to the server, the verifier is used in next steps to verify identity (handles Client).
"""
rand = random.SystemRandom()
code_verifier = "".join(rand.choices(string.ascii_letters + string.digits, k=128))
code_sha_256 = hashlib.sha256(code_verifier.encode("utf-8")).digest()
b64 = base64.urlsafe_b64encode(code_sha_256)
code_challenge = b64.decode("utf-8").replace("=", "")
return code_verifier, code_challenge
def write_tokens(auth_token, refresh_token, oauth_response):
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.api_key_timeout = int(time.time() + oauth_response["expires_in"])
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
if bpy.app.version >= (4, 2, 0):
from . import override_extension_draw
override_extension_draw.ensure_repository(api_key=auth_token)
#
def ensure_token_refresh() -> bool:
"""Check if API token needs refresh, call refresh and return True if so.
Otherwise do nothing and return False.
"""
preferences = bpy.context.preferences.addons[__package__].preferences # type: ignore
if preferences.api_key == "": # type: ignore
return False # not logged in
if preferences.api_key_refresh == "": # type: ignore
# Using manually inserted permanent token
return False
if time.time() + REFRESH_RESERVE < preferences.api_key_timeout: # type: ignore
# Token is not old
return False
# Token is at the end of life, refresh token exists, it is time to refresh
client_lib.refresh_token(preferences.api_key_refresh, preferences.api_key) # type: ignore
return True
class LoginOnline(bpy.types.Operator):
"""Login or register online on BlenderKit webpage"""
bl_idname = "wm.blenderkit_login"
bl_label = "BlenderKit login/signup"
bl_options = {"REGISTER", "UNDO"}
signup: BoolProperty( # type: ignore
name="create a new account",
description="True for register, otherwise login",
default=False,
options={"SKIP_SAVE"},
)
message: bpy.props.StringProperty( # type: ignore
name="Message",
description="",
default="You were logged out from BlenderKit.\n Clicking OK takes you to web login. ",
)
@classmethod
def poll(cls, context):
return True
def draw(self, context):
layout = self.layout
utils.label_multiline(layout, text=self.message, width=300)
def execute(self, context):
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.login_attempt = True
login(self.signup)
return {"FINISHED"}
def invoke(self, context, event):
wm = bpy.context.window_manager
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.api_key_refresh = ""
preferences.api_key = ""
return wm.invoke_props_dialog(self)
class Logout(bpy.types.Operator):
"""Logout from BlenderKit immediately"""
bl_idname = "wm.blenderkit_logout"
bl_label = "BlenderKit logout"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
logout()
return {"FINISHED"}
class CancelLoginOnline(bpy.types.Operator):
"""Cancel login attempt"""
bl_idname = "wm.blenderkit_login_cancel"
bl_label = "BlenderKit login cancel"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.login_attempt = False
return {"FINISHED"}
classes = (
LoginOnline,
CancelLoginOnline,
Logout,
)
def register():
for c in classes:
bpy.utils.register_class(c)
def unregister():
for c in classes:
bpy.utils.unregister_class(c)
@@ -0,0 +1,17 @@
{
"last_check": "2025-06-23 09:38:03.256719",
"backup_date": "",
"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",
"version": [
3,
16,
1,
250612
]
}
}
@@ -0,0 +1,37 @@
bl_info = {
"name": "BL UI Widgets",
"description": "UI Widgets to draw in the 3D view",
"author": "Jayanam",
"version": (0, 6, 4, 2),
"blender": (2, 80, 0),
"location": "View3D",
"category": "Object",
}
# Blender imports
import bpy
from bpy.props import *
addon_keymaps = []
def register():
bpy.utils.register_class(DP_OT_draw_operator)
kcfg = bpy.context.window_manager.keyconfigs.addon
if kcfg:
km = kcfg.keymaps.new(name="3D View", space_type="VIEW_3D")
addon_keymaps.append((km, kmi))
def unregister():
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
bpy.utils.unregister_class(DP_OT_draw_operator)
if __name__ == "__main__":
register()
@@ -0,0 +1,225 @@
import os
import blf
import bpy
import gpu
from .. import image_utils, ui_bgl
from .bl_ui_widget import BL_UI_Widget
class BL_UI_Button(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self._text_color = (1.0, 1.0, 1.0, 1.0)
self._hover_bg_color = (0.5, 0.5, 0.5, 1.0)
self._select_bg_color = (0.7, 0.7, 0.7, 1.0)
self._text = "Button"
self._text_size = 16
self._textpos = (x, y)
self.__state = 0
self.__image = None
self.__image_size = (24, 24)
self.__image_position = (4, 2)
@property
def text_color(self):
return self._text_color
@text_color.setter
def text_color(self, value):
if value != self._text_color:
bpy.context.region.tag_redraw()
self._text_color = value
@property
def text(self):
return self._text
@text.setter
def text(self, value):
if value != self._text:
bpy.context.region.tag_redraw()
self._text = value
@property
def text_size(self):
return self._text_size
@text_size.setter
def text_size(self, value):
if value != self._text_size:
bpy.context.region.tag_redraw()
self._text_size = value
@property
def hover_bg_color(self):
return self._hover_bg_color
@hover_bg_color.setter
def hover_bg_color(self, value):
if value != self._hover_bg_color:
bpy.context.region.tag_redraw()
self._hover_bg_color = value
@property
def select_bg_color(self):
return self._select_bg_color
@select_bg_color.setter
def select_bg_color(self, value):
if value != self._select_bg_color:
bpy.context.region.tag_redraw()
self._select_bg_color = value
def set_image_size(self, image_size):
self.__image_size = image_size
def set_image_position(self, image_position):
self.__image_position = image_position
def check_image_exists(self):
# it's possible image was removed and doesn't exist.
try:
self.__image
self.__image.filepath
# self.__image.pixels
except Exception as e:
self.__image = None
def set_image_colorspace(self, colorspace):
image_utils.set_colorspace(self.__image, colorspace)
def set_image(self, rel_filepath):
# first try to access the image, for cases where it can get removed
self.check_image_exists()
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()
except Exception as e:
print(f"BL_UI_BUTTON set_image() error: {e}")
self.__image = None
def get_image_path(self):
self.check_image_exists()
if self.__image is None:
return None
return self.__image.filepath
def update(self, x, y):
super().update(x, y)
self._textpos = [x, y]
def draw(self):
if not self._is_visible:
return
area_height = self.get_area_height()
gpu.state.blend_set("ALPHA")
self.shader.bind()
self.set_colors()
self.batch_panel.draw(self.shader)
self.draw_image()
# Draw text
self.draw_text(area_height)
def set_colors(self):
color = self._bg_color
# pressed
if self.__state == 1:
color = self._select_bg_color
# hover
elif self.__state == 2:
color = self._hover_bg_color
self.shader.uniform_float("color", color)
def draw_text(self, area_height):
font_id = 1
if bpy.app.version < (4, 0, 0):
blf.size(font_id, self._text_size, 72)
else:
blf.size(font_id, self._text_size)
size = blf.dimensions(font_id, self._text)
textpos_y = area_height - self._textpos[1] - (self.height + size[1]) / 2.0
blf.position(
font_id, self._textpos[0] + (self.width - size[0]) / 2.0, textpos_y + 1, 0
)
r, g, b, a = self._text_color
blf.color(font_id, r, g, b, a)
blf.draw(font_id, self._text)
def draw_image(self):
if self.__image is not None:
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(
self.x_screen + off_x,
y_screen_flip - off_y - sy,
sx,
sy,
self.__image,
1.0,
crop=(0, 0, 1, 1),
batch=None,
)
return True
return False
def set_mouse_down(self, mouse_down_func):
self.mouse_down_func = mouse_down_func
def mouse_down(self, x, y):
if self.is_in_rect(x, y):
self.__state = 1
try:
self.mouse_down_func(self)
except Exception as e:
print(e)
return True
return False
def mouse_move(self, x, y):
if self.is_in_rect(x, y):
if self.__state != 1:
# hover state
self.__state = 2
else:
self.__state = 0
def mouse_up(self, x, y):
if self.is_in_rect(x, y):
self.__state = 2
else:
self.__state = 0
@@ -0,0 +1,58 @@
from .bl_ui_widget import BL_UI_Widget
class BL_UI_Drag_Panel(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self.drag_offset_x = 0
self.drag_offset_y = 0
self.is_drag = False
self.widgets = []
def set_location(self, x, y):
super().set_location(x, y)
self.layout_widgets()
def add_widget(self, widget):
self.widgets.append(widget)
def add_widgets(self, widgets):
self.widgets = widgets
self.layout_widgets()
def layout_widgets(self):
for widget in self.widgets:
widget.update(self.x_screen + widget.x, self.y_screen + widget.y)
def update(self, x, y):
super().update(x - self.drag_offset_x, y + self.drag_offset_y)
def child_widget_focused(self, x, y):
for widget in self.widgets:
if widget.is_in_rect(x, y):
return True
return False
def mouse_down(self, x, y):
if self.child_widget_focused(x, y):
return False
if self.is_in_rect(x, y):
height = self.get_area_height()
self.is_drag = True
self.drag_offset_x = x - self.x_screen
self.drag_offset_y = y - (height - self.y_screen)
return True
return False
def mouse_move(self, x, y):
if self.is_drag:
height = self.get_area_height()
self.update(x, height - y)
self.layout_widgets()
def mouse_up(self, x, y):
self.is_drag = False
self.drag_offset_x = 0
self.drag_offset_y = 0
@@ -0,0 +1,116 @@
import bpy
from bpy.types import Operator
class BL_UI_OT_draw_operator(Operator):
bl_idname = "object.bl_ui_ot_draw_operator"
bl_label = "bl ui widgets operator"
bl_description = "Operator for bl ui widgets"
bl_options = {"REGISTER"}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.draw_handle = None
self.draw_event = None
self._finished = False
self.widgets = []
self._timer_interval = 0.1
def init_widgets(self, context, widgets):
self.widgets = widgets
for widget in self.widgets:
widget.init(context)
def on_invoke(self, context, event):
pass
def on_finish(self, context):
self._finished = True
def invoke(self, context, event):
self.on_invoke(context, event)
args = (self, context)
self.register_handlers(args, context, timer_interval=self._timer_interval)
context.window_manager.modal_handler_add(self)
# first set pointers to keep track if the area is still available
self.active_window_pointer = context.window.as_pointer()
self.active_area_pointer = context.area.as_pointer()
self.active_region_pointer = context.region.as_pointer()
context.region.tag_redraw()
return {"RUNNING_MODAL"}
def register_handlers(self, args, context, timer_interval=0.1):
self.draw_handle = bpy.types.SpaceView3D.draw_handler_add(
self.draw_callback_px, args, "WINDOW", "POST_PIXEL"
)
self.draw_event = context.window_manager.event_timer_add(
timer_interval, window=context.window
)
def unregister_handlers(self, context):
context.window_manager.event_timer_remove(self.draw_event)
bpy.types.SpaceView3D.draw_handler_remove(self.draw_handle, "WINDOW")
self.draw_handle = None
self.draw_event = None
def handle_widget_events(self, event):
result = False
# we iterate widgets reversed, so top buttons can get processed first if buttons overlap.
for widget in reversed(self.widgets):
if widget.handle_event(event):
result = True
return True # return prematurely to avoid conflicts.
return result
def modal(self, context, event):
if self._finished:
return {"FINISHED"}
if context.area:
context.region.tag_redraw()
if self.handle_widget_events(event):
return {"RUNNING_MODAL"}
if event.type in {"ESC"}:
self.finish()
return {"PASS_THROUGH"}
def finish(self):
self.unregister_handlers(bpy.context)
# it is possible that the area has been closed, so we check if it is still available
if bpy.context.region is not None:
bpy.context.region.tag_redraw()
self.on_finish(bpy.context)
# Draw handler to paint onto the screen
def draw_callback_px(self, op, context):
draw_callback_px_separated(self, op, context)
def cancel(self, context):
"""Cancel the modal operator and finish. This is called before unregistration on Blender quit. Has to be here, so BL_UI_Button, BL_UI_Drag_Panel, BL_UI_Image and other elements are removed with finish().
We cannot call this during unregister because at that stage Operator is already removed, but BL_UI_Button is kept in memory causing memory leaks. Issue: #770
"""
self.finish()
def draw_callback_px_separated(self, op, context):
# separated only for puprpose of profiling
try:
# hide during animation playback, to improve performance
if context.screen.is_animation_playing:
return
if context.area.as_pointer() == self.active_area_pointer:
for widget in self.widgets:
widget.draw()
except Exception as e:
print(e)
@@ -0,0 +1,108 @@
import os
import bpy
from .. import image_utils, ui_bgl
from .bl_ui_widget import BL_UI_Widget
class BL_UI_Image(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self.__state = 0
self.__image = None
self.__image_size = (24, 24)
self.__image_position = (4, 2)
def set_image_size(self, image_size):
self.__image_size = image_size
def set_image_position(self, image_position):
self.__image_position = image_position
def check_image_exists(self):
# it's possible image was removed and doesn't exist.
try:
self.__image
self.__image.filepath
except Exception as e:
self.__image = None
return None
def set_image(self, rel_filepath):
# first try to access the image, for cases where it can get removed
self.check_image_exists()
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()
except Exception as e:
print(f"BL_UI_BUTTON: exception in set_image(): {e}")
self.__image = None
def set_image_colorspace(self, colorspace):
image_utils.set_colorspace(self.__image, colorspace)
def get_image_path(self):
self.check_image_exists()
if self.__image is None:
return None
return self.__image.filepath
def update(self, x, y):
super().update(x, y)
def draw(self):
if not self._is_visible:
return
self.shader.bind()
self.batch_panel.draw(self.shader)
self.draw_image()
def draw_image(self):
if self.__image is not None:
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(
self.x_screen + off_x,
y_screen_flip - off_y - sy,
sx,
sy,
self.__image,
1.0,
crop=(0, 0, 1, 1),
batch=None,
)
return True
return False
def set_mouse_down(self, mouse_down_func):
self.mouse_down_func = mouse_down_func
def mouse_down(self, x, y):
return False
def mouse_move(self, x, y):
return
def mouse_up(self, x, y):
return
@@ -0,0 +1,91 @@
import blf
import bpy
from .bl_ui_widget import BL_UI_Widget
class BL_UI_Label(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self._text_color = (1.0, 1.0, 1.0, 1.0)
self._text = "Label"
self._text_size = 16
self._halign = "LEFT"
self._valign = "TOP"
# multiline
self.multiline = False
self.row_height = 20
@property
def text_color(self):
return self._text_color
@text_color.setter
def text_color(self, value):
if value != self._text_color:
bpy.context.region.tag_redraw()
self._text_color = value
@property
def text(self):
return self._text
@text.setter
def text(self, value):
if value != self._text:
bpy.context.region.tag_redraw()
self._text = value
@property
def text_size(self):
return self._text_size
@text_size.setter
def text_size(self, value):
if value != self._text_size:
bpy.context.region.tag_redraw()
self._text_size = value
def is_in_rect(self, x, y):
return False
def draw(self):
if not self._is_visible:
return
area_height = self.get_area_height()
font_id = 1
if bpy.app.version < (4, 0, 0):
blf.size(font_id, self._text_size, 72)
else:
blf.size(font_id, self._text_size)
textpos_y = area_height - self.y_screen - self.height
r, g, b, a = self._text_color
x = self.x_screen
y = textpos_y
if self._halign != "LEFT":
width, height = blf.dimensions(font_id, self._text)
if self._halign == "RIGHT":
x -= width
elif self._halign == "CENTER":
x -= width // 2
if self._valign == "CENTER":
y -= height // 2
# bottom could be here but there's no reason for it
if not self.multiline:
blf.position(font_id, x, y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, self._text)
else:
lines = self._text.split("\n")
for line in lines:
blf.position(font_id, x, y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, line)
y -= self.row_height
@@ -0,0 +1,235 @@
import bpy
import gpu
from gpu_extras.batch import batch_for_shader
class BL_UI_Widget:
def __init__(self, x, y, width, height):
self.x = x
self.y = y
self.x_screen = x
self.y_screen = y
self.width = width
self.height = height
self._bg_color = (0.8, 0.8, 0.8, 1.0)
self._tag = None
self.context = None
self.__inrect = False
self._mouse_down = False
self._mouse_down_right = False
self._is_visible = True
self._is_active = True # if the widget needs to be disabled
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()
self.x = x
self.y = y
self.x_screen = x
self.y_screen = y
self.update(x, y)
@property
def bg_color(self):
return self._bg_color
@bg_color.setter
def bg_color(self, value):
if value != self._bg_color:
bpy.context.region.tag_redraw()
self._bg_color = value
@property
def visible(self):
return self._is_visible
@visible.setter
def visible(self, value):
if value != self._is_visible:
bpy.context.region.tag_redraw()
self._is_visible = value
@property
def active(self):
return self._is_active
@visible.setter
def active(self, value):
if value != self._is_active:
bpy.context.region.tag_redraw()
self._is_active = value
@property
def tag(self):
return self._tag
@tag.setter
def tag(self, value):
self._tag = value
def draw(self):
if not self._is_visible:
return
self.shader.bind()
self.shader.uniform_float("color", self._bg_color)
self.batch_panel.draw(self.shader)
def init(self, context):
self.context = context
self.update(self.x, self.y)
def update(self, x, y):
area_height = self.get_area_height()
self.x_screen = x
self.y_screen = y
indices = ((0, 1, 2), (0, 2, 3))
y_screen_flip = area_height - self.y_screen
# bottom left, top left, top right, bottom right
vertices = (
(self.x_screen, y_screen_flip),
(self.x_screen, y_screen_flip - self.height),
(self.x_screen + self.width, y_screen_flip - self.height),
(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
)
bpy.context.region.tag_redraw()
def handle_event(self, event):
"""
returns True if the event was handled by the widget
# 'handled_pass', if the event was handled but the event should be passed to other widgets
False if the event was not handled by the widget
"""
if not self._is_visible:
return False
if not self._is_active:
return False
x = event.mouse_region_x
y = event.mouse_region_y
if event.type == "LEFTMOUSE":
if event.value == "PRESS":
self._mouse_down = True
bpy.context.region.tag_redraw()
return self.mouse_down(x, y)
else:
self._mouse_down = False
bpy.context.region.tag_redraw()
self.mouse_up(x, y)
return False
elif event.type == "RIGHTMOUSE":
if event.value == "PRESS":
self._mouse_down_right = True
bpy.context.region.tag_redraw()
return self.mouse_down_right(x, y)
else:
self._mouse_down_right = False
bpy.context.region.tag_redraw()
self.mouse_up(x, y)
elif event.type == "MOUSEMOVE":
self.mouse_move(x, y)
inrect = self.is_in_rect(x, y)
# we enter the rect
if not self.__inrect and inrect:
self.__inrect = True
self.mouse_enter(event, x, y)
# we tag redraw since the hover colors are picked in the draw function
bpy.context.region.tag_redraw()
# we are leaving the rect
elif self.__inrect and not inrect:
self.__inrect = False
self.mouse_exit(event, x, y)
bpy.context.region.tag_redraw()
# return always false to enable mouse exit events on other buttons.(would sometimes not hide the tooltip)
return False # self.__inrect
elif (
event.value == "PRESS"
and self.__inrect
and (event.ascii != "" or event.type in self.get_input_keys())
):
return self.text_input(event)
return False
def get_input_keys(self):
return []
def get_area_height(self):
return self.context.area.height
def is_in_rect(self, x, y):
area_height = self.get_area_height()
widget_y = area_height - self.y_screen
if (self.x_screen <= x <= (self.x_screen + self.width)) and (
widget_y >= y >= (widget_y - self.height)
):
# 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)
return True
return False
def text_input(self, event):
return False
def mouse_down(self, x, y):
return self.is_in_rect(x, y)
def mouse_down_right(self, x, y):
return self.is_in_rect(x, y)
def mouse_up(self, x, y):
pass
def mouse_enter_func(self, widget):
pass
def mouse_exit_func(self, widget):
pass
def set_mouse_enter(self, mouse_enter_func):
self.mouse_enter_func = mouse_enter_func
def call_mouse_enter(self):
if self.mouse_enter_func:
self.mouse_enter_func(self)
def mouse_enter(self, event, x, y):
self.call_mouse_enter()
def set_mouse_exit(self, mouse_exit_func):
self.mouse_exit_func = mouse_exit_func
def call_mouse_exit(self):
if self.mouse_exit_func:
self.mouse_exit_func(self)
def mouse_exit(self, event, x, y):
self.call_mouse_exit()
def mouse_move(self, x, y):
pass
@@ -0,0 +1,20 @@
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
name = "BlenderKit Online Asset Library"
tagline = "Drag & drop of assets from the community driven library"
maintainer = "Vilém Duha <admin@blenderkit.com>"
website = "https://blenderkit.com/"
tags = ["3D View"]
blender_version_min = "3.0.0"
license = ["SPDX:GPL-2.0-or-later",]
copyright = ["2019 Blender Kit s.r.o.",]
[permissions]
files = "Import and export assets"
network = "Search and download from blenderkit.com"
@@ -0,0 +1,282 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import json
import logging
import os
import shutil
import bpy
from . import client_tasks, global_vars, paths
bk_logger = logging.getLogger(__name__)
def filter_category(category):
"""filter categories with no assets, so they aren't shown in search panel"""
if category["assetCount"] < 1:
return True
else:
to_remove = []
for c in category["children"]:
if filter_category(c):
to_remove.append(c)
for c in to_remove:
category["children"].remove(c)
def filter_categories(categories):
for category in categories:
filter_category(category)
def get_category_path(categories, category):
"""finds the category in all possible subcategories and returns the path to it"""
category_path = []
check_categories = categories[:]
parents = {}
while len(check_categories) > 0:
ccheck = check_categories.pop()
if not ccheck.get("children"):
continue
for ch in ccheck["children"]:
parents[ch["slug"]] = ccheck["slug"]
if ch["slug"] == category:
category_path = [ch["slug"]]
slug = ch["slug"]
while parents.get(slug):
slug = parents.get(slug)
category_path.insert(0, slug)
return category_path
check_categories.append(ch)
return category_path
def get_category_name_path(categories, category):
"""finds the category in all possible subcategories and returns the path to it"""
category_path = []
check_categories = categories[:]
parents = {}
while len(check_categories) > 0:
ccheck = check_categories.pop()
if not ccheck.get("children"):
continue
for ch in ccheck["children"]:
parents[ch["slug"]] = ccheck
if ch["slug"] == category:
category_path = [ch["name"]]
slug = ch["slug"]
while parents.get(slug):
parent = parents.get(slug)
slug = parent["slug"]
category_path.insert(0, parent["name"])
return category_path
check_categories.append(ch)
return category_path
def get_category(categories, cat_path=()):
for category in cat_path:
for c in categories:
if c["slug"] == category:
categories = c["children"]
if category == cat_path[-1]:
return c
break
def handle_categories_task(task: client_tasks.Task):
"""Handle incomming categories_update task which contains information about fetching updated categories.
TODO: would be ideal if the file handling (saving, reading fallback JSON) would be done on the Client side.
"""
if task.status not in ["finished", "error"]:
return
tempdir = paths.get_temp_dir()
categories_filepath = os.path.join(tempdir, "categories.json")
global_vars.DATA["active_category_browse"] = {
"MODEL": ["model"],
"SCENE": ["scene"],
"HDR": ["hdr"],
"MATERIAL": ["material"],
"BRUSH": ["brush"],
"NODEGROUP": ["nodegroup"],
"PRINTABLE": ["printable"],
}
if task.status == "finished":
global_vars.DATA["bkit_categories"] = task.result
with open(categories_filepath, "w", encoding="utf-8") as file:
json.dump(
task.result, file, ensure_ascii=False, indent=4
) # TODO: do this in Client, just saving the file so next time it is updated even without internet
return
bk_logger.warning(task.message)
if not os.path.exists(categories_filepath):
source_path = paths.get_addon_file(subpath="data" + os.sep + "categories.json")
try:
shutil.copy(source_path, categories_filepath)
except Exception as e:
bk_logger.warning(f"Could not copy categories file: {e}")
return
try:
with open(categories_filepath, "r", encoding="utf-8") as catfile:
global_vars.DATA["bkit_categories"] = json.load(catfile)
except Exception as e:
bk_logger.warning(f"Could not read categories file: {e}")
# def get_upload_asset_type(self):
# typemapper = {
# bpy.types.Object.blenderkit: 'model',
# bpy.types.Scene.blenderkit: 'scene',
# bpy.types.Image.blenderkit: 'hdr',
# bpy.types.Material.blenderkit: 'material',
# bpy.types.Brush.blenderkit: 'brush'
# }
# asset_type = typemapper[type(self)]
# return asset_type
def update_category_enums(self, context):
"""Fixes if lower level is empty - sets it to None, because enum value can be higher."""
enums = get_subcategory_enums(self, context)
if enums[0][0] == "NONE" and (
self.subcategory != "NONE" and self.subcategory != "EMPTY"
):
self.subcategory = "NONE"
def update_subcategory_enums(self, context):
"""Fixes if lower level is empty - sets it to None, because enum value can be higher."""
enums = get_subcategory1_enums(self, context)
if enums[0][0] == "NONE" and (
self.subcategory1 != "NONE" and self.subcategory1 != "EMPTY"
):
self.subcategory1 = "NONE"
def get_category_enums(self, context):
props = bpy.context.window_manager.blenderkitUI
asset_type = props.asset_type.lower()
# asset_type = self.asset_type#get_upload_asset_type(self)
if global_vars.DATA.get("bkit_categories") is None:
return [
("EMPTY", "Empty", "no categories on this level defined"),
]
asset_categories = get_category(
global_vars.DATA["bkit_categories"], cat_path=(asset_type,)
)
items = []
for c in asset_categories["children"]:
items.append((c["slug"], c["name"], c["description"]))
if len(items) == 0:
items.append(("EMPTY", "Empty", "no categories on this level defined"))
else:
items.insert(
0,
("NONE", "None", "Default state, category not defined by user"),
)
return items
def get_subcategory_enums(self, context):
props = bpy.context.window_manager.blenderkitUI
asset_type = props.asset_type.lower()
if global_vars.DATA.get("bkit_categories") is None:
return [
("EMPTY", "Empty", "no categories on this level defined"),
]
items = []
if self.category != "None":
asset_categories = get_category(
global_vars.DATA["bkit_categories"],
cat_path=(
asset_type,
self.category,
),
)
if asset_categories is not None:
for c in asset_categories["children"]:
items.append((c["slug"], c["name"], c["description"]))
if len(items) == 0:
items.append(("EMPTY", "Empty", "no categories on this level defined"))
else:
items.insert(
0,
("NONE", "None", "Default state, category not defined by user"),
)
items.append(
(
"OTHER",
"Other...",
"The asset does not belong to any of the subcategories listed above.",
),
)
return items
def get_subcategory1_enums(self, context):
props = bpy.context.window_manager.blenderkitUI
asset_type = props.asset_type.lower()
if global_vars.DATA.get("bkit_categories") is None:
return [
("EMPTY", "Empty", "no categories on this level defined"),
]
items = []
if self.category != "None" and self.subcategory != "Empty":
asset_categories = get_category(
global_vars.DATA["bkit_categories"],
cat_path=(
asset_type,
self.category,
self.subcategory,
),
)
if asset_categories is not None:
for c in asset_categories["children"]:
items.append((c["slug"], c["name"], c["description"]))
if len(items) == 0:
items.append(("EMPTY", "Empty", "no categories on this level defined"))
else:
items.insert(
0,
("NONE", "None", "Default state, category not defined by user"),
)
items.append(
(
"OTHER",
"Other...",
"The asset does not belong to any of the sub-subcategories listed above.",
),
)
return items
@@ -0,0 +1,768 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import dataclasses
import logging
import os
import platform
import shutil
import subprocess
from os import path
from typing import Optional
from http.client import responses as http_responses
import bpy
import requests
from . import datas, global_vars, reports, utils
bk_logger = logging.getLogger(__name__)
NO_PROXIES = {"http": "", "https": ""}
TIMEOUT = (0.1, 1)
def get_address() -> str:
"""Get address of the BlenderKit-Client."""
return f"http://127.0.0.1:{get_port()}"
def get_port() -> str:
"""Get the most probable port of currently running BlenderKit-Client.
After add-on registration and if all goes well, the port is the same as
"""
return global_vars.CLIENT_PORTS[0]
def get_api_version() -> str:
"""Get version of API Client is expected to use. To keep stuff simple the API version is derrived from Client's version.
From Client version vX.Y.Z we remove the .Z part to effectively get the vX.Y version of the API. For nonbreaking changes
we increase the patch version of the Client. If the change breaks the API, then increase of minor/major version is expected.
"""
splitted = global_vars.CLIENT_VERSION.split(".")
return ".".join(splitted[:-1])
def get_base_url() -> str:
"""The base URL on which we will interact with the BlenderKit Client. Consists from address with port + version API path.
All requests to Client goes to URLs starting with base URL in format: 127.0.0.1:{port}/vX.Y
"""
address = get_address()
vapi = get_api_version()
return f"{address}/{vapi}"
def ensure_minimal_data(data: Optional[dict] = None) -> dict:
"""Ensure that the data send to the BlenderKit-Client contains:
- app_id is the process ID of the Blender instance, so BlenderKit-client can return reports to the correct instance.
- api_key is the authentication token for the BlenderKit server, so BlenderKit-Client can authenticate the user.
- addon_version is the version of the BlenderKit add-on, so BlenderKit-client has understanding of the version of the add-on making the request.
"""
if data is None:
data = {}
av = global_vars.VERSION
addon_version = f"{av[0]}.{av[1]}.{av[2]}.{av[3]}"
if "api_key" not in data:
# for BG instances, where preferences are not available
data.setdefault(
"api_key", bpy.context.preferences.addons[__package__].preferences.api_key # type: ignore
)
data.setdefault("app_id", os.getpid())
data.setdefault("platform_version", platform.platform())
data.setdefault("addon_version", addon_version)
return data
def ensure_minimal_data_class(data_class):
"""Ensure that the data send to the BlenderKit-Client contains:
- app_id is the process ID of the Blender instance, so BlenderKit-client can return reports to the correct instance.
- api_key is the authentication token for the BlenderKit server, so BlenderKit-Client can authenticate the user.
- addon_version is the version of the BlenderKit add-on, so BlenderKit-client has understanding of the version of the add-on making the request.
"""
if data_class == None:
data_class = dataclasses.dataclass()
av = global_vars.VERSION
if hasattr(data_class, "api_key"):
# for BG instances, where preferences are not available
api_key = bpy.context.preferences.addons[__package__].preferences.api_key
setattr(data_class, "api_key", api_key)
setattr(data_class, "app_id", os.getpid())
setattr(data_class, "platform_version", platform.platform())
setattr(data_class, "addon_version", f"{av[0]}.{av[1]}.{av[2]}.{av[3]}")
return data_class
def reorder_ports(port: str = ""):
"""Reorder CLIENT_PORTS so the specified port is first.
If no port is specified, the current first port is moved to back so second becomes the first.
"""
if port == "":
i = 1
else:
i = global_vars.CLIENT_PORTS.index(port)
global_vars.CLIENT_PORTS = (
global_vars.CLIENT_PORTS[i:] + global_vars.CLIENT_PORTS[:i]
)
bk_logger.info(
f"Ports reordered so first port is now {global_vars.CLIENT_PORTS[0]} (previous index was {i})"
)
def get_reports(app_id: str):
"""Get reports for all tasks of app_id Blender instance at once.
If few last calls failed, then try to get reports also from other than default ports.
"""
data = ensure_minimal_data({"app_id": app_id})
data["project_name"] = utils.get_project_name()
data["blender_version"] = utils.get_blender_version()
# on 10, there is second BlenderKit-Client start
if global_vars.CLIENT_FAILED_REPORTS < 10:
url = f"{get_base_url()}/report"
return request_report(url, data)
last_exception = None
for port in global_vars.CLIENT_PORTS:
vapi = get_api_version()
url = f"http://127.0.0.1:{port}/{vapi}/report"
try:
report = request_report(url, data)
bk_logger.warning(
f"Got reports from BlenderKit-Client on port {port}, setting it as default for this instance"
)
reorder_ports(port)
return report
except Exception as e:
bk_logger.info(f"Failed to get BlenderKit-Client reports: {e}")
last_exception = e
if last_exception is not None:
raise last_exception
def request_report(url: str, data: dict) -> dict:
"""Make HTTP request to /report endpoint. If all goes well a JSON dict is returned.
If something goes south, this function raises requests.HTTPError or requests.JSONDecodeError.
"""
with requests.Session() as session:
resp = session.get(url, json=data, timeout=TIMEOUT, proxies=NO_PROXIES)
if resp.status_code != 200:
# not using resp.raise_for_status() for better message
raise requests.HTTPError(
f"{http_responses[resp.status_code]}: {resp.text}", response=resp
)
return resp.json()
### ASSETS
# SEARCH
def asset_search(search_data: datas.SearchData):
"""Search for specified asset."""
bk_logger.info(f"Starting search request: {search_data.urlquery}")
search_data = ensure_minimal_data_class(search_data)
with requests.Session() as session:
url = get_base_url() + "/blender/asset_search"
resp = session.post(
url, json=datas.asdict(search_data), timeout=TIMEOUT, proxies=NO_PROXIES
)
bk_logger.debug("Got search response")
return resp.json()
# DOWNLOAD
def asset_download(data):
"""Download specified asset."""
data = ensure_minimal_data(data)
with requests.Session() as session:
url = get_base_url() + "/blender/asset_download"
resp = session.post(url, json=data, timeout=TIMEOUT, proxies=NO_PROXIES)
return resp.json()
def cancel_download(task_id: str):
"""Cancel the specified task with ID on the BlenderKit-Client."""
data = ensure_minimal_data({"task_id": task_id})
with requests.Session() as session:
url = get_base_url() + "/blender/cancel_download"
resp = session.get(url, json=data, timeout=TIMEOUT, proxies=NO_PROXIES)
return resp
# UPLOAD
def asset_upload(upload_data, export_data, upload_set):
"""Upload specified asset."""
data = {
"PREFS": utils.get_preferences_as_dict(),
"upload_data": upload_data,
"export_data": export_data,
"upload_set": upload_set,
}
data = ensure_minimal_data(data)
with requests.Session() as session:
url = get_base_url() + "/blender/asset_upload"
bk_logger.debug(f"making a request to: {url}")
resp = session.post(url, json=data, timeout=TIMEOUT, proxies=NO_PROXIES)
return resp
### PROFILES
def download_gravatar_image(author_data: datas.UserProfile) -> requests.Response:
"""Fetch gravatar image for specified user. Find it on disk or download it from server."""
data = {
"id": author_data.id,
"avatar128": author_data.avatar128,
"gravatarHash": author_data.gravatarHash,
}
data = ensure_minimal_data(data)
with requests.Session() as session:
url = get_base_url() + "/profiles/download_gravatar_image"
resp = session.get(url, json=data, timeout=TIMEOUT, proxies=NO_PROXIES)
return resp
def get_user_profile() -> requests.Response:
"""Fetch profile of currently logged-in user.
This creates task on BlenderKit-Client to fetch data which are later handled once available.
"""
data = ensure_minimal_data()
with requests.Session() as session:
return session.get(
f"{get_base_url()}/profiles/get_user_profile",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
### COMMENTS
def get_comments(asset_id, api_key=""):
"""Get all comments on the asset."""
data = ensure_minimal_data({"asset_id": asset_id})
with requests.Session() as session:
return session.post(
f"{get_base_url()}/comments/get_comments",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
def create_comment(asset_id, comment_text, api_key, reply_to_id=0):
"""Create a new comment."""
data = {
"asset_id": asset_id,
"comment_text": comment_text,
"reply_to_id": reply_to_id,
}
data = ensure_minimal_data(data)
with requests.Session() as session:
return session.post(
f"{get_base_url()}/comments/create_comment",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
def feedback_comment(asset_id, comment_id, api_key, flag="like"):
"""Feedback the comment - by default with like. Other flags can be used also."""
data = {
"asset_id": asset_id,
"comment_id": comment_id,
"flag": flag,
}
data = ensure_minimal_data(data)
with requests.Session() as session:
return session.post(
f"{get_base_url()}/comments/feedback_comment",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
def mark_comment_private(asset_id, comment_id, api_key, is_private=False):
"""Mark the comment as private or public."""
data = {
"asset_id": asset_id,
"comment_id": comment_id,
"is_private": is_private,
}
data = ensure_minimal_data(data)
with requests.Session() as session:
return session.post(
f"{get_base_url()}/comments/mark_comment_private",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
### NOTIFICATIONS
def mark_notification_read(notification_id):
"""Mark the notification as read on the server."""
data = ensure_minimal_data({"notification_id": notification_id})
with requests.Session() as session:
return session.post(
f"{get_base_url()}/notifications/mark_notification_read",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
### REPORTS
def report_usages(data: dict):
"""Report usages of assets in current scene via BlenderKit-Client to the server."""
data = ensure_minimal_data(data)
with requests.Session() as session:
return session.post(
f"{get_base_url()}/report_usages",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
# RATINGS
def get_rating(asset_id: str):
data = ensure_minimal_data({"asset_id": asset_id})
with requests.Session() as session:
return session.get(
f"{get_base_url()}/ratings/get_rating",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
def send_rating(asset_id: str, rating_type: str, rating_value: str):
data = {
"asset_id": asset_id,
"rating_type": rating_type,
"rating_value": rating_value,
}
data = ensure_minimal_data(data)
with requests.Session() as session:
return session.post(
f"{get_base_url()}/ratings/send_rating",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
# BOOKMARKS
def get_bookmarks():
data = ensure_minimal_data()
with requests.Session() as session:
return session.get(
f"{get_base_url()}/ratings/get_bookmarks",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
### BLOCKING WRAPPERS
def get_download_url(asset_data, scene_id, api_key):
"""Get download url from server. This is a blocking wrapper, will not return until results are available.
Returns: (bool, str, str) - can_download, download_url, filename.
"""
data = {
"resolution": "blend",
"asset_data": asset_data,
"api_key": api_key, # needs to be here, because prefs are not available in BG instances
"PREFS": {
"api_key": api_key,
"scene_id": scene_id,
},
}
data = ensure_minimal_data(data)
with requests.Session() as session:
resp = session.get(
f"{get_base_url()}/wrappers/get_download_url",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
resp = resp.json()
return (resp["can_download"], resp["download_url"], resp["filename"])
def complete_upload_file_blocking(
api_key, asset_id, filepath, filetype: str, fileindex: int
) -> bool:
"""Complete file upload in just one step, blocks until upload is finished. Useful for background scripts."""
data = {
"api_key": api_key,
"assetId": asset_id,
"fileType": filetype,
"fileIndex": fileindex,
"filePath": filepath,
"originalFilename": os.path.basename(filepath), # teoreticky asi nemusi byt
}
data = ensure_minimal_data(data)
with requests.Session() as session:
resp = session.get(
f"{get_base_url()}/wrappers/complete_upload_file_blocking",
json=data,
timeout=(1, 600),
proxies=NO_PROXIES,
)
print("complete_upload_file_blocking resp:", resp)
return resp.ok
def blocking_file_download(url: str, filepath: str, api_key: str) -> requests.Response:
"""Upload file to server. This is a blocking wrapper, will not return until results are available."""
data = {
"url": url,
"filepath": filepath,
}
data = ensure_minimal_data(data)
with requests.Session() as session:
return session.get(
f"{get_base_url()}/wrappers/blocking_file_download",
json=data,
timeout=(1, 600),
proxies=NO_PROXIES,
)
def blocking_request(
url: str,
method: str = "GET",
headers: Optional[dict] = None,
json_data: Optional[dict] = None,
timeout: tuple = TIMEOUT,
) -> requests.Response:
"""Make blocking HTTP request through BlenderKit-Client.
Will not return until results are available."""
if headers is None:
headers = {}
data = {
"url": url,
"method": method,
"headers": headers,
}
if json_data is not None:
data["json"] = json_data
with requests.Session() as session:
return session.get(
f"{get_base_url()}/wrappers/blocking_request",
json=data,
timeout=timeout,
proxies=NO_PROXIES,
)
### REQUEST WRAPPERS
def nonblocking_request(
url: str,
method: str,
headers: Optional[dict] = None,
json_data: Optional[dict] = None,
messages: Optional[dict] = None,
) -> requests.Response:
"""Make non-blocking HTTP request through BlenderKit-Client.
This function will return ASAP, not returning any actual data.
"""
if headers is None:
headers = {}
if messages is None:
messages = {}
data = {
"url": url,
"method": method,
"headers": headers,
"messages": messages,
}
data = ensure_minimal_data(data)
if json_data is not None:
data["json"] = json_data
with requests.Session() as session:
return session.get(
f"{get_base_url()}/wrappers/nonblocking_request",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
### AUTHORIZATION
def send_oauth_verification_data(code_verifier, state: str):
"""Send OAUTH2 Code Verifier and State parameters to BlenderKit-Client.
So it can later use them to authenticate the redirected response from the browser.
"""
data = ensure_minimal_data(
{
"code_verifier": code_verifier,
"state": state,
}
)
with requests.Session() as session:
resp = session.post(
f"{get_base_url()}/oauth2/verification_data",
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
return resp
def refresh_token(refresh_token, old_api_key):
"""Refresh authentication token. BlenderKit-Client will use refresh token to get new API key token to replace the old_api_key.
old_api_key is used later to replace token only in Blender instances with the same api_key. (User can be logged into multiple accounts.)
"""
bk_logger.info("Calling API token refresh")
data = ensure_minimal_data({"refresh_token": refresh_token})
with requests.Session() as session:
url = get_base_url() + "/refresh_token"
resp = session.get(
url,
json=data,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
return resp
def oauth2_logout():
"""Logout from OAUTH2. BlenderKit-Client will revoke the token on the server."""
data = ensure_minimal_data()
data["refresh_token"] = global_vars.PREFS["api_key_refresh"]
with requests.Session() as session:
url = get_base_url() + "/oauth2/logout"
resp = session.get(url, json=data, timeout=TIMEOUT, proxies=NO_PROXIES)
return resp
def unsubscribe_addon():
"""Unsubscribe the add-on from the BlenderKit-Client. Called when the add-on is disabled, uninstalled or when Blender is closed."""
data = ensure_minimal_data()
with requests.Session() as session:
url = get_base_url() + "/blender/unsubscribe_addon"
resp = session.get(url, json=data, timeout=TIMEOUT, proxies=NO_PROXIES)
return resp
def shutdown_client():
"""Request to shutdown the BlenderKit-Client."""
data = ensure_minimal_data()
with requests.Session() as session:
url = get_base_url() + "/shutdown"
resp = session.get(url, data=data, timeout=TIMEOUT, proxies=NO_PROXIES)
return resp
def handle_client_status_task(task):
if global_vars.CLIENT_RUNNING is False:
wm = bpy.context.window_manager
wm.blenderkitUI.logo_status = "logo"
global_vars.CLIENT_RUNNING = True
def check_blenderkit_client_return_code() -> tuple[int, str]:
"""Check the return code for the started BlenderKit-Client. If the return code returned from process.poll() is None - returned by this func as -1, it means Client still runs - we consider this a success!
However if the return code from poll() is present, it failed to start and we check the return code value. If the return code is known,
we print information to user about the reason. So they do not need to dig in the Client log.
"""
# Return codes - as defined in main.go
rcServerStartOtherError = 40
rcServerStartOtherNetworkingError = 41
rcServerStartOtherSyscallError = 42
rcServerStartSyscallEADDRINUSE = 43
rcServerStartSyscallEACCES = 44
if global_vars.client_process is None:
return -2, "Unexpectedly global_vars.client_process is None"
exit_code = global_vars.client_process.poll()
if exit_code is None:
return -1, "BlenderKit-Client process is running."
# need to initialize msg, was throwing an error
msg = f"Unknown error."
if exit_code == rcServerStartOtherError:
msg = f"Other starting problem."
if exit_code == rcServerStartOtherNetworkingError:
msg = f"Other networking problem."
if exit_code == rcServerStartOtherSyscallError:
msg = f"Other syscall error."
if exit_code == rcServerStartSyscallEADDRINUSE: # This is known solution
return (
exit_code,
"Address already in use: please change the port in add-on preferences.",
)
if exit_code == rcServerStartSyscallEACCES: # This needs verification
return (
exit_code,
"Access denied: change port in preferences, check permissions and antivirus rights.",
)
message = (
f"{msg} Please report a bug and paste content of log {get_client_log_path()}"
)
return exit_code, message
def start_blenderkit_client():
"""Start BlenderKit-client in separate process.
1. Check if binary is available at global_dir/client/vX.Y.Z/blenderkit-client-<os>-<arch>(.exe)
2. Copy the binary from add-on directory to global_dir/client/vX.Y.Z/
3. Start the BlenderKit-Client process which serves as bridge between BlenderKit add-on and BlenderKit server.
"""
ensure_client_binary_installed()
log_path = get_client_log_path()
client_binary_path, client_version = get_client_binary_path()
creation_flags = 0
if platform.system() == "Windows":
creation_flags = subprocess.CREATE_NO_WINDOW
try:
with open(log_path, "wb") as log:
global_vars.client_process = subprocess.Popen(
args=[
client_binary_path,
"--port",
get_port(),
"--server",
global_vars.SERVER,
"--proxy_which",
global_vars.PREFS.get("proxy_which", ""),
"--proxy_address",
global_vars.PREFS.get("proxy_address", ""),
"--trusted_ca_certs",
global_vars.PREFS.get("trusted_ca_certs", ""),
"--ssl_context",
global_vars.PREFS.get("ssl_context", ""),
"--version",
f"{global_vars.VERSION[0]}.{global_vars.VERSION[1]}.{global_vars.VERSION[2]}.{global_vars.VERSION[3]}",
"--software",
"Blender",
"--pid",
str(os.getpid()),
],
stdout=log,
stderr=log,
creationflags=creation_flags,
)
except Exception as e:
msg = f"Error: BlenderKit-Client {client_version} failed to start on {get_address()}:{e}"
reports.add_report(msg, type="ERROR")
raise (e)
bk_logger.info(f"BlenderKit-Client {client_version} starting on {get_address()}")
def decide_client_binary_name() -> str:
"""Decide the name of the BlenderKit-Client binary based on the current operating system and architecture.
Possible return values:
- blenderkit-client-windows-x86_64.exe
- blenderkit-client-windows-arm64.exe
- blenderkit-client-linux-x86_64
- blenderkit-client-linux-arm64
- 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 = "macos"
if architecture == "AMD64": # fix for windows
architecture = "x86_64"
if os_name == "Windows":
return f"blenderkit-client-{os_name}-{architecture}.exe".lower()
return f"blenderkit-client-{os_name}-{architecture}".lower()
def get_client_directory() -> str:
"""Get the path to the BlenderKit-Client directory located in global_dir."""
global_dir = bpy.context.preferences.addons[__package__].preferences.global_dir # type: ignore
directory = path.join(global_dir, "client")
return directory
def get_client_log_path() -> str:
"""Get path to BlenderKit-Client log file in global_dir/client.
If the port is the default port 62485, the log file is named default.log,
otherwise it is named client-<port>.log.
"""
port = get_port()
if port == "62485":
log_path = os.path.join(get_client_directory(), f"default.log")
else:
log_path = os.path.join(get_client_directory(), f"client-{get_port()}.log")
return path.abspath(log_path)
def get_preinstalled_client_path() -> str:
"""Get the path to the preinstalled BlenderKit-Client binary - located in add-on directory.
This is the binary that is shipped with the add-on. It is copied to global_dir/client/vX.Y.Z on first run.
"""
addon_dir = path.dirname(__file__)
binary_name = decide_client_binary_name()
binary_path = path.join(
addon_dir, "client", global_vars.CLIENT_VERSION, binary_name
)
return path.abspath(binary_path)
def get_client_binary_path():
"""Get the path to the BlenderKit-Client binary located in global_dir/client/bin/vX.Y.Z.
This is the binary that is used to start the client process.
We do not start from the add-on because it might block update or delete of the add-on.
Returns: (str, str) - path to the Client binary, version of the Client binary
"""
client_dir = get_client_directory()
binary_name = decide_client_binary_name()
ver_string = global_vars.CLIENT_VERSION
binary_path = path.join(client_dir, "bin", ver_string, binary_name)
return path.abspath(binary_path), ver_string
def ensure_client_binary_installed():
"""Ensure that the BlenderKit-Client binary is installed in global_dir/client/bin/vX.Y.Z.
If not, copy the binary from the add-on directory blenderkit/client.
As side effect, this function also creates the global_dir/client/bin/vX.Y.Z directory.
"""
client_binary_path, _ = get_client_binary_path()
if path.exists(client_binary_path):
return
preinstalled_client_path = get_preinstalled_client_path()
bk_logger.info(f"Copying BlenderKit-Client binary {preinstalled_client_path}")
os.makedirs(path.dirname(client_binary_path), exist_ok=True)
shutil.copy(preinstalled_client_path, client_binary_path)
os.chmod(client_binary_path, 0o711)
bk_logger.info(f"BlenderKit-Client binary copied to {client_binary_path}")
def get_addon_dir():
"""Get the path to the add-on directory."""
addon_dir = path.dirname(__file__)
return addon_dir
@@ -0,0 +1,56 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import uuid
from typing import Optional
class Task:
"""Holds all information needed for a task."""
def __init__(
self,
data: dict,
app_id: str,
task_type: str,
task_id: str = "",
message: str = "",
message_detailed: str = "",
progress: int = 0,
status: str = "created",
result: Optional[dict] = None,
):
if task_id == "":
task_id = str(uuid.uuid4())
self.data = data
self.task_id = task_id
self.app_id = app_id # TODO: implement solution for report to "all" Blenders
self.task_type = task_type
self.message = message
self.message_detailed = message_detailed
self.progress = progress
self.status = status # created / finished / error
if result is None:
self.result = {}
else:
self.result = result.copy()
def __str__(self):
return f"ID={self.task_id}, APP_ID={self.app_id}"
@@ -0,0 +1,30 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
"""
Module colors defines color palette for BlenderKit UI.
"""
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)
"""Color for validator reports."""
@@ -0,0 +1,93 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
from . import client_tasks, global_vars
bk_logger = logging.getLogger(__name__)
### COMMENTS
def handle_get_comments_task(task: client_tasks.Task):
"""Handle incomming task which downloads comments on asset."""
if task.status == "error":
return bk_logger.warning(f"failed to get comments: {task.message}")
if task.status == "finished":
comments = task.result["results"]
store_comments_local(task.data["asset_id"], comments)
return
def handle_create_comment_task(task: client_tasks.Task):
"""Handle incoming task for creating a new comment."""
if task.status == "finished":
return bk_logger.debug(f"Creating comment finished - {task.message}")
if task.status == "error":
return bk_logger.warning(f"Creating comment failed - {task.message}")
def handle_feedback_comment_task(task: client_tasks.Task):
"""Handle incomming task for update of feedback on comment."""
if task.status == "finished": # action not needed
return bk_logger.debug(f"Comment feedback finished - {task.message}")
if task.status == "error":
return bk_logger.warning(f"Comment feedback failed - {task.message}")
def handle_mark_comment_private_task(task: client_tasks.Task):
"""Handle incomming task for marking the comment as private/public."""
if task.status == "finished": # action not needed
return bk_logger.debug(f"Marking comment visibility finished - {task.message}")
if task.status == "error":
return bk_logger.warning(f"Marking comment visibility failed - {task.message}")
def store_comments_local(asset_id, comments):
global_vars.DATA["asset comments"][asset_id] = comments
def get_comments_local(asset_id):
return global_vars.DATA["asset comments"].get(asset_id)
### NOTIFICATIONS
def handle_notifications_task(task: client_tasks.Task):
"""Handle incomming task with notifications data."""
if task.status == "finished":
global_vars.DATA["bkit notifications"] = task.result
return
if task.status == "error":
return bk_logger.warning(f"Notifications fetching failed: {task.message}")
def check_notifications_read():
"""Check if all notifications were already read, and remove them if so."""
notifications = global_vars.DATA.get("bkit notifications")
if notifications is None:
return True
if notifications.get("count") == 0:
return True
for notification in notifications["results"]:
if notification["unread"] == 1:
return False
global_vars.DATA["bkit notifications"] = None
return True
@@ -0,0 +1,148 @@
import dataclasses
from typing import Optional
def asdict(data_class) -> dict:
return dataclasses.asdict(data_class)
@dataclasses.dataclass
class Prefs:
debug_value: int
binary_path: str
addon_dir: str
addon_module_name: str
app_id: int
download_counter: int
asset_popup_counter: int
welcome_operator_counter: int
api_key: str
api_key_refresh: str
api_key_timeout: int
experimental_features: bool
keep_preferences: bool
directory_behaviour: str
global_dir: str
project_subdir: str
unpack_files: bool
show_on_start: bool
thumb_size: int
max_assetbar_rows: int
search_field_width: int
search_in_header: bool
tips_on_start: bool
announcements_on_start: bool
client_port: str
ip_version: str
ssl_context: str
proxy_which: str
proxy_address: str
trusted_ca_certs: str
auto_check_update: bool
enable_prereleases: bool
updater_interval_months: int
updater_interval_days: int
resolution: str
@dataclasses.dataclass
class SearchData:
"""Data needed to make a Search request."""
PREFS: Prefs
tempdir: str
urlquery: str
asset_type: str
scene_uuid: str
get_next: bool
page_size: int
blender_version: str
addon_version: str = ""
platform_version: str = ""
api_key: str = ""
app_id: int = 0
is_validator: bool = (
False # Client makes some extra stuff for validators - like fetching all the ratings right away
)
history_id: str = ""
@dataclasses.dataclass
class SocialNetwork:
url: str
icon: str
name: str
order: int
def parse_social_networks(networks: list[dict]) -> list[SocialNetwork]:
social_networks = []
for network in networks:
url = network.get("url", "")
n = network.get("socialNetwork", {})
social_network = SocialNetwork(
url=url,
icon=n.get("icon", ""),
name=n.get("name", ""),
order=n.get("order", -1),
)
social_networks.append(social_network)
return social_networks
@dataclasses.dataclass
class UserProfile:
"""This is public information about profiles of others."""
aboutMe: str
aboutMeUrl: str
avatar128: str
firstName: str
fullName: str
gravatarHash: str
id: int
lastName: str
socialNetworks: list[SocialNetwork] = dataclasses.field(default_factory=list)
avatar256: str = ""
gravatarImg: str = "" # filled later from getGravatar task
tooltip: str = "" # generated later from Name and AboutMe etc.
@dataclasses.dataclass
class MineProfile:
"""This is private information about current user's profile."""
aboutMe: str = ""
aboutMeUrl: str = ""
avatar128: str = ""
avatar256: str = ""
avatar512: str = ""
currentPlanName: str = ""
email: str = ""
firstName: str = ""
fullName: str = ""
gravatarHash: str = ""
hasFreePlan: bool = True
id: int = -1
lastName: str = ""
remainingPrivateQuota: int = 0
sumAssetFilesSize: int = 0
sumPrivateAssetFilesSize: int = 0
username: str = ""
socialNetworks: list[SocialNetwork] = dataclasses.field(default_factory=list)
gravatarImg: str = "" # filled later from getGravatar task
tooltip: str = "" # generated later from Name and AboutMe etc.
canEditAllAssets: bool = False # whether User is validator
def __bool__(self):
return self.id != -1
@dataclasses.dataclass
class AssetRating:
bookmarks: Optional[int] = None # name kept as comes from API
quality: Optional[int] = None
quality_fetched: bool = False
working_hours: Optional[float] = None # name kept as comes from API
working_hours_fetched: bool = False
# TODO: Add last time ratings checked to improve caching
@@ -0,0 +1,327 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import random
import time
import bpy
from bpy.props import BoolProperty, IntProperty, StringProperty
from . import client_tasks, global_vars, paths, reports, tasks_queue, utils
from .bl_ui_widgets.bl_ui_button import BL_UI_Button
from .bl_ui_widgets.bl_ui_drag_panel import BL_UI_Drag_Panel
from .bl_ui_widgets.bl_ui_draw_op import BL_UI_OT_draw_operator
from .bl_ui_widgets.bl_ui_image import BL_UI_Image
from .ui_bgl import get_text_size
bk_logger = logging.getLogger("blenderkit")
disclaimer_counter = 0
class BlenderKitDisclaimerOperator(BL_UI_OT_draw_operator):
bl_idname = "view3d.blenderkit_disclaimer_widget"
bl_label = "BlenderKit disclaimer"
bl_description = "BlenderKit disclaimer"
bl_options = {"REGISTER"}
instances = []
message: StringProperty( # type: ignore[valid-type]
name="message",
description="message",
default="Welcome to BlenderKit!",
options={"SKIP_SAVE"},
)
url: StringProperty( # type: ignore[valid-type]
name="url",
description="ULR",
default="www.blenderkit.com",
options={"SKIP_SAVE"},
)
fadeout_time: IntProperty( # type: ignore[valid-type]
name="Fadout time",
description="after how many seconds do fadout",
default=5,
min=1,
max=50,
options={"SKIP_SAVE"},
)
tip: BoolProperty( # type: ignore[valid-type]
name="Tip",
description="Message is a tip, not from server",
default=True,
options={"SKIP_SAVE"},
)
def cancel_press(self, widget):
self.finish()
def open_link(self, widget):
if self.url == "":
return
server_url = global_vars.SERVER
if self.url[:4] != "http":
self.url = server_url + self.url
bpy.ops.wm.url_open(url=self.url)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
ui_scale = bpy.context.preferences.view.ui_scale
text_size = int(14 * ui_scale)
margin = int(10 * ui_scale)
area_margin = int(50 * ui_scale)
if self.tip:
self.bg_color = (0.05, 0.05, 0.05, 0.5)
self.hover_bg_color = (0.05, 0.05, 0.05, 1.0)
else:
self.bg_color = (0.127, 0.034, 1, 0.1)
self.hover_bg_color = (0.127, 0.034, 1, 1.0)
self.text_color = (0.9, 0.9, 0.9, 1)
pix_size = get_text_size(
font_id=1,
text=self.message,
text_size=text_size,
dpi=int(bpy.context.preferences.system.dpi / ui_scale),
)
self.height = pix_size[1] + 2 * margin
self.button_size = int(self.height)
self.width = (
pix_size[0] + 2 * margin + 2 * self.button_size
) # adding logo and cancel button to width
a = bpy.context.area
self.panel = BL_UI_Drag_Panel(
area_margin, a.height - self.height - area_margin, self.width, self.height
)
self.panel.bg_color = (0.2, 0.2, 0.2, 0.02)
self.logo = BL_UI_Image(0, 0, self.button_size, self.button_size)
self.label = BL_UI_Button(
self.button_size, 0, pix_size[0] + 2 * margin, self.height
)
self.label.text = self.message
self.label.text_size = text_size
self.label.text_color = self.text_color
self.label.bg_color = self.bg_color
self.label.hover_bg_color = self.hover_bg_color
self.label.set_mouse_down(self.open_link)
self.button_close = BL_UI_Button(
self.width - self.button_size, 0, self.button_size, self.button_size
)
self.button_close.bg_color = self.bg_color
self.button_close.hover_bg_color = self.hover_bg_color
self.button_close.text = ""
self.button_close.set_mouse_down(self.cancel_press)
def on_invoke(self, context, event):
# Add new widgets here (TODO: perhaps a better, more automated solution?)
self.context = context
self.instances.append(self)
widgets_panel = [self.label, self.button_close, self.logo]
widgets = [self.panel]
widgets += widgets_panel
# assign image to the cancel button
img_fp = paths.get_addon_thumbnail_path("vs_rejected.png")
img_size = int(self.button_size / 2)
img_pos = int(img_size / 2)
self.button_close.set_image(img_fp)
self.button_close.set_image_size((img_size, img_size))
self.button_close.set_image_position((img_pos, img_pos))
img_fp = paths.get_addon_thumbnail_path("blenderkit_logo.png")
self.logo.set_image(img_fp)
self.logo.set_image_size((img_size, img_size))
self.logo.set_image_position((img_pos, img_pos))
# self.logo.set_image_position(0,0)
self.init_widgets(context, widgets)
self.panel.add_widgets(widgets_panel)
self.start_time = time.time()
def modal(self, context, event):
if self._finished:
return {"FINISHED"}
if not context.area:
# end if area disappears
self.finish()
return {"FINISHED"} # so region.tag_redraw() is not called later
if self.handle_widget_events(event):
self.start_time = time.time()
self.reset_colours()
return {"RUNNING_MODAL"}
if event.type in {"ESC"}:
self.finish()
return {"FINISHED"}
if event.type == "TIMER":
run_time = time.time() - self.start_time
if run_time > self.fadeout_time:
self.fadeout()
return {"PASS_THROUGH"}
def reset_colours(self):
for widget in self.widgets:
widget.bg_color = self.bg_color
widget.hover_bg_color = self.hover_bg_color
if hasattr(widget, "text_color"):
widget.text_color = self.text_color
def fadeout(self):
"""Fade out widget after some time"""
m = 0.08
all_zero = True
for widget in self.widgets:
# background color
bc = widget.bg_color
widget.bg_color = (bc[0], bc[1], bc[2], max(0, bc[3] - m))
if widget.bg_color[3] > 0:
# wait for the last to fade out
all_zero = False
# text color
if hasattr(widget, "text_color"):
tc = widget.text_color
widget.text_color = (tc[0], tc[1], tc[2], max(0, tc[3] - m))
if widget.text_color[3] > 0:
# wait for the last to fade out
all_zero = False
if all_zero:
self.finish()
@classmethod
def unregister(cls):
bk_logger.debug(f"unregistering class {cls}")
instances_copy = cls.instances.copy()
for instance in instances_copy:
bk_logger.debug(f"- class instance {instance}")
try:
instance.unregister_handlers(instance.context)
except Exception as e:
bk_logger.debug(f"-- error unregister_handlers(): {e}")
try:
instance.on_finish(instance.context)
except Exception as e:
bk_logger.debug(f"-- error calling on_finish() {e}")
if bpy.context.region is not None:
bpy.context.region.tag_redraw()
cls.instances.remove(instance)
def run_disclaimer_task(message: str, url: str, tip: bool):
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]
fake_context,
"INVOKE_DEFAULT",
message=message,
url=url,
fadeout_time=8,
tip=tip,
)
else:
with bpy.context.temp_override(**fake_context): # type: ignore[attr-defined]
bpy.ops.view3d.blenderkit_disclaimer_widget( # type: ignore[attr-defined]
"INVOKE_DEFAULT",
message=message,
url=url,
fadeout_time=8,
tip=tip,
)
def handle_disclaimer_task(task: client_tasks.Task):
"""Handles incoming disclaimer task. If there are any results, it shows them in disclaimer popup.
If the results are empty, it shows random tip in the disclaimer popup.
"""
global disclaimer_counter
disclaimer_counter = -1
if task.status == "finished":
if task.result.get("count", 0) == 0:
return show_random_tip()
disclaimer = task.result["results"][0]
preferences = bpy.context.preferences.addons[__package__].preferences # type: ignore
if preferences.announcements_on_start is False: # type: ignore[union-attr]
bk_logger.warning(
f"Online announcements are disabled! Message hidden from GUI: {disclaimer['message']}"
)
return show_random_tip()
tasks_queue.add_task(
(run_disclaimer_task, (disclaimer["message"], disclaimer["url"], False)),
wait=0,
)
return
if task.status == "error":
msg = f"Error downloading disclaimer info: {task.message}"
reports.add_report(msg, timeout=5, type="ERROR")
return show_random_tip()
def show_random_tip():
"""Shows random tip in the disclaimer popup if tips_on_start are enabled."""
preferences = bpy.context.preferences.addons[__package__].preferences
if preferences.tips_on_start is False:
return
tip = random.choice(global_vars.TIPS)
tasks_queue.add_task((run_disclaimer_task, (tip[0], tip[1], True)), wait=0)
def register():
bpy.utils.register_class(BlenderKitDisclaimerOperator)
def unregister():
bpy.utils.unregister_class(BlenderKitDisclaimerOperator)
@bpy.app.handlers.persistent
def show_disclaimer_timer():
"""Timer responsible for showing the tip disclaimer after the startup once.
It waits for BlenderKit-Client to be online, then prompts Client to get the disclaimers and ends.
If Client does not go online in few seconds, it shows the tips instead and ends.
"""
global disclaimer_counter
if disclaimer_counter == -1:
return
if disclaimer_counter > 2:
show_random_tip()
return
disclaimer_counter = disclaimer_counter + 1
return disclaimer_counter
@@ -0,0 +1,182 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
from collections import deque
from logging import INFO, WARN
from os import environ
from subprocess import Popen
from typing import Optional
from . import datas
CLIENT_VERSION = "v1.4.0"
CLIENT_ACCESSIBLE = False
"""Is Client accessible? Can add-on access it and call stuff which uses it?"""
CLIENT_RUNNING = False
"""Just for on_startup_client_online_timer()."""
CLIENT_FAILED_REPORTS = 0
"""Number of failed requests to get reports from the BlenderKit-Client. If too many, something is wrong."""
CLIENT_PORTS = ["62485", "65425", "55428", "49452", "35452", "25152", "5152", "1234"]
"""Ports are ordered during the start, and later after malfunction."""
DATA: dict = { # TODO: move these
"images available": {},
"history steps": {},
"bkit notifications": None,
"asset comments": {},
}
TABS = {
"active_tab": 0, # Index of currently active tab
"tabs": [ # List of all tabs
{
"name": "Default", # Tab name
"history": [], # List of history steps
"history_index": -1, # Current position in history
}
],
}
RATINGS: dict[str, datas.AssetRating] = {}
BKIT_PROFILE: datas.MineProfile = datas.MineProfile()
"""Profile of the current user."""
BKIT_AUTHORS: dict[int, datas.UserProfile] = {}
"""All loaded profiles of other users. Current user is also present in stripped down version. Key is the UserProfile.id."""
LOGGING_LEVEL_BLENDERKIT = INFO
LOGGING_LEVEL_IMPORTED = WARN
PREFS = {}
SERVER = environ.get("BLENDERKIT_SERVER", "https://www.blenderkit.com")
DISCORD_INVITE_URL = "https://discord.gg/tCKyjFMRar"
TIPS = [
(
"You can disable tips in the add-on preferences.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#preferences",
),
("Ratings help us distribute funds to creators.", f"{SERVER}/docs/rating/"),
(
"Creators also gain credits for free assets from subscribers.",
f"{SERVER}/docs/fair-share/",
),
(
"Click on or drag a model or material into the scene to link or append it.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#basic-usage",
),
(
"Press ESC while dragging a model or material to cancel the action and avoid any download.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#cancel-drag-and-drop",
),
(
"During drag-and-drop, rotate the dragged asset's outline box by 90 degrees using the mouse wheel.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#rotate-asset",
),
(
"Right click in the asset bar for a detailed asset card.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation",
),
(
"Use Append in import settings if you want to edit downloaded objects.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#import-settings",
),
(
"Go to import settings to set default texture resolution.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#import-settings",
),
(
"Please rate responsively and plentifully. This helps us distribute rewards to the authors.",
f"{SERVER}/docs/rating/",
),
(
"All materials are free.",
f"{SERVER}/asset-gallery?query=category_subtree:material%20order:-created",
),
("Storage for public assets is unlimited.", f"{SERVER}/become-creator/"),
(
"Locked models are available if you subscribe to Full plan.",
f"{SERVER}/plans/pricing/",
),
("Login to upload your own models, materials or brushes.", f"{SERVER}/"),
(
"Use 'A' key over the asset bar to search assets by the same author.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#basic-usage",
),
(
"Use semicolon - ; to hide or show the AssetBar.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
("Support the authors by subscribing to Full plan.", f"{SERVER}/plans/pricing/"),
(
"Use the 'P' key over the asset bar to open the Author's profile on BlenderKit.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
(
"Use the 'W' key over the asset bar to open Author's personal webpage.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
(
"Use the 'R' key over the asset bar for fast rating of assets.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
(
"Use the 'X' key over the asset bar to delete the asset from your hard drive.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
(
"Use the 'S' key over the asset bar to search similar assets.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
(
"Use the 'C' key over the asset bar to search assets in same subcategory.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
(
"Use the 'B' key over the asset bar to bookmark the asset.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
(
"Get latest experimental versions of add-on by enabling prerelases in preferences.",
"",
),
(
"On Discord? Jump into assets & add-on talks.",
DISCORD_INVITE_URL,
),
(
"Right-click on the downloaded asset to rate, bookmark and more in the 'Selected Model' submenu.",
"",
),
(
"Use Ctrl+T to open a new tab, Ctrl+W to close the current tab in the asset bar.",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
(
"Navigate between tabs with Ctrl+Tab (next) and Ctrl+Shift+Tab (previous).",
"https://github.com/BlenderKit/blenderkit/wiki/BlenderKit-add-on-documentation#assetbar",
),
(
"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",
),
]
VERSION = [0, 0, 0, 0] # filled in register()
client_process: Optional[Popen] = None
"""Holds return value of subprocess.Popen() which starts the BlenderKit-Client."""
@@ -0,0 +1,130 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import os
import bpy
# We can store multiple preview collections here,
# however in this example we only store "main"
icon_collections = {}
icons_read = {
"fp.png": "free",
"flp.png": "full",
"trophy.png": "trophy",
"dumbbell.png": "dumbbell",
"cc0.png": "cc0",
"royalty_free.png": "royalty_free",
"categories.png": "categories",
"categories_active.png": "categories_active",
"filter.png": "filter",
"filter_active.png": "filter_active",
"filter_nsfw.png": "filter_active_nsfw", # NFSW filter is on, but other filters are not
"filter_active_nsfw.png": "filter_nsfw", # NSFW filter on, classic filters on
"nsfw.png": "nsfw",
"bell.png": "bell",
"post_comment.png": "post_comment",
"blenderkit_logo.png": "logo",
"blenderkit_logo_offline.png": "logo_offline",
"bookmark_full.png": "bookmark_full",
"bookmark_empty.png": "bookmark_empty",
"bar_slider_up.png": "bar_slider_up",
"logo_artstation.png": "logo_artstation",
"logo_discord.png": "logo_discord",
"logo_facebook.png": "logo_facebook",
"logo_instagram.png": "logo_instagram",
"logo_tiktok.png": "logo_tiktok",
"logo_vimeo.png": "logo_vimeo",
"logo_x.png": "logo_twitter",
"logo_youtube.png": "logo_youtube",
"asset_type_printable.png": "asset_type_printable",
}
# fill the icon_collections["previews"] with icons of numbers for complexity rating
possible_wh_values = [
0.2,
0.5,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
15,
20,
30,
50,
100,
150,
200,
250,
]
for w in possible_wh_values:
if w < 1:
icons_read[f"{w}.png"] = f"BK{w}"
else:
icons_read[f"{w:04}.png"] = f"BK{w}"
verification_icons = {
"vs_ready.png": "ready",
"vs_deleted.png": "deleted",
"vs_uploaded.png": "uploaded",
"vs_uploading.png": "uploading",
"vs_on_hold.png": "on_hold",
"vs_validated.png": "validated",
"vs_rejected.png": "rejected",
}
icons_read.update(verification_icons)
def register_icons():
# Note that preview collections returned by bpy.utils.previews
# are regular py objects - you can use them to store custom data.
import bpy.utils.previews
pcoll = bpy.utils.previews.new()
# path to the directory where the icon is
# the path is calculated relative to this py file inside the addon directory
icons_dir = os.path.join(os.path.dirname(__file__), "thumbnails")
# load a preview thumbnail of a file and store in the previews collection
for ir in icons_read.keys():
pcoll.load(icons_read[ir], os.path.join(icons_dir, ir), "IMAGE")
# iprev = pcoll.new(icons_read[ir])
# img = bpy.data.images.load(os.path.join(icons_dir, ir))
# iprev.image_size = (img.size[0], img.size[1])
# iprev.image_pixels_float = img.pixels[:]
icon_collections["main"] = pcoll
icon_collections["previews"] = bpy.utils.previews.new()
def unregister_icons():
for pcoll in icon_collections.values():
bpy.utils.previews.remove(pcoll)
icon_collections.clear()
@@ -0,0 +1,589 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import os
import time
import bpy
def get_orig_render_settings():
rs = bpy.context.scene.render
ims = rs.image_settings
vs = bpy.context.scene.view_settings
orig_settings = {
"file_format": ims.file_format,
"quality": ims.quality,
"color_mode": ims.color_mode,
"compression": ims.compression,
"exr_codec": ims.exr_codec,
"view_transform": vs.view_transform,
}
return orig_settings
def set_orig_render_settings(orig_settings):
rs = bpy.context.scene.render
ims = rs.image_settings
vs = bpy.context.scene.view_settings
ims.file_format = orig_settings["file_format"]
ims.quality = orig_settings["quality"]
ims.color_mode = orig_settings["color_mode"]
ims.compression = orig_settings["compression"]
ims.exr_codec = orig_settings["exr_codec"]
vs.view_transform = orig_settings["view_transform"]
def img_save_as(
img,
filepath="//",
file_format="JPEG",
quality=90,
color_mode="RGB",
compression=15,
view_transform="Raw",
exr_codec="DWAA",
):
"""Uses Blender 'save render' to save images - BLender isn't really able so save images with other methods correctly."""
ors = get_orig_render_settings()
rs = bpy.context.scene.render
vs = bpy.context.scene.view_settings
ims = rs.image_settings
ims.file_format = file_format
ims.quality = quality
ims.color_mode = color_mode
ims.compression = compression
ims.exr_codec = exr_codec
vs.view_transform = view_transform
img.save_render(filepath=bpy.path.abspath(filepath), scene=bpy.context.scene)
set_orig_render_settings(ors)
def set_colorspace(img, colorspace: str = ""):
"""sets image colorspace, but does so in a try statement, because some people might actually replace the default
colorspace settings, and it literally can't be guessed what these people use, even if it will mostly be the filmic addon.
"""
try:
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}")
def guess_colorspace():
display_device = bpy.context.scene.display_settings.display_device
if display_device == "sRGB":
return "sRGB"
if display_device == "ACES":
return "aces"
def analyze_image_is_true_hdr(image):
import numpy
scene = bpy.context.scene
ui_props = bpy.context.window_manager.blenderkitUI
size = image.size
imageWidth = size[0]
imageHeight = size[1]
tempBuffer = numpy.empty(imageWidth * imageHeight * 4, dtype=numpy.float32)
image.pixels.foreach_get(tempBuffer)
image.blenderkit.true_hdr = numpy.amax(tempBuffer) > 1.05
def generate_hdr_thumbnail():
import numpy
scene = bpy.context.scene
ui_props = bpy.context.window_manager.blenderkitUI
hdr_image = (
ui_props.hdr_upload_image
) # bpy.data.images.get(ui_props.hdr_upload_image)
base, ext = os.path.splitext(hdr_image.filepath)
thumb_path = base + ".jpg"
thumb_name = os.path.basename(thumb_path)
max_thumbnail_size = 2048
size = hdr_image.size
ratio = size[0] / size[1]
imageWidth = size[0]
imageHeight = size[1]
thumbnailWidth = min(size[0], max_thumbnail_size)
thumbnailHeight = min(size[1], int(max_thumbnail_size / ratio))
tempBuffer = numpy.empty(imageWidth * imageHeight * 4, dtype=numpy.float32)
inew = bpy.data.images.new(
thumb_name, imageWidth, imageHeight, alpha=False, float_buffer=False
)
hdr_image.pixels.foreach_get(tempBuffer)
hdr_image.blenderkit.true_hdr = numpy.amax(tempBuffer) > 1.05
inew.filepath = thumb_path
set_colorspace(inew, "Linear")
inew.pixels.foreach_set(tempBuffer)
bpy.context.view_layer.update()
if thumbnailWidth < imageWidth:
inew.scale(thumbnailWidth, thumbnailHeight)
img_save_as(inew, filepath=inew.filepath)
def find_color_mode(image):
if not isinstance(image, bpy.types.Image):
raise (TypeError)
else:
depth_mapping = {
8: "BW",
24: "RGB",
32: "RGBA", # can also be bw.. but image.channels doesn't work.
96: "RGB",
128: "RGBA",
}
return depth_mapping.get(image.depth, "RGB")
def find_image_depth(image):
if not isinstance(image, bpy.types.Image):
raise (TypeError)
else:
depth_mapping = {
8: "8",
24: "8",
32: "8", # can also be bw.. but image.channels doesn't work.
96: "16",
128: "16",
}
return depth_mapping.get(image.depth, "8")
def can_erase_alpha(na):
alpha = na[3::4]
alpha_sum = alpha.sum()
if alpha_sum == alpha.size:
print("image can have alpha erased")
# print(alpha_sum, alpha.size)
return alpha_sum == alpha.size
def is_image_black(na):
r = na[::4]
g = na[1::4]
b = na[2::4]
rgbsum = r.sum() + g.sum() + b.sum()
# print('rgb sum', rgbsum, r.sum(), g.sum(), b.sum())
if rgbsum == 0:
print("image can have alpha channel dropped")
return rgbsum == 0
def is_image_bw(na):
r = na[::4]
g = na[1::4]
b = na[2::4]
rg_equal = r == g
gb_equal = g == b
rgbequal = rg_equal.all() and gb_equal.all()
if rgbequal:
print("image is black and white, can have channels reduced")
return rgbequal
def numpytoimage(a, iname, width=0, height=0, channels=3):
t = time.time()
foundimage = False
for image in bpy.data.images:
if (
image.name[: len(iname)] == iname
and image.size[0] == a.shape[0]
and image.size[1] == a.shape[1]
):
i = image
foundimage = True
if not foundimage:
if channels == 4:
bpy.ops.image.new(
name=iname,
width=width,
height=height,
color=(0, 0, 0, 1),
alpha=True,
generated_type="BLANK",
float=True,
)
if channels == 3:
bpy.ops.image.new(
name=iname,
width=width,
height=height,
color=(0, 0, 0),
alpha=False,
generated_type="BLANK",
float=True,
)
i = None
for image in bpy.data.images:
# print(image.name[:len(iname)],iname, image.size[0],a.shape[0],image.size[1],a.shape[1])
if (
image.name[: len(iname)] == iname
and image.size[0] == width
and image.size[1] == height
):
i = image
if i is None:
i = bpy.data.images.new(
iname,
width,
height,
alpha=False,
float_buffer=False,
stereo3d=False,
is_data=False,
tiled=False,
)
# dropping this re-shaping code - just doing flat array for speed and simplicity
# d = a.shape[0] * a.shape[1]
# a = a.swapaxes(0, 1)
# a = a.reshape(d)
# a = a.repeat(channels)
# a[3::4] = 1
i.pixels.foreach_set(a) # this gives big speedup!
print("\ntime " + str(time.time() - t))
return i
def imagetonumpy_flat(i):
t = time.time()
import numpy
width = i.size[0]
height = i.size[1]
# print(i.channels)
size = width * height * i.channels
na = numpy.empty(size, numpy.float32)
i.pixels.foreach_get(na)
# dropping this re-shaping code - just doing flat array for speed and simplicity
# na = na[::4]
# na = na.reshape(height, width, i.channels)
# na = na.swapaxnes(0, 1)
# print('\ntime of image to numpy ' + str(time.time() - t))
return na
def imagetonumpy(i):
t = time.time()
import numpy as np
width = i.size[0]
height = i.size[1]
# print(i.channels)
size = width * height * i.channels
na = np.empty(size, np.float32)
i.pixels.foreach_get(na)
# dropping this re-shaping code - just doing flat array for speed and simplicity
# na = na[::4]
na = na.reshape(height, width, i.channels)
na = na.swapaxes(0, 1)
# print('\ntime of image to numpy ' + str(time.time() - t))
return na
def downscale(i):
minsize = 128
sx, sy = i.size[:]
sx = round(sx / 2)
sy = round(sy / 2)
if sx > minsize and sy > minsize:
i.scale(sx, sy)
def get_rgb_mean(i):
"""checks if normal map values are ok."""
import numpy
na = imagetonumpy_flat(i)
r = na[::4]
g = na[1::4]
b = na[2::4]
rmean = r.mean()
gmean = g.mean()
bmean = b.mean()
rmedian = numpy.median(r)
gmedian = numpy.median(g)
bmedian = numpy.median(b)
# return(rmedian,gmedian, bmedian)
return (rmean, gmean, bmean)
def check_nmap_mean_ok(i):
"""checks if normal map values are in standard range."""
rmean, gmean, bmean = get_rgb_mean(i)
# we could/should also check blue, but some ogl substance exports have 0-1, while 90% nmaps have 0.5 - 1.
nmap_ok = 0.45 < rmean < 0.55 and 0.45 < gmean < 0.55
return nmap_ok
def check_nmap_ogl_vs_dx(i, mask=None, generated_test_images=False):
"""
checks if normal map is directX or OpenGL.
Returns - String value - DirectX and OpenGL
"""
import numpy
width = i.size[0]
height = i.size[1]
rmean, gmean, bmean = get_rgb_mean(i)
na = imagetonumpy(i)
if mask:
mask = imagetonumpy(mask)
red_x_comparison = numpy.zeros((width, height), numpy.float32)
green_y_comparison = numpy.zeros((width, height), numpy.float32)
if generated_test_images:
red_x_comparison_img = numpy.empty(
(width, height, 4), numpy.float32
) # images for debugging purposes
green_y_comparison_img = numpy.empty(
(width, height, 4), numpy.float32
) # images for debugging purposes
ogl = numpy.zeros((width, height), numpy.float32)
dx = numpy.zeros((width, height), numpy.float32)
if generated_test_images:
ogl_img = numpy.empty(
(width, height, 4), numpy.float32
) # images for debugging purposes
dx_img = numpy.empty(
(width, height, 4), numpy.float32
) # images for debugging purposes
for y in range(0, height):
for x in range(0, width):
# try to mask with UV mask image
if mask is None or mask[x, y, 3] > 0:
last_height_x = ogl[max(x - 1, 0), min(y, height - 1)]
last_height_y = ogl[max(x, 0), min(y - 1, height - 1)]
diff_x = (na[x, y, 0] - rmean) / ((na[x, y, 2] - 0.5))
diff_y = (na[x, y, 1] - gmean) / ((na[x, y, 2] - 0.5))
calc_height = (last_height_x + last_height_y) - diff_x - diff_y
calc_height = calc_height / 2
ogl[x, y] = calc_height
if generated_test_images:
rgb = calc_height * 0.1 + 0.5
ogl_img[x, y] = [rgb, rgb, rgb, 1]
# green channel
last_height_x = dx[max(x - 1, 0), min(y, height - 1)]
last_height_y = dx[max(x, 0), min(y - 1, height - 1)]
diff_x = (na[x, y, 0] - rmean) / ((na[x, y, 2] - 0.5))
diff_y = (na[x, y, 1] - gmean) / ((na[x, y, 2] - 0.5))
calc_height = (last_height_x + last_height_y) - diff_x + diff_y
calc_height = calc_height / 2
dx[x, y] = calc_height
if generated_test_images:
rgb = calc_height * 0.1 + 0.5
dx_img[x, y] = [rgb, rgb, rgb, 1]
ogl_std = ogl.std()
dx_std = dx.std()
# print(mean_ogl, mean_dx)
# print(max_ogl, max_dx)
print(ogl_std, dx_std)
print(i.name)
# if abs(mean_ogl) > abs(mean_dx):
if abs(ogl_std) > abs(dx_std):
print("this is probably a DirectX texture")
else:
print("this is probably an OpenGL texture")
if generated_test_images:
# red_x_comparison_img = red_x_comparison_img.swapaxes(0,1)
# red_x_comparison_img = red_x_comparison_img.flatten()
#
# green_y_comparison_img = green_y_comparison_img.swapaxes(0,1)
# green_y_comparison_img = green_y_comparison_img.flatten()
#
# numpytoimage(red_x_comparison_img, 'red_' + i.name, width=width, height=height, channels=1)
# numpytoimage(green_y_comparison_img, 'green_' + i.name, width=width, height=height, channels=1)
ogl_img = ogl_img.swapaxes(0, 1)
ogl_img = ogl_img.flatten()
dx_img = dx_img.swapaxes(0, 1)
dx_img = dx_img.flatten()
numpytoimage(ogl_img, "OpenGL", width=width, height=height, channels=1)
numpytoimage(dx_img, "DirectX", width=width, height=height, channels=1)
if abs(ogl_std) > abs(dx_std):
return "DirectX"
return "OpenGL"
def make_possible_reductions_on_image(
teximage, input_filepath, do_reductions=False, do_downscale=False
):
"""checks the image and saves it to drive with possibly reduced channels.
Also can remove the image from the asset if the image is pure black
- it finds it's usages and replaces the inputs where the image is used
with zero/black color.
currently implemented file type conversions:
PNG->JPG
"""
colorspace = teximage.colorspace_settings.name
teximage.colorspace_settings.name = "Non-Color"
# teximage.colorspace_settings.name = 'sRGB' color correction mambo jambo.
JPEG_QUALITY = 90
# is_image_black(na)
# is_image_bw(na)
rs = bpy.context.scene.render
ims = rs.image_settings
orig_file_format = ims.file_format
orig_quality = ims.quality
orig_color_mode = ims.color_mode
orig_compression = ims.compression
orig_depth = ims.color_depth
# if is_image_black(na):
# # just erase the image from the asset here, no need to store black images.
# pass;
# fp = teximage.filepath
# setup image depth, 8 or 16 bit.
# this should normally divide depth with number of channels, but blender always states that number of channels is 4, even if there are only 3
print(teximage.name)
print(teximage.depth)
print(teximage.channels)
bpy.context.scene.display_settings.display_device = "None"
image_depth = find_image_depth(teximage)
ims.color_mode = find_color_mode(teximage)
# image_depth = str(max(min(int(teximage.depth / 3), 16), 8))
print("resulting depth set to:", image_depth)
fp = input_filepath
if do_reductions:
na = imagetonumpy_flat(teximage)
if can_erase_alpha(na):
print(teximage.file_format)
if teximage.file_format == "PNG":
print("changing type of image to JPG")
base, ext = os.path.splitext(fp)
teximage["original_extension"] = ext
fp = fp.replace(".png", ".jpg")
fp = fp.replace(".PNG", ".jpg")
teximage.name = teximage.name.replace(".png", ".jpg")
teximage.name = teximage.name.replace(".PNG", ".jpg")
teximage.file_format = "JPEG"
ims.quality = JPEG_QUALITY
ims.color_mode = "RGB"
image_depth = "8"
if is_image_bw(na):
ims.color_mode = "BW"
ims.file_format = teximage.file_format
ims.color_depth = image_depth
# all pngs with max compression
if ims.file_format == "PNG":
ims.compression = 100
# all jpgs brought to reasonable quality
if ims.file_format == "JPG":
ims.quality = JPEG_QUALITY
if do_downscale:
downscale(teximage)
# it's actually very important not to try to change the image filepath and packed file filepath before saving,
# blender tries to re-pack the image after writing to image.packed_image.filepath and reverts any changes.
teximage.save_render(filepath=bpy.path.abspath(fp), scene=bpy.context.scene)
if len(teximage.packed_files) > 0:
teximage.unpack(method="REMOVE")
teximage.filepath = fp
teximage.filepath_raw = fp
teximage.reload()
teximage.colorspace_settings.name = colorspace
ims.file_format = orig_file_format
ims.quality = orig_quality
ims.color_mode = orig_color_mode
ims.compression = orig_compression
ims.color_depth = orig_depth
@@ -0,0 +1,107 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import re
import sys
from . import global_vars
bk_logger = logging.getLogger(__name__)
class BlenderKitFormatter(logging.Formatter):
"""Add emojis for logging level and mask API key tokens.
Replace temporary tokens with *** and permanent tokens with *****.
"""
EMOJIS = {
logging.DEBUG: "🐞",
logging.INFO: "",
logging.WARNING: "⚠️ ",
logging.ERROR: "",
logging.CRITICAL: "🔥",
}
def format(self, record):
record.levelname = self.EMOJIS.get(record.levelno, "")
msg = super().format(record)
msg = re.sub(r'(?<=["\'\s])\b[A-Za-z0-9]{30}\b(?=["\'\s])', r"***", msg)
msg = re.sub(r'(?<=["\'\s])\b[A-Za-z0-9]{40}\b(?=["\'\s])', r"*****", msg)
return msg
def get_blenderkit_formatter():
"""Get default sensitive formatter for BlenderKit loggers."""
return BlenderKitFormatter(
fmt="%(levelname)s blenderkit: %(message)s [%(asctime)s.%(msecs)03d, %(filename)s:%(lineno)d]",
datefmt="%H:%M:%S",
)
class SensitiveFormatter(logging.Formatter):
"""Mask API key tokens. Replace temporary tokens with *** and permanent tokens with *****."""
def format(self, record):
msg = super().format(record)
msg = re.sub(r'(?<=["\'\s])\b[A-Za-z0-9]{30}\b(?=["\'\s])', r"***", msg)
msg = re.sub(r'(?<=["\'\s])\b[A-Za-z0-9]{40}\b(?=["\'\s])', r"*****", msg)
return msg
def get_sensitive_formatter():
"""Get default sensitive formatter for BlenderKit loggers."""
return SensitiveFormatter(
fmt="blenderkit %(levelname)s: %(message)s [%(asctime)s.%(msecs)03d, %(filename)s:%(lineno)d]",
datefmt="%H:%M:%S",
)
def configure_bk_logger():
"""Configure 'blenderkit' logger to which all other logs defined as `bk_logger = logging.getLogger(__name__)` writes.
Sets it logging level to `global_vars.LOGGING_LEVEL_BLENDERKIT`.
"""
bk_logger = logging.getLogger(__name__.removesuffix(".log"))
bk_logger.setLevel(global_vars.LOGGING_LEVEL_BLENDERKIT)
bk_logger.propagate = False
bk_logger.handlers = []
stream_handler = logging.StreamHandler()
stream_handler.stream = sys.stdout # 517
stream_handler.setFormatter(get_blenderkit_formatter())
bk_logger.addHandler(stream_handler)
def configure_imported_loggers():
"""Configure loggers for imported modules so they can have different logging level `global_vars.LOGGING_LEVEL_IMPORTED` than main blenderkit logger."""
urllib3_logger = logging.getLogger("urllib3")
urllib3_logger.propagate = False
urllib3_logger.handlers = []
urllib3_handler = logging.StreamHandler()
urllib3_handler.stream = sys.stdout # 517
urllib3_handler.setLevel(global_vars.LOGGING_LEVEL_IMPORTED)
urllib3_handler.setFormatter(get_sensitive_formatter())
urllib3_logger.addHandler(urllib3_handler)
def configure_loggers():
"""Configure all loggers for BlenderKit addon. See called functions for details."""
configure_bk_logger()
configure_imported_loggers()
@@ -0,0 +1,487 @@
"""
This is a separate library that overrides the extension_draw_item method from Blender extensions list display.
The original code is in the bl_extension_ui.py file in the Blender source code.
The override library can be placed in multiple addons, and the override should happen only once.
The override is done by replacing the original method with the new one, and backing up the original method.
The original method is then called from the new method, with the same arguments, but with the new code added.
"""
import json
import os
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
EXTENSIONS_API_URL = "https://www.blenderkit.com/api/v1/extensions/"
def extension_draw_item_blenderkit(
layout,
*,
pkg_id, # `str`
item_local, # `PkgManifest_Normalized | None`
item_remote, # `PkgManifest_Normalized | None`
is_enabled, # `bool`
is_outdated, # `bool`
show, # `bool`.
mark, # `bool | None`.
# General vars.
repo_index, # `int`
repo_item, # `RepoItem`
operation_in_progress, # `bool`
extensions_warnings, # `dict[str, list[str]]`
show_developer_ui, # `bool`
):
### BlenderKit cache code
# 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
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
item = item_local or item_remote
is_installed = item_local is not None
has_remote = repo_item.remote_url != ""
if item_remote is not None:
pkg_block = item_remote.block
else:
pkg_block = None
if is_enabled:
item_warnings = extensions_warnings.get(
exui.pkg_repo_module_prefix(repo_item) + pkg_id, []
)
else:
item_warnings = []
# Left align so the operator text isn't centered.
colsub = layout.column()
row = colsub.row(align=True)
if show:
props = row.operator(
"extensions.package_show_clear", text="", icon="DOWNARROW_HLT", emboss=False
)
else:
props = row.operator(
"extensions.package_show_set", text="", icon="RIGHTARROW", emboss=False
)
props.pkg_id = pkg_id
props.repo_index = repo_index
if mark is not None:
if mark:
props = row.operator(
"extensions.package_mark_clear",
text="",
icon="RADIOBUT_ON",
emboss=False,
)
else:
props = row.operator(
"extensions.package_mark_set",
text="",
icon="RADIOBUT_OFF",
emboss=False,
)
props.pkg_id = pkg_id
props.repo_index = repo_index
sub = row.row()
sub.active = is_enabled
# Without checking `is_enabled` here, there is no way for the user to know if an extension
# is enabled or not, which is useful to show - when they may be considering removing/updating
# extensions based on them being used or not.
if pkg_block or item_warnings:
sub.label(text=item.name, icon="ERROR", translate=False)
else:
sub.label(text=item.name, translate=False)
# Add a top-level row so `row_right` can have a grayed out button/label
# without graying out the menu item since# that is functional.
row_right_toplevel = row.row(align=True)
if operation_in_progress:
row_right_toplevel.enabled = False
row_right_toplevel.alignment = "RIGHT"
row_right = row_right_toplevel.row()
row_right.alignment = "RIGHT"
if has_remote and (item_remote is not None):
if pkg_block is not None:
row_right.label(text="Blocked ")
elif is_installed:
if is_outdated:
props = row_right.operator("extensions.package_install", text="Update")
props.repo_index = repo_index
props.pkg_id = pkg_id
props.enable_on_install = is_enabled
else:
### BlenderKit specific code
# blenderkit logo icon
pcoll = icons.icon_collections["main"]
icon_value = pcoll["logo"].icon_id
# row.label(text="", icon_value=icon_value)
# only enable install for those for whom it's available
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,
)
props.repo_index = repo_index
props.pkg_id = pkg_id
# Full plan addons
elif not bk_cache_pkg.get("is_free") and not bk_cache_pkg.get(
"is_for_sale"
):
# open website to subscribe
props = row_right.operator(
"wm.url_open",
text="Subscribe to Full Plan",
icon_value=icon_value,
)
props.url = "https://www.blenderkit.com/plans/pricing/"
# Paid addons get a buy button and lead to their website link
else:
props = row_right.operator(
"wm.url_open",
text=f"Buy online ${bk_cache_pkg.get('base_price')}",
icon_value=icon_value,
)
props.url = bk_cache_pkg.get("website")
### end of BlenderKit specific code
else:
# Right space for alignment with the button.
if has_remote and (item_remote is None):
# There is a local item with no remote
row_right.label(text="Orphan ")
row_right.active = False
row_right = row_right_toplevel.row(align=True)
row_right.alignment = "RIGHT"
row_right.separator()
# NOTE: Keep space between any buttons and this menu to prevent stray clicks accidentally running install.
# The separator is around together with the align to give some space while keeping the button and the menu
# still close-by. Used `extension_path` so the menu can access "this" extension.
row_right.context_string_set(
"extension_path", "{:s}.{:s}".format(repo_item.module, pkg_id)
)
row_right.menu("USERPREF_MT_extensions_item", text="", icon="DOWNARROW_HLT")
if show:
import os
from bpy.app.translations import pgettext_iface as iface_
col = layout.column()
row = col.row()
row.active = is_enabled
# The full tagline may be multiple lines (not yet supported by Blender's UI).
row.label(text=" {:s}.".format(item.tagline), translate=False)
col.separator(type="LINE")
col_info = layout.column()
col_info.active = is_enabled
split = col_info.split(factor=0.15)
col_a = split.column()
col_b = split.column()
col_a.alignment = "RIGHT"
if pkg_block is not None:
col_a.label(text="Blocked")
col_b.label(text=pkg_block.reason, translate=False)
if item_warnings:
col_a.label(text="Warning")
col_b.label(text=item_warnings[0])
if len(item_warnings) > 1:
for value in item_warnings[1:]:
col_a.label(text="")
col_b.label(text=value)
# pylint: disable-next=undefined-loop-variable
if value := (item_remote or item_local).website:
col_a.label(text="Website")
col_b.split(factor=0.5).operator(
"wm.url_open",
text=exui.domain_extract_from_url(value),
icon="URL",
).url = value
del value
if item.type == "add-on":
col_a.label(text="Permissions")
# WARNING: while this is documented to be a dict, old packages may contain a list of strings.
# As it happens dictionary keys & list values both iterate over string,
# however we will want to show the dictionary values eventually.
if value := item.permissions:
col_b.label(
text=", ".join([iface_(x).title() for x in value]), translate=False
)
else:
col_b.label(text="No permissions specified")
del value
col_a.label(text="Maintainer")
col_b.label(text=item.maintainer, translate=False)
col_a.label(text="Version")
if is_outdated:
col_b.label(
text=iface_("{:s} ({:s} available)").format(
item.version, item_remote.version
),
translate=False,
)
else:
col_b.label(text=item.version, translate=False)
if has_remote and (item_remote is not None):
col_a.label(text="Size")
col_b.label(
text=exui.size_as_fmt_string(item_remote.archive_size), translate=False
)
col_a.label(text="License")
col_b.label(text=item.license, translate=False)
col_a.label(text="Repository")
col_b.label(text=repo_item.name, translate=False)
if is_installed:
col_a.label(text="Path")
col_b.label(text=os.path.join(repo_item.directory, pkg_id), translate=False)
def extension_draw_item_override(
layout,
*,
pkg_id, # `str`
item_local, # `PkgManifest_Normalized | None`
item_remote, # `PkgManifest_Normalized | None`
is_enabled, # `bool`
is_outdated, # `bool`
show, # `bool`.
mark, # `bool | None`.
# General vars.
repo_index, # `int`
repo_item, # `RepoItem`
operation_in_progress, # `bool`
extensions_warnings, # `dict[str, list[str]]`
show_developer_ui=False, # `bool`
):
# filter by verification state, only for blenderkit repository
if repo_item.remote_url == EXTENSIONS_API_URL:
extension_draw_item_blenderkit(
layout,
pkg_id=pkg_id,
item_local=item_local,
item_remote=item_remote,
is_enabled=is_enabled,
is_outdated=is_outdated,
show=show,
mark=mark,
repo_index=repo_index,
repo_item=repo_item,
operation_in_progress=operation_in_progress,
extensions_warnings=extensions_warnings,
show_developer_ui=show_developer_ui,
)
return True
# show developer ui only needs to be passed since blender 4.4
if bpy.app.version >= (4, 4):
exui.extension_draw_item_original(
layout,
pkg_id=pkg_id,
item_local=item_local,
item_remote=item_remote,
is_enabled=is_enabled,
is_outdated=is_outdated,
show=show,
mark=mark,
repo_index=repo_index,
repo_item=repo_item,
operation_in_progress=operation_in_progress,
extensions_warnings=extensions_warnings,
show_developer_ui=show_developer_ui,
)
else:
exui.extension_draw_item_original(
layout,
pkg_id=pkg_id,
item_local=item_local,
item_remote=item_remote,
is_enabled=is_enabled,
is_outdated=is_outdated,
show=show,
mark=mark,
repo_index=repo_index,
repo_item=repo_item,
operation_in_progress=operation_in_progress,
extensions_warnings=extensions_warnings,
)
return True
def override_draw_function():
if hasattr(exui, "extension_draw_item_original"):
return False
exui.extension_draw_item_original = exui.extension_draw_item
exui.extension_draw_item = extension_draw_item_override
return True
def get_repository_by_url(url: str):
"""Get the repository by its remote URL, from registered blenderkit Extension repositories."""
for r in bpy.context.preferences.extensions.repos:
if r.remote_url == url:
return r
return None
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
"""
# return if cache already exists
if "blenderkit_extensions_repo_cache" in bpy.context.window_manager:
return
blenderkit_repository = get_repository_by_url(EXTENSIONS_API_URL)
if blenderkit_repository is None:
return
# 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
def ensure_repo_order():
"""Ensure order of repositories in Blender's preferences."""
# get the blenderkit repository
blenderkit_repository = get_repository_by_url(EXTENSIONS_API_URL)
if blenderkit_repository is None:
return
# get all repositories
all_repos = bpy.context.preferences.extensions.repos
# get all online repositories except blenderkit
online_repos = [] # need to convert repos to dicts
remove_online_repos = []
for r in all_repos:
if r.remote_url != EXTENSIONS_API_URL and r.remote_url != "":
repo_dict = {
"name": r.name,
"module": r.module,
"use_remote_url": r.use_remote_url,
"remote_url": r.remote_url,
"use_sync_on_startup": r.use_sync_on_startup,
"use_cache": r.use_cache,
"use_access_token": r.use_access_token,
"access_token": r.access_token,
"use_custom_directory": r.use_custom_directory,
"custom_directory": r.custom_directory,
"enabled": r.enabled,
}
online_repos.append(repo_dict)
remove_online_repos.append(r)
# remove all online repositories except blenderkit
for r in remove_online_repos:
all_repos.remove(r)
# add all other repositories back
for r in online_repos:
# complete list of properties of a repository:
#'access_token', 'custom_directory', 'directory', 'enabled', 'module', 'name', 'remote_url', 'rna_type', 'source', 'use_access_token', 'use_cache', 'use_custom_directory', 'use_remote_url', 'use_sync_on_startup'
new_repo = all_repos.new()
new_repo.name = r["name"]
new_repo.module = r["module"]
new_repo.use_remote_url = r["use_remote_url"]
new_repo.remote_url = r["remote_url"]
new_repo.use_sync_on_startup = r["use_sync_on_startup"]
new_repo.use_cache = r["use_cache"]
new_repo.use_access_token = r["use_access_token"]
new_repo.access_token = r["access_token"]
new_repo.use_custom_directory = r["use_custom_directory"]
new_repo.custom_directory = r["custom_directory"]
new_repo.enabled = r["enabled"]
def ensure_repository(api_key: str = ""):
"""Ensure that the blenderkit extensions repository is correctly added in Blender's preferences.
If the repository is not present, it is added. If the repository is present, but the API key is not set, it is set.
"""
blenderkit_repository = get_repository_by_url(EXTENSIONS_API_URL)
if blenderkit_repository is None:
blenderkit_repository = bpy.context.preferences.extensions.repos.new()
blenderkit_repository.name = "www.blenderkit.com"
blenderkit_repository.module = "www_blenderkit_com"
blenderkit_repository.use_remote_url = True
blenderkit_repository.remote_url = EXTENSIONS_API_URL
blenderkit_repository.use_sync_on_startup = True
if api_key != "":
blenderkit_repository.use_access_token = True
blenderkit_repository.access_token = api_key
else:
# let's try to import blenderkit preferences and get the api key
# try:
user_preferences = bpy.context.preferences.addons[__package__].preferences
api_key = user_preferences.api_key
if api_key != "":
blenderkit_repository.use_access_token = True
blenderkit_repository.access_token = api_key
# except:
# pass
# ensure_repo_order()
ensure_repo_cache()
def register():
ensure_repository()
override_draw_function()
def unregister():
exui.extension_draw_item = exui.extension_draw_item_original
del exui.extension_draw_item_original
@@ -0,0 +1,316 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import bpy
import mathutils
from bpy.types import Operator
def getNodes(nt, node_type="OUTPUT_MATERIAL"):
chnodes = nt.nodes[:]
nodes = []
while len(chnodes) > 0:
n = chnodes.pop()
if n.type == node_type:
nodes.append(n)
if n.type == "GROUP":
chnodes.extend(n.node_tree.nodes)
return nodes
def getShadersCrawl(nt, chnodes):
shaders = []
done_nodes = chnodes[:]
while len(chnodes) > 0:
check_node = chnodes.pop()
is_shader = False
for o in check_node.outputs:
if o.type == "SHADER":
is_shader = True
for i in check_node.inputs:
if i.type == "SHADER":
is_shader = False # this is for mix nodes and group inputs..
if len(i.links) > 0:
for l in i.links:
fn = l.from_node
if fn not in done_nodes:
done_nodes.append(fn)
chnodes.append(fn)
if fn.type == "GROUP":
group_outputs = getNodes(
fn.node_tree, node_type="GROUP_OUTPUT"
)
shaders.extend(
getShadersCrawl(fn.node_tree, group_outputs)
)
if check_node.type == "GROUP":
is_shader = False
if is_shader:
shaders.append((check_node, nt))
return shaders
def addColorCorrectors(material):
nt = material.node_tree
output = getNodes(nt, "OUTPUT_MATERIAL")[0]
shaders = getShadersCrawl(nt, [output])
correctors = []
for shader, nt in shaders:
if shader.type != "BSDF_TRANSPARENT": # exclude transparent for color tweaks
for i in shader.inputs:
if i.type == "RGBA":
if len(i.links) > 0:
l = i.links[0]
if not (
l.from_node.type == "GROUP"
and l.from_node.node_tree.name == "bkit_asset_tweaker"
):
from_socket = l.from_socket
to_socket = l.to_socket
g = nt.nodes.new(type="ShaderNodeGroup")
g.node_tree = bpy.data.node_groups["bkit_asset_tweaker"]
g.location = shader.location
g.location.x -= 100
nt.links.new(from_socket, g.inputs[0])
nt.links.new(g.outputs[0], to_socket)
else:
g = l.from_node
tweakers.append(g)
else:
g = nt.nodes.new(type="ShaderNodeGroup")
g.node_tree = bpy.data.node_groups["bkit_asset_tweaker"]
g.location = shader.location
g.location.x -= 100
nt.links.new(g.outputs[0], i)
correctors.append(g)
# def modelProxy():
# utils.p('No proxies in Blender anymore')
# return False
#
# s = bpy.context.scene
# ao = bpy.context.active_object
# if utils.is_linked_asset(ao):
# utils.activate(ao)
#
# g = ao.instance_collection
#
# rigs = []
#
# for ob in g.objects:
# if ob.type == 'ARMATURE':
# rigs.append(ob)
#
# if len(rigs) == 1:
#
# ao.instance_collection = None
# bpy.ops.object.duplicate()
# new_ao = bpy.context.view_layer.objects.active
# new_ao.instance_collection = g
# new_ao.empty_display_type = 'SPHERE'
# new_ao.empty_display_size *= 0.1
#
# # bpy.ops.object.proxy_make(object=rigs[0].name)
# proxy = bpy.context.active_object
# bpy.context.view_layer.objects.active = ao
# ao.select_set(True)
# new_ao.select_set(True)
# new_ao.use_extra_recalc_object = True
# new_ao.use_extra_recalc_data = True
# bpy.ops.object.parent_set(type='OBJECT', keep_transform=True)
# return True
# else: # TODO report this to ui
# utils.p('not sure what to proxify')
# return False
eevee_transp_nodes = [
"BSDF_GLASS",
"BSDF_REFRACTION",
"BSDF_TRANSPARENT",
"PRINCIPLED_VOLUME",
"VOLUME_ABSORPTION",
"VOLUME_SCATTER",
]
def ensure_eevee_transparency(m):
"""ensures alpha for transparent materials when the user didn't set it up correctly"""
# if the blend mode is opaque, it means user probably ddidn't know or forgot to
# set up material properly
if m.blend_method != "OPAQUE":
return
alpha = False
for n in m.node_tree.nodes:
if n.type in eevee_transp_nodes:
alpha = True
elif n.type == "BSDF_PRINCIPLED":
if bpy.app.version < (4, 0, 0):
i = n.inputs["Transmission"]
else:
i = n.inputs["Transmission Weight"]
if i.default_value > 0 or len(i.links) > 0:
alpha = True
if alpha:
m.blend_method = "HASHED"
m.shadow_method = "HASHED"
class BringToScene(Operator):
"""Bring linked object hierarchy to scene and make it editable"""
bl_idname = "object.blenderkit_bring_to_scene"
bl_label = "BlenderKit bring objects to scene"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return bpy.context.view_layer.objects.active is not None
def execute(self, context):
s = bpy.context.scene
sobs = s.collection.all_objects
aob = bpy.context.active_object
dg = aob.instance_collection
vlayer = bpy.context.view_layer
instances_emptys = []
# first, find instances of this collection in the scene
for ob in sobs:
if ob.instance_collection == dg and ob not in instances_emptys:
instances_emptys.append(ob)
ob.instance_collection = None
ob.instance_type = "NONE"
# dg.make_local
parent = None
obs = []
for ob in dg.objects:
dg.objects.unlink(ob)
try:
s.collection.objects.link(ob)
ob.select_set(True)
obs.append(ob)
if ob.parent == None:
parent = ob
bpy.context.view_layer.objects.active = parent
except Exception as e:
print(e)
bpy.ops.object.make_local(type="ALL")
for i, ob in enumerate(obs):
if ob.name in vlayer.objects:
obs[i] = vlayer.objects[ob.name]
try:
ob.select_set(True)
except Exception as e:
print(
"failed to select an object from the collection, getting a replacement."
)
print(e)
related = []
for i, ob in enumerate(instances_emptys):
if i > 0:
bpy.ops.object.duplicate(linked=True)
related.append(
[
ob,
bpy.context.active_object,
mathutils.Vector(bpy.context.active_object.scale),
]
)
for relation in related:
bpy.ops.object.select_all(action="DESELECT")
bpy.context.view_layer.objects.active = relation[0]
relation[0].select_set(True)
relation[1].select_set(True)
relation[1].matrix_world = relation[0].matrix_world
relation[1].scale.x = relation[2].x * relation[0].scale.x
relation[1].scale.y = relation[2].y * relation[0].scale.y
relation[1].scale.z = relation[2].z * relation[0].scale.z
bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
return {"FINISHED"}
# class ModelProxy(Operator):
# """Attempt to create proxy armature from the asset"""
# bl_idname = "object.blenderkit_make_proxy"
# bl_label = "BlenderKit Make Proxy"
#
# @classmethod
# def poll(cls, context):
# return bpy.context.view_layer.objects.active is not None
#
# def execute(self, context):
# result = modelProxy()
# if not result:
# self.report({'INFO'}, 'No proxy made.There is no armature or more than one in the model.')
# return {'FINISHED'}
class ColorCorrector(Operator):
"""Add color corector to the asset."""
bl_idname = "object.blenderkit_color_corrector"
bl_label = "Add color corrector"
@classmethod
def poll(cls, context):
return bpy.context.view_layer.objects.active is not None
def execute(self, context):
ao = bpy.context.active_object
g = ao.instance_collection
ao["color correctors"] = []
mats = []
for o in g.objects:
for ms in o.material_slots:
if ms.material not in mats:
mats.append(ms.material)
for mat in mats:
correctors = addColorCorrectors(mat)
return "FINISHED"
def register_overrides():
bpy.utils.register_class(BringToScene)
# bpy.utils.register_class(ModelProxy)
bpy.utils.register_class(ColorCorrector)
def unregister_overrides():
bpy.utils.unregister_class(BringToScene)
# bpy.utils.unregister_class(ModelProxy)
bpy.utils.unregister_class(ColorCorrector)
@@ -0,0 +1,491 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import getpass
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import bpy
from . import client_lib, global_vars, reports, utils
bk_logger = logging.getLogger(__name__)
BLENDERKIT_API = f"{global_vars.SERVER}/api/v1"
BLENDERKIT_OAUTH_LANDING_URL = f"{global_vars.SERVER}/oauth-landing"
BLENDERKIT_PLANS_URL = f"{global_vars.SERVER}/plans/pricing"
BLENDERKIT_REPORT_URL = f"{global_vars.SERVER}/usage_report"
BLENDERKIT_USER_ASSETS_URL = f"{global_vars.SERVER}/my-assets"
BLENDERKIT_MANUAL_URL = "https://youtu.be/0P8ZjfbUjeA"
BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/upload/"
BLENDERKIT_MATERIAL_UPLOAD_INSTRUCTIONS_URL = (
f"{global_vars.SERVER}/docs/uploading-material/"
)
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_LOGIN_URL = f"{global_vars.SERVER}/accounts/login"
BLENDERKIT_SIGNUP_URL = f"{global_vars.SERVER}/accounts/register"
WINDOWS_PATH_LIMIT = 250
def cleanup_old_directories():
"""function to clean up any historical directories for BlenderKit. By now removes the temp directory."""
orig_temp = os.path.join(os.path.expanduser("~"), "blenderkit_data", "temp")
if os.path.isdir(orig_temp):
try:
shutil.rmtree(orig_temp)
except Exception as e:
bk_logger.error(f"could not delete old temp directory: {e}")
def find_in_local(text=""):
fs = []
for p, d, f in os.walk("."):
for file in f:
if text in file:
fs.append(file)
return fs
def get_author_gallery_url(author_id: int):
return f"{global_vars.SERVER}/asset-gallery?query=author_id:{author_id}"
def get_asset_gallery_url(asset_id):
return f"{global_vars.SERVER}/asset-gallery-detail/{asset_id}/"
def default_global_dict():
home = os.path.expanduser("~")
data_home = os.environ.get("XDG_DATA_HOME")
if data_home != None:
home = data_home
return home + os.sep + "blenderkit_data"
def get_categories_filepath():
tempdir = get_temp_dir()
return os.path.join(tempdir, "categories.json")
dirs_exist_dict = {} # cache these results since this is used very often
# this causes the function to fail if user deletes the directory while blender is running,
# but comes back when blender is restarted.
def get_temp_dir(subdir=None):
user_preferences = bpy.context.preferences.addons[__package__].preferences
# first try cached results
if subdir is not None:
d = dirs_exist_dict.get(subdir)
if d is not None:
return d
else:
d = dirs_exist_dict.get("top")
if d is not None:
return d
try: # if USERNAME envvar is unset on Win, getuser() fallbacks to pwd module which is not available on Windows
username = getpass.getuser()
except ModuleNotFoundError as e:
username = "bkuser"
safe_username = "".join(c for c in username if c.isalnum())
tempdir = os.path.join(tempfile.gettempdir(), f"bktemp_{safe_username}")
if tempdir.startswith("//"):
tempdir = bpy.path.abspath(tempdir)
try:
if not os.path.exists(tempdir):
os.makedirs(tempdir)
dirs_exist_dict["top"] = tempdir
if subdir is not None:
tempdir = os.path.join(tempdir, subdir)
if not os.path.exists(tempdir):
os.makedirs(tempdir)
dirs_exist_dict[subdir] = tempdir
cleanup_old_directories()
except Exception as e:
reports.add_report("Cache directory not found. Resetting Cache directory path.")
bk_logger.warning(f"due to exception: {e}")
p = default_global_dict()
if p == user_preferences.global_dir:
message = "Global dir was already default, plese set a global directory in addon preferences to a dir where you have write permissions."
reports.add_report(message)
return None
user_preferences.global_dir = p
tempdir = get_temp_dir(subdir=subdir)
return tempdir
def get_download_dirs(asset_type):
"""get directories where assets will be downloaded"""
plurals_mapping = {
"brush": "brushes",
"texture": "textures",
"model": "models",
"scene": "scenes",
"material": "materials",
"hdr": "hdrs",
"nodegroup": "nodegroups",
"printable": "printables",
}
dirs = []
if global_vars.PREFS.get("directory_behaviour") is None:
global_vars.PREFS = utils.get_preferences_as_dict()
if global_vars.PREFS["directory_behaviour"] in ("BOTH", "GLOBAL"):
ddir = global_vars.PREFS["global_dir"]
if ddir.startswith("//"):
ddir = bpy.path.abspath(ddir)
subd = plurals_mapping[asset_type]
subdir = os.path.join(ddir, subd)
if not os.path.exists(subdir):
os.makedirs(subdir)
dirs.append(subdir)
if (
global_vars.PREFS["directory_behaviour"] in ("BOTH", "LOCAL")
and bpy.data.is_saved
): # it's important local get's solved as second, since for the linking process only last filename will be taken. For download process first name will be taken and if 2 filenames were returned, file will be copied to the 2nd path.
ddir = global_vars.PREFS["project_subdir"]
ddir = bpy.path.abspath(ddir)
subdir = os.path.join(ddir, plurals_mapping[asset_type])
if sys.platform == "win32" and len(subdir) > WINDOWS_PATH_LIMIT:
bk_logger.warning(
f"Skipping LOCAL download directory. Over 250 characters: {ddir}"
)
return (
dirs # project subdir is over 250, no space for adding filenames later
)
if not os.path.exists(subdir):
os.makedirs(subdir) # this would fail if path was over 260
dirs.append(subdir)
return dirs
def slugify(input: str) -> str:
"""
Slugify converts a string to a URL-friendly slug.
Converts to lowercase, replaces non-alphanumeric characters with hyphens.
Ensures only one hyphen between words and that string starts and ends with a letter or number.
It also ensures that the slug does not exceed 50 characters.
Same as: utils.go/Slugify()
"""
# Normalize string: convert to lowercase
slug = input.lower()
# Remove non-alpha characters, and convert spaces to hyphens.
slug = re.sub(r"[^a-z0-9]+", "-", slug)
# Replace multiple hyphens with a single one
slug = re.sub(r"[-]+", "-", slug)
# Ensure the slug does not exceed 50 characters
if len(slug) > 50:
slug = slug[:50]
# Ensure slug starts and ends with alphanum character
slug = slug.strip("-")
return slug
def extract_filename_from_url(url):
"""Mirrors utils.go/ExtractFilenameFromURL()"""
if url is None:
return ""
filename = url.split("/")[-1]
filename = filename.split("?")[0]
return filename
resolution_suffix = {
"blend": "",
"resolution_0_5K": "_05k",
"resolution_1K": "_1k",
"resolution_2K": "_2k",
"resolution_4K": "_4k",
"resolution_8K": "_8k",
}
resolutions = {
"resolution_0_5K": 512,
"resolution_1K": 1024,
"resolution_2K": 2048,
"resolution_4K": 4096,
"resolution_8K": 8192,
}
def round_to_closest_resolution(res):
rdist = 1000000
# while res/2>1:
# p2res*=2
# res = res/2
for rkey in resolutions:
d = abs(res - resolutions[rkey])
if d < rdist:
rdist = d
p2res = rkey
return p2res
def get_res_file(asset_data, resolution, find_closest_with_url=False):
"""
Returns closest resolution that current asset can offer.
If there are no resolutions, return orig file.
If orig file is requested, return it.
params
asset_data
resolution - ideal resolution
find_closest_with_url:
returns only resolutions that already containt url in the asset data, used in scenes where asset is/was already present.
Returns:
resolution file
resolution, so that other processess can pass correctly which resolution is downloaded.
"""
orig = None
res = None
closest = None
target_resolution = resolutions.get(resolution)
mindist = 100000000
for f in asset_data["files"]:
if f["fileType"] == "blend":
orig = f
if resolution == "blend":
# orig file found, return.
return orig, "blend"
if f["fileType"] == resolution:
# exact match found, return.
return f, resolution
# find closest resolution if the exact match won't be found.
rval = resolutions.get(f["fileType"])
if rval and target_resolution:
rdiff = abs(target_resolution - rval)
if rdiff < mindist:
closest = f
mindist = rdiff
if not res and not closest:
return orig, "blend"
return closest, closest["fileType"]
def server_to_local_filename(server_filename: str, asset_name: str) -> str:
"""
Convert server format filename to human readable local filename. Function mirrors: utils.go/ServerToLocalFilename()
"resolution_2K_d5368c9d-092e-4319-afe1-dd765de6da01.blend" > "asset-name_2K_d5368c9d-092e-4319-afe1-dd765de6da01.blend"
"blend_d5368c9d-092e-4319-afe1-dd765de6da01.blend" > "asset-name_d5368c9d-092e-4319-afe1-dd765de6da01.blend"
"""
fn = server_filename.replace("blend_", "")
fn = fn.replace("resolution_", "")
local_filename = slugify(asset_name) + "_" + fn
return local_filename
def get_texture_directory(asset_data, resolution="blend"):
tex_dir_path = f"//textures{resolution_suffix[resolution]}{os.sep}"
return tex_dir_path
def get_asset_directory_name(asset_name: str, asset_id: str) -> str:
"""Get name of the directory for the asset."""
name_slug = slugify(asset_name)
if len(name_slug) > 16:
name_slug = name_slug[:16]
return f"{name_slug}_{asset_id}"
def get_asset_directories(asset_data):
"""Only get path where all asset files are stored."""
asset_dir_name = get_asset_directory_name(asset_data["name"], asset_data["id"])
dirs = get_download_dirs(asset_data["assetType"])
asset_dirs = []
for d in dirs:
asset_dir_path = os.path.join(d, asset_dir_name)
asset_dirs.append(asset_dir_path)
return asset_dirs
def get_download_filepaths(asset_data, resolution="blend", can_return_others=False):
"""Get all possible paths of the asset and resolution. Usually global and local directory."""
dirs = get_download_dirs(asset_data["assetType"])
res_file, resolution = get_res_file(
asset_data, resolution, find_closest_with_url=can_return_others
)
asset_dir_name = get_asset_directory_name(asset_data["name"], asset_data["id"])
file_names = []
if not res_file:
return file_names
# fn = asset_data['file_name'].replace('blend_', '')
if res_file.get("url") is not None:
# Tweak the names a bit:
# remove resolution and blend words in names
#
serverFilename = extract_filename_from_url(res_file["url"])
localFilename = server_to_local_filename(serverFilename, asset_data["name"])
for dir in dirs:
asset_dir_path = os.path.join(dir, asset_dir_name)
if sys.platform == "win32" and len(asset_dir_path) > WINDOWS_PATH_LIMIT:
reports.add_report(
"The path to assets is too long, "
"only Global directory can be used. "
"Move your .blend file to another "
"directory with shorter path to "
"store assets in a subdirectory of your project.",
timeout=60,
type="ERROR",
)
continue
if not os.path.exists(asset_dir_path):
os.makedirs(asset_dir_path)
file_name = os.path.join(asset_dir_path, localFilename)
file_names.append(file_name)
utils.p("file paths", file_names)
for f in file_names:
if sys.platform == "win32" and len(f) > WINDOWS_PATH_LIMIT:
reports.add_report(
"The path to assets is too long, "
"only Global directory can be used. "
"Move your .blend file to another "
"directory with shorter path to "
"store assets in a subdirectory of your project.",
timeout=60,
type="ERROR",
)
file_names.remove(f)
return file_names
def delete_asset_debug(asset_data):
"""TODO fix this for resolutions - should get ALL files from ALL resolutions."""
user_preferences = bpy.context.preferences.addons[__package__].preferences
api_key = user_preferences.api_key
_, download_url, file_name = client_lib.get_download_url(
asset_data, utils.get_scene_id(), api_key
)
asset_data["files"][0]["url"] = download_url
asset_data["files"][0]["file_name"] = file_name
filepaths = get_download_filepaths(asset_data)
for file in filepaths:
asset_dir = os.path.dirname(file)
if os.path.isdir(asset_dir) is False:
continue
try:
shutil.rmtree(asset_dir)
bk_logger.info(f"deleted {asset_dir}")
except Exception as err:
e = sys.exc_info()[0]
bk_logger.error(f"{e} - {err}")
def get_clean_filepath():
script_path = os.path.dirname(os.path.realpath(__file__))
subpath = "blendfiles" + os.sep + "cleaned.blend"
cp = os.path.join(script_path, subpath)
return cp
def get_thumbnailer_filepath():
script_path = os.path.dirname(os.path.realpath(__file__))
# fpath = os.path.join(p, subpath)
subpath = "blendfiles" + os.sep + "thumbnailer.blend"
return os.path.join(script_path, subpath)
def get_material_thumbnailer_filepath():
script_path = os.path.dirname(os.path.realpath(__file__))
# fpath = os.path.join(p, subpath)
subpath = "blendfiles" + os.sep + "material_thumbnailer_cycles.blend"
return os.path.join(script_path, subpath)
"""
for p in bpy.utils.script_paths():
testfname= os.path.join(p, subpath)#p + '%saddons%sobject_fracture%sdata.blend' % (s,s,s)
if os.path.isfile( testfname):
fname=testfname
return(fname)
return None
"""
def get_addon_file(subpath=""):
script_path = os.path.dirname(os.path.realpath(__file__))
# fpath = os.path.join(p, subpath)
return os.path.join(script_path, subpath)
script_path = os.path.dirname(os.path.realpath(__file__))
def get_addon_thumbnail_path(name):
global script_path
# fpath = os.path.join(p, subpath)
ext = name.split(".")[-1]
next = ""
if not (ext == "jpg" or ext == "png"): # already has ext?
next = ".jpg"
subpath = f"thumbnails{os.sep}{name}{next}"
return os.path.join(script_path, subpath)
def get_config_dir_path() -> str:
"""Get the path to the config directory in global_dir."""
global_dir = bpy.context.preferences.addons[__package__].preferences.global_dir # type: ignore
directory = os.path.join(global_dir, "config")
return os.path.abspath(directory)
def ensure_config_dir_exists():
"""Ensure that the config directory exists."""
config_dir = get_config_dir_path()
if not os.path.exists(config_dir):
os.makedirs(config_dir)
return config_dir
def open_path_in_file_browser(dir_path):
"""Open the path in the file browser."""
if sys.platform == "win32":
os.startfile(dir_path)
elif sys.platform == "darwin":
subprocess.Popen(["open", dir_path])
else:
subprocess.Popen(["xdg-open", dir_path])
@@ -0,0 +1,353 @@
# This file is @generated by PDM.
# It is not intended for manual editing.
[metadata]
groups = ["default"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:c76a62dd151343ab8bade20c52ba9ab72a8c97afba4d30e648c98eb624af333e"
[[metadata.targets]]
requires_python = ">=3.10"
[[package]]
name = "black"
version = "24.1.0"
requires_python = ">=3.8"
summary = "The uncompromising code formatter."
groups = ["default"]
dependencies = [
"click>=8.0.0",
"mypy-extensions>=0.4.3",
"packaging>=22.0",
"pathspec>=0.9.0",
"platformdirs>=2",
"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"},
]
[[package]]
name = "certifi"
version = "2024.8.30"
requires_python = ">=3.6"
summary = "Python package for providing Mozilla's CA Bundle."
groups = ["default"]
files = [
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.0"
requires_python = ">=3.7.0"
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
groups = ["default"]
files = [
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"},
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"},
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"},
{file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"},
{file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"},
{file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"},
{file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"},
{file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"},
{file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"},
{file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"},
{file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"},
{file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"},
{file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"},
]
[[package]]
name = "click"
version = "8.1.7"
requires_python = ">=3.7"
summary = "Composable command line interface toolkit"
groups = ["default"]
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"},
]
[[package]]
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"]
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 = "idna"
version = "3.10"
requires_python = ">=3.6"
summary = "Internationalized Domain Names in Applications (IDNA)"
groups = ["default"]
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
[[package]]
name = "isort"
version = "5.13.2"
requires_python = ">=3.8.0"
summary = "A Python utility / library to sort Python imports."
groups = ["default"]
files = [
{file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
{file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
]
[[package]]
name = "mypy"
version = "1.13.0"
requires_python = ">=3.8"
summary = "Optional static typing for Python"
groups = ["default"]
dependencies = [
"mypy-extensions>=1.0.0",
"tomli>=1.1.0; python_version < \"3.11\"",
"typing-extensions>=4.6.0",
]
files = [
{file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"},
{file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"},
{file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"},
{file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"},
{file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"},
{file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"},
{file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"},
{file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"},
{file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"},
{file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"},
{file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"},
{file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"},
{file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"},
{file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"},
{file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"},
{file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"},
{file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"},
{file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"},
{file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"},
{file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"},
{file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"},
{file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"},
]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
requires_python = ">=3.5"
summary = "Type system extensions for programs checked with the mypy type checker."
groups = ["default"]
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"},
]
[[package]]
name = "numpy"
version = "1.26.4"
requires_python = ">=3.9"
summary = "Fundamental package for array computing in Python"
groups = ["default"]
files = [
{file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"},
{file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"},
{file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"},
{file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"},
{file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"},
{file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"},
{file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"},
{file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"},
{file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"},
{file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"},
{file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"},
{file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"},
{file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"},
{file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"},
{file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"},
{file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"},
{file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"},
{file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"},
{file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"},
{file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"},
{file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"},
{file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"},
{file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"},
{file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"},
{file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"},
]
[[package]]
name = "packaging"
version = "24.2"
requires_python = ">=3.8"
summary = "Core utilities for Python packages"
groups = ["default"]
files = [
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
]
[[package]]
name = "pathspec"
version = "0.12.1"
requires_python = ">=3.8"
summary = "Utility library for gitignore style pattern matching of file paths."
groups = ["default"]
files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
name = "platformdirs"
version = "4.3.6"
requires_python = ">=3.8"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"]
files = [
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
]
[[package]]
name = "requests"
version = "2.32.3"
requires_python = ">=3.8"
summary = "Python HTTP for Humans."
groups = ["default"]
dependencies = [
"certifi>=2017.4.17",
"charset-normalizer<4,>=2",
"idna<4,>=2.5",
"urllib3<3,>=1.21.1",
]
files = [
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
]
[[package]]
name = "tomli"
version = "2.1.0"
requires_python = ">=3.8"
summary = "A lil' TOML parser"
groups = ["default"]
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"},
]
[[package]]
name = "types-requests"
version = "2.32.0.20241016"
requires_python = ">=3.8"
summary = "Typing stubs for requests"
groups = ["default"]
dependencies = [
"urllib3>=2",
]
files = [
{file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"},
{file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"},
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
requires_python = ">=3.8"
summary = "Backported and Experimental Type Hints for Python 3.8+"
groups = ["default"]
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"},
]
[[package]]
name = "urllib3"
version = "2.2.3"
requires_python = ">=3.8"
summary = "HTTP library with thread-safe connection pooling, file post, and more."
groups = ["default"]
files = [
{file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
{file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
]
@@ -0,0 +1,183 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import json
import logging
import os
import bpy
from . import paths, utils
bk_logger = logging.getLogger(__name__)
def get_preferences_path() -> str:
"""Return path to the persistent JSON preferences file."""
config_dir = paths.get_config_dir_path()
preferences_path = os.path.join(config_dir, "preferences.json")
return os.path.abspath(preferences_path)
def write_preferences_to_JSON(preferences: dict):
"""Write preferences to JSON file, called on save_prefs()."""
paths.ensure_config_dir_exists()
preferences_path = get_preferences_path()
try:
with open(preferences_path, "w", encoding="utf-8") as s:
json.dump(preferences, s, ensure_ascii=False, indent=4)
bk_logger.info(f"Saved preferences to {preferences_path}")
except Exception as e:
bk_logger.warning(f"Failed to save preferences: {e}")
def load_preferences_from_JSON():
"""Load preferences from JSON file and update the user preferences accordingly."""
preferences_path = get_preferences_path()
if os.path.exists(preferences_path) is not True:
return utils.get_preferences_as_dict()
try:
with open(preferences_path, "r", encoding="utf-8") as s:
prefs = json.load(s)
except Exception as e:
bk_logger.warning("Failed to read preferences from JSON: {e}")
os.remove(preferences_path)
return utils.get_preferences_as_dict()
user_preferences = bpy.context.preferences.addons[__package__].preferences
user_preferences.preferences_lock = True
# STATISTICS
user_preferences.download_counter = prefs.get(
"download_counter", user_preferences.download_counter
)
user_preferences.asset_popup_counter = prefs.get(
"asset_popup_counter", user_preferences.asset_popup_counter
)
user_preferences.welcome_operator_counter = prefs.get(
"welcome_operator_counter", user_preferences.welcome_operator_counter
)
# MAIN PREFERENCES
user_preferences.api_key = prefs.get("api_key", user_preferences.api_key)
user_preferences.api_key_refresh = prefs.get(
"api_key_refresh", user_preferences.api_key_refresh
)
user_preferences.api_key_timeout = prefs.get(
"api_key_timeout", user_preferences.api_key_timeout
)
user_preferences.experimental_features = prefs.get(
"experimental_features", user_preferences.experimental_features
)
user_preferences.keep_preferences = prefs.get(
"keep_preferences", user_preferences.keep_preferences
)
# FILE PATHS
user_preferences.directory_behaviour = prefs.get(
"directory_behaviour", user_preferences.directory_behaviour
)
user_preferences.global_dir = prefs.get("global_dir", user_preferences.global_dir)
user_preferences.project_subdir = prefs.get(
"project_subdir", user_preferences.project_subdir
)
user_preferences.unpack_files = prefs.get(
"unpack_files", user_preferences.unpack_files
)
# GUI
user_preferences.show_on_start = prefs.get(
"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.search_field_width = prefs.get(
"search_field_width", user_preferences.search_field_width
)
user_preferences.search_in_header = prefs.get(
"search_in_header", user_preferences.search_in_header
)
user_preferences.tips_on_start = prefs.get(
"tips_on_start", user_preferences.tips_on_start
)
user_preferences.announcements_on_start = prefs.get(
"announcements_on_start", user_preferences.announcements_on_start
)
# NETWORK
user_preferences.client_port = prefs.get(
"client_port", user_preferences.client_port
)
user_preferences.ip_version = prefs.get("ip_version", user_preferences.ip_version)
try:
user_preferences.ssl_context = prefs.get(
"ssl_context", user_preferences.ssl_context
)
except Exception as e:
print(f"Failed to load ssl_context: {e}")
user_preferences.proxy_which = prefs.get(
"proxy_which", user_preferences.proxy_which
)
user_preferences.proxy_address = prefs.get(
"proxy_address", user_preferences.proxy_address
)
user_preferences.trusted_ca_certs = prefs.get(
"trusted_ca_certs", user_preferences.trusted_ca_certs
)
# UPDATES
user_preferences.auto_check_update = prefs.get(
"auto_check_update", user_preferences.auto_check_update
)
user_preferences.enable_prereleases = prefs.get(
"enable_prereleases", user_preferences.enable_prereleases
)
user_preferences.updater_interval_months = prefs.get(
"updater_interval_months", user_preferences.updater_interval_months
)
user_preferences.updater_interval_days = prefs.get(
"updater_interval_days", user_preferences.updater_interval_days
)
# IMPORT SETTINGS
user_preferences.resolution = prefs.get("resolution", user_preferences.resolution)
bk_logger.info(f"Successfully loaded preferences from {preferences_path}")
user_preferences.preferences_lock = False
return prefs
def property_keep_preferences_updated(user_preferences, context):
"""Runs when keep_preferences BoolProperty is updated.
Delete preferences JSON file if set to False. Call save_prefs() in all cases.
"""
if user_preferences.keep_preferences is True:
return utils.save_prefs(user_preferences, context)
preferences_path = get_preferences_path()
if os.path.exists(preferences_path) is False:
return utils.save_prefs(user_preferences, context)
try:
os.remove(preferences_path)
bk_logger.info(f"Deleted preferences file {preferences_path}")
except Exception as e:
bk_logger.error(f"Failed to delete preferences file {preferences_path}: {e}")
utils.save_prefs(user_preferences, context)
@@ -0,0 +1,48 @@
[project]
requires-python = ">=3.10"
dependencies = [
"requests>=2.18.4",
"types-requests>=2.31.0.5",
"numpy<2.0.0",
"black==24.1.0",
"isort==5.13.2",
"mypy==1.13.0",
]
[tool.isort]
multi_line_output = 3
lines_after_imports = 2
skip = ["lib", "out", ".venv"]
profile = "black"
[tool.black]
target-version = ['py310']
include = '\.pyi?$'
exclude = '''
/(\.git|lib|out)/
'''
[tool.ruff]
exclude = ["lib", "out", "addon_updater.py", "addon_updater_ops.py"]
ignore = [
"E501", # Line too long
]
target-version = "py310"
[tool.mypy]
exclude = ['test_*', 'out', 'lib']
disallow_untyped_globals = false # remove this in the future
[[tool.mypy.overrides]]
module = [
"bpy",
"bpy.*",
"bpy_extras",
"mathutils",
"addonutils",
"blf",
"gpu",
"gpu_extras.*"
]
ignore_missing_imports = true # Ignore missing type hints for bpy
@@ -0,0 +1,391 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import bpy
from bpy.props import StringProperty
from bpy.types import Gizmo, GizmoGroup, Operator
from mathutils import Matrix
from . import (
client_lib,
datas,
global_vars,
icons,
ratings_utils,
search,
ui,
ui_panels,
utils,
)
bk_logger = logging.getLogger(__name__)
def get_assets_for_rating():
"""Get assets from scene that could/should be rated by the user. TODO: this is only a draft"""
assets = []
for ob in bpy.context.scene.objects:
if should_be_rated(ob):
assets.append(ob)
for m in bpy.data.materials:
if m.get("asset_data"):
assets.append(m)
for b in bpy.data.brushes:
if b.get("asset_data"):
assets.append(b)
return assets
asset_types = (
("MODEL", "Model", "set of objects"),
("PRINTABLE", "Printable", "3D printable model"),
("SCENE", "Scene", "scene"),
("HDR", "HDR", "hdr"),
("MATERIAL", "Material", "any .blend Material"),
("TEXTURE", "Texture", "a texture, or texture set"),
("BRUSH", "Brush", "brush, can be any type of blender brush"),
("ADDON", "Addon", "addnon"),
)
def draw_ratings_menu(self, context, layout):
pcoll = icons.icon_collections["main"]
if not utils.user_logged_in():
user_preferences = bpy.context.preferences.addons[__package__].preferences
if user_preferences.login_attempt:
ui_panels.draw_login_progress(layout)
else:
layout.operator_context = "EXEC_DEFAULT"
layout.operator(
"wm.blenderkit_login",
text="Login to Rate and Comment assets",
icon="URL",
).signup = False
return
col = layout.column()
# layout.template_icon_view(bkit_ratings, property, show_labels=False, scale=6.0, scale_popup=5.0)
row = col.row()
if self.asset_data.get("canDownload") is not True:
row.label(text="Asset in Full Plan. Subscribe to rate it.", icon="SOLO_ON")
return
profile_name = ""
profile = global_vars.BKIT_PROFILE
if profile and len(profile.firstName) > 0:
profile_name = " " + profile.firstName
row.label(text="Rate Quality:", icon="SOLO_ON")
# row = col.row()
# row.label(text='Please help the community by rating quality:')
row = col.row()
row.prop(self, "rating_quality_ui", expand=True, icon_only=True, emboss=False)
if self.rating_quality > 0:
row.label(text=f" Thanks{profile_name}!", icon="FUND")
col.separator()
col.separator()
row = col.row()
row.label(text="Rate Complexity:", icon_value=pcoll["dumbbell"].icon_id)
row = col.row()
row.label(text=f"How many hours did this {self.asset_type} save you?")
if utils.profile_is_validator():
row = col.row()
row.prop(self, "rating_work_hours")
row = col.row()
row.prop(self, "rating_work_hours_ui", expand=True, icon_only=False, emboss=True)
if self.rating_work_hours > 100:
utils.label_multiline(
col,
text=f"\nThat's huge! please be sure to give such rating only to godly {self.asset_type}s.\n",
width=300,
)
elif float(self.rating_work_hours) > 18:
col.separator()
utils.label_multiline(
col,
text=f"\nThat's a lot! please be sure to give such rating only to amazing {self.asset_type}s.\n",
width=300,
)
if self.rating_work_hours > 0:
row = col.row()
row.label(text=f"Thanks{profile_name}, you are amazing!", icon="FUND")
class FastRateMenu(Operator, ratings_utils.RatingProperties):
"""Rating of the assets, also directly from the asset bar - without need to download assets"""
bl_idname = "wm.blenderkit_menu_rating_upload"
bl_label = "Ratings"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
return True
def draw(self, context):
# when rating gets recieved while the window is already open, we need to prefill.
self.prefill_ratings()
layout = self.layout
layout.label(text=f"Rating of the {self.asset_type}: {self.asset_data['name']}")
draw_ratings_menu(self, context, layout)
layout.template_icon(icon_value=self.img.preview.icon_id, scale=12)
def execute(self, context):
ui_props = bpy.context.window_manager.blenderkitUI
# get asset id
if ui_props.active_index > -1:
sr = search.get_search_results()
self.asset_data = dict(sr[ui_props.active_index])
self.asset_id = self.asset_data["id"]
self.asset_type = self.asset_data["assetType"]
else:
if bpy.context.view_layer.objects.active is not None:
ob = utils.get_active_model()
ad = ob.get("asset_data")
if ad:
self.asset_data = ad
self.asset_id = self.asset_data["id"]
self.asset_type = self.asset_data["assetType"]
self.asset = ob
if self.asset_id == "":
return {"CANCELLED"}
wm = context.window_manager
self.img = ui.get_large_thumbnail_image(self.asset_data)
utils.img_to_preview(self.img, copy_original=True)
ratings_utils.ensure_rating(self.asset_id)
self.prefill_ratings()
if self.asset_type in ("model", "scene"):
# spawn a wider one for validators for the enum buttons
return wm.invoke_popup(self, width=400)
else:
return wm.invoke_popup(self, width=250)
class SetBookmark(bpy.types.Operator):
"""Add or remove bookmarking of the asset.\nShortcut: hover over asset in the asset bar and press 'B'."""
bl_idname = "wm.blenderkit_bookmark_asset"
bl_label = "BlenderKit bookmark assest"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
asset_id: StringProperty( # type: ignore[valid-type]
name="Asset Base Id",
description="Unique id of the asset (hidden)",
default="",
options={"SKIP_SAVE"},
)
# bookmark: bpy.props.BoolProperty(
# name="bookmark",
# description="Pass current state of bookmark, gets inverted",
# default=True)
@classmethod
def poll(cls, context):
return True
def execute(self, context):
rating = ratings_utils.get_rating_local(self.asset_id)
if rating is None:
rating = datas.AssetRating()
if rating.bookmarks == 1:
bookmark_value = 0
else:
bookmark_value = 1
ratings_utils.store_rating_local(
self.asset_id, rating_type="bookmarks", value=bookmark_value
)
client_lib.send_rating(self.asset_id, "bookmarks", bookmark_value)
return {"FINISHED"}
def rating_menu_draw(self, context):
layout = self.layout
ui_props = context.window_manager.blenderkitUI
sr = search.get_search_results()
asset_search_index = ui_props.active_index
if asset_search_index > -1:
asset_data = dict(sr["results"][asset_search_index])
col = layout.column()
layout.label(text="Admin rating Tools:")
col.operator_context = "INVOKE_DEFAULT"
op = col.operator("wm.blenderkit_menu_rating_upload", text="Add Rating")
op.asset_id = asset_data["id"]
op.asset_name = asset_data["name"]
op.asset_type = asset_data["assetType"]
# Coordinates (each one is a triangle).
custom_shape_verts = (
(0.1896940916776657, 0.2608509361743927, 0.0),
(0.2438376545906067, 0.09421423077583313, 0.0),
(0.2979812026023865, 0.2608509361743927, 0.0),
(0.1896940916776657, 0.2608509361743927, 0.0),
(0.052547797560691833, 0.2484826147556305, 0.0),
(0.15623150765895844, 0.1578637957572937, 0.0),
(0.15623150765895844, 0.1578637957572937, 0.0),
(0.12561391294002533, 0.023607879877090454, 0.0),
(0.2438376545906067, 0.09421423077583313, 0.0),
(0.2438376545906067, 0.09421423077583313, 0.0),
(0.36206138134002686, 0.023607879877090454, 0.0),
(0.33144378662109375, 0.1578637957572937, 0.0),
(0.33144378662109375, 0.1578637957572937, 0.0),
(0.4351276159286499, 0.2484826147556305, 0.0),
(0.2979812026023865, 0.2608509361743927, 0.0),
(0.2979812026023865, 0.2608509361743927, 0.0),
(0.2438376396894455, 0.3874630033969879, 0.0),
(0.1896940916776657, 0.2608509361743927, 0.0),
(0.1896940916776657, 0.2608509361743927, 0.0),
(0.15623150765895844, 0.1578637957572937, 0.0),
(0.2438376545906067, 0.09421423077583313, 0.0),
(0.2438376545906067, 0.09421423077583313, 0.0),
(0.33144378662109375, 0.1578637957572937, 0.0),
(0.2979812026023865, 0.2608509361743927, 0.0),
)
class RatingStarWidget(Gizmo):
bl_idname = "VIEW3D_GT_custom_shape_widget"
__slots__ = (
"custom_shape",
"init_mouse_y",
"init_value",
)
def _update_draw_matrix(self):
R = bpy.context.region_data.view_rotation.to_matrix().to_4x4()
loc, _, scale = self.matrix_basis.decompose()
self.matrix_basis = Matrix.Translation(loc) @ R @ Matrix.Diagonal(scale.to_4d())
def draw(self, context):
self._update_draw_matrix()
self.draw_custom_shape(self.custom_shape)
def draw_select(self, context, select_id):
self._update_draw_matrix()
self.draw_custom_shape(self.custom_shape, select_id=select_id)
def setup(self):
if not hasattr(self, "custom_shape"):
self.custom_shape = self.new_custom_shape("TRIS", custom_shape_verts)
def invoke(self, context, event):
return {"RUNNING_MODAL"}
def exit(self, context, cancel):
pass
def modal(self, context, event, tweak):
return {"FINISHED"}
def should_be_rated(ob) -> bool:
ad = ob.get("asset_data")
if ad is None:
return False
rating = ratings_utils.get_rating_local(ad["id"])
if rating is None:
# is None would work too, but would show rating option and then hide it when the assets are already rated
return True
return False
class RatingStarWidgetGroup(GizmoGroup):
bl_idname = "OBJECT_GGT_light_test"
bl_label = "Test Light Widget"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
@classmethod
def poll(cls, context):
if not utils.profile_is_validator():
return False
if bpy.context.view_layer.objects.active is not None:
ob = utils.get_active_model()
return should_be_rated(ob)
return False
def setup(self, context):
ob = utils.get_active_model()
gz = self.gizmos.new(RatingStarWidget.bl_idname)
props = gz.target_set_operator("wm.blenderkit_menu_rating_upload")
props.asset_id = ob["asset_data"]["assetBaseId"]
gz.color = 0.5, 0.5, 0.0
gz.alpha = 0.5
gz.color_highlight = 1.0, 1.0, 1.0
gz.alpha_highlight = 0.5
gz.scale_basis = 1
gz.use_draw_modal = True
self.energy_gizmo = gz
def refresh(self, context):
ob = utils.get_active_model()
gz = self.energy_gizmo
R = bpy.context.region_data.view_rotation.to_matrix().to_4x4()
loc, _, _ = ob.matrix_world.decompose()
_, _, scale = gz.matrix_basis.decompose()
gz.matrix_basis = Matrix.Translation(loc) @ R @ Matrix.Diagonal(scale.to_4d())
classes = (
FastRateMenu,
SetBookmark,
RatingStarWidget,
RatingStarWidgetGroup,
ratings_utils.RatingProperties,
# ratings_utils.RatingPropsCollection,
)
def register_ratings():
for cls in classes:
bpy.utils.register_class(cls)
def unregister_ratings():
for cls in classes:
bpy.utils.unregister_class(cls)
@@ -0,0 +1,429 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
from typing import Optional, Union
# mainly update functions and callbacks for ratings properties, here to avoid circular imports.
import bpy
from bpy.props import (
BoolProperty,
EnumProperty,
FloatProperty,
IntProperty,
StringProperty,
)
from bpy.types import Context, PropertyGroup
from . import (
client_lib,
client_tasks,
datas,
global_vars,
icons,
reports,
tasks_queue,
utils,
)
bk_logger = logging.getLogger(__name__)
def handle_get_rating_task(task: client_tasks.Task):
"""Handle incomming get_rating task by saving the results into global_vars."""
if task.status == "created":
return
if task.status == "error":
return bk_logger.warning(f"{task.task_type} task failed: {task.message}")
asset_id = task.data["asset_id"]
ratings = task.result["results"]
if len(ratings) == 0:
store_rating_local(asset_id, "quality", None)
store_rating_local(asset_id, "working_hours", None)
return
for rating in ratings:
store_rating_local(asset_id, rating["ratingType"], rating["score"])
def handle_get_ratings_task(task: client_tasks.Task):
"""Handle incomming get_ratings task. This is a special task used only by validators which fetches the ratings
in big batch right after the search results come into the Client. This is used only to signal problems in the
Goroutine which fetches the ratings. The individual ratings are then sent as normal 'get_rating' tasks.
"""
if task.status == "error": # only reason this task type exists right now
return bk_logger.warning(f"{task.task_type} task failed: {task.message}")
def handle_get_bookmarks_task(task: client_tasks.Task):
"""Handle incomming get_bookmarks task by saving the results into global_vars.
This is different from standard ratings - the results come from elastic search API
instead of ratings API.
"""
if task.status == "created":
return
if task.status == "error":
return bk_logger.warning(f"{task.task_type} task failed: {task.message}")
for asset in task.result["results"]:
store_rating_local(asset["id"], "bookmarks", 1)
def handle_send_rating_task(task: client_tasks.Task):
"""Handle send rating task."""
if task.status == "created":
return
if task.status == "error":
return reports.add_report(
task.message, type="ERROR", details=task.message_detailed
)
if task.status == "finished":
if utils.profile_is_validator():
return reports.add_report(task.message, type="VALIDATOR")
def store_rating_local(
asset_id: str, rating_type: str = "quality", value: Optional[int] = None
):
"""Store the rating locally in the global_vars.
- rating_type can be: "quality", "working_hours", "bookmarks"
- value set None to create empty rating and prevent add-on from fetching it again next time
"""
allowed_rating_types = ["quality", "working_hours", "bookmarks"]
if rating_type not in allowed_rating_types:
raise ValueError(f"rating_type must be one of {allowed_rating_types}")
rating = global_vars.RATINGS.get(asset_id, datas.AssetRating())
rating.working_hours_fetched = True
rating.quality_fetched = True
setattr(rating, rating_type, value)
global_vars.RATINGS[asset_id] = rating
def get_rating_local(asset_id: str) -> Optional[datas.AssetRating]:
"""Get the rating locally from global_vars.RATINGS."""
return global_vars.RATINGS.get(asset_id)
def ensure_rating(asset_id: str):
"""Ensure the rating is available. First check if it is available in local cache. If it is not then download it from the server.
If the rating is present, we need to check if rating.quality_fetched and rating.working_hours_fetched are not False
because bookmarked assets will have rating created, but for them the quality and wh was not fetched (bookmarked are get from search
and these data does not contain quality and working_hours - and even bookmarked but that can be deduced from searching for bookmarked).
"""
rating = get_rating_local(asset_id)
if rating is None:
client_lib.get_rating(asset_id)
return
if not rating.quality_fetched or rating.working_hours_fetched:
client_lib.get_rating(asset_id)
def update_ratings_quality(self, context: Context):
if not (hasattr(self, "rating_quality")):
# first option is for rating of assets that are from scene
asset = self.id_data
bkit_ratings = asset.bkit_ratings
asset_id = asset["asset_data"]["id"]
else:
# this part is for operator rating:
bkit_ratings = self
asset_id = self.asset_id
local_rating = get_rating_local(self.asset_id)
if local_rating is None:
local_rating = datas.AssetRating(quality=0)
if local_rating.quality == self.rating_quality:
return store_rating_local(
asset_id, rating_type="quality", value=bkit_ratings.rating_quality
)
store_rating_local(
asset_id, rating_type="quality", value=bkit_ratings.rating_quality
)
if self.rating_quality_lock is True:
return
args = (asset_id, "quality", bkit_ratings.rating_quality)
tasks_queue.add_task((client_lib.send_rating, args), wait=0.5, only_last=True)
def update_ratings_work_hours(self, context: Context):
if not (hasattr(self, "rating_work_hours")):
# first option is for rating of assets that are from scene
asset = self.id_data
bkit_ratings = asset.bkit_ratings
asset_id = asset["asset_data"]["id"]
else:
# this part is for operator rating:
bkit_ratings = self
asset_id = self.asset_id
local_rating = get_rating_local(self.asset_id)
if local_rating is None: # rating was not available online
local_rating = datas.AssetRating(working_hours=0)
if local_rating.working_hours == self.rating_work_hours:
return store_rating_local(
asset_id, rating_type="working_hours", value=bkit_ratings.rating_work_hours
)
store_rating_local(
asset_id, rating_type="working_hours", value=bkit_ratings.rating_work_hours
)
if self.rating_work_hours_lock is True:
return
args = (asset_id, "working_hours", bkit_ratings.rating_work_hours)
tasks_queue.add_task((client_lib.send_rating, args), wait=0.5, only_last=True)
def update_quality_ui(self, context: Context):
"""Converts the _ui the enum into actual quality number."""
user_preferences = bpy.context.preferences.addons[__package__].preferences # type: ignore
api_key = user_preferences.api_key # type: ignore
# we need to check for matching value not to update twice/call the popup twice.
if api_key == "" and self.rating_quality != int(self.rating_quality_ui):
bpy.ops.wm.blenderkit_login( # type: ignore
"INVOKE_DEFAULT",
message="Please login/signup to rate assets. Clicking OK takes you to web login.",
)
return
self.rating_quality = int(self.rating_quality_ui)
def update_ratings_work_hours_ui(self, context: Context):
user_preferences = bpy.context.preferences.addons[__package__].preferences # type: ignore
api_key = user_preferences.api_key # type: ignore
if api_key == "" and self.rating_work_hours != float(self.rating_work_hours_ui):
bpy.ops.wm.blenderkit_login( # type: ignore
"INVOKE_DEFAULT",
message="Please login/signup to rate assets. Clicking OK takes you to web login.",
)
return
self.rating_work_hours = float(self.rating_work_hours_ui)
def stars_enum_callback(self, context):
"""regenerates the enum property used to display rating stars, so that there are filled/empty stars correctly."""
items = []
for a in range(0, 11):
if a == 0:
icon = "REMOVE"
elif self.rating_quality < a:
icon = "SOLO_OFF"
else:
icon = "SOLO_ON"
# has to have something before the number in the value, otherwise fails on registration.
items.append((f"{a}", " ", "", icon, a))
return items
def wh_enum_callback(self, context):
"""Regenerates working hours enum."""
if self.asset_type in ("model", "scene"):
possible_wh_values = [
0,
0.5,
1,
2,
3,
4,
5,
6,
8,
10,
15,
20,
30,
50,
100,
150,
200,
250,
]
elif self.asset_type == "hdr":
possible_wh_values = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
else: # for material, brush assets
possible_wh_values = [0, 0.2, 0.5, 1, 2, 3, 4, 5]
work_hours = self.rating_work_hours
if work_hours < 1:
work_hours = int(work_hours * 10) / 10
else:
work_hours = int(work_hours)
if work_hours not in possible_wh_values:
closest_index = 0
closest_diff = abs(possible_wh_values[0] - work_hours)
for i in range(1, len(possible_wh_values)):
diff = abs(possible_wh_values[i] - work_hours)
if diff < closest_diff:
closest_diff = diff
closest_index = i
possible_wh_values[closest_index] = work_hours
items = []
items.append(("0", " ", "", "REMOVE", 0))
pcoll = icons.icon_collections["main"]
for w in possible_wh_values:
if w > 0:
if w < 1:
icon_name = f"BK{int(w*10)/10}"
else:
icon_name = f"BK{int(w)}"
if icon_name not in pcoll:
icon_name = "bar_slider_up"
icon = pcoll[icon_name]
# index of the item(last value) is multiplied by 10 to get always integer values that aren't zero
items.append((f"{w}", " ", "", icon.icon_id, int(w * 10)))
return items
class RatingProperties(PropertyGroup):
message: StringProperty( # type: ignore
name="message",
description="message",
default="Rating asset",
options={"SKIP_SAVE"},
)
asset_id: StringProperty( # type: ignore
name="Asset Base Id",
description="Unique id of the asset (hidden)",
default="",
options={"SKIP_SAVE"},
)
asset_name: StringProperty( # type: ignore
name="Asset Name",
description="Name of the asset (hidden)",
default="",
options={"SKIP_SAVE"},
)
asset_type: StringProperty( # type: ignore
name="Asset type", description="asset type", default="", options={"SKIP_SAVE"}
)
### QUALITY RATING
rating_quality_lock: BoolProperty( # type: ignore
name="Quality Lock",
description="Quality is locked -> rating is not sent online",
default=False,
options={"SKIP_SAVE"},
)
rating_quality: IntProperty( # type: ignore
name="Quality",
description="quality of the material",
default=0,
min=-1,
max=10,
update=update_ratings_quality,
options={"SKIP_SAVE"},
)
# the following enum is only to ease interaction - enums support 'drag over' and enable to draw the stars easily.
rating_quality_ui: EnumProperty( # type: ignore
name="Quality",
items=stars_enum_callback,
description="Rate the quality of the asset from 1 to 10 stars.\nShortcut: Hover over asset in the asset bar and press 'R' to show rating menu",
default=0,
update=update_quality_ui,
options={"SKIP_SAVE"},
)
### WORK HOURS RATING
rating_work_hours_lock: BoolProperty( # type: ignore
name="Work Hours Lock",
description="Work hours are locked -> rating is not sent online",
default=False,
options={"SKIP_SAVE"},
)
rating_work_hours: FloatProperty( # type: ignore
name="Work Hours",
description="nonUI How many hours did this work take?\nShortcut: Hover over asset in the asset bar and press 'R' to show rating menu.",
default=0.00,
min=0.0,
max=300,
update=update_ratings_work_hours,
options={"SKIP_SAVE"},
)
rating_work_hours_ui: EnumProperty( # type: ignore
name="Work Hours",
description="UI How many hours did this work take?\nShortcut: Hover over asset in the asset bar and press 'R' to show rating menu",
items=wh_enum_callback,
default=0,
update=update_ratings_work_hours_ui,
options={"SKIP_SAVE"},
)
def prefill_ratings(self) -> None:
"""Pre-fill the quality and work hours ratings if available.
Locks the ratings locks so that the update function is not called and ratings are not sent online.
"""
if not utils.user_logged_in():
return
rating = get_rating_local(self.asset_id)
if rating is None:
return
if rating.quality is None and rating.working_hours is None:
return
if self.rating_quality != 0:
return # return if the rating was already filled
if self.rating_work_hours != 0:
return # return if the rating was already filled
if rating.quality is not None:
self.rating_quality_lock = True
self.rating_quality = int(rating.quality)
self.rating_quality_lock = False
if rating.working_hours is not None:
wh: Union[float, int]
if rating.working_hours >= 1:
wh = int(rating.working_hours)
else:
wh = round(rating.working_hours, 1)
whs = str(wh)
self.rating_work_hours_lock = True
self.rating_work_hours = round(rating.working_hours, 2)
try:
# when the value is not in the enum, it throws an error
if whs == "0.0":
whs = "0"
self.rating_work_hours_ui = whs
except Exception as e:
bk_logger.warning(f"exception setting rating_work_hours_ui: {e}")
self.rating_work_hours = round(rating.working_hours, 2)
self.rating_work_hours_lock = False
bpy.context.area.tag_redraw()
# class RatingPropsCollection(PropertyGroup):
# ratings = CollectionProperty(type = RatingProperties)
@@ -0,0 +1,116 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
from inspect import getframeinfo, stack
from logging import getLogger
from os.path import basename
from re import search
from time import time
import bpy
from . import asset_bar_op, colors, ui_bgl, utils
bk_logger = getLogger(__name__)
reports = []
# check for same reports and just make them longer by the timeout.
def add_report(text="", timeout=-1, type="INFO", details=""):
"""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.
"""
global reports
text = text.strip()
full_message = text
details = details.strip()
if details != "":
full_message = f"{text} {details}"
if timeout == -1:
if type == "ERROR":
timeout = 15
else:
timeout = 5
if type == "ERROR":
regex = r"\[[^\[\]:]+:\d+\]"
if search(regex, text) is None:
caller = getframeinfo(stack()[1][0])
location = f"[{basename(caller.filename)}:{caller.lineno}]"
text = f"{text} {location}"
full_message = f"{full_message} {location}"
bk_logger.error(full_message, stacklevel=2)
color = colors.RED
elif type == "INFO":
bk_logger.info(full_message, stacklevel=2)
color = colors.GREEN
elif type == "VALIDATOR":
bk_logger.info(full_message, stacklevel=2)
color = colors.BLUE
# check for same reports and just make them longer by the timeout.
for old_report in reports:
if old_report.text == text:
old_report.timeout = old_report.age + timeout
return
report = Report(text=text, timeout=timeout, color=color)
reports.append(report)
class Report:
def __init__(self, text="", timeout=5, color=(0.5, 1, 0.5, 1)):
self.text = text
self.timeout = timeout
self.start_time = time()
self.color = color
self.draw_color = color
self.age = 0
self.active_area_pointer = asset_bar_op.active_area_pointer
if asset_bar_op.active_area_pointer == 0:
w, a, r = utils.get_largest_area(area_type="VIEW_3D")
if a is not None:
self.active_area_pointer = a.as_pointer()
def fade(self):
fade_time = 1
self.age = time() - self.start_time
if self.age + fade_time > self.timeout:
alpha_multiplier = (self.timeout - self.age) / fade_time
self.draw_color = (
self.color[0],
self.color[1],
self.color[2],
self.color[3] * alpha_multiplier,
)
if self.age > self.timeout:
global reports
try:
reports.remove(self)
except Exception as e:
bk_logger.warning(f"exception in fading: {e}")
def draw(self, x, y):
if (
bpy.context.area is not None
and bpy.context.area.as_pointer() == self.active_area_pointer
):
ui_bgl.draw_text(self.text, x, y + 8, 16, self.draw_color)
@@ -0,0 +1,90 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import json
import logging
import os
import sys
import time
import bpy
from . import paths, utils
bk_logger = logging.getLogger(__name__)
resolutions = {
"resolution_0_5K": 512,
"resolution_1K": 1024,
"resolution_2K": 2048,
"resolution_4K": 4096,
"resolution_8K": 8192,
}
rkeys = list(resolutions.keys())
resolution_props_to_server = {
"512": "resolution_0_5K",
"1024": "resolution_1K",
"2048": "resolution_2K",
"4096": "resolution_4K",
"8192": "resolution_8K",
"ORIGINAL": "blend",
}
def get_current_resolution():
actres = 0
for i in bpy.data.images:
if i.name != "Render Result":
actres = max(actres, i.size[0], i.size[1])
return actres
def regenerate_thumbnail_material(data):
# this should re-generate material thumbnail and re-upload it.
# first let's skip procedural assets
base_fpath = bpy.data.filepath
blend_file_name = os.path.basename(base_fpath)
bpy.ops.mesh.primitive_cube_add()
aob = bpy.context.active_object
bpy.ops.object.material_slot_add()
aob.material_slots[0].material = bpy.data.materials[0]
props = aob.active_material.blenderkit
props.thumbnail_generator_type = "BALL"
props.thumbnail_background = False
props.thumbnail_resolution = "256"
# layout.prop(props, 'thumbnail_generator_type')
# layout.prop(props, 'thumbnail_scale')
# layout.prop(props, 'thumbnail_background')
# if props.thumbnail_background:
# layout.prop(props, 'thumbnail_background_lightness')
# layout.prop(props, 'thumbnail_resolution')
# layout.prop(props, 'thumbnail_samples')
# layout.prop(props, 'thumbnail_denoising')
# layout.prop(props, 'adaptive_subdivision')
# preferences = bpy.context.preferences.addons['blenderkit'].preferences
# layout.prop(preferences, "thumbnail_use_gpu")
# TODO: here it should call start_material_thumbnailer , but with the wait property on, so it can upload afterwards.
bpy.ops.object.blenderkit_generate_material_thumbnail()
time.sleep(130)
# save
# this does the actual job
return
@@ -0,0 +1,161 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import logging
import queue
from typing import Tuple
import bpy
from bpy.app.handlers import persistent
from . import utils
bk_logger = logging.getLogger(__name__)
@persistent
def scene_load(context):
if bpy.app.background is True:
return
if not (bpy.app.timers.is_registered(queue_worker)):
bpy.app.timers.register(queue_worker)
def get_queue():
# we pick just a random one of blender types, to try to get a persistent queue
t = bpy.types.Scene
if not hasattr(t, "task_queue"):
t.task_queue = queue.Queue()
return t.task_queue
class task_object:
def __init__(
self,
command="",
arguments=(),
wait=0,
only_last=False,
fake_context=False,
fake_context_area="VIEW_3D",
):
self.command = command
self.arguments = arguments
self.wait = wait
self.only_last = only_last
self.fake_context = fake_context
self.fake_context_area = fake_context_area
def add_task(
task: Tuple,
wait=0,
only_last=False,
fake_context=False,
fake_context_area="VIEW_3D",
):
q = get_queue()
taskob = task_object(
task[0],
task[1],
wait=wait,
only_last=only_last,
fake_context=fake_context,
fake_context_area=fake_context_area,
)
q.put(taskob)
# @bpy.app.handlers.persistent
def queue_worker():
# utils.p('start queue worker timer')
# bk_logger.debug('timer queue worker')
time_step = 0.3
q = get_queue()
# save some performance by returning early
if q.empty():
return time_step
back_to_queue = [] # delayed events
stashed = {}
# first round we get all tasks that are supposed to be stashed and run only once (only_last option)
# stashing finds tasks with the property only_last and same command and executes only the last one.
while not q.empty():
task = q.get()
if task.only_last:
# this now makes the keys not only by task, but also two arguments.
# by now stashing is only used for ratings, where the first argument is url, second rating type.
# This enables fast rating of multiple assets while allowing larger delay for uploading of ratings.
# this avoids a duplicate request error on the server
stashed[f"{task.command}-{task.arguments[0]}-{task.arguments[1]}"] = task
else:
back_to_queue.append(task)
# return tasks to que except for stashed
for task in back_to_queue:
q.put(task)
# return stashed tasks to queue
for k in stashed.keys():
q.put(stashed[k])
# second round, execute or put back waiting tasks.
back_to_queue = []
while not q.empty():
# print('window manager', bpy.context.window_manager)
task = q.get()
if task.wait > 0:
task.wait -= time_step
back_to_queue.append(task)
else:
bk_logger.debug(
"task queue task:" + str(task.command) + str(task.arguments)
)
try:
if task.fake_context:
fc = utils.get_fake_context(
bpy.context, area_type=task.fake_context_area
)
if bpy.app.version < (4, 0, 0):
task.command(fc, *task.arguments)
else:
with bpy.context.temp_override(**fc):
task.command(*task.arguments)
else:
task.command(*task.arguments)
except Exception as e:
bk_logger.error(
"task queue failed task:"
+ str(task.command)
+ str(task.arguments)
+ str(e)
)
# bk_logger.exception('Got exception on main handler')
# raise
# print('queue while 2')
for task in back_to_queue:
q.put(task)
# utils.p('end queue worker timer')
return time_step
def register():
bpy.app.handlers.load_post.append(scene_load)
def unregister():
bpy.app.handlers.load_post.remove(scene_load)

Some files were not shown because too many files have changed in this diff Show More