2025-07-01
This commit is contained in:
@@ -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)
|
||||
+339
@@ -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.
|
||||
+2591
File diff suppressed because it is too large
Load Diff
+1787
File diff suppressed because it is too large
Load Diff
+1555
File diff suppressed because it is too large
Load Diff
+511
@@ -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
|
||||
+2178
File diff suppressed because it is too large
Load Diff
+1188
File diff suppressed because it is too large
Load Diff
+447
@@ -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
|
||||
+859
@@ -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)
|
||||
+245
@@ -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)
|
||||
+267
@@ -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)
|
||||
+320
@@ -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)
|
||||
+67
@@ -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
|
||||
+273
@@ -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)
|
||||
+17
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
+37
@@ -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()
|
||||
+225
@@ -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
|
||||
+58
@@ -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
|
||||
+116
@@ -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)
|
||||
+108
@@ -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
|
||||
+91
@@ -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
|
||||
+235
@@ -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
|
||||
+20
@@ -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"
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+282
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+768
@@ -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
|
||||
+56
@@ -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}"
|
||||
+30
@@ -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."""
|
||||
+93
@@ -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
|
||||
+6210
File diff suppressed because it is too large
Load Diff
+148
@@ -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
|
||||
+327
@@ -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
|
||||
+1553
File diff suppressed because it is too large
Load Diff
+182
@@ -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."""
|
||||
+130
@@ -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()
|
||||
+589
@@ -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
|
||||
+107
@@ -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()
|
||||
+487
@@ -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
|
||||
+316
@@ -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)
|
||||
+491
@@ -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])
|
||||
+353
@@ -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"},
|
||||
]
|
||||
+183
@@ -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)
|
||||
+48
@@ -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
|
||||
|
||||
+391
@@ -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)
|
||||
+429
@@ -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)
|
||||
+116
@@ -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)
|
||||
+90
@@ -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
|
||||
+1778
File diff suppressed because it is too large
Load Diff
+161
@@ -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)
|
||||
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0001.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0002.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0003.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0004.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0005.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0006.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0007.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0008.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0009.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0010.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0015.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0020.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0030.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0050.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0100.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0150.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0200.png
LFS
BIN
Binary file not shown.
extensions/user_default/blenderkit/bl_ext.user_default.blenderkit_updater/backup/thumbnails/0250.png
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user