2025-12-01
This commit is contained in:
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. 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
|
||||
them 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 prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. 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.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey 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;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If 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 convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
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.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
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.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
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
|
||||
state 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 3 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, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program 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, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU 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. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Asset Pipeline
|
||||
|
||||
This add-on was designed to enable simultaneous work on the same asset (eg. a character) by multiple artists.
|
||||
You can find the documentation [here](https://studio.blender.org/tools/addons/asset_pipeline).
|
||||
@@ -0,0 +1,52 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import importlib
|
||||
|
||||
from . import ui, ops, props, prefs
|
||||
|
||||
bl_info = {
|
||||
"name": "Asset Pipeline",
|
||||
"author": "Nick Alberelli",
|
||||
"description": "Asset data merger for studio collaboration",
|
||||
"blender": (4, 1, 0),
|
||||
"version": (0, 3, 0),
|
||||
"location": "View3D",
|
||||
"warning": "",
|
||||
"doc_url": "",
|
||||
"tracker_url": "https://projects.blender.org/studio/blender-studio-tools/src/branch/main/scripts-blender/addons/asset_pipeline",
|
||||
"category": "Generic",
|
||||
}
|
||||
|
||||
|
||||
def reload() -> None:
|
||||
global ui
|
||||
global ops
|
||||
global props
|
||||
global prefs
|
||||
importlib.reload(ui)
|
||||
importlib.reload(ops)
|
||||
importlib.reload(props)
|
||||
importlib.reload(prefs)
|
||||
|
||||
|
||||
_need_reload = "ui" in locals()
|
||||
if _need_reload:
|
||||
reload()
|
||||
|
||||
# ----------------REGISTER--------------.
|
||||
|
||||
|
||||
def register() -> None:
|
||||
ui.register()
|
||||
ops.register()
|
||||
props.register()
|
||||
prefs.register()
|
||||
|
||||
|
||||
def unregister() -> None:
|
||||
ui.unregister()
|
||||
ops.unregister()
|
||||
props.unregister()
|
||||
prefs.unregister()
|
||||
@@ -0,0 +1,109 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import bpy
|
||||
from typing import List
|
||||
|
||||
asset_file_cache = None
|
||||
cat_data_cache = None
|
||||
asset_cat_dict = {}
|
||||
|
||||
|
||||
def find_asset_cat_file(directory: str) -> str:
|
||||
"""Find Asset Catalog file in directory or parent directories, recursively
|
||||
|
||||
Args:
|
||||
directory (str): Directory to search for Asset Catalog file
|
||||
|
||||
Returns:
|
||||
str: Path to Asset Catalog file or None if not found
|
||||
"""
|
||||
global asset_file_cache
|
||||
if asset_file_cache is not None:
|
||||
return asset_file_cache
|
||||
asset_file = os.path.join(directory, "blender_assets.cats.txt")
|
||||
if os.path.exists(asset_file):
|
||||
return asset_file
|
||||
|
||||
parent_dir = os.path.dirname(directory)
|
||||
if parent_dir == directory:
|
||||
return None
|
||||
|
||||
return find_asset_cat_file(parent_dir)
|
||||
|
||||
|
||||
def get_asset_catalog_items(reload: bool = False) -> List[str]:
|
||||
"""Generate List of Asset Catalog Items, and Dictionary of
|
||||
Asset Catalog UUIDs with their matching names. When this function
|
||||
is called the list and dict of asset catalog items will be updated.
|
||||
|
||||
The List is used in the UI to populate the Asset Catalog List, and the
|
||||
Dictionary is used to look up the asset catalog UUID based on the name.
|
||||
|
||||
Args:
|
||||
reload (bool, optional): Forces reload of list/dict if True. Defaults to False.
|
||||
|
||||
Returns:
|
||||
List[str]: Returns list of strings representing Asset Catalog Names
|
||||
"""
|
||||
global cat_data_cache
|
||||
global asset_cat_dict
|
||||
asset_cat_list = []
|
||||
|
||||
# Return Empty List if File doesn't exist
|
||||
asset_cat_file = find_asset_cat_file(Path(bpy.data.filepath).parent.__str__())
|
||||
if asset_cat_file is None:
|
||||
return asset_cat_list
|
||||
|
||||
# Return Cached List if exists and reload is False
|
||||
if cat_data_cache is not None and not reload:
|
||||
return cat_data_cache
|
||||
|
||||
asset_cat_dict.clear() # Reset dict so it is in sync with name list
|
||||
|
||||
# Loop over items in file to find asset catalog
|
||||
with (Path(asset_cat_file)).open() as file:
|
||||
for line in file.readlines():
|
||||
if line.startswith(("#", "VERSION", "\n")):
|
||||
continue
|
||||
# Each line contains : 'uuid:catalog_tree:catalog_name' + eol ('\n')
|
||||
name = line.split(':', 1)[1].split(":")[-1].strip("\n")
|
||||
uuid = line.split(':', 1)[0]
|
||||
asset_cat_dict[uuid] = name # Populate dict of uuid:name
|
||||
asset_cat_list.append(name) # Populate list of asset catalogue names
|
||||
|
||||
cat_data_cache = asset_cat_list # Update Cache List
|
||||
return asset_cat_list
|
||||
|
||||
|
||||
def get_asset_id(name: str) -> str:
|
||||
"""Get Asset Catalog UUID based on Asset Catalog Name
|
||||
|
||||
Args:
|
||||
name (str): Asset Catalog Name
|
||||
|
||||
Returns:
|
||||
str: Asset Catalog UUID or None if not found
|
||||
"""
|
||||
global asset_cat_dict
|
||||
for key, value in asset_cat_dict.items():
|
||||
if value == name:
|
||||
return key
|
||||
|
||||
|
||||
def get_asset_name(id: str) -> str:
|
||||
"""Get Asset Catalog UUID based on Asset Catalog Name
|
||||
|
||||
Args:
|
||||
name (str): Asset Catalog Name
|
||||
|
||||
Returns:
|
||||
str: Asset Catalog UUID or None if not found
|
||||
"""
|
||||
global asset_cat_dict
|
||||
for key, value in asset_cat_dict.items():
|
||||
if key == id:
|
||||
return value
|
||||
@@ -0,0 +1,19 @@
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "blender_studio_asset_pipeline"
|
||||
version = "0.3.0"
|
||||
name = "Blender Studio Asset Pipeline"
|
||||
tagline = "Asset data merger for studio collaboration"
|
||||
maintainer = "Demeter Dzadik <demeter@blender.org>"
|
||||
type = "add-on"
|
||||
website = "https://studio.blender.org/tools/addons/asset_pipeline"
|
||||
tags = ["Pipeline"]
|
||||
|
||||
blender_version_min = "4.1.0"
|
||||
|
||||
license = [
|
||||
"SPDX:GPL-3.0-or-later",
|
||||
]
|
||||
copyright = [
|
||||
"2024-25 Nick Alberelli & Blender Studio"
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from pathlib import Path
|
||||
import json
|
||||
from . import constants
|
||||
|
||||
|
||||
TASK_LAYER_TYPES = {}
|
||||
TRANSFER_DATA_DEFAULTS = {}
|
||||
ATTRIBUTE_DEFAULTS = {}
|
||||
ASSET_CATALOG_ID = ""
|
||||
|
||||
|
||||
def get_task_layer_json_filepath() -> Path:
|
||||
directory = Path(bpy.data.filepath).parent
|
||||
json_file_path = directory.joinpath(constants.TASK_LAYER_CONFIG_NAME)
|
||||
return json_file_path
|
||||
|
||||
|
||||
def get_task_layer_dict(file_path_str="") -> dict:
|
||||
if file_path_str == "":
|
||||
json_file_path = get_task_layer_json_filepath()
|
||||
else:
|
||||
json_file_path = Path(file_path_str)
|
||||
if not json_file_path.exists():
|
||||
return
|
||||
return json.load(open(json_file_path))
|
||||
|
||||
|
||||
def get_task_layer_presets_path():
|
||||
return Path(__file__).parent.joinpath(constants.TASK_LAYER_CONFIG_DIR_NAME)
|
||||
|
||||
|
||||
def verify_task_layer_json_data(json_file_path=""):
|
||||
global TASK_LAYER_TYPES
|
||||
global TRANSFER_DATA_DEFAULTS
|
||||
global ATTRIBUTE_DEFAULTS
|
||||
global ASSET_CATALOG_ID
|
||||
|
||||
json_content = get_task_layer_dict(json_file_path)
|
||||
|
||||
if not json_content:
|
||||
return
|
||||
try:
|
||||
TASK_LAYER_TYPES = json_content["TASK_LAYER_TYPES"]
|
||||
TRANSFER_DATA_DEFAULTS = json_content["TRANSFER_DATA_DEFAULTS"]
|
||||
ATTRIBUTE_DEFAULTS = json_content["ATTRIBUTE_DEFAULTS"]
|
||||
|
||||
# Asset Catalog is an optional value in task_layers.json and doesn't exist by default
|
||||
if "ASSET_CATALOG_ID" in json_content:
|
||||
ASSET_CATALOG_ID = json_content["ASSET_CATALOG_ID"]
|
||||
return True
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
|
||||
def write_json_file(asset_path: Path, source_file_path: Path):
|
||||
json_file_path = asset_path.joinpath(constants.TASK_LAYER_CONFIG_NAME)
|
||||
json_file = open(source_file_path)
|
||||
json_content = json.load(json_file)
|
||||
json_dump = json.dumps(json_content, indent=4)
|
||||
with open(json_file_path, "w") as config_output:
|
||||
config_output.write(json_dump)
|
||||
|
||||
|
||||
def update_task_layer_json_data(task_layer_dict: dict):
|
||||
filepath = get_task_layer_json_filepath()
|
||||
with filepath.open("w") as json_file:
|
||||
json.dump(task_layer_dict, json_file, indent=4)
|
||||
verify_task_layer_json_data()
|
||||
@@ -0,0 +1,128 @@
|
||||
ADDON_NAME = "asset_pipeline"
|
||||
|
||||
# Delimiter used for naming data within Blender
|
||||
NAME_DELIMITER = "-"
|
||||
|
||||
# Delimiter used for naming .blend files
|
||||
FILE_DELIMITER = NAME_DELIMITER
|
||||
|
||||
###################
|
||||
# MERGE
|
||||
###################
|
||||
|
||||
# Delimiter used by suffixes in the merge process
|
||||
MERGE_DELIMITER = "."
|
||||
|
||||
# Suffixes used when naming items to merge. Max 3 chars!
|
||||
LOCAL_SUFFIX = "LOC"
|
||||
EXTERNAL_SUFFIX = "EXT"
|
||||
|
||||
|
||||
###################
|
||||
# Task Layers
|
||||
###################
|
||||
|
||||
# Name of directory containing task layer prefixes internal to add-on
|
||||
TASK_LAYER_CONFIG_DIR_NAME = "task_layer_configs"
|
||||
|
||||
# Name of task layer file found a the root of an asset
|
||||
TASK_LAYER_CONFIG_NAME = "task_layers.json"
|
||||
|
||||
|
||||
###################
|
||||
# Transferable Data
|
||||
###################
|
||||
|
||||
# Keys for transferable data
|
||||
NONE_KEY = "NONE"
|
||||
VERTEX_GROUP_KEY = "GROUP_VERTEX"
|
||||
MODIFIER_KEY = "MODIFIER"
|
||||
CONSTRAINT_KEY = "CONSTRAINT"
|
||||
MATERIAL_SLOT_KEY = "MATERIAL"
|
||||
SHAPE_KEY_KEY = "SHAPE_KEY"
|
||||
ATTRIBUTE_KEY = "ATTRIBUTE"
|
||||
PARENT_KEY = "PARENT"
|
||||
CUSTOM_PROP_KEY = "CUSTOM_PROP"
|
||||
|
||||
# Information about supported transferable data.
|
||||
# UI Bools are defined in props.py file
|
||||
# {Key string : ("UI Name", 'ICON')}
|
||||
TRANSFER_DATA_TYPES = {
|
||||
NONE_KEY: ("None", "BLANK1"),
|
||||
VERTEX_GROUP_KEY: ("Vertex Groups", 'GROUP_VERTEX'),
|
||||
MODIFIER_KEY: ("Modifiers", 'MODIFIER'),
|
||||
CONSTRAINT_KEY: ("Constraints", 'CONSTRAINT'),
|
||||
MATERIAL_SLOT_KEY: ("Materials", 'MATERIAL'),
|
||||
SHAPE_KEY_KEY: ("Shape Keys", 'SHAPEKEY_DATA'),
|
||||
ATTRIBUTE_KEY: ("Attributes", 'MOD_DATA_TRANSFER'),
|
||||
PARENT_KEY: ("Parent", 'FILE_PARENT'),
|
||||
CUSTOM_PROP_KEY: ("Custom Properties", 'PROPERTIES'),
|
||||
}
|
||||
|
||||
# Convert it to the format that EnumProperty.items wants:
|
||||
# List of 5-tuples; Re-use name as description at 3rd element, add index at 5th.
|
||||
TRANSFER_DATA_TYPES_ENUM_ITEMS = [
|
||||
(tup[0], tup[1][0], tup[1][0], tup[1][1], i)
|
||||
for i, tup in enumerate(TRANSFER_DATA_TYPES.items())
|
||||
]
|
||||
|
||||
|
||||
# Name used in all material transferable data
|
||||
MATERIAL_TRANSFER_DATA_ITEM_NAME = "All Materials"
|
||||
|
||||
# Name used in parent transferable data
|
||||
PARENT_TRANSFER_DATA_ITEM_NAME = "Parent Relationship"
|
||||
|
||||
MATERIAL_ATTRIBUTE_NAME = "material_index"
|
||||
|
||||
ADDON_OWN_PROPERTIES = ['asset_id_owner', 'asset_id_surrender', 'transfer_data_ownership']
|
||||
|
||||
###################
|
||||
# SHARED IDs
|
||||
###################
|
||||
|
||||
# SHARED ID Icons
|
||||
GEO_NODE = "GEOMETRY_NODES"
|
||||
IMAGE = "IMAGE_DATA"
|
||||
BLANK = "BLANK1"
|
||||
|
||||
|
||||
###################
|
||||
# Publish
|
||||
###################
|
||||
|
||||
# List of different states used when Publishing a Final Asset
|
||||
PUBLISH_TYPES = [
|
||||
(
|
||||
"publish",
|
||||
"Active",
|
||||
"Active version that will become the latest published version, used in production files",
|
||||
),
|
||||
(
|
||||
"staged",
|
||||
"Staged",
|
||||
"""Staged version that will replace the last active version as the Push/Pull/Sync target. Not used in production files""",
|
||||
),
|
||||
(
|
||||
"sandbox",
|
||||
"Sandbox",
|
||||
"Test the results that will be published in the sandbox area, will not be used as Push/Pull target",
|
||||
),
|
||||
]
|
||||
PUBLISH_KEYS = [pub_type[0] for pub_type in PUBLISH_TYPES]
|
||||
ACTIVE_PUBLISH_KEY = PUBLISH_KEYS[0]
|
||||
STAGED_PUBLISH_KEY = PUBLISH_KEYS[1]
|
||||
SANDBOX_PUBLISH_KEY = PUBLISH_KEYS[2]
|
||||
|
||||
|
||||
#############
|
||||
# Logging
|
||||
#############
|
||||
|
||||
LOGGER_LEVEL_ITEMS = (
|
||||
('10', 'Debug', ''),
|
||||
('20', 'Info', ''),
|
||||
('30', 'Warning', ''),
|
||||
('40', 'Error', ''),
|
||||
('50', 'Critical', ''),
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import bpy
|
||||
|
||||
from asset_pipeline.hooks import hook
|
||||
|
||||
|
||||
'''
|
||||
|
||||
Rules:
|
||||
merge_mode: ['pull', 'push'] # Run hook only during pull or push (both if left blank)
|
||||
merge_status: ['pre', 'post'] # Run hook either before or after push/pull (both if left blank)
|
||||
|
||||
Keyword Arguments:
|
||||
asset_col: bpy.types.Collection # Get the top level collection for the current asset
|
||||
|
||||
Notes:
|
||||
Function Naming: Must be unique between production hooks and asset hooks files
|
||||
Production Hook Path: 'your_project_name/svn/pro/assets/scripts/asset_pipeline/hooks.py'
|
||||
Asset Hook Path: 'your_project_name/svn/pro/assets/{asset_type}/{asset_name}/hooks.py'
|
||||
|
||||
|
||||
'''
|
||||
|
||||
|
||||
@hook(merge_mode='pull', merge_status="pre")
|
||||
def asset_pre_pull(asset_col: bpy.types.Collection, **kwargs):
|
||||
# Only runs before pull
|
||||
print(f"Asset Collection Name '{asset_col.name}'")
|
||||
print("PRE PULL asset hook running!")
|
||||
|
||||
|
||||
@hook(merge_mode='pull', merge_status="post")
|
||||
def asset_post_pull(**kwargs):
|
||||
# Only runs after pull
|
||||
print("POST PULL asset hook running!")
|
||||
|
||||
|
||||
@hook(merge_mode='push', merge_status="pre")
|
||||
def asset_pre_push(**kwargs):
|
||||
# Only runs before push
|
||||
print("PRE PUSH asset hook running!")
|
||||
|
||||
|
||||
@hook(merge_mode='push', merge_status="post")
|
||||
def asset_post_push(**kwargs):
|
||||
# Only runs after push
|
||||
print("POST PUSH asset hook running!")
|
||||
@@ -0,0 +1,46 @@
|
||||
import bpy
|
||||
|
||||
from asset_pipeline.hooks import hook
|
||||
|
||||
|
||||
'''
|
||||
|
||||
Rules:
|
||||
merge_mode: ['pull', 'push'] # Run hook only during pull or push (both if left blank)
|
||||
merge_status: ['pre', 'post'] # Run hook either before or after push/pull (both if left blank)
|
||||
|
||||
Keyword Arguments:
|
||||
asset_col: bpy.types.Collection # Get the top level collection for the current asset
|
||||
|
||||
Notes:
|
||||
Function Naming: Must be unique between production hooks and asset hooks files
|
||||
Production Hook Path: 'your_project_name/svn/pro/assets/scripts/asset_pipeline/hooks.py'
|
||||
Asset Hook Path: 'your_project_name/svn/pro/assets/{asset_type}/{asset_name}/hooks.py'
|
||||
|
||||
|
||||
'''
|
||||
|
||||
|
||||
@hook(merge_mode='pull', merge_status="pre")
|
||||
def prod_pre_pull(asset_col: bpy.types.Collection, **kwargs):
|
||||
# Only runs before pull
|
||||
print(f"Asset Collection Name '{asset_col.name}'")
|
||||
print("PRE PULL production level asset hook running!")
|
||||
|
||||
|
||||
@hook(merge_mode='pull', merge_status="post")
|
||||
def prod_post_pull(**kwargs):
|
||||
# Only runs after pull
|
||||
print("POST PULL production level asset hook running!")
|
||||
|
||||
|
||||
@hook(merge_mode='push', merge_status="pre")
|
||||
def prod_pre_push(**kwargs):
|
||||
# Only runs before push
|
||||
print("PRE PUSH production level asset hook running!")
|
||||
|
||||
|
||||
@hook(merge_mode='push', merge_status="post")
|
||||
def prod_post_push(**kwargs):
|
||||
# Only runs after push
|
||||
print("POST PUSH production level asset hook running!")
|
||||
@@ -0,0 +1,203 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import sys
|
||||
import pathlib
|
||||
from typing import *
|
||||
import bpy
|
||||
import typing
|
||||
import types
|
||||
import importlib
|
||||
from . import prefs, logging
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Wildcard:
|
||||
pass
|
||||
|
||||
|
||||
class DoNotMatch:
|
||||
pass
|
||||
|
||||
|
||||
MatchCriteriaType = typing.Union[
|
||||
str, typing.List[str], typing.Type[Wildcard], typing.Type[DoNotMatch]
|
||||
]
|
||||
"""
|
||||
The MatchCriteriaType is a type definition for the parameters of the `hook` decorator.
|
||||
|
||||
The matching parameters can use multiple types to detect how the matching criteria
|
||||
would work.
|
||||
|
||||
* `str`: would perform an exact string match.
|
||||
* `typing.Iterator[str]`: would perform an exact string match with any of the given strings.
|
||||
* `typing.Type[Wildcard]`: would match any type for this parameter. This would be used so a hook
|
||||
is called for any value.
|
||||
* `typing.Type[DoNotMatch]`: would ignore this hook when matching the hook parameter. This is the default
|
||||
value for the matching criteria and would normally not be set directly in a
|
||||
production configuration.
|
||||
"""
|
||||
|
||||
MatchingRulesType = typing.Dict[str, MatchCriteriaType]
|
||||
"""
|
||||
Hooks are stored as `_asset_pipeline_rules' attribute on the function.
|
||||
The MatchingRulesType is the type definition of the `_asset_pipeline_rules` attribute.
|
||||
"""
|
||||
|
||||
HookFunction = typing.Callable[[typing.Any], None]
|
||||
|
||||
|
||||
def _match_hook_parameter(
|
||||
hook_criteria: MatchCriteriaType, match_query: typing.Optional[str]
|
||||
) -> bool:
|
||||
if hook_criteria == None:
|
||||
return True
|
||||
if hook_criteria == DoNotMatch:
|
||||
return match_query is None
|
||||
if hook_criteria == Wildcard:
|
||||
return True
|
||||
if isinstance(hook_criteria, str):
|
||||
return match_query == hook_criteria
|
||||
if isinstance(hook_criteria, list):
|
||||
return match_query in hook_criteria
|
||||
return False
|
||||
|
||||
|
||||
class Hooks:
|
||||
def __init__(self):
|
||||
self._hooks: typing.List[HookFunction] = []
|
||||
|
||||
def matches(
|
||||
self,
|
||||
hook: HookFunction,
|
||||
merge_mode: typing.Optional[str] = None,
|
||||
merge_status: typing.Optional[str] = None,
|
||||
**kwargs: typing.Optional[str],
|
||||
) -> bool:
|
||||
assert not kwargs
|
||||
rules = typing.cast(MatchingRulesType, getattr(hook, '_asset_pipeline_rules'))
|
||||
return all(
|
||||
(
|
||||
_match_hook_parameter(rules['merge_mode'], merge_mode),
|
||||
_match_hook_parameter(rules['merge_status'], merge_status),
|
||||
)
|
||||
)
|
||||
|
||||
def filter(self, **kwargs: typing.Optional[str]) -> typing.Iterator[HookFunction]:
|
||||
for hook in self._hooks:
|
||||
if self.matches(hook=hook, **kwargs):
|
||||
yield hook
|
||||
|
||||
def execute_hooks(
|
||||
self, merge_mode: str = None, merge_status: str = None, *args, **kwargs
|
||||
) -> None:
|
||||
for hook in self._hooks:
|
||||
if self.matches(hook, merge_mode=merge_mode, merge_status=merge_status):
|
||||
hook(*args, **kwargs)
|
||||
|
||||
def import_hook(self, path: List[str]) -> None:
|
||||
logger = logging.get_logger()
|
||||
with SystemPathInclude(path) as _include:
|
||||
try:
|
||||
import hooks as production_hooks
|
||||
|
||||
importlib.reload(production_hooks)
|
||||
self.register_hooks(production_hooks)
|
||||
except ModuleNotFoundError:
|
||||
logger.warning(f"Did not find `hooks.py` configuration file at {path}")
|
||||
|
||||
def load_hooks(self, context):
|
||||
prod_hooks = get_production_hook_dir()
|
||||
asset_hooks = get_asset_hook_dir()
|
||||
for path in [prod_hooks.resolve().__str__(), asset_hooks.resolve().__str__()]:
|
||||
self.import_hook([path])
|
||||
|
||||
def register(self, func: HookFunction) -> None:
|
||||
if func not in self._hooks:
|
||||
self._hooks.append(func)
|
||||
|
||||
def register_hooks(self, module: types.ModuleType) -> None:
|
||||
"""
|
||||
Register all hooks inside the given module.
|
||||
"""
|
||||
for module_item_str in dir(module):
|
||||
module_item = getattr(module, module_item_str)
|
||||
if not isinstance(module_item, types.FunctionType):
|
||||
continue
|
||||
if module_item.__module__ != module.__name__:
|
||||
continue
|
||||
if not hasattr(module_item, "_asset_pipeline_rules"):
|
||||
continue
|
||||
self.register(module_item)
|
||||
|
||||
|
||||
def get_production_hook_dir() -> Path:
|
||||
root_dir = Path(prefs.project_root_dir_get())
|
||||
asset_dir = root_dir.joinpath("svn/pro/")
|
||||
if not asset_dir.exists():
|
||||
raise Exception(f"Directory {str(asset_dir)} doesn't exist")
|
||||
hook_dir = asset_dir.joinpath("config/asset_pipeline")
|
||||
hook_dir.mkdir(parents=True, exist_ok=True)
|
||||
return hook_dir
|
||||
|
||||
|
||||
def get_asset_hook_dir() -> Path:
|
||||
return Path(bpy.data.filepath).parent
|
||||
|
||||
|
||||
def hook(
|
||||
merge_mode: MatchCriteriaType = None,
|
||||
merge_status: MatchCriteriaType = None,
|
||||
) -> typing.Callable[[types.FunctionType], types.FunctionType]:
|
||||
"""
|
||||
Decorator to add custom logic when pushing/pulling an asset.
|
||||
|
||||
Hooks are used to extend the configuration that would be not part of the core logic of the asset pipeline.
|
||||
"""
|
||||
rules = {
|
||||
'merge_mode': merge_mode,
|
||||
'merge_status': merge_status,
|
||||
}
|
||||
|
||||
def wrapper(func: types.FunctionType) -> types.FunctionType:
|
||||
setattr(func, '_asset_pipeline_rules', rules)
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class SystemPathInclude:
|
||||
"""
|
||||
Resource class to temporary include system paths to `sys.paths`.
|
||||
|
||||
Usage:
|
||||
```
|
||||
paths = [pathlib.Path("/home/guest/my_python_scripts")]
|
||||
with SystemPathInclude(paths) as t:
|
||||
import my_module
|
||||
reload(my_module)
|
||||
```
|
||||
|
||||
It is possible to nest multiple SystemPathIncludes.
|
||||
"""
|
||||
|
||||
def __init__(self, paths_to_add: List[pathlib.Path]):
|
||||
# TODO: Check if all paths exist and are absolute.
|
||||
self.__paths = paths_to_add
|
||||
self.__original_sys_path: List[str] = []
|
||||
|
||||
def __enter__(self):
|
||||
self.__original_sys_path = sys.path
|
||||
new_sys_path = []
|
||||
for path_to_add in self.__paths:
|
||||
# Do not add paths that are already in the sys path.
|
||||
path_to_add_str = str(path_to_add)
|
||||
if path_to_add_str in self.__original_sys_path:
|
||||
continue
|
||||
new_sys_path.append(path_to_add_str)
|
||||
new_sys_path.extend(self.__original_sys_path)
|
||||
sys.path = new_sys_path
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
sys.path = self.__original_sys_path
|
||||
@@ -0,0 +1,18 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from pathlib import Path
|
||||
from .prefs import get_addon_prefs
|
||||
|
||||
|
||||
def save_images():
|
||||
prefs = get_addon_prefs()
|
||||
user_path = Path(prefs.save_images_path)
|
||||
default_path = Path(bpy.data.filepath).parent.joinpath("images")
|
||||
save_path = default_path if prefs.save_images_path == "" else user_path
|
||||
for img in bpy.data.images:
|
||||
if img.is_dirty:
|
||||
filepath = save_path.joinpath(img.name).__str__() + ".png"
|
||||
img.save(filepath=filepath)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
from . import prefs, constants
|
||||
|
||||
|
||||
def get_logger(name="asset_pipeline"):
|
||||
logger = logging.getLogger(name)
|
||||
addon_prefs = prefs.get_addon_prefs()
|
||||
logging_level = int(addon_prefs.logger_level)
|
||||
# Return logger if it has already been setup
|
||||
if len(logger.handlers) > 0:
|
||||
return logger
|
||||
|
||||
# create console handler and set level to debug
|
||||
logger.setLevel(logging_level)
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging_level)
|
||||
|
||||
# create formatter
|
||||
formatter = logging.Formatter('%(levelname)s-%(name)s: %(message)s')
|
||||
|
||||
# add formatter to ch
|
||||
ch.setFormatter(formatter)
|
||||
|
||||
# add ch to logger
|
||||
logger.addHandler(ch)
|
||||
return logger
|
||||
|
||||
|
||||
PROFILE_KEYS = {
|
||||
"IMPORT": "To import Collection & add suffixes",
|
||||
"MAPPING": "To create Asset Mapping",
|
||||
"TRANSFER_DATA": "To apply all Transferable Data",
|
||||
"OBJECTS": "To remap all Obejcts",
|
||||
"INDEXES": "To restore Active Indexes on all Objects",
|
||||
"COLLECTIONS": "To remap all Collections",
|
||||
"SHARED_IDS": "To remap all Shared IDs",
|
||||
"MERGE": "To complete entire merge process",
|
||||
"TOTAL": "Total time to sync this direction",
|
||||
}
|
||||
|
||||
TD_KEYS = [type for type in constants.TRANSFER_DATA_TYPES]
|
||||
|
||||
INFO_KEYS = ["TOTAL"] # Profile Keys to print in the logger's info mode
|
||||
|
||||
_profiler_instance = None
|
||||
|
||||
|
||||
def get_profiler():
|
||||
global _profiler_instance
|
||||
if not _profiler_instance:
|
||||
_profiler_instance = Profiler()
|
||||
return _profiler_instance
|
||||
|
||||
|
||||
class Profiler:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.pull_profiles = {}
|
||||
self.push_profiles = {}
|
||||
self._logger = get_logger()
|
||||
|
||||
def add(self, elapsed_time: int, key: str):
|
||||
if self._is_push:
|
||||
profiles = self.push_profiles
|
||||
else: # is pull
|
||||
profiles = self.pull_profiles
|
||||
|
||||
if key not in profiles:
|
||||
profiles[key] = elapsed_time
|
||||
else:
|
||||
profiles[key] += elapsed_time
|
||||
|
||||
def log_all(self):
|
||||
self.log_profiles("PULL", self.pull_profiles)
|
||||
self.log_profiles("PUSH", self.push_profiles)
|
||||
|
||||
def log_profiles(self, direction: str, profiles: dict):
|
||||
if profiles == {}:
|
||||
return
|
||||
for key, value in profiles.items():
|
||||
seconds = self.get_non_scientific_number(value)
|
||||
# Special case for transfer data keys
|
||||
if key in TD_KEYS:
|
||||
name = constants.TRANSFER_DATA_TYPES[key][0]
|
||||
self._logger.debug(
|
||||
f"{direction} TD: {name.upper()} - {seconds} seconds to transfer {name} data for all objects"
|
||||
)
|
||||
continue
|
||||
msg = f"{direction} {key} - {seconds} seconds {PROFILE_KEYS[key]}"
|
||||
if key in INFO_KEYS:
|
||||
self._logger.info(msg)
|
||||
else:
|
||||
self._logger.debug(msg)
|
||||
|
||||
def get_non_scientific_number(self, x: float):
|
||||
float_str = f'{x:.64f}'.rstrip('0')
|
||||
|
||||
significant_digits = 0
|
||||
for index, c in enumerate(float_str):
|
||||
if significant_digits == 3:
|
||||
return float_str[:index:]
|
||||
|
||||
if c != "0" and c != ".":
|
||||
significant_digits += 1
|
||||
|
||||
def reset(self):
|
||||
self.pull_profiles = {}
|
||||
self._is_push = False
|
||||
self._logger = get_logger()
|
||||
|
||||
def set_push(self, is_push=True):
|
||||
self._is_push = is_push
|
||||
@@ -0,0 +1,329 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from typing import Dict, Set
|
||||
from .naming import (
|
||||
merge_get_target_name,
|
||||
task_layer_prefix_basename_get,
|
||||
)
|
||||
from .util import get_storage_of_id
|
||||
from .shared_ids import get_shared_ids
|
||||
from .. import constants, logging
|
||||
|
||||
|
||||
class AssetTransferMapping:
|
||||
"""
|
||||
The AssetTranfserMapping class represents a mapping between a source and a target.
|
||||
It contains an object mapping which connects each source object with a target
|
||||
object as well as a collection mapping.
|
||||
The mapping process relies heavily on suffixes, which is why we use
|
||||
MergeCollections as input that store a suffix.
|
||||
|
||||
Instances of this class will be pased TaskLayer data transfer function so Users
|
||||
can easily write their merge instructions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
local_coll: bpy.types.Collection,
|
||||
external_coll: bpy.types.Collection,
|
||||
local_tls: Set[str],
|
||||
):
|
||||
self._local_top_col = local_coll
|
||||
self._external_col = external_coll
|
||||
self._local_tls = local_tls
|
||||
|
||||
self.external_col_to_remove: Set[bpy.types.Object] = set()
|
||||
self.external_col_to_add: Set[bpy.types.Object] = set()
|
||||
self.external_obj_to_add: Set[bpy.types.Object] = set()
|
||||
self.surrendered_obj_to_remove: Set[bpy.types.Object] = set()
|
||||
self._no_match_source_objs: Set[bpy.types.Object] = set()
|
||||
|
||||
self._no_match_source_colls: Set[bpy.types.Object] = set()
|
||||
self._no_match_target_colls: Set[bpy.types.Object] = set()
|
||||
|
||||
self.conflict_ids: list[bpy.types.ID] = []
|
||||
self.conflict_transfer_data = [] # Item of bpy.types.CollectionProperty
|
||||
self.transfer_data_map: Dict[bpy.types.Collection, bpy.types.Collection] = {}
|
||||
|
||||
self.logger = logging.get_logger()
|
||||
|
||||
self.generate_mapping()
|
||||
|
||||
def generate_mapping(self) -> None:
|
||||
self.object_map = self._gen_object_map()
|
||||
self.collection_map = self._gen_collection_map()
|
||||
self.shared_id_map = self._gen_shared_id_map()
|
||||
self._gen_transfer_data_map()
|
||||
self.index_map = self._gen_active_index_map()
|
||||
|
||||
def _get_external_object(self, local_obj):
|
||||
external_obj_name = merge_get_target_name(
|
||||
local_obj.name,
|
||||
)
|
||||
external_obj = self._external_col.all_objects.get(external_obj_name)
|
||||
if not external_obj:
|
||||
self.logger.debug(f"Failed to find match obj {external_obj_name} for {local_obj.name}")
|
||||
self._no_match_source_objs.add(local_obj)
|
||||
return
|
||||
return external_obj
|
||||
|
||||
def _check_id_conflict(self, external_id, local_id):
|
||||
if local_id.asset_id_owner not in self._local_tls:
|
||||
# If the local ID was not owned by any task layer of the current file
|
||||
# in the first place, there cannot be a conflict.
|
||||
return
|
||||
if external_id.asset_id_owner != local_id.asset_id_owner and (
|
||||
local_id.asset_id_surrender == external_id.asset_id_surrender
|
||||
):
|
||||
self.conflict_ids.append(local_id)
|
||||
|
||||
def _gen_object_map(self) -> Dict[bpy.types.Object, bpy.types.Object]:
|
||||
"""
|
||||
Tries to link all objects in source collection to an object in
|
||||
target collection. Uses suffixes to match them up.
|
||||
"""
|
||||
object_map: Dict[bpy.types.Object, bpy.types.Object] = {}
|
||||
for local_obj in self._local_top_col.all_objects:
|
||||
# Skip items with no owner
|
||||
if local_obj.asset_id_owner == "NONE":
|
||||
continue
|
||||
if local_obj.library:
|
||||
continue
|
||||
external_obj = self._get_external_object(local_obj)
|
||||
if not external_obj:
|
||||
self.logger.debug(f"Couldn't find external obj for {local_obj}")
|
||||
continue
|
||||
self._check_id_conflict(external_obj, local_obj)
|
||||
# IF ITEM IS OWNED BY LOCAL TASK LAYERS
|
||||
|
||||
if (
|
||||
external_obj.asset_id_surrender
|
||||
and not local_obj.asset_id_surrender
|
||||
and local_obj.asset_id_owner != external_obj.asset_id_owner
|
||||
):
|
||||
self.logger.debug(f"Skipping {external_obj} is surrendered")
|
||||
object_map[external_obj] = local_obj
|
||||
continue
|
||||
|
||||
if (
|
||||
local_obj.asset_id_surrender
|
||||
and not external_obj.asset_id_surrender
|
||||
and local_obj.asset_id_owner != external_obj.asset_id_owner
|
||||
):
|
||||
self.logger.debug(f"Skipping {local_obj} is surrendered")
|
||||
object_map[local_obj] = external_obj
|
||||
continue
|
||||
|
||||
if local_obj.asset_id_owner in self._local_tls:
|
||||
object_map[external_obj] = local_obj
|
||||
# IF ITEM IS NOT OWNED BY LOCAL TASK LAYERS
|
||||
else:
|
||||
object_map[local_obj] = external_obj
|
||||
|
||||
# Find new objects to add to local_col
|
||||
for external_obj in self._external_col.all_objects:
|
||||
if external_obj.library:
|
||||
continue
|
||||
local_col_objs = self._local_top_col.all_objects
|
||||
obj = local_col_objs.get(merge_get_target_name(external_obj.name))
|
||||
if not obj and external_obj.asset_id_owner not in self._local_tls:
|
||||
self.external_obj_to_add.add(external_obj)
|
||||
return object_map
|
||||
|
||||
def _gen_collection_map(self) -> Dict[bpy.types.Collection, bpy.types.Collection]:
|
||||
"""
|
||||
Tries to link all source collections to a target collection.
|
||||
Uses suffixes to match them up.
|
||||
"""
|
||||
coll_map: Dict[bpy.types.Collection, bpy.types.Collection] = {}
|
||||
|
||||
for local_task_layer_col in self._local_top_col.children:
|
||||
if local_task_layer_col.asset_id_owner not in self._local_tls:
|
||||
# Replace source object suffix with target suffix to get target object.
|
||||
external_col_name = merge_get_target_name(local_task_layer_col.name)
|
||||
local_col = bpy.data.collections.get(external_col_name)
|
||||
if local_col:
|
||||
coll_map[local_task_layer_col] = local_col
|
||||
else:
|
||||
self.logger.debug(
|
||||
f"Failed to find match collection {local_task_layer_col.name} for {external_col_name}"
|
||||
)
|
||||
self._no_match_source_colls.add(local_task_layer_col)
|
||||
|
||||
external_top_col_name = merge_get_target_name(self._local_top_col.name)
|
||||
external_top_col = bpy.data.collections.get(external_top_col_name)
|
||||
|
||||
# TODO Refactor
|
||||
for external_col in external_top_col.children:
|
||||
local_col_name = merge_get_target_name(external_col.name)
|
||||
local_col = bpy.data.collections.get(local_col_name)
|
||||
if not local_col and external_col.asset_id_owner not in self._local_tls:
|
||||
self.external_col_to_add.add(external_col)
|
||||
|
||||
for local_col in self._local_top_col.children:
|
||||
external_col_name = merge_get_target_name(local_col.name)
|
||||
external_col = bpy.data.collections.get(external_col_name)
|
||||
if not external_col and local_col.asset_id_owner not in self._local_tls:
|
||||
self.external_col_to_remove.add(local_col)
|
||||
|
||||
all_tgt_colls = set(self._external_col.children_recursive)
|
||||
all_tgt_colls.add(self._external_col)
|
||||
match_target_colls = set([coll for coll in coll_map.values()])
|
||||
self._no_match_target_colls = all_tgt_colls - match_target_colls
|
||||
|
||||
return coll_map
|
||||
|
||||
def _get_transfer_data_dict(self, transfer_data_item):
|
||||
return {
|
||||
'name': transfer_data_item.name,
|
||||
"owner": transfer_data_item.owner,
|
||||
"surrender": transfer_data_item.surrender,
|
||||
}
|
||||
|
||||
def _transfer_data_pair_not_local(self, td_1, td_2):
|
||||
# Returns true if neither owners are local to current file
|
||||
if td_1.owner not in self._local_tls and td_2.owner not in self._local_tls:
|
||||
return True
|
||||
|
||||
def _transfer_data_pair_local(self, td_1, td_2):
|
||||
# Returns true both owners are local to current file
|
||||
if td_1.owner in self._local_tls and td_2.owner in self._local_tls:
|
||||
return True
|
||||
|
||||
def _transfer_data_check_conflict(self, obj, transfer_data_item):
|
||||
matching_transfer_data_item = self._transfer_data_get_matching(transfer_data_item)
|
||||
if matching_transfer_data_item is None:
|
||||
return
|
||||
if self._transfer_data_pair_not_local(matching_transfer_data_item, transfer_data_item):
|
||||
return
|
||||
if matching_transfer_data_item.owner != transfer_data_item.owner and not (
|
||||
matching_transfer_data_item.surrender or transfer_data_item.surrender
|
||||
):
|
||||
# Skip conflict checker if both owners are local to current file
|
||||
if self._transfer_data_pair_local(matching_transfer_data_item, transfer_data_item):
|
||||
return
|
||||
self.conflict_transfer_data.append(transfer_data_item)
|
||||
self.logger.critical(f"Transfer Data Conflict for {transfer_data_item.name}")
|
||||
return True
|
||||
|
||||
def _transfer_data_get_matching(self, transfer_data_item):
|
||||
obj = transfer_data_item.id_data
|
||||
other_obj = bpy.data.objects.get(merge_get_target_name(obj.name))
|
||||
# Find Related Transferable Data Item on Target/Source Object
|
||||
for other_obj_transfer_data_item in other_obj.transfer_data_ownership:
|
||||
if other_obj_transfer_data_item.type == transfer_data_item.type and (
|
||||
task_layer_prefix_basename_get(other_obj_transfer_data_item.name)
|
||||
== task_layer_prefix_basename_get(transfer_data_item.name)
|
||||
):
|
||||
return other_obj_transfer_data_item
|
||||
return None
|
||||
|
||||
def _transfer_data_is_surrendered(self, transfer_data_item):
|
||||
matching_td = self._transfer_data_get_matching(transfer_data_item)
|
||||
if matching_td:
|
||||
if (
|
||||
transfer_data_item.surrender
|
||||
and not matching_td.surrender
|
||||
and transfer_data_item.owner != matching_td.owner
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _transfer_data_map_item_add(self, source_obj, target_obj, transfer_data_item):
|
||||
"""Adds item to Transfer Data Map"""
|
||||
if self._transfer_data_is_surrendered(transfer_data_item):
|
||||
return
|
||||
td_type_key = transfer_data_item.type
|
||||
transfer_data_dict = self._get_transfer_data_dict(transfer_data_item)
|
||||
|
||||
if not source_obj in self.transfer_data_map:
|
||||
self.transfer_data_map[source_obj] = {
|
||||
"target_obj": target_obj,
|
||||
"td_types": {td_type_key: [transfer_data_dict]},
|
||||
}
|
||||
return
|
||||
|
||||
if not td_type_key in self.transfer_data_map[source_obj]["td_types"]:
|
||||
self.transfer_data_map[source_obj]["td_types"][td_type_key] = [transfer_data_dict]
|
||||
return
|
||||
else:
|
||||
self.transfer_data_map[source_obj]["td_types"][td_type_key].append(transfer_data_dict)
|
||||
|
||||
def _transfer_data_map_item(self, source_obj, target_obj, transfer_data_item):
|
||||
"""Verifies if Transfer Data Item is valid/can be mapped"""
|
||||
|
||||
# If item is locally owned and is part of local file
|
||||
if transfer_data_item.owner in self._local_tls and source_obj.name.endswith(
|
||||
constants.LOCAL_SUFFIX
|
||||
):
|
||||
self._transfer_data_map_item_add(source_obj, target_obj, transfer_data_item)
|
||||
|
||||
# If item is externally owned and is not part of local file
|
||||
if (
|
||||
transfer_data_item.owner not in self._local_tls
|
||||
and transfer_data_item.owner != "NONE"
|
||||
and source_obj.name.endswith(constants.EXTERNAL_SUFFIX)
|
||||
):
|
||||
self._transfer_data_map_item_add(source_obj, target_obj, transfer_data_item)
|
||||
|
||||
def _gen_transfer_data_map(self):
|
||||
# Generate Mapping for Transfer Data Items
|
||||
for objs in self.object_map.items():
|
||||
_, target_obj = objs
|
||||
for obj in objs:
|
||||
# Must execute for both objs in map (so we map external and local TD)
|
||||
# Must include maps even if obj==target_obj to preserve exisiting local TD entry
|
||||
for transfer_data_item in obj.transfer_data_ownership:
|
||||
if self._transfer_data_check_conflict(obj, transfer_data_item):
|
||||
continue
|
||||
self._transfer_data_map_item(obj, target_obj, transfer_data_item)
|
||||
return self.transfer_data_map
|
||||
|
||||
def _gen_active_index_map(self):
|
||||
# Generate a Map of Indexes that need to be set post merge
|
||||
# Stores active_uv & active_color_attribute
|
||||
index_map = {}
|
||||
|
||||
for source_obj in self.transfer_data_map:
|
||||
target_obj = self.transfer_data_map[source_obj]["target_obj"]
|
||||
td_types = self.transfer_data_map[source_obj]["td_types"]
|
||||
for td_type_key, _ in td_types.items():
|
||||
if td_type_key != constants.MATERIAL_SLOT_KEY:
|
||||
continue
|
||||
if source_obj.type != 'MESH':
|
||||
continue
|
||||
|
||||
active_uv_name = (
|
||||
source_obj.data.uv_layers.active.name
|
||||
if source_obj.data.uv_layers.active
|
||||
else ''
|
||||
)
|
||||
active_color_attribute_name = source_obj.data.color_attributes.active_color_name
|
||||
index_map[source_obj] = {
|
||||
'active_uv_name': active_uv_name,
|
||||
'active_color_attribute_name': active_color_attribute_name,
|
||||
'target_obj': target_obj,
|
||||
}
|
||||
|
||||
return index_map
|
||||
|
||||
def _gen_shared_id_map(self):
|
||||
shared_id_map: Dict[bpy.types.ID, bpy.types.ID] = {}
|
||||
for local_id in get_shared_ids(self._local_top_col):
|
||||
external_id_name = merge_get_target_name(local_id.name)
|
||||
id_storage = get_storage_of_id(local_id)
|
||||
external_id = id_storage.get(external_id_name)
|
||||
if not external_id:
|
||||
continue
|
||||
self._check_id_conflict(external_id, local_id)
|
||||
if local_id.asset_id_owner in self._local_tls and local_id.asset_id_owner != "NONE":
|
||||
if external_id:
|
||||
shared_id_map[external_id] = local_id
|
||||
else:
|
||||
if external_id:
|
||||
shared_id_map[local_id] = external_id
|
||||
|
||||
return shared_id_map
|
||||
@@ -0,0 +1,327 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from ..merge.naming import task_layer_prefix_transfer_data_update
|
||||
from .asset_mapping import AssetTransferMapping
|
||||
from .transfer_data.transfer_core import (
|
||||
init_transfer_data,
|
||||
transfer_data_is_missing,
|
||||
apply_transfer_data,
|
||||
transfer_data_clean,
|
||||
)
|
||||
from .transfer_data.transfer_util import (
|
||||
transfer_data_add_entry,
|
||||
simplify,
|
||||
)
|
||||
from .naming import (
|
||||
merge_add_suffix_to_hierarchy,
|
||||
merge_remove_suffix_from_hierarchy,
|
||||
get_id_type_name,
|
||||
)
|
||||
from .transfer_data.transfer_functions.transfer_function_util.active_indexes import (
|
||||
transfer_active_uv_layer_index,
|
||||
transfer_active_color_attribute_index,
|
||||
)
|
||||
from pathlib import Path
|
||||
from .. import constants, logging
|
||||
import time
|
||||
|
||||
|
||||
def ownership_transfer_data_cleanup(
|
||||
asset_pipe: 'bpy.types.AssetPipeline',
|
||||
obj: bpy.types.Object,
|
||||
) -> None:
|
||||
"""Remove Transferable Data ownership items if the corresponding data is missing
|
||||
|
||||
Args:
|
||||
obj (bpy.types.Object): Object that contains the Transferable Data
|
||||
"""
|
||||
local_task_layer_keys = asset_pipe.get_local_task_layers()
|
||||
transfer_data = obj.transfer_data_ownership
|
||||
to_remove = []
|
||||
for transfer_data_item in transfer_data:
|
||||
if transfer_data_item.owner in local_task_layer_keys:
|
||||
if transfer_data_is_missing(transfer_data_item):
|
||||
to_remove.append(transfer_data_item.name)
|
||||
|
||||
for name in to_remove:
|
||||
transfer_data.remove(transfer_data.keys().index(name))
|
||||
|
||||
|
||||
def ownership_get(
|
||||
local_col: bpy.types.Collection,
|
||||
scene: bpy.types.Scene,
|
||||
) -> None:
|
||||
"""Find new Transferable Data owned by the local task layer.
|
||||
Marks items as owned by the local task layer if they are in the
|
||||
corresponding task layer collection and have no owner.
|
||||
|
||||
Args:
|
||||
local_col (bpy.types.Collection): The top level asset collection that is local to the file
|
||||
task_layer_name (str): Name of the current task layer that will be the owner of the data
|
||||
temp_transfer_data (bpy.types.CollectionProperty): Collection property containing newly found
|
||||
data and the object that contains this data.
|
||||
|
||||
Returns:
|
||||
list[bpy.types.Object]: Returns a list of objects that have no owner and will not be included
|
||||
in the merge process
|
||||
"""
|
||||
asset_pipe = scene.asset_pipeline
|
||||
asset_pipe.temp_transfer_data.clear()
|
||||
|
||||
default_task_layer = asset_pipe.get_local_task_layers()[0]
|
||||
|
||||
for col in asset_pipe.asset_collection.children:
|
||||
if col.asset_id_owner == "NONE":
|
||||
col.asset_id_owner = default_task_layer
|
||||
|
||||
task_layer_objs = get_task_layer_objects(asset_pipe)
|
||||
|
||||
for obj in local_col.all_objects:
|
||||
# TODO REPLACE This is expensive to loop over everything again
|
||||
for transfer_data_item in obj.transfer_data_ownership:
|
||||
task_layer_prefix_transfer_data_update(transfer_data_item)
|
||||
|
||||
# Mark Asset ID Owner for objects in the current task layers collection
|
||||
if obj.asset_id_owner == "NONE" and obj in task_layer_objs:
|
||||
obj.asset_id_owner = default_task_layer
|
||||
# obj.name = asset_prefix_name_get(obj.name)
|
||||
# Skip items that have no owner
|
||||
if obj.asset_id_owner == "NONE":
|
||||
continue
|
||||
ownership_transfer_data_cleanup(asset_pipe, obj)
|
||||
init_transfer_data(scene, obj)
|
||||
|
||||
|
||||
def ownership_set(temp_transfer_data: bpy.types.CollectionProperty) -> None:
|
||||
"""Add new Transferable Data items on each object found in the
|
||||
temp Transferable Data collection property
|
||||
|
||||
Args:
|
||||
temp_transfer_data (bpy.types.CollectionProperty): Collection property containing newly found
|
||||
data and the object that contains this data.
|
||||
"""
|
||||
for transfer_data_item in temp_transfer_data:
|
||||
obj = bpy.data.objects.get(transfer_data_item.obj_name)
|
||||
transfer_data = obj.transfer_data_ownership
|
||||
transfer_data_add_entry(
|
||||
transfer_data,
|
||||
transfer_data_item.name,
|
||||
transfer_data_item.type,
|
||||
transfer_data_item.owner,
|
||||
transfer_data_item.surrender,
|
||||
)
|
||||
|
||||
|
||||
def get_invalid_objects(
|
||||
asset_pipe: 'bpy.types.AssetPipeline',
|
||||
local_col: bpy.types.Collection,
|
||||
) -> list[bpy.types.Object]:
|
||||
"""Returns a list of objects not used in the merge processing,
|
||||
which are considered invalid. The objects will be excluded from
|
||||
the merge process.
|
||||
|
||||
Args:
|
||||
local_col (bpy.types.Collection): The top level asset collection that is local to the file
|
||||
scene (bpy.types.Scene): Scene that contains a the file's asset
|
||||
|
||||
Returns:
|
||||
list[bpy.types.Object]: List of Invalid Objects
|
||||
"""
|
||||
local_task_layer_keys = asset_pipe.get_local_task_layers()
|
||||
task_layer_objs = get_task_layer_objects(asset_pipe)
|
||||
|
||||
invalid_obj = []
|
||||
for obj in local_col.all_objects:
|
||||
if obj.library:
|
||||
# Linked objects don't have or need ownership data, so they don't count
|
||||
# as invalid.
|
||||
continue
|
||||
if obj.asset_id_owner == "NONE":
|
||||
invalid_obj.append(obj)
|
||||
if obj not in task_layer_objs and obj.asset_id_owner in local_task_layer_keys:
|
||||
invalid_obj.append(obj)
|
||||
return invalid_obj
|
||||
|
||||
|
||||
def remap_user(source_datablock: bpy.types.ID, target_datablock: bpy.types.ID) -> None:
|
||||
"""Remap datablock and append name to datablock that has been remapped
|
||||
|
||||
Args:
|
||||
source_datablock (bpy.types.ID): datablock that will be replaced by the target
|
||||
target_datablock (bpy.types.ID): datablock that will replace the source
|
||||
"""
|
||||
logger = logging.get_logger()
|
||||
logger.debug(
|
||||
f"Remapping {source_datablock.rna_type.name}: {source_datablock.name} to {target_datablock.name}"
|
||||
)
|
||||
source_datablock.user_remap(target_datablock)
|
||||
source_datablock.name += "_Users_Remapped"
|
||||
|
||||
|
||||
def merge_task_layer(
|
||||
context: bpy.types.Context,
|
||||
local_tls: list[str],
|
||||
external_file: Path,
|
||||
) -> None:
|
||||
"""Combines data from an external task layer collection in the local
|
||||
task layer collection. By finding the owner of each collection,
|
||||
object and Transferable Data item and keeping each layer of data via a copy
|
||||
from its respective owners.
|
||||
|
||||
This ensures that objects owned by an external task layer will always be kept
|
||||
linked into the scene, and any local Transferable Data like a modifier will be applied
|
||||
ontop of that external object of vice versa. Ownership is stored in an objects properties,
|
||||
and map is created to match each object to its respective owner.
|
||||
|
||||
Args:
|
||||
context: (bpy.types.Context): context of current .blend
|
||||
local_tls: (list[str]): list of task layers that are local to the current file
|
||||
external_file (Path): external file to pull data into the current file from
|
||||
"""
|
||||
|
||||
logger = logging.get_logger()
|
||||
profiles = logging.get_profiler()
|
||||
|
||||
start_time = time.time()
|
||||
local_col = context.scene.asset_pipeline.asset_collection
|
||||
if not local_col:
|
||||
return "Unable to find Asset Collection"
|
||||
col_base_name = local_col.name
|
||||
local_suffix = constants.LOCAL_SUFFIX
|
||||
external_suffix = constants.EXTERNAL_SUFFIX
|
||||
merge_add_suffix_to_hierarchy(local_col, local_suffix)
|
||||
|
||||
external_col = import_data_from_lib(external_file, "collections", col_base_name)
|
||||
assert external_col, f"Failed to append collection {col_base_name} from {external_file}"
|
||||
merge_add_suffix_to_hierarchy(external_col, external_suffix)
|
||||
imported_time = time.time()
|
||||
profiles.add((imported_time - start_time), "IMPORT")
|
||||
|
||||
local_col = bpy.data.collections[f"{col_base_name}.{local_suffix}"]
|
||||
|
||||
# External col may come from publish, ensure it is not marked as asset so it purges correctly
|
||||
external_col.asset_clear()
|
||||
|
||||
map = AssetTransferMapping(local_col, external_col, local_tls)
|
||||
error_msg = ''
|
||||
if len(map.conflict_transfer_data) != 0:
|
||||
for conflict in map.conflict_transfer_data:
|
||||
error_msg += f"Transferable Data conflict found for '{conflict.name}' on obj '{conflict.id_data.name}'\n"
|
||||
return error_msg
|
||||
|
||||
if len(map.conflict_ids) != 0:
|
||||
for conflict_obj in map.conflict_ids:
|
||||
type_name = get_id_type_name(type(conflict_obj))
|
||||
error_msg += f"Ownership conflict found for {type_name}: '{conflict_obj.name}'\n"
|
||||
return error_msg
|
||||
mapped_time = time.time()
|
||||
profiles.add((mapped_time - imported_time), "MAPPING")
|
||||
|
||||
# Remove all Transferable Data from target objects
|
||||
for source_obj in map.object_map:
|
||||
target_obj = map.object_map[source_obj]
|
||||
target_obj.transfer_data_ownership.clear()
|
||||
|
||||
with simplify(context.scene):
|
||||
apply_transfer_data(context, map.transfer_data_map)
|
||||
apply_td_time = time.time()
|
||||
profiles.add((apply_td_time - mapped_time), "TRANSFER_DATA")
|
||||
|
||||
for source_obj, target_obj in map.object_map.items():
|
||||
remap_user(source_obj, target_obj)
|
||||
transfer_data_clean(target_obj)
|
||||
obj_remap_time = time.time()
|
||||
profiles.add((obj_remap_time - apply_td_time), "OBJECTS")
|
||||
|
||||
# Restore Active UV Layer and Active Color Attributes
|
||||
for _, index_map_item in map.index_map.items():
|
||||
target_obj = index_map_item.get('target_obj')
|
||||
transfer_active_uv_layer_index(target_obj, index_map_item.get('active_uv_name'))
|
||||
transfer_active_color_attribute_index(
|
||||
target_obj, index_map_item.get('active_color_attribute_name')
|
||||
)
|
||||
index_time = time.time()
|
||||
profiles.add((index_time - obj_remap_time), "INDEXES")
|
||||
|
||||
for source_col, target_col in map.collection_map.items():
|
||||
remap_user(source_col, target_col)
|
||||
|
||||
for col in map.external_col_to_add:
|
||||
local_col.children.link(col)
|
||||
|
||||
for col in map.external_col_to_remove:
|
||||
local_col.children.unlink(col)
|
||||
col_remap_time = time.time()
|
||||
profiles.add((col_remap_time - index_time), "COLLECTIONS")
|
||||
|
||||
for source_id, target_id in map.shared_id_map.items():
|
||||
remap_user(source_id, target_id)
|
||||
shared_id_remap_time = time.time()
|
||||
profiles.add((shared_id_remap_time - col_remap_time), "SHARED_IDS")
|
||||
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=False, do_recursive=True)
|
||||
merge_remove_suffix_from_hierarchy(local_col)
|
||||
profiles.add((time.time() - start_time), "MERGE")
|
||||
|
||||
def import_data_from_lib(
|
||||
libpath: Path,
|
||||
data_category: str,
|
||||
data_name: str,
|
||||
link: bool = False,
|
||||
) -> bpy.types.ID:
|
||||
"""Appends/Links data from an external file into the current file.
|
||||
|
||||
Args:
|
||||
libpath (Path): path to .blend file that contains library
|
||||
data_category (str): bpy.types, like object or collection
|
||||
data_name (str): name of datablock to link/append
|
||||
link (bool, optional): Set to link library otherwise append. Defaults to False.
|
||||
|
||||
Returns:
|
||||
bpy.types.ID: returns datablock that was linked/appended
|
||||
"""
|
||||
|
||||
noun = "Appended"
|
||||
if link:
|
||||
noun = "Linked"
|
||||
logger = logging.get_logger()
|
||||
data_local_collprop = getattr(bpy.data, data_category)
|
||||
with bpy.data.libraries.load(libpath.as_posix(), relative=True, link=link) as (
|
||||
data_from,
|
||||
data_to,
|
||||
):
|
||||
data_from_collprop = getattr(data_from, data_category)
|
||||
data_to_collprop = getattr(data_to, data_category)
|
||||
if data_name not in data_from_collprop:
|
||||
logger.critical(
|
||||
f"Failed to import {data_category} {data_name} from {libpath.as_posix()}. Doesn't exist in file.",
|
||||
)
|
||||
|
||||
# Check if datablock with same name already exists in blend file.
|
||||
existing_datablock = data_local_collprop.get(data_name)
|
||||
if existing_datablock:
|
||||
logger.critical(
|
||||
f"{data_name} already in bpy.data.{data_category} of this blendfile.",
|
||||
)
|
||||
|
||||
# Append data block.
|
||||
data_to_collprop.append(data_name)
|
||||
logger.info(f"{noun}:{data_name} from library: {libpath.as_posix()}")
|
||||
|
||||
if link:
|
||||
return data_local_collprop.get((data_name, bpy.path.relpath(libpath.as_posix())))
|
||||
|
||||
return data_local_collprop.get(data_name)
|
||||
|
||||
|
||||
def get_task_layer_objects(asset_pipe):
|
||||
local_task_layer_keys = asset_pipe.get_local_task_layers()
|
||||
local_col = asset_pipe.asset_collection
|
||||
task_layer_objs = []
|
||||
for col in local_col.children:
|
||||
if col.asset_id_owner in local_task_layer_keys:
|
||||
task_layer_objs = task_layer_objs + list(col.all_objects)
|
||||
return task_layer_objs
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from bpy_extras.id_map_utils import get_id_reference_map, get_all_referenced_ids
|
||||
from .util import get_storage_of_id
|
||||
from .. import constants, config
|
||||
from .util import data_type_from_transfer_data_key
|
||||
|
||||
|
||||
def merge_get_target_suffix(suffix: str) -> str:
|
||||
"""Get the corresponding suffix for a given suffix
|
||||
|
||||
Args:
|
||||
suffix (str): Suffix for External or Local Datablock
|
||||
|
||||
Returns:
|
||||
str: Returns External Suffix if given Local suffix for vice-versa
|
||||
"""
|
||||
if suffix.endswith(constants.EXTERNAL_SUFFIX):
|
||||
return constants.LOCAL_SUFFIX
|
||||
if suffix.endswith(constants.LOCAL_SUFFIX):
|
||||
return constants.EXTERNAL_SUFFIX
|
||||
|
||||
|
||||
def merge_get_target_name(name: str) -> str:
|
||||
"""Get the corresponding target name for a given datablock's suffix.
|
||||
Suffixes are set by the add_suffix_to_hierarchy() function prior to
|
||||
calling this function.
|
||||
|
||||
Args:
|
||||
name (str): Name of a given datablock including its suffix
|
||||
|
||||
Returns:
|
||||
str: Returns datablock name with the opposite suffix
|
||||
"""
|
||||
old = name.split(constants.MERGE_DELIMITER)[-1]
|
||||
new = merge_get_target_suffix(old)
|
||||
assert new, "Failed to flip the LOC/EXT suffix of this name, this should never happen : " + name
|
||||
li = name.rsplit(old, 1)
|
||||
return new.join(li)
|
||||
|
||||
|
||||
def merge_get_basename(name: str) -> str:
|
||||
"""Returns the name of an asset without its suffix"""
|
||||
if name.endswith(constants.LOCAL_SUFFIX) or name.endswith(
|
||||
constants.EXTERNAL_SUFFIX
|
||||
):
|
||||
return constants.MERGE_DELIMITER.join(
|
||||
name.split(constants.MERGE_DELIMITER)[:-1]
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def merge_remove_suffix_from_hierarchy(collection: bpy.types.Collection) -> None:
|
||||
"""Removes the suffix after a set delimiter from all datablocks
|
||||
referenced by a collection, itself included
|
||||
|
||||
Args:
|
||||
collection (bpy.types.Collection): Collection that as been suffixed
|
||||
"""
|
||||
ref_map = get_id_reference_map()
|
||||
datablocks = get_all_referenced_ids(collection, ref_map)
|
||||
datablocks.add(collection)
|
||||
for action in bpy.data.actions:
|
||||
datablocks.add(action)
|
||||
for db in datablocks:
|
||||
if db == None:
|
||||
# Not sure why this would happen.
|
||||
raise Exception(
|
||||
f"None value in datablock list"
|
||||
)
|
||||
if db.library:
|
||||
# Don't rename linked datablocks.
|
||||
continue
|
||||
try:
|
||||
db.name = merge_get_basename(db.name)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def merge_add_suffix_to_hierarchy(
|
||||
collection: bpy.types.Collection, suffix_base: str
|
||||
) -> None:
|
||||
"""Add a suffix to the names of all datablocks referenced by a collection,
|
||||
itself included.
|
||||
|
||||
Args:
|
||||
collection (bpy.types.Collection): Collection that needs to be suffixed
|
||||
suffix_base (str): Suffix to append to collection and items linked to collection
|
||||
"""
|
||||
|
||||
suffix = f"{constants.MERGE_DELIMITER}{suffix_base}"
|
||||
|
||||
ref_map = get_id_reference_map()
|
||||
datablocks = get_all_referenced_ids(collection, ref_map)
|
||||
datablocks.add(collection)
|
||||
for db in datablocks:
|
||||
if db == None:
|
||||
# Not sure why this would happen.
|
||||
continue
|
||||
if len(db.name) > 59:
|
||||
raise Exception(
|
||||
f"Datablock name too long, must be max 59 characters: {db.name}"
|
||||
)
|
||||
if db.library:
|
||||
# Don't rename linked datablocks.
|
||||
continue
|
||||
collision_db = get_storage_of_id(db).get(db.name + suffix)
|
||||
if collision_db:
|
||||
collision_db.name += f'{constants.MERGE_DELIMITER}OLD'
|
||||
try:
|
||||
new_name = db.name + suffix
|
||||
db.name = new_name
|
||||
assert (
|
||||
db.name == new_name
|
||||
), "This should never happen here, unless some add-on suffix is >3 characters. Avoid!"
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def asset_prefix_name_get(name: str) -> str:
|
||||
"""Returns a string with the asset prefix if it is not already set.
|
||||
Users can specify a prefix to live on all objects during the
|
||||
asset creation process. This prefix is stored in the scene.
|
||||
|
||||
Args:
|
||||
name (str): Name to add prefix to
|
||||
|
||||
Returns:
|
||||
str: Returns name with prefix
|
||||
"""
|
||||
asset_pipe = bpy.context.scene.asset_pipeline
|
||||
if name.startswith(asset_pipe.prefix + constants.NAME_DELIMITER):
|
||||
return name
|
||||
prefix = (
|
||||
asset_pipe.prefix + constants.NAME_DELIMITER if asset_pipe.prefix != "" else ""
|
||||
)
|
||||
return prefix + name
|
||||
|
||||
|
||||
def task_layer_prefix_name_get(name: str, task_layer_owner: str) -> str:
|
||||
"""Returns a string with the task layer prefix if one is not already set.
|
||||
Prefix for assets is defined task_layer.json file within TASK_LAYER_TYPES
|
||||
Will return early if any prefix is found, cannot replace existing prefixes.
|
||||
|
||||
Args:
|
||||
name (str): Name to add prefix to
|
||||
task_layer_owner (str):
|
||||
|
||||
Returns:
|
||||
str: Returns name with prefix
|
||||
"""
|
||||
prefix = config.TASK_LAYER_TYPES[task_layer_owner]
|
||||
return prefix + constants.NAME_DELIMITER + task_layer_prefix_basename_get(name)
|
||||
|
||||
|
||||
def task_layer_prefix_basename_get(name: str) -> str:
|
||||
"""Get the base of a name if it contains a task layer prefix.
|
||||
This prefix is set on some Transferable Data items, this functions
|
||||
removes the prefixes and returns the basename
|
||||
|
||||
Args:
|
||||
name (str): Original name including prefix
|
||||
|
||||
Returns:
|
||||
str: Returns name without task layer prefix
|
||||
"""
|
||||
for task_layer_key in config.TASK_LAYER_TYPES:
|
||||
prefix = config.TASK_LAYER_TYPES[task_layer_key] + constants.NAME_DELIMITER
|
||||
if name.startswith(prefix):
|
||||
return name[len(prefix):]
|
||||
return name
|
||||
|
||||
|
||||
def task_layer_prefix_legacy_basename(name) -> str:
|
||||
# TODO Remove this is legacy code (coordinate with team)
|
||||
if "." in name:
|
||||
legacy_name = name.replace(".", "")
|
||||
for task_layer_key in config.TASK_LAYER_TYPES:
|
||||
if legacy_name.startswith(config.TASK_LAYER_TYPES[task_layer_key]):
|
||||
return legacy_name.replace(config.TASK_LAYER_TYPES[task_layer_key], "")
|
||||
|
||||
|
||||
def task_layer_prefix_transfer_data_update(
|
||||
transfer_data_item: bpy.types.CollectionProperty,
|
||||
) -> bool:
|
||||
"""Task Layer Prefix can become out of date with the actual owner of the task layer.
|
||||
This will update the existing prefixes on transfer_data_item so it can match the
|
||||
owner of that transfer_data_item. Will update both the transfer_data_item.name and the
|
||||
name of the actual data the transfer_data_item is referring to.
|
||||
|
||||
Args:
|
||||
transfer_data_item (bpy.types.CollectionProperty): Transferable Data Item that is named with prefix
|
||||
|
||||
Returns:
|
||||
bool: Returns True if a change to the name was completed
|
||||
"""
|
||||
prefix_types = [constants.MODIFIER_KEY, constants.CONSTRAINT_KEY]
|
||||
if transfer_data_item.type not in prefix_types:
|
||||
return
|
||||
obj = transfer_data_item.id_data
|
||||
td_data = data_type_from_transfer_data_key(obj, transfer_data_item.type)
|
||||
|
||||
# TODO Remove this
|
||||
# Legacy Prefix Name was used during add-on testing stage but not production
|
||||
legacy_name = task_layer_prefix_legacy_basename(transfer_data_item.name)
|
||||
|
||||
if legacy_name:
|
||||
base_name = legacy_name
|
||||
else:
|
||||
base_name = task_layer_prefix_basename_get(transfer_data_item.name)
|
||||
|
||||
prefix = config.TASK_LAYER_TYPES[transfer_data_item.owner]
|
||||
new_name = prefix + constants.NAME_DELIMITER + base_name
|
||||
if new_name == transfer_data_item.name or not td_data.get(transfer_data_item.name):
|
||||
return
|
||||
|
||||
# Ensure no period in name
|
||||
# TODO Remove this is legacy code (coordinate with team)
|
||||
new_name = new_name.replace(".", "")
|
||||
|
||||
td_data[transfer_data_item.name].name = new_name
|
||||
transfer_data_item.name = new_name
|
||||
return True
|
||||
|
||||
|
||||
def get_id_type_name(id_type: bpy.types) -> str:
|
||||
"""Return the cosmetic name of a given ID type
|
||||
|
||||
Args:
|
||||
id_type (bpy.types): An ID type e.g. bpy.types.Object
|
||||
|
||||
Returns:
|
||||
str: Name of an ID type e.g. bpy.types.Object will return 'Object'
|
||||
"""
|
||||
return str(id_type).split("'bpy_types.")[1].replace("'>", "")
|
||||
@@ -0,0 +1,121 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from bpy.types import Collection
|
||||
from .transfer_data.transfer_functions.shape_keys import shape_key_set_active
|
||||
from .. import prefs
|
||||
|
||||
class Perserve:
|
||||
def __init__(self, local_col: Collection) -> None:
|
||||
self._local_col = local_col
|
||||
self._action_dict: dict = {}
|
||||
self.generate_preserve_maps()
|
||||
|
||||
def generate_preserve_maps(self) -> None:
|
||||
addon_prefs = prefs.get_addon_prefs()
|
||||
if addon_prefs.preserve_action:
|
||||
self._action_dict = self._get_action_map()
|
||||
if addon_prefs.preserve_indexes:
|
||||
self._active_index_dict = self._get_active_index_map()
|
||||
|
||||
def _get_action_map(self):
|
||||
action_dict = {}
|
||||
for obj in self._local_col.all_objects:
|
||||
# Set action map for armatures only
|
||||
if obj.type != "ARMATURE":
|
||||
continue
|
||||
# Only set action map if obj has action assignment
|
||||
if not obj.animation_data or not obj.animation_data.action:
|
||||
continue
|
||||
|
||||
# Store obj name as obj may removed during merge
|
||||
action = obj.animation_data.action
|
||||
action_dict[obj.name] = (action, obj.animation_data.action_slot, action.use_fake_user)
|
||||
action.use_fake_user = True
|
||||
return action_dict
|
||||
|
||||
def set_action_map(self):
|
||||
for obj_name, action_info in self._action_dict.items():
|
||||
action, slot, fake_user = action_info
|
||||
obj = bpy.data.objects.get(obj_name)
|
||||
if not obj:
|
||||
continue
|
||||
if not obj.animation_data:
|
||||
obj.animation_data_create()
|
||||
obj.animation_data.action = action
|
||||
obj.animation_data.action_slot = slot
|
||||
action.use_fake_user = fake_user
|
||||
|
||||
def unassign_actions(self):
|
||||
for obj_name, action_info in self._action_dict.items():
|
||||
action, slot, fake_user = action_info
|
||||
obj = bpy.data.objects.get(obj_name)
|
||||
if not obj:
|
||||
continue
|
||||
if obj.animation_data:
|
||||
obj.animation_data.action = None
|
||||
action.use_fake_user = fake_user
|
||||
|
||||
def _get_active_index_map(self):
|
||||
active_index = {}
|
||||
for obj in self._local_col.all_objects:
|
||||
indexes = {}
|
||||
|
||||
if getattr(obj.data, "uv_layers", None) and getattr(obj.data.uv_layers, "active", None):
|
||||
indexes['uv_layer'] = obj.data.uv_layers.active.name
|
||||
|
||||
if getattr(obj.vertex_groups, "active", None):
|
||||
indexes['vertex_group'] = obj.vertex_groups.active.name
|
||||
|
||||
if (
|
||||
getattr(obj.data, "color_attributes", None) and
|
||||
getattr(obj.data.color_attributes, "active_color", None) and
|
||||
len(obj.data.color_attributes) > 0
|
||||
):
|
||||
indexes['color_attribute'] = obj.data.color_attributes.active_color_name
|
||||
|
||||
if getattr(obj.data, "attributes", None) and getattr(
|
||||
obj.data.attributes, "active", None
|
||||
):
|
||||
indexes['attribute'] = obj.data.attributes.active.name
|
||||
|
||||
if getattr(obj.data, "shape_keys", None) and getattr(obj, "active_shape_key", None):
|
||||
indexes['shape_key'] = obj.active_shape_key.name
|
||||
|
||||
active_index[obj.name] = indexes
|
||||
return active_index
|
||||
|
||||
def set_active_index_map(self):
|
||||
for obj_name, indexes in self._active_index_dict.items():
|
||||
obj = bpy.data.objects.get(obj_name)
|
||||
if not obj:
|
||||
continue
|
||||
|
||||
if indexes.get('uv_layer'):
|
||||
uv_layer = obj.data.uv_layers[indexes.get('uv_layer')]
|
||||
if uv_layer:
|
||||
obj.data.uv_layers.active = uv_layer
|
||||
|
||||
if indexes.get('vertex_group'):
|
||||
vertex_group = obj.vertex_groups.get(indexes.get('vertex_group'))
|
||||
if vertex_group:
|
||||
obj.vertex_groups.active = vertex_group
|
||||
|
||||
# Setting color_attribute active also sets attribute active, so attribute must always follow color_attribute
|
||||
if indexes.get('color_attribute'):
|
||||
color_attribute = obj.data.color_attributes.get(indexes.get('color_attribute'))
|
||||
for index, color_attribute in enumerate(obj.data.color_attributes):
|
||||
if color_attribute.name == indexes.get('color_attribute'):
|
||||
obj.data.color_attributes.active_color_index = index
|
||||
|
||||
if indexes.get('attribute'):
|
||||
attribute = obj.data.attributes.get(indexes.get('attribute'))
|
||||
if attribute:
|
||||
obj.data.attributes.active = attribute
|
||||
|
||||
if indexes.get('shape_key'):
|
||||
shape_key = obj.data.shape_keys.key_blocks.get(indexes.get('shape_key'))
|
||||
if shape_key:
|
||||
shape_key_set_active(obj, indexes.get('shape_key'))
|
||||
@@ -0,0 +1,163 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
from .. import constants
|
||||
import bpy
|
||||
|
||||
|
||||
def find_file_version(published_file: Path) -> int:
|
||||
"""Returns the version number from a published file's name
|
||||
|
||||
Args:
|
||||
file (Path): Path to a publish file, naming convention is
|
||||
asset_name-v{3-digit_version}.blend`
|
||||
|
||||
Returns:
|
||||
int: returns current version in filename as integer
|
||||
"""
|
||||
name_without_ext = published_file.name.strip(".blend")
|
||||
|
||||
# Support Legacy Delimiter
|
||||
# TODO Remove this is legacy code (coordinate with team)
|
||||
if "." in name_without_ext:
|
||||
return int(name_without_ext.split(".")[1].replace("v", ""))
|
||||
|
||||
return int(name_without_ext.split(constants.FILE_DELIMITER)[1].replace("v", ""))
|
||||
|
||||
|
||||
def get_next_published_file(
|
||||
current_file: Path, publish_type=constants.ACTIVE_PUBLISH_KEY
|
||||
) -> Path:
|
||||
"""Returns the path where the next published file version should be saved to
|
||||
|
||||
Args:
|
||||
current_file (Path): Current file, which must be a task file at root of asset directory
|
||||
publish_type (_type_, optional): Publish type, 'publish', 'staged', 'sandbox'. Defaults to 'publish'.
|
||||
|
||||
Returns:
|
||||
Path: Path where the next published file should be saved to, path doesn't exist yet
|
||||
""" """"""
|
||||
last_publish = find_latest_publish(current_file, publish_type)
|
||||
base_name = bpy.context.scene.asset_pipeline.name
|
||||
publish_dir = current_file.parent.joinpath(publish_type)
|
||||
publish_dir.mkdir(parents=True, exist_ok=True) # Create Directory if it doesn't exist
|
||||
if not last_publish:
|
||||
new_version_number = 1
|
||||
|
||||
else:
|
||||
new_version_number = find_file_version(last_publish) + 1
|
||||
new_version = "{0:0=3d}".format(new_version_number)
|
||||
return publish_dir.joinpath(
|
||||
base_name + constants.FILE_DELIMITER + "v" + new_version + ".blend"
|
||||
)
|
||||
|
||||
|
||||
def get_asset_catalogues():
|
||||
folder = Path(bpy.data.filepath).parent
|
||||
target_catalog = "Catalog"
|
||||
|
||||
with (folder / "blender_assets.cats.txt").open() as f:
|
||||
for line in f.readlines():
|
||||
if line.startswith(("#", "VERSION", "\n")):
|
||||
continue
|
||||
# Each line contains : 'uuid:catalog_tree:catalog_name' + eol ('\n')
|
||||
name = line.split(":")[2].split("\n")[0]
|
||||
if name == target_catalog:
|
||||
uuid = line.split(":")[0]
|
||||
obj = bpy.data.objects["Suzanne"] # Object name, case-sensitive !
|
||||
asset_data = obj.asset_data
|
||||
asset_data.catalog_id = uuid
|
||||
|
||||
|
||||
def create_next_published_file(
|
||||
current_file: Path, publish_type=constants.ACTIVE_PUBLISH_KEY, catalog_id: str = ''
|
||||
) -> str:
|
||||
"""Creates new Published version of a given Publish Type
|
||||
|
||||
Args:
|
||||
current_file (Path): Current file, which must be a task file at root of asset directory
|
||||
publish_type (_type_, optional): Publish type, 'publish', 'staged', 'sandbox'. Defaults to 'publish'.
|
||||
"""
|
||||
# TODO Set Catalogue here
|
||||
|
||||
new_file_path = get_next_published_file(current_file, publish_type)
|
||||
asset_col = bpy.context.scene.asset_pipeline.asset_collection
|
||||
if publish_type == constants.ACTIVE_PUBLISH_KEY:
|
||||
asset_col.asset_mark()
|
||||
if catalog_id != '' or catalog_id != 'NONE':
|
||||
asset_col.asset_data.catalog_id = catalog_id
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(new_file_path), copy=True)
|
||||
asset_col.asset_clear()
|
||||
return str(new_file_path)
|
||||
|
||||
|
||||
def find_all_published(current_file: Path, publish_type: str) -> list[Path]:
|
||||
"""Retuns a list of published files of a given type,
|
||||
each publish type is seperated into its own folder at the
|
||||
root of the asset's directory
|
||||
Args:
|
||||
current_file (Path): Current file, which must be a task file at root of asset directory
|
||||
publish_type (_type_, optional): Publish type, 'publish', 'staged', 'sandbox'. Defaults to 'publish'.
|
||||
|
||||
Returns:
|
||||
list[Path]: list of published files of a given publish type
|
||||
"""
|
||||
publish_dir = current_file.parent.joinpath(publish_type)
|
||||
if not publish_dir.exists():
|
||||
return
|
||||
published_files = list(publish_dir.glob('*.blend'))
|
||||
published_files.sort(key=find_file_version)
|
||||
return published_files
|
||||
|
||||
|
||||
def find_latest_publish(
|
||||
current_file: Path, publish_type=constants.ACTIVE_PUBLISH_KEY
|
||||
) -> Path:
|
||||
"""Returns the path to the latest published file in a given folder
|
||||
|
||||
Args:
|
||||
current_file (Path): Current file, which must be a task file at root of asset directory
|
||||
publish_type (_type_, optional): Publish type, 'publish', 'staged', 'sandbox'. Defaults to 'publish'.
|
||||
|
||||
Returns:
|
||||
Path: Path to latest publish file of a given publish type
|
||||
"""
|
||||
published_files = find_all_published(current_file, publish_type)
|
||||
if published_files:
|
||||
return published_files[-1]
|
||||
|
||||
|
||||
def find_sync_target(current_file: Path) -> Path:
|
||||
"""Returns the latest published file to use as push/pull a.k.a sync target
|
||||
this will either be the latest active publish, or the latest staged asset if
|
||||
any asset is staged
|
||||
|
||||
Args:
|
||||
current_file (Path): Current file, which must be a task file at root of asset directory
|
||||
|
||||
Returns:
|
||||
Path: Path to latest active or staged publish file
|
||||
""" """"""
|
||||
latest_staged = find_latest_publish(
|
||||
current_file, publish_type=constants.STAGED_PUBLISH_KEY
|
||||
)
|
||||
if latest_staged:
|
||||
return latest_staged
|
||||
return find_latest_publish(current_file, publish_type=constants.ACTIVE_PUBLISH_KEY)
|
||||
|
||||
|
||||
def is_staged_publish(current_file: Path) -> bool:
|
||||
"""Checks if there is a staged publish file, which
|
||||
will be used as the push/pull target.
|
||||
|
||||
Args:
|
||||
current_file (Path): Current file, which must be a task file at root of asset directory
|
||||
|
||||
Returns:
|
||||
bool: True if staged file exists
|
||||
"""
|
||||
return bool(
|
||||
find_latest_publish(current_file, publish_type=constants.STAGED_PUBLISH_KEY)
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from bpy_extras.id_map_utils import get_id_reference_map, get_all_referenced_ids
|
||||
from .util import get_fundamental_id_type
|
||||
from .. import constants
|
||||
|
||||
|
||||
def get_shared_ids(collection: bpy.types.Collection) -> list[bpy.types.ID]:
|
||||
"""Returns a list of any ID that is not covered by the merge process
|
||||
|
||||
Args:
|
||||
collection (bpy.types.Collection): Collection that contains data that references 'shared_ids'
|
||||
|
||||
Returns:
|
||||
list[bpy.types.ID]: List of 'shared_ids'
|
||||
"""
|
||||
ref_map = get_id_reference_map()
|
||||
all_ids_of_coll = get_all_referenced_ids(collection, ref_map)
|
||||
return [
|
||||
id
|
||||
for id in all_ids_of_coll
|
||||
if (isinstance(id, bpy.types.NodeTree) or isinstance(id, bpy.types.Image))
|
||||
and id.library is None
|
||||
]
|
||||
|
||||
|
||||
def init_shared_ids(scene: bpy.types.Scene) -> list[bpy.types.ID]:
|
||||
"""Intilizes any ID not covered by the transfer process as an 'shared_id'
|
||||
and marks all 'shared_ids' without an owner to the current task layer
|
||||
|
||||
Args:
|
||||
scene (bpy.types.Scene): Scene that contains a the file's asset
|
||||
|
||||
Returns:
|
||||
list[bpy.types.ID]: A list of new 'shared_ids' owned by the file's task layer
|
||||
"""
|
||||
asset_pipe = scene.asset_pipeline
|
||||
task_layer_key = asset_pipe.get_local_task_layers()[0]
|
||||
shared_ids = []
|
||||
local_col = asset_pipe.asset_collection
|
||||
for id in get_shared_ids(local_col):
|
||||
if id.asset_id_owner == 'NONE':
|
||||
id.asset_id_owner = task_layer_key
|
||||
shared_ids.append(id)
|
||||
return shared_ids
|
||||
|
||||
|
||||
def get_shared_id_icon(id: bpy.types.ID) -> str:
|
||||
if bpy.types.NodeTree == get_fundamental_id_type(id):
|
||||
return constants.GEO_NODE
|
||||
if bpy.types.Image == get_fundamental_id_type(id):
|
||||
return constants.IMAGE
|
||||
return constants.BLANK
|
||||
@@ -0,0 +1,105 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from .. import constants
|
||||
from .. import config
|
||||
|
||||
|
||||
def get_default_task_layer_owner(td_type: str, name="") -> [str, bool]:
|
||||
if td_type == constants.ATTRIBUTE_KEY:
|
||||
if name in config.ATTRIBUTE_DEFAULTS:
|
||||
return (
|
||||
config.ATTRIBUTE_DEFAULTS[name]['default_owner'],
|
||||
config.ATTRIBUTE_DEFAULTS[name]['auto_surrender'],
|
||||
)
|
||||
|
||||
try:
|
||||
return (
|
||||
config.TRANSFER_DATA_DEFAULTS[td_type]['default_owner'],
|
||||
config.TRANSFER_DATA_DEFAULTS[td_type]['auto_surrender'],
|
||||
)
|
||||
except KeyError:
|
||||
from .. import logging
|
||||
|
||||
logger = logging.get_logger()
|
||||
logger.fatal(f"Task Layer File missing key {td_type}")
|
||||
# TODO stop execution of operator at this point if this fails
|
||||
|
||||
|
||||
def get_transfer_data_owner(
|
||||
asset_pipe: bpy.types.PropertyGroup,
|
||||
td_type_key: str,
|
||||
name="",
|
||||
) -> [str, bool]:
|
||||
default_tl, auto_surrender = get_default_task_layer_owner(td_type_key, name)
|
||||
if default_tl in asset_pipe.get_local_task_layers():
|
||||
# If the default owner is local to the file, don't use auto_surrender
|
||||
return default_tl, False
|
||||
else:
|
||||
# If the default owner is not local, pass auto surrender value
|
||||
return asset_pipe.get_local_task_layers()[0], auto_surrender
|
||||
|
||||
|
||||
def draw_task_layer_selection(
|
||||
context: bpy.types.Context,
|
||||
layout: bpy.types.UILayout,
|
||||
data: bpy.types.CollectionProperty or bpy.types.ID,
|
||||
show_all_task_layers=False,
|
||||
text="",
|
||||
data_owner_name="",
|
||||
current_data_owner=None,
|
||||
) -> None:
|
||||
"""Draw an prop search UI for ownership of either OBJ/COL or Task Layer.
|
||||
It has three modes, 'Show All Task Layers" "Show All Task Layers Greyed Out" and
|
||||
"Only Show Local Task Layers"
|
||||
|
||||
- When the property is already set to a local task layer show: "Only Show Local Task Layers"
|
||||
- When a property is owned by an external task layer: "Show All Task Layers Greyed Out" so they user cannot edit it
|
||||
- When a user is overriding or the object is new (using default ownership): "Show All Task Layers"
|
||||
Args:
|
||||
layout (bpy.types.UILayout): Any UI Layout element like self.layout or row
|
||||
data (bpy.types.CollectionProperty or bpy.types.ID or bpy.types.Operator): Python object that owns the ownership data.
|
||||
show_all_task_layers (bool, optional): Used when we want to list all task layers in the production as options.
|
||||
text (str, optional): Title of prop search.
|
||||
data_owner_name(str, optional): Name of Data if it needs to be specified
|
||||
current_data_owner(str, optional): Property that is named by data_owner_name so it can be checked, property should return a string
|
||||
"""
|
||||
|
||||
# Set data_owner_name based on type of it hasn't been passed
|
||||
if data_owner_name == "":
|
||||
# These rna_type.names are defined by class names in props.py
|
||||
if data.rna_type.name in ["AssetTransferData", 'AssetTransferDataTemp']:
|
||||
data_owner_name = "owner"
|
||||
else:
|
||||
data_owner_name = "asset_id_owner"
|
||||
|
||||
# Get the current data owner from OBJ/COL or Transferable Data Item if it hasn't been passed
|
||||
if current_data_owner is None:
|
||||
current_data_owner = getattr(data, data_owner_name)
|
||||
|
||||
asset_pipe = context.scene.asset_pipeline
|
||||
|
||||
row = layout.row()
|
||||
if current_data_owner not in asset_pipe.local_task_layers:
|
||||
show_all_task_layers = True
|
||||
if not isinstance(data, bpy.types.Operator):
|
||||
row.enabled = False
|
||||
|
||||
if show_all_task_layers:
|
||||
row.prop_search(
|
||||
data,
|
||||
data_owner_name,
|
||||
asset_pipe,
|
||||
'all_task_layers',
|
||||
text=text,
|
||||
)
|
||||
else:
|
||||
row.prop_search(
|
||||
data,
|
||||
data_owner_name,
|
||||
asset_pipe,
|
||||
'local_task_layers',
|
||||
text=text,
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
import time
|
||||
from .transfer_functions import (
|
||||
attributes,
|
||||
constraints,
|
||||
custom_props,
|
||||
modifiers,
|
||||
parent,
|
||||
shape_keys,
|
||||
vertex_groups,
|
||||
materials,
|
||||
)
|
||||
from typing import List
|
||||
from ... import constants, logging
|
||||
from .transfer_util import (
|
||||
transfer_data_add_entry,
|
||||
find_ownership_data,
|
||||
link_objs_to_collection,
|
||||
isolate_collection,
|
||||
)
|
||||
|
||||
|
||||
# TODO use logging module here
|
||||
def copy_transfer_data_ownership(
|
||||
td_type_key: str, target_obj: bpy.types.Object, transfer_data_dict: dict
|
||||
) -> None:
|
||||
"""Copy Transferable Data item to object if non entry exists
|
||||
|
||||
Args:
|
||||
transfer_data_item: Item of bpy.types.CollectionProperty from source object
|
||||
target_obj (bpy.types.Object): Object to add Transferable Data item to
|
||||
"""
|
||||
transfer_data = target_obj.transfer_data_ownership
|
||||
ownership_data = find_ownership_data(
|
||||
transfer_data,
|
||||
transfer_data_dict["name"],
|
||||
td_type_key,
|
||||
)
|
||||
if not ownership_data:
|
||||
transfer_data_add_entry(
|
||||
transfer_data,
|
||||
transfer_data_dict["name"],
|
||||
td_type_key,
|
||||
transfer_data_dict["owner"],
|
||||
transfer_data_dict["surrender"],
|
||||
)
|
||||
|
||||
|
||||
def transfer_data_clean(obj):
|
||||
vertex_groups.vertex_groups_clean(obj)
|
||||
modifiers.modifiers_clean(obj)
|
||||
constraints.constraints_clean(obj)
|
||||
custom_props.custom_prop_clean(obj)
|
||||
shape_keys.shape_keys_clean(obj)
|
||||
attributes.attribute_clean(obj)
|
||||
parent.parent_clean(obj)
|
||||
|
||||
|
||||
def transfer_data_is_missing(transfer_data_item) -> bool:
|
||||
"""Check if Transferable Data item is missing
|
||||
|
||||
Args:
|
||||
transfer_data_item: Item of class ASSET_TRANSFER_DATA
|
||||
|
||||
Returns:
|
||||
bool: bool if item is missing
|
||||
"""
|
||||
return bool(
|
||||
vertex_groups.vertex_group_is_missing(transfer_data_item)
|
||||
or modifiers.modifier_is_missing(transfer_data_item)
|
||||
or constraints.constraint_is_missing(transfer_data_item)
|
||||
or custom_props.custom_prop_is_missing(transfer_data_item)
|
||||
or shape_keys.shape_key_is_missing(transfer_data_item)
|
||||
or attributes.attribute_is_missing(transfer_data_item)
|
||||
)
|
||||
|
||||
|
||||
def init_transfer_data(
|
||||
scene: bpy.types.Scene,
|
||||
obj: bpy.types.Object,
|
||||
):
|
||||
"""Collect Transferable Data Items on a given object
|
||||
|
||||
Args:
|
||||
obj (bpy.types.Object): Target object for Transferable Data
|
||||
task_layer_name (str): Name of task layer
|
||||
temp_transfer_data: Item of class ASSET_TRANSFER_DATA_TEMP
|
||||
"""
|
||||
if obj.library:
|
||||
# Don't create ownership data for object data if the object is linked.
|
||||
return
|
||||
|
||||
constraints.init_constraints(scene, obj)
|
||||
custom_props.init_custom_prop(scene, obj)
|
||||
parent.init_parent(scene, obj)
|
||||
modifiers.init_modifiers(scene, obj)
|
||||
|
||||
if not obj.data or obj.data.library:
|
||||
# Don't create ownership data for mesh data if the mesh is linked, or Empties.
|
||||
return
|
||||
|
||||
vertex_groups.init_vertex_groups(scene, obj)
|
||||
materials.init_materials(scene, obj)
|
||||
shape_keys.init_shape_keys(scene, obj)
|
||||
attributes.init_attributes(scene, obj)
|
||||
|
||||
|
||||
def apply_transfer_data_items(
|
||||
context,
|
||||
source_obj: bpy.types.Object,
|
||||
target_obj: bpy.types.Object,
|
||||
td_type_key: str,
|
||||
transfer_data_dicts: List[dict],
|
||||
):
|
||||
logger = logging.get_logger()
|
||||
# Get source/target from first item in list, because all items in list are same object/type
|
||||
if target_obj is None:
|
||||
logger.warning(f"Failed to Transfer {td_type_key.title()} from {source_obj.name}")
|
||||
return
|
||||
|
||||
for transfer_data_dict in transfer_data_dicts:
|
||||
copy_transfer_data_ownership(td_type_key, target_obj, transfer_data_dict)
|
||||
|
||||
# if TD Source is Target, restore the ownership data but don't transfer anything
|
||||
if source_obj == target_obj:
|
||||
return
|
||||
|
||||
if td_type_key == constants.VERTEX_GROUP_KEY:
|
||||
# Transfer All Vertex Groups in one go
|
||||
logger.debug(f"Transferring All Vertex Groups from {source_obj.name} to {target_obj.name}.")
|
||||
vertex_groups.transfer_vertex_groups(
|
||||
vertex_group_names=[item["name"] for item in transfer_data_dicts],
|
||||
target_obj=target_obj,
|
||||
source_obj=source_obj,
|
||||
)
|
||||
if td_type_key == constants.MODIFIER_KEY:
|
||||
for transfer_data_dict in transfer_data_dicts:
|
||||
logger.debug(
|
||||
f"Transferring Modifier {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
|
||||
)
|
||||
modifiers.transfer_modifier(
|
||||
context,
|
||||
modifier_name=transfer_data_dict["name"],
|
||||
target_obj=target_obj,
|
||||
source_obj=source_obj,
|
||||
)
|
||||
if td_type_key == constants.CONSTRAINT_KEY:
|
||||
for transfer_data_dict in transfer_data_dicts:
|
||||
logger.debug(
|
||||
f"Transferring Constraint {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
|
||||
)
|
||||
constraints.transfer_constraint(
|
||||
constraint_name=transfer_data_dict["name"],
|
||||
target_obj=target_obj,
|
||||
source_obj=source_obj,
|
||||
)
|
||||
if td_type_key == constants.CUSTOM_PROP_KEY:
|
||||
for transfer_data_dict in transfer_data_dicts:
|
||||
logger.debug(
|
||||
f"Transferring Custom Property {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
|
||||
)
|
||||
custom_props.transfer_custom_prop(
|
||||
prop_name=transfer_data_dict["name"],
|
||||
target_obj=target_obj,
|
||||
source_obj=source_obj,
|
||||
)
|
||||
if td_type_key == constants.MATERIAL_SLOT_KEY:
|
||||
logger.debug(f"Transferring Materials from {source_obj.name} to {target_obj.name}.")
|
||||
for transfer_data_dict in transfer_data_dicts:
|
||||
materials.transfer_materials(
|
||||
target_obj=target_obj,
|
||||
source_obj=source_obj,
|
||||
)
|
||||
if td_type_key == constants.SHAPE_KEY_KEY:
|
||||
for transfer_data_dict in transfer_data_dicts:
|
||||
logger.debug(
|
||||
f"Transferring Shape Key {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
|
||||
)
|
||||
shape_keys.transfer_shape_key(
|
||||
context=context,
|
||||
target_obj=target_obj,
|
||||
source_obj=source_obj,
|
||||
shape_key_name=transfer_data_dict["name"],
|
||||
)
|
||||
if td_type_key == constants.ATTRIBUTE_KEY:
|
||||
for transfer_data_dict in transfer_data_dicts:
|
||||
logger.debug(
|
||||
f"Transferring Attribute {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
|
||||
)
|
||||
attributes.transfer_attribute(
|
||||
target_obj=target_obj,
|
||||
source_obj=source_obj,
|
||||
attribute_name=transfer_data_dict["name"],
|
||||
)
|
||||
if td_type_key == constants.PARENT_KEY:
|
||||
for transfer_data_dict in transfer_data_dicts:
|
||||
logger.debug(
|
||||
f"Transferring Parent Relationship from {source_obj.name} to {target_obj.name}."
|
||||
)
|
||||
parent.transfer_parent(
|
||||
target_obj=target_obj,
|
||||
source_obj=source_obj,
|
||||
)
|
||||
|
||||
|
||||
def apply_transfer_data(context: bpy.types.Context, transfer_data_map) -> None:
|
||||
"""Apply all Transferable Data from Transferable Data map onto objects.
|
||||
Copies any Transferable Data owned by local layer onto objects owned by external layers.
|
||||
Applies Transferable Data from external layers onto objects owned by local layers
|
||||
|
||||
Transfer_data_map is generated by class 'AssetTransferMapping'
|
||||
|
||||
Args:
|
||||
context (bpy.types.Context): context of .blend file
|
||||
transfer_data_map: Map generated by class AssetTransferMapping
|
||||
"""
|
||||
# Create/isolate tmp collection to reduce depsgraph update time
|
||||
profiler = logging.get_profiler()
|
||||
td_col = bpy.data.collections.new("ISO_COL_TEMP")
|
||||
with isolate_collection(context, td_col):
|
||||
# Loop over objects in Transfer data map
|
||||
for source_obj in transfer_data_map:
|
||||
target_obj = transfer_data_map[source_obj]["target_obj"]
|
||||
td_types = transfer_data_map[source_obj]["td_types"]
|
||||
with link_objs_to_collection(set([target_obj, source_obj]), td_col):
|
||||
for td_type_key, td_dicts in td_types.items():
|
||||
start_time = time.time()
|
||||
apply_transfer_data_items(
|
||||
context, source_obj, target_obj, td_type_key, td_dicts
|
||||
)
|
||||
profiler.add(time.time() - start_time, td_type_key)
|
||||
bpy.data.collections.remove(td_col)
|
||||
@@ -0,0 +1,261 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
import bmesh
|
||||
import numpy as np
|
||||
from .transfer_function_util.proximity_core import (
|
||||
tris_per_face,
|
||||
closest_face_to_point,
|
||||
closest_tri_on_face,
|
||||
is_obdata_identical,
|
||||
transfer_corner_data,
|
||||
)
|
||||
from ..transfer_util import find_ownership_data
|
||||
from ...naming import merge_get_basename
|
||||
from ...task_layer import get_transfer_data_owner
|
||||
from .... import constants, logging
|
||||
|
||||
|
||||
def attributes_get_editable(attributes):
|
||||
return [
|
||||
attribute
|
||||
for attribute in attributes
|
||||
if not (
|
||||
attribute.is_internal
|
||||
or attribute.is_required
|
||||
# Material Index is part of material transfer and should be skipped
|
||||
or attribute.name == 'material_index'
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def attribute_clean(obj):
|
||||
logger = logging.get_logger()
|
||||
if obj.type != "MESH":
|
||||
return
|
||||
attributes = attributes_get_editable(obj.data.attributes)
|
||||
attributes_to_remove = []
|
||||
for attribute in attributes:
|
||||
ownership_data = find_ownership_data(
|
||||
obj.transfer_data_ownership,
|
||||
merge_get_basename(attribute.name),
|
||||
constants.ATTRIBUTE_KEY,
|
||||
)
|
||||
if not ownership_data:
|
||||
attributes_to_remove.append(attribute.name)
|
||||
|
||||
for attribute_name_to_remove in reversed(attributes_to_remove):
|
||||
attribute_to_remove = obj.data.attributes.get(attribute_name_to_remove)
|
||||
logger.debug(f"Cleaning attribute {attribute.name}")
|
||||
obj.data.attributes.remove(attribute_to_remove)
|
||||
|
||||
|
||||
def attribute_is_missing(transfer_data_item):
|
||||
obj = transfer_data_item.id_data
|
||||
if obj.type != "MESH":
|
||||
return
|
||||
attributes = attributes_get_editable(obj.data.attributes)
|
||||
attribute_names = [attribute.name for attribute in attributes]
|
||||
if (
|
||||
transfer_data_item.type == constants.ATTRIBUTE_KEY
|
||||
and not transfer_data_item["name"] in attribute_names
|
||||
):
|
||||
return True
|
||||
|
||||
|
||||
def init_attributes(scene, obj):
|
||||
asset_pipe = scene.asset_pipeline
|
||||
if obj.type != "MESH":
|
||||
return
|
||||
transfer_data = obj.transfer_data_ownership
|
||||
td_type_key = constants.ATTRIBUTE_KEY
|
||||
for atttribute in attributes_get_editable(obj.data.attributes):
|
||||
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
|
||||
ownership_data = find_ownership_data(transfer_data, atttribute.name, td_type_key)
|
||||
if not ownership_data:
|
||||
task_layer_owner, auto_surrender = get_transfer_data_owner(
|
||||
asset_pipe, td_type_key, atttribute.name
|
||||
)
|
||||
asset_pipe.add_temp_transfer_data(
|
||||
name=atttribute.name,
|
||||
owner=task_layer_owner,
|
||||
type=td_type_key,
|
||||
obj_name=obj.name,
|
||||
surrender=auto_surrender,
|
||||
)
|
||||
|
||||
|
||||
def transfer_attribute(
|
||||
attribute_name: str,
|
||||
target_obj: bpy.types.Object,
|
||||
source_obj: bpy.types.Object,
|
||||
):
|
||||
source_attributes = source_obj.data.attributes
|
||||
target_attributes = target_obj.data.attributes
|
||||
source_attribute = source_attributes.get(attribute_name)
|
||||
target_attribute = target_attributes.get(attribute_name)
|
||||
|
||||
logger = logging.get_logger()
|
||||
if not source_attribute:
|
||||
logger.debug(f"Failed to find attribute to transfer: {attribute_name}")
|
||||
return
|
||||
|
||||
if target_attribute:
|
||||
target_attributes.remove(target_attribute)
|
||||
|
||||
target_attribute = target_attributes.new(
|
||||
name=attribute_name,
|
||||
type=source_attribute.data_type,
|
||||
domain=source_attribute.domain,
|
||||
)
|
||||
if not target_attribute:
|
||||
logger.debug(f"Failed to create attribute: {target_obj.name} -> {attribute_name}")
|
||||
return
|
||||
|
||||
if not is_obdata_identical(source_obj, target_obj):
|
||||
proximity_transfer_single_attribute(
|
||||
source_obj, target_obj, source_attribute, target_attribute
|
||||
)
|
||||
return
|
||||
|
||||
for source_data_item in source_attribute.data.items():
|
||||
index = source_data_item[0]
|
||||
source_data = source_data_item[1]
|
||||
keys = set(source_data.bl_rna.properties.keys()) - set(
|
||||
bpy.types.Attribute.bl_rna.properties.keys()
|
||||
)
|
||||
for key in list(keys):
|
||||
target_data = target_attribute.data[index]
|
||||
setattr(target_data, key, getattr(source_data, key))
|
||||
|
||||
|
||||
def proximity_transfer_single_attribute(
|
||||
source_obj: bpy.types.Object,
|
||||
target_obj: bpy.types.Object,
|
||||
source_attribute: bpy.types.Attribute,
|
||||
target_attribute: bpy.types.Attribute,
|
||||
):
|
||||
# src_dat = source_obj.data
|
||||
# tgt_dat = target_obj.data
|
||||
# if type(src_dat) is not type(tgt_dat) or not (src_dat or tgt_dat):
|
||||
# return False
|
||||
# if type(tgt_dat) is not bpy.types.Mesh: # TODO: support more types
|
||||
# return False
|
||||
|
||||
# If target attribute already exists, remove it.
|
||||
# tgt_attr = tgt_dat.attributes.get(source_attribute.name)
|
||||
# if tgt_attr is not None:
|
||||
# try:
|
||||
# tgt_dat.attributes.remove(tgt_attr)
|
||||
# except RuntimeError:
|
||||
# # Built-ins like "position" cannot be removed, and should be skipped.
|
||||
# return
|
||||
|
||||
# Create target attribute.
|
||||
# target_attribute = tgt_dat.attributes.new(
|
||||
# source_attribute.name, source_attribute.data_type, source_attribute.domain
|
||||
# )
|
||||
logger = logging.get_logger()
|
||||
|
||||
data_sfx = {
|
||||
'INT8': 'value',
|
||||
'INT': 'value',
|
||||
'FLOAT': 'value',
|
||||
'FLOAT2': 'vector',
|
||||
'BOOLEAN': 'value',
|
||||
'STRING': 'value',
|
||||
'BYTE_COLOR': 'color',
|
||||
'FLOAT_COLOR': 'color',
|
||||
'FLOAT_VECTOR': 'vector',
|
||||
}
|
||||
|
||||
data_sfx = data_sfx[source_attribute.data_type]
|
||||
|
||||
# if topo_match:
|
||||
# # TODO: optimize using foreach_get/set rather than loop
|
||||
# for i in range(len(source_attribute.data)):
|
||||
# setattr(tgt_attr.data[i], data_sfx, getattr(source_attribute.data[i], data_sfx))
|
||||
# return
|
||||
|
||||
# proximity fallback
|
||||
if source_attribute.data_type == 'STRING':
|
||||
# TODO: add NEAREST transfer fallback for attributes without interpolation
|
||||
logger.warning(
|
||||
f'Proximity based transfer for generic attributes of type STRING not supported yet. Skipping attribute {source_attribute.name} on {target_obj}.'
|
||||
)
|
||||
return
|
||||
|
||||
domain = source_attribute.domain
|
||||
if (
|
||||
domain == 'POINT'
|
||||
): # TODO: deduplicate interpolated point domain proximity transfer
|
||||
bm_source = bmesh.new()
|
||||
bm_source.from_mesh(source_obj.data)
|
||||
bm_source.faces.ensure_lookup_table()
|
||||
|
||||
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
|
||||
|
||||
tris_dict = tris_per_face(bm_source)
|
||||
|
||||
for i, vert in enumerate(target_obj.data.vertices):
|
||||
p = vert.co
|
||||
face = closest_face_to_point(bm_source, p, bvh_tree)
|
||||
|
||||
(tri, point) = closest_tri_on_face(tris_dict, face, p)
|
||||
if not tri:
|
||||
continue
|
||||
weights = mathutils.interpolate.poly_3d_calc(
|
||||
[tri[i].vert.co for i in range(3)], point
|
||||
)
|
||||
|
||||
if data_sfx in ['color']:
|
||||
vals_weighted = [
|
||||
weights[i]
|
||||
* (
|
||||
np.array(
|
||||
getattr(source_attribute.data[tri[i].vert.index], data_sfx)
|
||||
)
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
else:
|
||||
vals_weighted = [
|
||||
weights[i]
|
||||
* (getattr(source_attribute.data[tri[i].vert.index], data_sfx))
|
||||
for i in range(3)
|
||||
]
|
||||
setattr(target_attribute.data[i], data_sfx, sum(np.array(vals_weighted)))
|
||||
return
|
||||
elif domain == 'EDGE':
|
||||
# TODO support proximity fallback for generic edge attributes
|
||||
logger.warning(
|
||||
f'Proximity based transfer of generic edge attributes not supported yet. Skipping attribute {source_attribute.name} on {target_obj}.'
|
||||
)
|
||||
return
|
||||
elif domain == 'FACE':
|
||||
bm_source = bmesh.new()
|
||||
bm_source.from_mesh(source_obj.data)
|
||||
bm_source.faces.ensure_lookup_table()
|
||||
|
||||
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
|
||||
for i, face in enumerate(target_obj.data.polygons):
|
||||
p_target = face.center
|
||||
closest_face = closest_face_to_point(bm_source, p_target, bvh_tree)
|
||||
setattr(
|
||||
target_attribute.data[i],
|
||||
data_sfx,
|
||||
getattr(source_attribute.data[closest_face.index], data_sfx),
|
||||
)
|
||||
return
|
||||
elif domain == 'CORNER':
|
||||
transfer_corner_data(
|
||||
source_obj,
|
||||
target_obj,
|
||||
source_attribute.data,
|
||||
target_attribute.data,
|
||||
data_suffix=data_sfx,
|
||||
)
|
||||
return
|
||||
@@ -0,0 +1,88 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from ..transfer_util import (
|
||||
transfer_data_clean,
|
||||
transfer_data_item_is_missing,
|
||||
find_ownership_data,
|
||||
)
|
||||
from ...naming import task_layer_prefix_name_get
|
||||
from .transfer_function_util.drivers import transfer_drivers, cleanup_drivers
|
||||
from ...task_layer import get_transfer_data_owner
|
||||
from .... import constants, logging
|
||||
|
||||
|
||||
def constraints_clean(obj):
|
||||
cleaned_names = transfer_data_clean(
|
||||
obj=obj, data_list=obj.constraints, td_type_key=constants.CONSTRAINT_KEY
|
||||
)
|
||||
|
||||
# Remove Drivers that match the cleaned item's name
|
||||
for name in cleaned_names:
|
||||
cleanup_drivers(obj, 'constraints', name)
|
||||
|
||||
def constraint_is_missing(transfer_data_item):
|
||||
return transfer_data_item_is_missing(
|
||||
transfer_data_item=transfer_data_item,
|
||||
td_type_key=constants.CONSTRAINT_KEY,
|
||||
data_list=transfer_data_item.id_data.constraints,
|
||||
)
|
||||
|
||||
|
||||
def init_constraints(scene, obj):
|
||||
td_type_key = constants.CONSTRAINT_KEY
|
||||
transfer_data = obj.transfer_data_ownership
|
||||
asset_pipe = scene.asset_pipeline
|
||||
task_layer_owner, auto_surrender = get_transfer_data_owner(
|
||||
asset_pipe,
|
||||
td_type_key,
|
||||
)
|
||||
for const in obj.constraints:
|
||||
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
|
||||
ownership_data = find_ownership_data(transfer_data, const.name, td_type_key)
|
||||
if not ownership_data:
|
||||
ownership_data = asset_pipe.add_temp_transfer_data(
|
||||
name=const.name,
|
||||
owner=task_layer_owner,
|
||||
type=td_type_key,
|
||||
obj_name=obj.name,
|
||||
surrender=auto_surrender,
|
||||
)
|
||||
|
||||
const.name = task_layer_prefix_name_get(const.name, ownership_data.owner)
|
||||
|
||||
|
||||
def transfer_constraint(constraint_name, target_obj, source_obj):
|
||||
logger = logging.get_logger()
|
||||
context = bpy.context
|
||||
# Remove old and sync existing constraints.
|
||||
old_con = target_obj.constraints.get(constraint_name)
|
||||
if old_con:
|
||||
target_obj.constraints.remove(old_con)
|
||||
|
||||
src_idx = source_obj.constraints.find(constraint_name)
|
||||
if src_idx == -1:
|
||||
# This happens if a modifier's transfer data is still around, but the modifier
|
||||
# itself was removed.
|
||||
logger.debug(f"Constraint Transfer cancelled, '{constraint_name}' not found on '{source_obj.name}'")
|
||||
return
|
||||
|
||||
src_con = source_obj.constraints[src_idx]
|
||||
new_con = target_obj.constraints.new(src_con.type)
|
||||
new_con.name = src_con.name
|
||||
|
||||
props = [p.identifier for p in src_con.bl_rna.properties if not p.is_readonly]
|
||||
for prop in props:
|
||||
value = getattr(src_con, prop)
|
||||
setattr(new_con, prop, value)
|
||||
|
||||
# Armature constraints have some nested properties we need to copy...
|
||||
if src_con.type == "ARMATURE":
|
||||
for target_item in src_con.targets:
|
||||
new_target = new_con.targets.new()
|
||||
new_target.target = target_item.target
|
||||
new_target.subtarget = target_item.subtarget
|
||||
|
||||
transfer_drivers(source_obj, target_obj, 'constraints', constraint_name)
|
||||
@@ -0,0 +1,61 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from ..transfer_util import find_ownership_data
|
||||
from ...task_layer import get_transfer_data_owner
|
||||
|
||||
from .... import constants
|
||||
from .transfer_function_util.properties import (
|
||||
get_all_runtime_prop_names,
|
||||
remove_property,
|
||||
copy_runtime_property,
|
||||
)
|
||||
|
||||
def transfer_custom_prop(prop_name, target_obj, source_obj):
|
||||
copy_runtime_property(source_obj, target_obj, prop_name)
|
||||
|
||||
|
||||
def custom_prop_clean(obj):
|
||||
cleaned_item_names = set()
|
||||
for key in get_valid_runtime_prop_names(obj):
|
||||
ownership_data = find_ownership_data(
|
||||
obj.transfer_data_ownership,
|
||||
key,
|
||||
constants.CUSTOM_PROP_KEY,
|
||||
)
|
||||
if not ownership_data:
|
||||
cleaned_item_names.add(key)
|
||||
remove_property(obj, key)
|
||||
|
||||
return cleaned_item_names
|
||||
|
||||
|
||||
def custom_prop_is_missing(transfer_data_item):
|
||||
obj = transfer_data_item.id_data
|
||||
return transfer_data_item.type == constants.CUSTOM_PROP_KEY and not transfer_data_item["name"] in get_valid_runtime_prop_names(obj)
|
||||
|
||||
|
||||
def init_custom_prop(scene, obj):
|
||||
asset_pipe = scene.asset_pipeline
|
||||
transfer_data = obj.transfer_data_ownership
|
||||
td_type_key = constants.CUSTOM_PROP_KEY
|
||||
|
||||
for prop_name in get_valid_runtime_prop_names(obj):
|
||||
ownership_data = find_ownership_data(transfer_data, prop_name, td_type_key)
|
||||
if not ownership_data:
|
||||
task_layer_owner, auto_surrender = get_transfer_data_owner(
|
||||
asset_pipe, td_type_key, prop_name
|
||||
)
|
||||
asset_pipe.add_temp_transfer_data(
|
||||
name=prop_name,
|
||||
owner=task_layer_owner,
|
||||
type=td_type_key,
|
||||
obj_name=obj.name,
|
||||
surrender=auto_surrender,
|
||||
)
|
||||
|
||||
|
||||
def get_valid_runtime_prop_names(id):
|
||||
all_props = get_all_runtime_prop_names(id)
|
||||
return [p for p in all_props if p not in constants.ADDON_OWN_PROPERTIES]
|
||||
@@ -0,0 +1,107 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from .attributes import transfer_attribute
|
||||
from ..transfer_util import find_ownership_data
|
||||
from ...task_layer import get_transfer_data_owner
|
||||
from .... import constants
|
||||
from .transfer_function_util.proximity_core import (
|
||||
is_obdata_identical,
|
||||
)
|
||||
|
||||
|
||||
def materials_clean(obj):
|
||||
# Material slots cannot use generic transfer_data_clean() function
|
||||
|
||||
ownership_data = find_ownership_data(
|
||||
obj.transfer_data_ownership,
|
||||
constants.MATERIAL_TRANSFER_DATA_ITEM_NAME,
|
||||
constants.MATERIAL_SLOT_KEY,
|
||||
)
|
||||
|
||||
# Clear Materials if No Transferable Data is Found
|
||||
if ownership_data:
|
||||
return
|
||||
|
||||
if obj.data and hasattr(obj.data, 'materials'):
|
||||
obj.data.materials.clear()
|
||||
|
||||
|
||||
def materials_is_missing(transfer_data_item):
|
||||
if (
|
||||
transfer_data_item.type == constants.MATERIAL_SLOT_KEY
|
||||
and len(transfer_data_item.id_data.material_slots) == 0
|
||||
):
|
||||
return True
|
||||
|
||||
|
||||
def init_materials(scene, obj):
|
||||
asset_pipe = scene.asset_pipeline
|
||||
td_type_key = constants.MATERIAL_SLOT_KEY
|
||||
name = constants.MATERIAL_TRANSFER_DATA_ITEM_NAME
|
||||
transfer_data = obj.transfer_data_ownership
|
||||
|
||||
if not (obj.data and hasattr(obj.data, 'materials')):
|
||||
return
|
||||
|
||||
ownership_data = find_ownership_data(transfer_data, name, td_type_key)
|
||||
# Only add new ownership transfer_data_item if material doesn't have an owner
|
||||
if not ownership_data:
|
||||
task_layer_owner, auto_surrender = get_transfer_data_owner(
|
||||
asset_pipe,
|
||||
td_type_key,
|
||||
)
|
||||
asset_pipe.add_temp_transfer_data(
|
||||
name=name,
|
||||
owner=task_layer_owner,
|
||||
type=td_type_key,
|
||||
obj_name=obj.name,
|
||||
surrender=auto_surrender,
|
||||
)
|
||||
|
||||
|
||||
def transfer_materials(target_obj: bpy.types.Object, source_obj):
|
||||
# Delete all material slots of target object.
|
||||
target_obj.data.materials.clear()
|
||||
|
||||
# Transfer material slots
|
||||
for idx in range(len(source_obj.material_slots)):
|
||||
target_obj.data.materials.append(source_obj.material_slots[idx].material)
|
||||
target_obj.material_slots[idx].link = source_obj.material_slots[idx].link
|
||||
|
||||
# Transfer active material slot index
|
||||
target_obj.active_material_index = source_obj.active_material_index
|
||||
|
||||
# Transfer material slot assignments for curve
|
||||
if target_obj.type == "CURVE":
|
||||
for spl_to, spl_from in zip(target_obj.data.splines, source_obj.data.splines):
|
||||
spl_to.material_index = spl_from.material_index
|
||||
|
||||
if source_obj.type == "MESH":
|
||||
if source_obj.data.attributes.get(constants.MATERIAL_ATTRIBUTE_NAME):
|
||||
transfer_attribute(constants.MATERIAL_ATTRIBUTE_NAME, target_obj, source_obj)
|
||||
|
||||
transfer_uv_seams(source_obj, target_obj)
|
||||
|
||||
|
||||
def transfer_uv_seams(source_obj, target_obj):
|
||||
if is_obdata_identical(source_obj, target_obj):
|
||||
for edge_from, edge_to in zip(source_obj.data.edges, target_obj.data.edges):
|
||||
edge_to.use_seam = edge_from.use_seam
|
||||
elif len(source_obj.data.edges) > 0 and len(target_obj.data.edges) > 0:
|
||||
# Create proxy object as transfer source to avoid transferring from evaluated mesh
|
||||
temp_source_obj = bpy.data.objects.new('TEMP', source_obj.data)
|
||||
with bpy.context.temp_override(
|
||||
object=temp_source_obj,
|
||||
active_object=temp_source_obj,
|
||||
selected_editable_objects=[temp_source_obj, target_obj],
|
||||
):
|
||||
bpy.ops.object.data_transfer(
|
||||
data_type="SEAM",
|
||||
edge_mapping="NEAREST",
|
||||
mix_mode="REPLACE",
|
||||
use_object_transform=False,
|
||||
)
|
||||
bpy.data.objects.remove(temp_source_obj)
|
||||
@@ -0,0 +1,206 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from .transfer_function_util.drivers import transfer_drivers, cleanup_drivers
|
||||
from .transfer_function_util.visibility import override_obj_visibility
|
||||
from ..transfer_util import (
|
||||
transfer_data_clean,
|
||||
transfer_data_item_is_missing,
|
||||
find_ownership_data,
|
||||
activate_shapekey,
|
||||
disable_modifiers,
|
||||
)
|
||||
from ...naming import task_layer_prefix_name_get, task_layer_prefix_basename_get
|
||||
from ...task_layer import get_transfer_data_owner
|
||||
from .... import constants, logging
|
||||
|
||||
BIND_OPS = {
|
||||
'SURFACE_DEFORM': bpy.ops.object.surfacedeform_bind,
|
||||
'MESH_DEFORM': bpy.ops.object.meshdeform_bind,
|
||||
'CORRECTIVE_SMOOTH': bpy.ops.object.correctivesmooth_bind,
|
||||
}
|
||||
|
||||
|
||||
def modifiers_clean(obj):
|
||||
cleaned_names = transfer_data_clean(
|
||||
obj=obj, data_list=obj.modifiers, td_type_key=constants.MODIFIER_KEY
|
||||
)
|
||||
|
||||
# Remove Drivers that match the cleaned item's name
|
||||
for name in cleaned_names:
|
||||
cleanup_drivers(obj, 'modifiers', name)
|
||||
|
||||
|
||||
def modifier_is_missing(transfer_data_item):
|
||||
return transfer_data_item_is_missing(
|
||||
transfer_data_item=transfer_data_item,
|
||||
td_type_key=constants.MODIFIER_KEY,
|
||||
data_list=transfer_data_item.id_data.modifiers,
|
||||
)
|
||||
|
||||
|
||||
def init_modifiers(scene, obj):
|
||||
asset_pipe = scene.asset_pipeline
|
||||
td_type_key = constants.MODIFIER_KEY
|
||||
transfer_data = obj.transfer_data_ownership
|
||||
task_layer_owner, auto_surrender = get_transfer_data_owner(
|
||||
asset_pipe,
|
||||
td_type_key,
|
||||
)
|
||||
|
||||
for mod in obj.modifiers:
|
||||
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
|
||||
ownership_data = find_ownership_data(transfer_data, mod.name, td_type_key)
|
||||
if not ownership_data:
|
||||
ownership_data = asset_pipe.add_temp_transfer_data(
|
||||
name=mod.name,
|
||||
owner=task_layer_owner,
|
||||
type=td_type_key,
|
||||
obj_name=obj.name,
|
||||
surrender=auto_surrender,
|
||||
)
|
||||
|
||||
mod.name = task_layer_prefix_name_get(mod.name, ownership_data.owner)
|
||||
|
||||
|
||||
def transfer_modifier(context, modifier_name, target_obj, source_obj):
|
||||
"""Transfer a single modifier from source_obj to target_obj.
|
||||
For example, when pulling into rigging and transferring a rigging modifier,
|
||||
then source_obj will be the local object, and target_obj will be the external object.
|
||||
"""
|
||||
logger = logging.get_logger()
|
||||
|
||||
source_mod = source_obj.modifiers.get(modifier_name)
|
||||
target_mod = target_obj.modifiers.get(modifier_name)
|
||||
|
||||
if not source_mod:
|
||||
# This happens if a modifier's transfer data is still around, but the modifier
|
||||
# itself was removed.
|
||||
logger.debug(
|
||||
f"Modifer Transfer cancelled, '{modifier_name}' not found on '{source_obj.name}'"
|
||||
)
|
||||
if target_mod:
|
||||
target_obj.modifiers.remove(target_mod)
|
||||
return
|
||||
|
||||
if not target_mod:
|
||||
target_mod = target_obj.modifiers.new(source_mod.name, source_mod.type)
|
||||
|
||||
place_modifier_in_stack(source_obj, target_obj, modifier_name)
|
||||
transfer_modifier_props(context, source_mod, target_mod)
|
||||
transfer_drivers(source_obj, target_obj, 'modifiers', modifier_name)
|
||||
if is_modifier_bound(source_mod):
|
||||
bind_modifier(context, target_obj, modifier_name)
|
||||
|
||||
def place_modifier_in_stack(source_obj, target_obj, modifier_name):
|
||||
"""Modifiers will try to be placed below the modifier they were below on the source object.
|
||||
This is not very foolproof, since re-ordering multiple modifiers or renaming plus re-ordering,
|
||||
or removing plus re-ordering, all in one step, could make it hard to determine the ideal order.
|
||||
In such cases, user may need to fix the order and sync a 2nd time.
|
||||
"""
|
||||
|
||||
logger = logging.get_logger()
|
||||
idx_tgt = target_obj.modifiers.find(modifier_name)
|
||||
idx_src = source_obj.modifiers.find(modifier_name)
|
||||
|
||||
idx_new = 0
|
||||
name_anchor = ""
|
||||
# Order modifier based on previous modifier in source obj.
|
||||
if idx_src > 0:
|
||||
mod_anchor = source_obj.modifiers[idx_src - 1]
|
||||
name_anchor = task_layer_prefix_basename_get(mod_anchor.name)
|
||||
|
||||
for idx, mod_of_tgt in enumerate(target_obj.modifiers):
|
||||
if name_anchor == task_layer_prefix_basename_get(mod_of_tgt.name):
|
||||
idx_new = min(len(target_obj.modifiers)-1, idx+1)
|
||||
break
|
||||
|
||||
if idx_tgt != idx_new:
|
||||
target_obj.modifiers.move(idx_tgt, idx_new)
|
||||
msg = f" Moved {modifier_name} to index {idx_new}"
|
||||
if name_anchor:
|
||||
msg += f"(after {name_anchor})"
|
||||
logger.debug(msg)
|
||||
|
||||
|
||||
def transfer_modifier_props(context, source_mod, target_mod):
|
||||
props = [p.identifier for p in source_mod.bl_rna.properties if not p.is_readonly]
|
||||
for prop in props:
|
||||
value = getattr(source_mod, prop)
|
||||
setattr(target_mod, prop, value)
|
||||
|
||||
if source_mod.type == 'NODES':
|
||||
# NOTE: This matches inputs by their internal name, not their display name.
|
||||
# That means you can rename sockets, but removing and adding new ones might cause trouble.
|
||||
|
||||
# Transfer geo node attributes
|
||||
for key, value in source_mod.items():
|
||||
typ = type(getattr(target_mod, f'["{key}"]'))
|
||||
if typ in (int, float, bool, str):
|
||||
value = typ(value)
|
||||
target_mod[key] = value
|
||||
|
||||
# Transfer geo node bake settings
|
||||
target_mod.bake_directory = source_mod.bake_directory
|
||||
for index, target_bake in enumerate(target_mod.bakes):
|
||||
source_bake = source_mod.bakes[index]
|
||||
props = [p.identifier for p in source_bake.bl_rna.properties if not p.is_readonly]
|
||||
for prop in props:
|
||||
value = getattr(source_bake, prop)
|
||||
setattr(target_bake, prop, value)
|
||||
|
||||
# refresh node modifier UI
|
||||
if target_mod.node_group:
|
||||
target_mod.node_group.interface_update(context)
|
||||
|
||||
|
||||
def bind_modifier(context, obj, modifier_name):
|
||||
"""Binding data cannot be transferred. Instead, modifiers that require binding will have the bind operator executed.
|
||||
|
||||
Sometimes binding is meant to be done in a bind pose other than the default. For this, shape keys can be added
|
||||
to the deforming and/or the deformed mesh, named "BIND-<name_of_modifier_with_prefix>". Such shape keys will be enabled
|
||||
during binding. Other deforming modifiers will be disabled during binding.
|
||||
"""
|
||||
|
||||
# NOTE: This could be optimized by not re-binding unnecessarily, but Blender doesn't allow checking
|
||||
# if the binding is broken or not. https://projects.blender.org/blender/blender/issues/140550
|
||||
# Another way to get around this is to let the rigging task layer own the object base.
|
||||
|
||||
logger = logging.get_logger()
|
||||
modifier = obj.modifiers.get(modifier_name)
|
||||
assert modifier
|
||||
bind_op = BIND_OPS.get(modifier.type)
|
||||
if (
|
||||
not bind_op or
|
||||
(hasattr(modifier, 'target') and not modifier.target) or
|
||||
not modifier.show_viewport or
|
||||
(modifier.type=='CORRECTIVE_SMOOTH' and modifier.rest_source=='ORCO')
|
||||
):
|
||||
return
|
||||
|
||||
objs = [obj]
|
||||
if hasattr(modifier, 'target') and modifier.target:
|
||||
objs.append(modifier.target)
|
||||
with activate_shapekey(objs, "BIND-"+modifier_name):
|
||||
modifiers_to_disable = ['LATTICE', 'ARMATURE', 'SHRINKWRAP', 'SMOOTH']
|
||||
if modifier.type != 'CORRECTIVE_SMOOTH':
|
||||
modifiers_to_disable.append('CORRECTIVE_SMOOTH')
|
||||
with disable_modifiers(objs, modifiers_to_disable):
|
||||
for i in range(2):
|
||||
context.view_layer.update()
|
||||
with override_obj_visibility(obj=obj, scene=context.scene):
|
||||
with context.temp_override(object=obj, active_object=obj):
|
||||
bind_op(modifier=modifier.name)
|
||||
word = "Bound" if is_modifier_bound(modifier) else "Un-bound"
|
||||
logger.debug(f"{word} {modifier_name} on {obj.name}")
|
||||
if is_modifier_bound(modifier):
|
||||
return
|
||||
|
||||
|
||||
def is_modifier_bound(modifier) -> bool | None:
|
||||
if modifier.type == 'CORRECTIVE_SMOOTH':
|
||||
return modifier.is_bind
|
||||
elif hasattr(modifier, 'is_bound'):
|
||||
return modifier.is_bound
|
||||
@@ -0,0 +1,69 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from ..transfer_util import find_ownership_data
|
||||
from ...task_layer import get_transfer_data_owner
|
||||
from ...naming import merge_get_basename
|
||||
from .... import constants, logging
|
||||
|
||||
|
||||
def parent_clean(obj):
|
||||
logger = logging.get_logger()
|
||||
ownership_data = find_ownership_data(
|
||||
obj.transfer_data_ownership,
|
||||
merge_get_basename(constants.PARENT_TRANSFER_DATA_ITEM_NAME),
|
||||
constants.PARENT_KEY,
|
||||
)
|
||||
|
||||
if ownership_data:
|
||||
return
|
||||
|
||||
obj.parent = None
|
||||
logger.debug("Cleaning Parent Relationship")
|
||||
|
||||
|
||||
def parent_is_missing(transfer_data_item):
|
||||
if (
|
||||
transfer_data_item.type == constants.PARENT_KEY
|
||||
and transfer_data_item.id_data.parent == None
|
||||
):
|
||||
return True
|
||||
|
||||
|
||||
def init_parent(scene, obj):
|
||||
asset_pipe = scene.asset_pipeline
|
||||
td_type_key = constants.PARENT_KEY
|
||||
name = constants.PARENT_TRANSFER_DATA_ITEM_NAME
|
||||
transfer_data = obj.transfer_data_ownership
|
||||
|
||||
if obj.parent not in list(asset_pipe.asset_collection.all_objects) and obj.parent is not None:
|
||||
raise Exception(f"Object parent {obj.parent.name} cannot be outside of asset collection")
|
||||
ownership_data = find_ownership_data(transfer_data, name, td_type_key)
|
||||
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
|
||||
if not ownership_data:
|
||||
task_layer_owner, auto_surrender = get_transfer_data_owner(
|
||||
asset_pipe,
|
||||
td_type_key,
|
||||
)
|
||||
asset_pipe.add_temp_transfer_data(
|
||||
name=name,
|
||||
owner=task_layer_owner,
|
||||
type=td_type_key,
|
||||
obj_name=obj.name,
|
||||
surrender=auto_surrender,
|
||||
)
|
||||
|
||||
|
||||
def transfer_parent(target_obj, source_obj):
|
||||
target_obj.parent = source_obj.parent
|
||||
target_obj.parent_type = source_obj.parent_type
|
||||
target_obj.parent_bone = source_obj.parent_bone
|
||||
|
||||
target_obj.location = source_obj.location
|
||||
if source_obj.rotation_mode == 'QUATERNION':
|
||||
target_obj.rotation_quaternion = source_obj.rotation_quaternion
|
||||
else:
|
||||
target_obj.rotation_euler = source_obj.rotation_euler
|
||||
target_obj.scale = source_obj.scale
|
||||
target_obj.matrix_parent_inverse = source_obj.matrix_parent_inverse.copy()
|
||||
@@ -0,0 +1,164 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
import bmesh
|
||||
import numpy as np
|
||||
from .transfer_function_util.proximity_core import (
|
||||
tris_per_face,
|
||||
closest_face_to_point,
|
||||
closest_tri_on_face,
|
||||
)
|
||||
from .transfer_function_util.drivers import transfer_drivers, cleanup_drivers
|
||||
from ..transfer_util import (
|
||||
transfer_data_item_is_missing,
|
||||
transfer_data_item_init,
|
||||
find_ownership_data,
|
||||
)
|
||||
from ...naming import merge_get_basename
|
||||
from .... import constants, logging
|
||||
|
||||
|
||||
def shape_key_set_active(obj, shape_key_name):
|
||||
for index, shape_key in enumerate(obj.data.shape_keys.key_blocks):
|
||||
if shape_key.name == shape_key_name:
|
||||
obj.active_shape_key_index = index
|
||||
|
||||
|
||||
def shape_keys_clean(obj):
|
||||
if obj.type != "MESH" or obj.data.shape_keys is None:
|
||||
return
|
||||
|
||||
cleaned_item_names = set()
|
||||
|
||||
for shape_key in obj.data.shape_keys.key_blocks:
|
||||
ownership_data = find_ownership_data(
|
||||
obj.transfer_data_ownership,
|
||||
merge_get_basename(shape_key.name),
|
||||
constants.SHAPE_KEY_KEY,
|
||||
)
|
||||
if not ownership_data:
|
||||
cleaned_item_names.add(shape_key.name)
|
||||
obj.shape_key_remove(shape_key)
|
||||
|
||||
if not obj.data.shape_keys:
|
||||
# It's possible there are no shape keys anymore.
|
||||
return
|
||||
|
||||
for name in cleaned_item_names:
|
||||
cleanup_drivers(obj.data.shape_keys, 'key_blocks', name)
|
||||
|
||||
|
||||
def shape_key_is_missing(transfer_data_item):
|
||||
if not transfer_data_item.type == constants.SHAPE_KEY_KEY:
|
||||
return
|
||||
obj = transfer_data_item.id_data
|
||||
if obj.type != 'MESH':
|
||||
return
|
||||
if not obj.data.shape_keys:
|
||||
return True
|
||||
return transfer_data_item_is_missing(
|
||||
transfer_data_item=transfer_data_item,
|
||||
td_type_key=constants.SHAPE_KEY_KEY,
|
||||
data_list=obj.data.shape_keys.key_blocks,
|
||||
)
|
||||
|
||||
|
||||
def init_shape_keys(scene, obj):
|
||||
if obj.type != "MESH" or obj.data.shape_keys is None:
|
||||
return
|
||||
|
||||
# Check that the order is legal.
|
||||
# Key Blocks must be ordered after the key they are Relative To.
|
||||
for i, kb in enumerate(obj.data.shape_keys.key_blocks):
|
||||
if kb.relative_key:
|
||||
base_shape_idx = obj.data.shape_keys.key_blocks.find(kb.relative_key.name)
|
||||
if base_shape_idx > i:
|
||||
raise Exception(
|
||||
f'Shape Key "{kb.name}" must be ordered after its base shape "{kb.relative_key.name}" on object "{obj.name}".'
|
||||
)
|
||||
|
||||
transfer_data_item_init(
|
||||
scene=scene,
|
||||
obj=obj,
|
||||
data_list=obj.data.shape_keys.key_blocks,
|
||||
td_type_key=constants.SHAPE_KEY_KEY,
|
||||
)
|
||||
|
||||
|
||||
def transfer_shape_key(
|
||||
context: bpy.types.Context,
|
||||
shape_key_name: str,
|
||||
target_obj: bpy.types.Object,
|
||||
source_obj: bpy.types.Object,
|
||||
):
|
||||
logger = logging.get_logger()
|
||||
if not source_obj.data.shape_keys:
|
||||
return
|
||||
sk_source = source_obj.data.shape_keys.key_blocks.get(shape_key_name)
|
||||
assert sk_source
|
||||
|
||||
sk_target = None
|
||||
if not target_obj.data.shape_keys:
|
||||
sk_target = target_obj.shape_key_add()
|
||||
if not sk_target:
|
||||
sk_target = target_obj.data.shape_keys.key_blocks.get(shape_key_name)
|
||||
if not sk_target:
|
||||
sk_target = target_obj.shape_key_add()
|
||||
|
||||
sk_target.name = sk_source.name
|
||||
sk_target.value = 0
|
||||
sk_target.vertex_group = sk_source.vertex_group
|
||||
if sk_source.relative_key != sk_source:
|
||||
relative_key = None
|
||||
if target_obj.data.shape_keys:
|
||||
relative_key = target_obj.data.shape_keys.key_blocks.get(sk_source.relative_key.name)
|
||||
if relative_key:
|
||||
sk_target.relative_key = relative_key
|
||||
else:
|
||||
# If the base shape of one of our shapes was removed by another task layer,
|
||||
# the result will probably be pretty bad, but it's not a catastrophic failure.
|
||||
# Proceed with a warning.
|
||||
logger.warning(
|
||||
f'Base shape "{sk_source.relative_key.name}" of Key "{sk_source.name}" was removed from "{target_obj.name}"'
|
||||
)
|
||||
|
||||
sk_target.slider_min = sk_source.slider_min
|
||||
sk_target.slider_max = sk_source.slider_max
|
||||
sk_target.value = sk_source.value
|
||||
sk_target.mute = sk_source.mute
|
||||
|
||||
bm_source = bmesh.new()
|
||||
bm_source.from_mesh(source_obj.data)
|
||||
bm_source.faces.ensure_lookup_table()
|
||||
|
||||
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
|
||||
tris_dict = tris_per_face(bm_source)
|
||||
for i, vert in enumerate(target_obj.data.vertices):
|
||||
p = vert.co
|
||||
face = closest_face_to_point(bm_source, p, bvh_tree)
|
||||
|
||||
(tri, point) = closest_tri_on_face(tris_dict, face, p)
|
||||
if not tri:
|
||||
continue
|
||||
weights = mathutils.interpolate.poly_3d_calc([tri[i].vert.co for i in range(3)], point)
|
||||
|
||||
vals_weighted = [
|
||||
weights[i]
|
||||
* (
|
||||
sk_source.data[tri[i].vert.index].co
|
||||
- source_obj.data.vertices[tri[i].vert.index].co
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
val = mathutils.Vector(sum(np.array(vals_weighted)))
|
||||
sk_target.data[i].co = vert.co + val
|
||||
|
||||
if source_obj.data.shape_keys is None:
|
||||
return
|
||||
|
||||
transfer_drivers(
|
||||
source_obj.data.shape_keys, target_obj.data.shape_keys, 'key_blocks', shape_key_name
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
def transfer_active_color_attribute_index(target_obj, active_color_name):
|
||||
# active_color_name = source_obj.data.color_attributes.active_color_name
|
||||
if active_color_name is None or active_color_name == "":
|
||||
return
|
||||
for color_attribute in target_obj.data.color_attributes:
|
||||
if color_attribute.name == active_color_name:
|
||||
target_obj.data.color_attributes.active_color = color_attribute
|
||||
|
||||
|
||||
def transfer_active_uv_layer_index(target_obj, active_uv_name):
|
||||
# active_uv = source_obj.data.uv_layers.active
|
||||
if active_uv_name is None or active_uv_name == "":
|
||||
return
|
||||
for uv_layer in target_obj.data.uv_layers:
|
||||
if uv_layer.name == active_uv_name:
|
||||
target_obj.data.uv_layers.active = uv_layer
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
|
||||
def copy_driver(
|
||||
from_fcurve: bpy.types.FCurve, target: bpy.types.ID, data_path=None, index=None
|
||||
) -> bpy.types.FCurve:
|
||||
"""Copy an existing FCurve containing a driver to a new ID, by creating a copy
|
||||
of the existing driver on the target ID.
|
||||
|
||||
Args:
|
||||
from_fcurve (bpy.types.FCurve): FCurve containing a driver
|
||||
target (bpy.types.ID): ID that can have drivers added to it
|
||||
data_path (_type_, optional): Data Path of existing driver. Defaults to None.
|
||||
index (_type_, optional): array index of the property drive. Defaults to None.
|
||||
|
||||
Returns:
|
||||
bpy.types.FCurve: Fcurve containing copy of driver on target ID
|
||||
"""
|
||||
|
||||
if not target.animation_data:
|
||||
target.animation_data_create()
|
||||
|
||||
new_fc = target.animation_data.drivers.from_existing(src_driver = from_fcurve)
|
||||
|
||||
if data_path:
|
||||
new_fc.data_path = data_path
|
||||
if index:
|
||||
new_fc.array_index = index
|
||||
|
||||
return new_fc
|
||||
|
||||
|
||||
def find_drivers(id: bpy.types.ID, target_type: str, target_name: str) -> list[bpy.types.FCurve]:
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
drivers (list[bpy.types.FCurve]): List or Collection Property containing Fcurves with drivers
|
||||
target_type (str): Name of data type found in driver data path, e.g. "modifiers"
|
||||
target_name (str): Name of data found in driver path, e.g. modifier's name
|
||||
|
||||
Returns:
|
||||
list[bpy.types.FCurve]: List of FCurves containing drivers that match type & name
|
||||
"""
|
||||
|
||||
if not id.animation_data:
|
||||
return []
|
||||
|
||||
found_drivers = []
|
||||
if id.animation_data is None or id.animation_data.drivers is None:
|
||||
return found_drivers
|
||||
drivers = id.animation_data.drivers
|
||||
for driver in drivers:
|
||||
if f'{target_type}["{target_name}"]' in driver.data_path:
|
||||
found_drivers.append(driver)
|
||||
return found_drivers
|
||||
|
||||
|
||||
def transfer_drivers(
|
||||
source_id: bpy.types.ID, target_id: bpy.types.ID, target_type: str, target_name: str
|
||||
) -> None:
|
||||
"""Transfers Drivers from one ID to another, will copy and new drivres from source to from
|
||||
source to target, and will remove any drivers on the target that are not in the source.
|
||||
|
||||
Args:
|
||||
source_id (bpy.types.ID): Source ID, containing drivers to copy
|
||||
target_id (bpy.types.ID): Target ID, which will recieve the drivers from source
|
||||
target_type (str): Name of driver target's type, like `modifier` or `constraint`
|
||||
target_name (str): Name of driver target, e.g. name of a modifier or contraint
|
||||
"""
|
||||
source_fcurves = find_drivers(source_id, target_type, target_name)
|
||||
target_fcurves = find_drivers(target_id, target_type, target_name)
|
||||
|
||||
# Clear old drivers
|
||||
for old_fcurve in list(set(target_fcurves) - set(source_fcurves)):
|
||||
target_id.animation_data.drivers.remove(old_fcurve)
|
||||
|
||||
# Transfer new drivers
|
||||
for fcurve in source_fcurves:
|
||||
copy_driver(from_fcurve=fcurve, target=target_id)
|
||||
|
||||
|
||||
def cleanup_drivers(id: bpy.types.ID, target_type: str, target_name: str) -> None:
|
||||
"""Remove all drivers for transfer data that has been removed.
|
||||
|
||||
Args:
|
||||
object (bpy.types.ID): ID, which has drivers to remove
|
||||
target_type (str): Name of driver target's type, like `modifier` or `constraint`
|
||||
target_name (str): Name of driver target, e.g. name of a modifier or contraint
|
||||
"""
|
||||
target_fcurves = find_drivers(id, target_type, target_name)
|
||||
for fcurve in target_fcurves:
|
||||
id.animation_data.drivers.remove(fcurve)
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from bpy.types import PropertyGroup, bpy_prop_collection, Object
|
||||
from rna_prop_ui import IDPropertyGroup
|
||||
from bpy.utils import flip_name
|
||||
|
||||
# Functions to manage runtime properties, which include custom properties and add-on properties.
|
||||
# These functions aim to abstract away that distinction, and also abstract away whether something is a single value,
|
||||
# a PropertyGroup, or a CollectionProperty.
|
||||
# Currently the minimum Blender version for this code is 5.0, but it could probably be made backwards-compatible.
|
||||
|
||||
def copy_all_runtime_properties(src_id, tgt_id, x_mirror=False):
|
||||
"""Copy add-on and custom properties from source to target.
|
||||
Both should be the same type.
|
||||
Should support anything that supports custom properties or property registration.
|
||||
"""
|
||||
for prop_name in get_all_runtime_prop_names(src_id):
|
||||
copy_runtime_property(src_id, tgt_id, prop_name, x_mirror)
|
||||
|
||||
def get_all_runtime_prop_names(owner):
|
||||
custom_props = list(owner.keys())
|
||||
addon_props = get_addon_prop_names(owner)
|
||||
props = custom_props + addon_props
|
||||
return props
|
||||
|
||||
def get_addon_prop_names(owner):
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
return list(owner.bl_system_properties_get().keys())
|
||||
else:
|
||||
return [prop_name for prop_name in owner.keys() if is_addon_prop(owner, prop_name)]
|
||||
|
||||
def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
|
||||
"""Copy add-on properties or custom properties."""
|
||||
if is_addon_prop(src_id, prop_name):
|
||||
if is_registered_addon_prop(src_id, prop_name):
|
||||
src_prop = getattr(src_id, prop_name)
|
||||
tgt_prop = getattr(tgt_id, prop_name)
|
||||
if isinstance(src_prop, bpy_prop_collection):
|
||||
copy_coll_prop(src_prop, tgt_prop, x_mirror)
|
||||
elif isinstance(src_prop, PropertyGroup):
|
||||
copy_property_group(src_prop, tgt_prop, x_mirror)
|
||||
else:
|
||||
copy_single_addon_prop(src_id, tgt_id, prop_name, x_mirror)
|
||||
else:
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
# HACK: If we need to copy add-on properties, but the add-on is not present,
|
||||
# we have to write to the system properties, which is API abuse that could
|
||||
# lose support any moment, but there is no other way to do this atm.
|
||||
try:
|
||||
tgt_id.bl_system_properties_get()[prop_name] = src_id.bl_system_properties_get()[prop_name]
|
||||
except TypeError:
|
||||
# Happens for at least a mysterious "booleans" custom property which seems to be an empty PropGroup. Where is it coming from!?
|
||||
pass
|
||||
else:
|
||||
tgt_id[prop_name] = src_id[prop_name]
|
||||
else:
|
||||
copy_custom_property(src_id, tgt_id, prop_name)
|
||||
|
||||
def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=False):
|
||||
"""
|
||||
Copy the values from one PropertyGroup into another of the same type.
|
||||
Optionally, X-mirror names (e.g., ".L" <-> ".R") in strings and Object references.
|
||||
"""
|
||||
assert isinstance(tgt_pg, PropertyGroup) and isinstance(src_pg, PropertyGroup)
|
||||
assert tgt_pg.__class__ == src_pg.__class__
|
||||
|
||||
for prop_name in src_pg.bl_rna.properties.keys():
|
||||
if prop_name in ('rna_type', 'bl_rna'):
|
||||
continue
|
||||
if not src_pg.is_property_set(prop_name):
|
||||
tgt_pg.property_unset(prop_name)
|
||||
continue
|
||||
value = getattr(src_pg, prop_name)
|
||||
if isinstance(value, bpy_prop_collection):
|
||||
tgt_collprop = getattr(tgt_pg, prop_name)
|
||||
copy_coll_prop(value, tgt_collprop)
|
||||
elif isinstance(value, PropertyGroup):
|
||||
copy_property_group(value, getattr(tgt_pg, prop_name), x_mirror)
|
||||
else:
|
||||
copy_single_addon_prop(src_pg, tgt_pg, prop_name, x_mirror)
|
||||
for prop_name in src_pg.keys():
|
||||
if is_custom_prop(src_pg, prop_name):
|
||||
# PropertyGroups also support custom properties.
|
||||
copy_custom_property(src_pg, tgt_pg, prop_name, x_mirror)
|
||||
|
||||
def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
|
||||
tgt_cp.clear()
|
||||
for src_pg in src_cp:
|
||||
assert isinstance(src_pg, PropertyGroup)
|
||||
tgt_pg = tgt_cp.add()
|
||||
copy_property_group(src_pg, tgt_pg, x_mirror)
|
||||
|
||||
def copy_custom_property(src_owner, tgt_owner, prop_name, x_mirror=False):
|
||||
"""Copy a custom property (one that was created via the UI or via Python dictionary syntax)."""
|
||||
prop = src_owner.id_properties_ui(prop_name)
|
||||
assert prop, f'Property "{prop_name}" not found in {src_owner}.'
|
||||
value = src_owner[prop_name]
|
||||
if x_mirror:
|
||||
value = x_mirror_value(value)
|
||||
|
||||
tgt_owner[prop_name] = value
|
||||
new_prop = tgt_owner.id_properties_ui(prop_name)
|
||||
new_prop.update_from(prop)
|
||||
|
||||
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
|
||||
if src.is_property_readonly(prop_name):
|
||||
# This "early" exit has to come after CollectionProperty & PropertyGroup
|
||||
# checks, since they are technically read-only.
|
||||
return False
|
||||
|
||||
value = getattr(src, prop_name)
|
||||
if x_mirror:
|
||||
value = x_mirror_value(value)
|
||||
|
||||
setattr(tgt, prop_name, value)
|
||||
return True
|
||||
|
||||
def x_mirror_value(value):
|
||||
if isinstance(value, str):
|
||||
return flip_name(value)
|
||||
elif isinstance(value, Object):
|
||||
get_opposite_obj(value)
|
||||
else:
|
||||
return value
|
||||
|
||||
def get_opposite_obj(obj: Object) -> Object:
|
||||
"""Return the X-mirrored version of a Blender object by name (and library if linked)."""
|
||||
flipped_name = flip_name(obj.name)
|
||||
lib = obj.library
|
||||
return (
|
||||
bpy.data.objects.get((lib, flipped_name)) if lib else
|
||||
bpy.data.objects.get(flipped_name)
|
||||
) or obj
|
||||
|
||||
def is_addon_prop(owner, prop_name):
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
return prop_name in owner.bl_system_properties_get().keys()
|
||||
else:
|
||||
# NOTE: I don't think it's possible to detect pre-5.0 non-PropertyGroup/CollectionProperty non-registered add-on properties.
|
||||
# They just behave completely as custom properties.
|
||||
return prop_name in owner and (isinstance(owner[prop_name], IDPropertyGroup) or isinstance(owner[prop_name], list))
|
||||
|
||||
def is_registered_addon_prop(owner, prop_name):
|
||||
return is_addon_prop(owner, prop_name) and prop_name in owner.bl_rna.properties
|
||||
|
||||
def is_custom_prop(owner, prop_name):
|
||||
return prop_name in owner.keys() and not is_addon_prop(owner, prop_name)
|
||||
|
||||
def remove_property(obj, prop_name):
|
||||
if is_custom_prop(obj, prop_name):
|
||||
del obj[prop_name]
|
||||
if is_registered_addon_prop(obj, prop_name):
|
||||
obj.property_unset(prop_name)
|
||||
elif is_addon_prop(obj, prop_name):
|
||||
disabled_addon_props = obj.bl_system_properties_get()
|
||||
del disabled_addon_props[prop_name]
|
||||
else:
|
||||
raise KeyError(f"{prop_name} not found in {obj.name}")
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
import bmesh
|
||||
import numpy as np
|
||||
|
||||
|
||||
def closest_face_to_point(bm_source, p_target, bvh_tree=None):
|
||||
if not bvh_tree:
|
||||
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
|
||||
(loc, norm, index, distance) = bvh_tree.find_nearest(p_target)
|
||||
return bm_source.faces[index]
|
||||
|
||||
|
||||
def tris_per_face(bm_source):
|
||||
tris_source = bm_source.calc_loop_triangles()
|
||||
tris_dict = dict()
|
||||
for face in bm_source.faces:
|
||||
tris_face = []
|
||||
for i in range(len(tris_source))[::-1]:
|
||||
if tris_source[i][0] in face.loops:
|
||||
tris_face.append(tris_source.pop(i))
|
||||
tris_dict[face] = tris_face
|
||||
return tris_dict
|
||||
|
||||
|
||||
def closest_tri_on_face(tris_dict, face, p):
|
||||
points = []
|
||||
dist = []
|
||||
tris = []
|
||||
for tri in tris_dict[face]:
|
||||
point = mathutils.geometry.closest_point_on_tri(
|
||||
p, *[tri[i].vert.co for i in range(3)]
|
||||
)
|
||||
tris.append(tri)
|
||||
points.append(point)
|
||||
dist.append((point - p).length)
|
||||
min_idx = np.argmin(np.array(dist))
|
||||
point = points[min_idx]
|
||||
tri = tris[min_idx]
|
||||
return (tri, point)
|
||||
|
||||
|
||||
def closest_edge_on_face_to_line(face, p1, p2, skip_edges=None):
|
||||
"""Returns edge of a face which is closest to line."""
|
||||
for edge in face.edges:
|
||||
if skip_edges:
|
||||
if edge in skip_edges:
|
||||
continue
|
||||
res = mathutils.geometry.intersect_line_line(
|
||||
p1, p2, *[edge.verts[i].co for i in range(2)]
|
||||
)
|
||||
if not res:
|
||||
continue
|
||||
(p_traversal, p_edge) = res
|
||||
frac_1 = (edge.verts[1].co - edge.verts[0].co).dot(
|
||||
p_edge - edge.verts[0].co
|
||||
) / (edge.verts[1].co - edge.verts[0].co).length ** 2.0
|
||||
frac_2 = (p2 - p1).dot(p_traversal - p1) / (p2 - p1).length ** 2.0
|
||||
if (frac_1 >= 0 and frac_1 <= 1) and (frac_2 >= 0 and frac_2 <= 1):
|
||||
return edge
|
||||
return None
|
||||
|
||||
|
||||
def edge_data_split(edge, data_layer, data_suffix: str):
|
||||
for vert in edge.verts:
|
||||
vals = []
|
||||
for loop in vert.link_loops:
|
||||
loops_edge_vert = set([loop for f in edge.link_faces for loop in f.loops])
|
||||
if loop not in loops_edge_vert:
|
||||
continue
|
||||
dat = data_layer[loop.index]
|
||||
element = list(getattr(dat, data_suffix))
|
||||
if not vals:
|
||||
vals.append(element)
|
||||
elif not vals[0] == element:
|
||||
vals.append(element)
|
||||
if len(vals) > 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def interpolate_data_from_face(
|
||||
bm_source, tris_dict, face, p, data_layer_source, data_suffix=''
|
||||
):
|
||||
"""Returns interpolated value of a data layer within a face closest to a point."""
|
||||
|
||||
(tri, point) = closest_tri_on_face(tris_dict, face, p)
|
||||
if not tri:
|
||||
return None
|
||||
weights = mathutils.interpolate.poly_3d_calc(
|
||||
[tri[i].vert.co for i in range(3)], point
|
||||
)
|
||||
|
||||
if not data_suffix:
|
||||
cols_weighted = [
|
||||
weights[i] * np.array(data_layer_source[tri[i].index]) for i in range(3)
|
||||
]
|
||||
col = sum(np.array(cols_weighted))
|
||||
else:
|
||||
cols_weighted = [
|
||||
weights[i] * np.array(getattr(data_layer_source[tri[i].index], data_suffix))
|
||||
for i in range(3)
|
||||
]
|
||||
col = sum(np.array(cols_weighted))
|
||||
return col
|
||||
|
||||
|
||||
def transfer_corner_data(
|
||||
obj_source, obj_target, data_layer_source, data_layer_target, data_suffix=''
|
||||
):
|
||||
"""
|
||||
Transfers interpolated face corner data from data layer of a source object to data layer of a
|
||||
target object, while approximately preserving data seams (e.g. necessary for UV Maps).
|
||||
The transfer is face interpolated per target corner within the source face that is closest
|
||||
to the target corner point and does not have any data seams on the way back to the
|
||||
source face that is closest to the target face's center.
|
||||
"""
|
||||
|
||||
bm_source = bmesh.new()
|
||||
bm_source.from_mesh(obj_source.data)
|
||||
bm_source.faces.ensure_lookup_table()
|
||||
bm_target = bmesh.new()
|
||||
bm_target.from_mesh(obj_target.data)
|
||||
bm_target.faces.ensure_lookup_table()
|
||||
|
||||
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
|
||||
|
||||
tris_dict = tris_per_face(bm_source)
|
||||
|
||||
for face_target in bm_target.faces:
|
||||
face_target_center = face_target.calc_center_median()
|
||||
|
||||
face_source = closest_face_to_point(bm_source, face_target_center, bvh_tree)
|
||||
|
||||
for corner_target in face_target.loops:
|
||||
# find nearest face on target compared to face that loop belongs to
|
||||
p = corner_target.vert.co
|
||||
|
||||
face_source_closest = closest_face_to_point(bm_source, p, bvh_tree)
|
||||
enclosed = face_source_closest is face_source
|
||||
face_source_int = face_source
|
||||
if not enclosed:
|
||||
# traverse faces between point and face center
|
||||
traversed_faces = set()
|
||||
traversed_edges = set()
|
||||
while face_source_int is not face_source_closest:
|
||||
traversed_faces.add(face_source_int)
|
||||
edge = closest_edge_on_face_to_line(
|
||||
face_source_int,
|
||||
face_target_center,
|
||||
p,
|
||||
skip_edges=traversed_edges,
|
||||
)
|
||||
if edge == None:
|
||||
break
|
||||
if len(edge.link_faces) != 2:
|
||||
break
|
||||
traversed_edges.add(edge)
|
||||
|
||||
split = edge_data_split(edge, data_layer_source, data_suffix)
|
||||
if split:
|
||||
break
|
||||
|
||||
# set new source face to other face belonging to edge
|
||||
face_source_int = (
|
||||
edge.link_faces[1]
|
||||
if edge.link_faces[1] is not face_source_int
|
||||
else edge.link_faces[0]
|
||||
)
|
||||
|
||||
# avoid looping behaviour
|
||||
if face_source_int in traversed_faces:
|
||||
face_source_int = face_source
|
||||
break
|
||||
|
||||
# interpolate data from selected face
|
||||
col = interpolate_data_from_face(
|
||||
bm_source, tris_dict, face_source_int, p, data_layer_source, data_suffix
|
||||
)
|
||||
if col is None:
|
||||
continue
|
||||
if not data_suffix:
|
||||
data_layer_target.data[corner_target.index] = col
|
||||
else:
|
||||
setattr(data_layer_target[corner_target.index], data_suffix, list(col))
|
||||
return
|
||||
|
||||
|
||||
def is_mesh_identical(mesh_a, mesh_b) -> bool:
|
||||
if len(mesh_a.vertices) != len(mesh_b.vertices):
|
||||
return False
|
||||
if len(mesh_a.edges) != len(mesh_b.edges):
|
||||
return False
|
||||
if len(mesh_a.polygons) != len(mesh_b.polygons):
|
||||
return False
|
||||
for e1, e2 in zip(mesh_a.edges, mesh_b.edges):
|
||||
for v1, v2 in zip(e1.vertices, e2.vertices):
|
||||
if v1 != v2:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_curve_identical(curve_a: bpy.types.Curve, curve_b: bpy.types.Curve) -> bool:
|
||||
if len(curve_a.splines) != len(curve_b.splines):
|
||||
return False
|
||||
for spline1, spline2 in zip(curve_a.splines, curve_b.splines):
|
||||
if len(spline1.points) != len(spline2.points):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_obdata_identical(
|
||||
a: bpy.types.Object or bpy.types.Mesh, b: bpy.types.Object or bpy.types.Mesh
|
||||
) -> bool:
|
||||
"""Checks if two objects have matching topology (efficiency over exactness)"""
|
||||
if type(a) == bpy.types.Object:
|
||||
a = a.data
|
||||
if type(b) == bpy.types.Object:
|
||||
b = b.data
|
||||
|
||||
if type(a) != type(b):
|
||||
return False
|
||||
|
||||
if type(a) == bpy.types.Mesh:
|
||||
return is_mesh_identical(a, b)
|
||||
elif type(a) == bpy.types.Curve:
|
||||
return is_curve_identical(a, b)
|
||||
else:
|
||||
# TODO: Support geometry types other than mesh or curve.
|
||||
return
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
import contextlib
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_visibility_driver(obj) -> Optional[bpy.types.FCurve]:
|
||||
obj = bpy.data.objects.get(obj.name)
|
||||
assert obj, "Object was renamed while its visibility was being ensured?"
|
||||
if hasattr(obj, "animation_data") and obj.animation_data:
|
||||
return obj.animation_data.drivers.find("hide_viewport")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def override_obj_visibility(obj: bpy.types.Object, scene: bpy.types.Scene):
|
||||
"""Temporarily Change the visibility of an Object so an bpy.ops or other
|
||||
function that requires the object to be visible can be called.
|
||||
|
||||
Args:
|
||||
obj (bpy.types.Object): Object to un-hide
|
||||
scene (bpy.types.Scene): Scene Object is in
|
||||
"""
|
||||
hide = obj.hide_get() # eye icon
|
||||
hide_viewport = obj.hide_viewport # hide viewport
|
||||
select = obj.hide_select # selectable
|
||||
|
||||
driver = get_visibility_driver(obj)
|
||||
if driver:
|
||||
driver_mute = driver.mute
|
||||
|
||||
try:
|
||||
obj.hide_set(False)
|
||||
obj.hide_viewport = False
|
||||
obj.hide_select = False
|
||||
if driver:
|
||||
driver.mute = True
|
||||
|
||||
assigned_to_scene_root = False
|
||||
if obj.name not in scene.collection.objects:
|
||||
assigned_to_scene_root = True
|
||||
scene.collection.objects.link(obj)
|
||||
|
||||
yield
|
||||
|
||||
finally:
|
||||
obj.hide_set(hide)
|
||||
obj.hide_viewport = hide_viewport
|
||||
obj.hide_select = select
|
||||
if driver:
|
||||
driver.mute = driver_mute
|
||||
|
||||
if assigned_to_scene_root and obj.name in scene.collection.objects:
|
||||
scene.collection.objects.unlink(obj)
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector, kdtree
|
||||
from typing import Dict, Tuple, List
|
||||
from ..transfer_util import (
|
||||
transfer_data_clean,
|
||||
transfer_data_item_is_missing,
|
||||
transfer_data_item_init,
|
||||
)
|
||||
from .transfer_function_util.proximity_core import (
|
||||
is_obdata_identical,
|
||||
)
|
||||
from .... import constants, logging
|
||||
|
||||
|
||||
def vertex_groups_clean(obj):
|
||||
transfer_data_clean(
|
||||
obj=obj, data_list=obj.vertex_groups, td_type_key=constants.VERTEX_GROUP_KEY
|
||||
)
|
||||
|
||||
|
||||
def vertex_group_is_missing(transfer_data_item):
|
||||
return transfer_data_item_is_missing(
|
||||
transfer_data_item=transfer_data_item,
|
||||
td_type_key=constants.VERTEX_GROUP_KEY,
|
||||
data_list=transfer_data_item.id_data.vertex_groups,
|
||||
)
|
||||
|
||||
|
||||
def init_vertex_groups(scene, obj):
|
||||
transfer_data_item_init(
|
||||
scene=scene,
|
||||
obj=obj,
|
||||
data_list=obj.vertex_groups,
|
||||
td_type_key=constants.VERTEX_GROUP_KEY,
|
||||
)
|
||||
|
||||
|
||||
def transfer_vertex_groups(
|
||||
vertex_group_names: List[str],
|
||||
target_obj: bpy.types.Object,
|
||||
source_obj: bpy.types.Object,
|
||||
):
|
||||
logger = logging.get_logger()
|
||||
for vertex_group_name in vertex_group_names:
|
||||
if not source_obj.vertex_groups.get(vertex_group_name):
|
||||
logger.error(f"Vertex Group {vertex_group_name} not found in {source_obj.name}")
|
||||
return
|
||||
|
||||
# If topology matches transfer directly, otherwise use vertex proximity
|
||||
if is_obdata_identical(source_obj, target_obj):
|
||||
for vertex_group_name in vertex_group_names:
|
||||
transfer_single_vgroup_by_topology(source_obj, target_obj, vertex_group_name)
|
||||
else:
|
||||
precalc_and_transfer_multiple_groups(source_obj, target_obj, vertex_group_names, expand=2)
|
||||
|
||||
|
||||
def transfer_single_vgroup_by_topology(source_obj, target_obj, vgroup_name):
|
||||
"""Function to quickly transfer single vertex group between mesh objects in case of matching topology."""
|
||||
|
||||
remove_vgroups([target_obj], [vgroup_name])
|
||||
|
||||
vgroup_src = source_obj.vertex_groups.get(vgroup_name)
|
||||
vgroup_tgt = target_obj.vertex_groups.new(name=vgroup_name)
|
||||
|
||||
for v in source_obj.data.vertices:
|
||||
if vgroup_src.index in [g.group for g in v.groups]:
|
||||
vgroup_tgt.add([v.index], vgroup_src.weight(v.index), 'REPLACE')
|
||||
|
||||
|
||||
def remove_vgroups(objs, vgroup_names):
|
||||
for obj in objs:
|
||||
for vgroup_name in vgroup_names:
|
||||
target_vgroup = obj.vertex_groups.get(vgroup_name)
|
||||
if target_vgroup:
|
||||
obj.vertex_groups.remove(target_vgroup)
|
||||
|
||||
|
||||
def precalc_and_transfer_multiple_groups(source_obj, target_obj, vgroup_names, expand=2):
|
||||
"""Convenience function to transfer multiple groups."""
|
||||
|
||||
remove_vgroups([target_obj], vgroup_names)
|
||||
|
||||
kd_tree = build_kdtree(source_obj.data)
|
||||
vert_influence_map = build_vert_influence_map(source_obj, target_obj, kd_tree, expand)
|
||||
transfer_multiple_vertex_groups(
|
||||
source_obj,
|
||||
target_obj,
|
||||
vert_influence_map,
|
||||
src_vgroups=[source_obj.vertex_groups[name] for name in vgroup_names],
|
||||
)
|
||||
|
||||
|
||||
def precalc_and_transfer_single_group(source_obj, target_obj, vgroup_name, expand=2):
|
||||
"""Convenience function to transfer a single group. For transferring multiple groups,
|
||||
precalc_and_transfer_multiple_groups should be used as it is more efficient."""
|
||||
|
||||
remove_vgroups([target_obj], [vgroup_name])
|
||||
|
||||
kd_tree = build_kdtree(source_obj.data)
|
||||
vert_influence_map = build_vert_influence_map(source_obj, target_obj, kd_tree, expand)
|
||||
|
||||
transfer_multiple_vertex_groups(
|
||||
source_obj,
|
||||
target_obj,
|
||||
vert_influence_map,
|
||||
[source_obj.vertex_groups[vgroup_name]],
|
||||
)
|
||||
|
||||
|
||||
def build_kdtree(mesh):
|
||||
kd = kdtree.KDTree(len(mesh.vertices))
|
||||
for i, v in enumerate(mesh.vertices):
|
||||
kd.insert(v.co, i)
|
||||
kd.balance()
|
||||
return kd
|
||||
|
||||
|
||||
def build_vert_influence_map(obj_from, obj_to, kd_tree, expand=2):
|
||||
verts_of_edge = {i: (e.vertices[0], e.vertices[1]) for i, e in enumerate(obj_from.data.edges)}
|
||||
|
||||
edges_of_vert: Dict[int, List[int]] = {}
|
||||
for edge_idx, edge in enumerate(obj_from.data.edges):
|
||||
for vert_idx in edge.vertices:
|
||||
if vert_idx not in edges_of_vert:
|
||||
edges_of_vert[vert_idx] = []
|
||||
edges_of_vert[vert_idx].append(edge_idx)
|
||||
|
||||
# A mapping from target vertex index to a list of source vertex indicies and
|
||||
# their influence.
|
||||
# This can be pre-calculated once per object pair, to minimize re-calculations
|
||||
# of subsequent transferring of individual vertex groups.
|
||||
vert_influence_map: List[int, List[Tuple[int, float]]] = {}
|
||||
for i, dest_vert in enumerate(obj_to.data.vertices):
|
||||
vert_influence_map[i] = get_source_vert_influences(
|
||||
dest_vert, obj_from, kd_tree, expand, edges_of_vert, verts_of_edge
|
||||
)
|
||||
|
||||
return vert_influence_map
|
||||
|
||||
|
||||
def get_source_vert_influences(
|
||||
target_vert, obj_from, kd_tree, expand=2, edges_of_vert={}, verts_of_edge={}
|
||||
) -> List[Tuple[int, float]]:
|
||||
_coord, idx, dist = get_nearest_vert(target_vert.co, kd_tree)
|
||||
source_vert_indices = [idx]
|
||||
|
||||
if dist == 0:
|
||||
# If the vertex position is a perfect match, just use that one vertex with max influence.
|
||||
return [(idx, 1)]
|
||||
|
||||
for i in range(0, expand):
|
||||
new_indices = []
|
||||
for vert_idx in source_vert_indices:
|
||||
for edge in edges_of_vert[vert_idx]:
|
||||
vert_other = other_vert_of_edge(edge, vert_idx, verts_of_edge)
|
||||
if vert_other not in source_vert_indices:
|
||||
new_indices.append(vert_other)
|
||||
source_vert_indices.extend(new_indices)
|
||||
|
||||
distances: List[Tuple[int, float]] = []
|
||||
distance_total = 0
|
||||
for src_vert_idx in source_vert_indices:
|
||||
distance = (target_vert.co - obj_from.data.vertices[src_vert_idx].co).length
|
||||
distance_total += distance
|
||||
distances.append((src_vert_idx, distance))
|
||||
|
||||
# Calculate influences such that the total of all influences adds up to 1.0,
|
||||
# and the influence is inversely correlated with the distance.
|
||||
parts = [1 / (dist / distance_total) for idx, dist in distances]
|
||||
parts_sum = sum(parts)
|
||||
|
||||
influences = [
|
||||
(idx, 1 if dist == 0 else part / parts_sum) for part, dist in zip(parts, distances)
|
||||
]
|
||||
|
||||
return influences
|
||||
|
||||
|
||||
def get_nearest_vert(coords: Vector, kd_tree: kdtree.KDTree) -> Tuple[Vector, int, float]:
|
||||
"""Return coordinate, index, and distance of nearest vert to coords in kd_tree."""
|
||||
return kd_tree.find(coords)
|
||||
|
||||
|
||||
def other_vert_of_edge(edge: int, vert: int, verts_of_edge: Dict[int, Tuple[int, int]]) -> int:
|
||||
verts = verts_of_edge[edge]
|
||||
assert vert in verts, f"Vert {vert} not part of edge {edge}."
|
||||
return verts[0] if vert == verts[1] else verts[1]
|
||||
|
||||
|
||||
def transfer_multiple_vertex_groups(obj_from, obj_to, vert_influence_map, src_vgroups):
|
||||
"""Transfer src_vgroups in obj_from to obj_to using a pre-calculated vert_influence_map."""
|
||||
|
||||
for src_vg in src_vgroups:
|
||||
target_vg = obj_to.vertex_groups.get(src_vg.name)
|
||||
if target_vg == None:
|
||||
target_vg = obj_to.vertex_groups.new(name=src_vg.name)
|
||||
|
||||
for i, dest_vert in enumerate(obj_to.data.vertices):
|
||||
source_verts = vert_influence_map[i]
|
||||
|
||||
# Vertex Group Name : Weight
|
||||
vgroup_weights = {}
|
||||
|
||||
for src_vert_idx, influence in source_verts:
|
||||
for group in obj_from.data.vertices[src_vert_idx].groups:
|
||||
group_idx = group.group
|
||||
vg = obj_from.vertex_groups[group_idx]
|
||||
if vg not in src_vgroups:
|
||||
continue
|
||||
if vg.name not in vgroup_weights:
|
||||
vgroup_weights[vg.name] = 0
|
||||
vgroup_weights[vg.name] += vg.weight(src_vert_idx) * influence
|
||||
|
||||
# Assign final weights of this vertex in the vertex groups.
|
||||
for vg_name in vgroup_weights.keys():
|
||||
target_vg = obj_to.vertex_groups.get(vg_name)
|
||||
target_vg.add([dest_vert.index], vgroup_weights[vg_name], 'REPLACE')
|
||||
@@ -0,0 +1,91 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from ... import constants
|
||||
from ..task_layer import draw_task_layer_selection
|
||||
|
||||
|
||||
def draw_transfer_data_type(
|
||||
context: bpy.types.Context,
|
||||
layout: bpy.types.UILayout,
|
||||
transfer_data: bpy.types.CollectionProperty,
|
||||
) -> None:
|
||||
"""Draw UI Element for items of a Transferable Data type"""
|
||||
asset_pipe = bpy.context.scene.asset_pipeline
|
||||
if transfer_data == []:
|
||||
return
|
||||
name, icon = constants.TRANSFER_DATA_TYPES[transfer_data[0].type]
|
||||
|
||||
box = layout.box()
|
||||
header, panel = box.panel(transfer_data[0].obj_name + name, default_closed=True)
|
||||
header.label(text=name, icon=icon)
|
||||
if not panel:
|
||||
return
|
||||
|
||||
box = panel.box()
|
||||
for transfer_data_item in transfer_data:
|
||||
main_row = box.row()
|
||||
main_row.label(text=f"{transfer_data_item.name}: ")
|
||||
|
||||
if transfer_data_item.surrender:
|
||||
# Disable entire row if the item is surrendered
|
||||
main_row.operator(
|
||||
"assetpipe.update_surrendered_transfer_data"
|
||||
).transfer_data_item_name = transfer_data_item.name
|
||||
|
||||
draw_task_layer_selection(
|
||||
context,
|
||||
layout=main_row.row(),
|
||||
data=transfer_data_item,
|
||||
)
|
||||
surrender_icon = "ORPHAN_DATA" if transfer_data_item.surrender else "HEART"
|
||||
surrender_row = main_row.row()
|
||||
surrender_row.enabled = transfer_data_item.owner in asset_pipe.local_task_layers
|
||||
surrender_row.prop(
|
||||
transfer_data_item, "surrender", text="", icon=surrender_icon
|
||||
)
|
||||
|
||||
|
||||
def draw_transfer_data(
|
||||
context: bpy.types.Context,
|
||||
transfer_data: bpy.types.CollectionProperty,
|
||||
layout: bpy.types.UILayout,
|
||||
) -> None:
|
||||
"""Draw UI List of Transferable Data"""
|
||||
vertex_groups = []
|
||||
material_slots = []
|
||||
modifiers = []
|
||||
constraints = []
|
||||
custom_props = []
|
||||
shape_keys = []
|
||||
attributes = []
|
||||
parent = []
|
||||
|
||||
for transfer_data_item in transfer_data:
|
||||
if transfer_data_item.type == constants.VERTEX_GROUP_KEY:
|
||||
vertex_groups.append(transfer_data_item)
|
||||
if transfer_data_item.type == constants.MATERIAL_SLOT_KEY:
|
||||
material_slots.append(transfer_data_item)
|
||||
if transfer_data_item.type == constants.MODIFIER_KEY:
|
||||
modifiers.append(transfer_data_item)
|
||||
if transfer_data_item.type == constants.CONSTRAINT_KEY:
|
||||
constraints.append(transfer_data_item)
|
||||
if transfer_data_item.type == constants.CUSTOM_PROP_KEY:
|
||||
custom_props.append(transfer_data_item)
|
||||
if transfer_data_item.type == constants.SHAPE_KEY_KEY:
|
||||
shape_keys.append(transfer_data_item)
|
||||
if transfer_data_item.type == constants.ATTRIBUTE_KEY:
|
||||
attributes.append(transfer_data_item)
|
||||
if transfer_data_item.type == constants.PARENT_KEY:
|
||||
parent.append(transfer_data_item)
|
||||
|
||||
draw_transfer_data_type(context, layout, vertex_groups)
|
||||
draw_transfer_data_type(context, layout, modifiers)
|
||||
draw_transfer_data_type(context, layout, material_slots)
|
||||
draw_transfer_data_type(context, layout, constraints)
|
||||
draw_transfer_data_type(context, layout, custom_props)
|
||||
draw_transfer_data_type(context, layout, shape_keys)
|
||||
draw_transfer_data_type(context, layout, attributes)
|
||||
draw_transfer_data_type(context, layout, parent)
|
||||
@@ -0,0 +1,211 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from ..naming import merge_get_basename, task_layer_prefix_basename_get
|
||||
from ..task_layer import get_transfer_data_owner
|
||||
import contextlib
|
||||
from ...props import AssetTransferData
|
||||
|
||||
def find_ownership_data(
|
||||
transfer_data: bpy.types.CollectionProperty,
|
||||
key: str,
|
||||
td_type_key: str,
|
||||
) -> AssetTransferData | None:
|
||||
"""Return matching AssetTransferData if it exists."""
|
||||
existing_items = [
|
||||
transfer_data_item
|
||||
for transfer_data_item in transfer_data
|
||||
if transfer_data_item.type == td_type_key and key == transfer_data_item.name
|
||||
]
|
||||
if existing_items:
|
||||
return existing_items[0]
|
||||
|
||||
|
||||
def transfer_data_add_entry(
|
||||
transfer_data: bpy.types.CollectionProperty,
|
||||
name: str,
|
||||
td_type_key: str,
|
||||
task_layer_name: str,
|
||||
surrender: bool,
|
||||
):
|
||||
"""Add entry to Transferable Data ownership
|
||||
|
||||
Args:
|
||||
ownership (bpy.types.CollectionProperty): Transferable Data of an object
|
||||
name (str): Name of new Transferable Data item
|
||||
td_type_key (str): Type of Transferable Data
|
||||
task_layer_name (str): Name of current task layer
|
||||
surrender (bool): Whether this data's ownership should be surrendered to begin with
|
||||
"""
|
||||
transfer_data_item = transfer_data.add()
|
||||
transfer_data_item.name = name
|
||||
transfer_data_item.owner = task_layer_name
|
||||
transfer_data_item.type = td_type_key
|
||||
transfer_data_item.surrender = surrender
|
||||
return transfer_data_item
|
||||
|
||||
|
||||
def transfer_data_clean(
|
||||
obj: bpy.types.Object, data_list: bpy.types.CollectionProperty, td_type_key: str
|
||||
):
|
||||
"""Removes data if a transfer_data_item doesn't exist but the data does exist
|
||||
Args:
|
||||
obj (bpy.types.Object): Object containing Transferable Data
|
||||
data_list (bpy.types.CollectionProperty): Collection Property containing a type of possible Transferable Data e.g. obj.modifiers
|
||||
td_type_key (str): Key for the Transferable Data type
|
||||
"""
|
||||
cleaned_item_names = set()
|
||||
|
||||
for item in data_list:
|
||||
ownership_data = find_ownership_data(
|
||||
obj.transfer_data_ownership,
|
||||
merge_get_basename(item.name),
|
||||
td_type_key,
|
||||
)
|
||||
if not ownership_data:
|
||||
cleaned_item_names.add(item.name)
|
||||
data_list.remove(item)
|
||||
|
||||
return cleaned_item_names
|
||||
|
||||
|
||||
def transfer_data_item_is_missing(
|
||||
transfer_data_item, data_list: bpy.types.CollectionProperty, td_type_key: str
|
||||
) -> bool:
|
||||
"""Returns true if a transfer_data_item exists the data doesn't exist
|
||||
|
||||
Args:
|
||||
transfer_data_item (_type_): Item of Transferable Data
|
||||
data_list (bpy.types.CollectionProperty): Collection Property containing a type of possible Transferable Data e.g. obj.modifiers
|
||||
td_type_key (str): Key for the Transferable Data type
|
||||
Returns:
|
||||
bool: Returns True if transfer_data_item is missing
|
||||
"""
|
||||
if transfer_data_item.type == td_type_key and not data_list.get(
|
||||
transfer_data_item["name"]
|
||||
):
|
||||
return True
|
||||
|
||||
|
||||
"""Intilize Transferable Data to a temporary collection property, used
|
||||
to draw a display of new Transferable Data to the user before merge process.
|
||||
"""
|
||||
|
||||
|
||||
def transfer_data_item_init(
|
||||
scene: bpy.types.Scene,
|
||||
obj: bpy.types.Object,
|
||||
data_list: bpy.types.CollectionProperty,
|
||||
td_type_key: str,
|
||||
):
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
scene (bpy.types.Scene): Scene that contains a the file's asset
|
||||
obj (bpy.types.Object): Object containing possible Transferable Data
|
||||
data_list (bpy.types.CollectionProperty): Collection Property containing a type of possible Transferable Data e.g. obj.modifiers
|
||||
td_type_key (str): Key for the Transferable Data type
|
||||
"""
|
||||
asset_pipe = scene.asset_pipeline
|
||||
transfer_data = obj.transfer_data_ownership
|
||||
|
||||
for item in data_list:
|
||||
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
|
||||
ownership_data = find_ownership_data(transfer_data, item.name, td_type_key)
|
||||
if not ownership_data:
|
||||
task_layer_owner, auto_surrender = get_transfer_data_owner(
|
||||
asset_pipe,
|
||||
td_type_key,
|
||||
)
|
||||
asset_pipe.add_temp_transfer_data(
|
||||
name=item.name,
|
||||
owner=task_layer_owner,
|
||||
type=td_type_key,
|
||||
obj_name=obj.name,
|
||||
surrender=auto_surrender,
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def isolate_collection(context, iso_col: bpy.types.Collection):
|
||||
col_exclude = {}
|
||||
view_layer_col = context.view_layer.layer_collection
|
||||
view_layer_col.collection.children.link(iso_col)
|
||||
for col in view_layer_col.children:
|
||||
col_exclude[col.name] = col.exclude
|
||||
|
||||
try:
|
||||
# Exclude all collections that are not iso collection
|
||||
for col in view_layer_col.children:
|
||||
col.exclude = col.name != iso_col.name
|
||||
yield
|
||||
|
||||
finally:
|
||||
for col in view_layer_col.children:
|
||||
col.exclude = col_exclude[col.name]
|
||||
view_layer_col.collection.children.unlink(iso_col)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def link_objs_to_collection(objs: set, col: bpy.types.Collection):
|
||||
try:
|
||||
for obj in objs:
|
||||
col.objects.link(obj)
|
||||
yield
|
||||
|
||||
finally:
|
||||
for obj in objs:
|
||||
col.objects.unlink(obj)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def activate_shapekey(objs: set, sk_name: str):
|
||||
old_values = {}
|
||||
try:
|
||||
for obj in objs:
|
||||
if not obj.data.shape_keys:
|
||||
continue
|
||||
sk = obj.data.shape_keys.key_blocks.get(sk_name)
|
||||
if not sk:
|
||||
continue
|
||||
old_values[obj] = sk.value
|
||||
sk.value = 1
|
||||
yield
|
||||
|
||||
finally:
|
||||
for obj, val in old_values.items():
|
||||
obj.data.shape_keys.key_blocks[sk_name].value = val
|
||||
|
||||
@contextlib.contextmanager
|
||||
def disable_modifiers(objs: set, mod_types: set[str]):
|
||||
mods_to_enable = {obj: [] for obj in objs}
|
||||
try:
|
||||
for obj in objs:
|
||||
for mod in obj.modifiers:
|
||||
if mod.type in mod_types and mod.show_viewport:
|
||||
mods_to_enable[obj].append(mod.name)
|
||||
mod.show_viewport = False
|
||||
yield
|
||||
|
||||
finally:
|
||||
for obj, mod_names in mods_to_enable.items():
|
||||
for mod_name in mod_names:
|
||||
obj.modifiers[mod_name].show_viewport = True
|
||||
|
||||
@contextlib.contextmanager
|
||||
def simplify(scene):
|
||||
"""Disable subdivision surface modifiers globally using the scene's Simplify setting.
|
||||
Important for binding modifiers, but also probably doesn't hurt for general performance.
|
||||
"""
|
||||
orig_simplify = scene.render.use_simplify
|
||||
levels = scene.render.simplify_subdivision
|
||||
|
||||
scene.render.use_simplify = True
|
||||
scene.render.simplify_subdivision = 0
|
||||
|
||||
yield
|
||||
|
||||
scene.render.use_simplify = orig_simplify
|
||||
scene.render.simplify_subdivision = levels
|
||||
@@ -0,0 +1,97 @@
|
||||
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from typing import Any, Tuple, Generator, List
|
||||
from .. import constants
|
||||
import bpy
|
||||
|
||||
|
||||
####################################
|
||||
############# ID Stuff #############
|
||||
####################################
|
||||
|
||||
|
||||
def get_id_info() -> List[Tuple[type, str, str]]:
|
||||
"""Return a list of tuples with the python class, type identifier string, and bpy.data container name
|
||||
of each ID type present in the blend file.
|
||||
Eg. when called in a file containing meshes and objects, it will return at least:
|
||||
[
|
||||
(bpy.types.Object, 'OBJECT', "objects"),
|
||||
(bpy.types.Mesh, 'MESH', "meshes"),
|
||||
]
|
||||
"""
|
||||
bpy_prop_collection = type(bpy.data.objects)
|
||||
id_info = []
|
||||
for prop_name in dir(bpy.data):
|
||||
coll_prop = getattr(bpy.data, prop_name)
|
||||
if type(coll_prop) == bpy_prop_collection:
|
||||
if len(coll_prop) == 0:
|
||||
# We can't get full info about the ID type if there isn't at least one entry of it.
|
||||
# But we shouldn't need it, since we don't have any entries of it!
|
||||
continue
|
||||
|
||||
id_info.append((get_fundamental_id_type(coll_prop[0]), coll_prop[0].id_type, prop_name))
|
||||
return id_info
|
||||
|
||||
|
||||
def get_id_identifier_from_class(id_type: type):
|
||||
"""Return the string name of an ID type class.
|
||||
eg. bpy.types.Object -> 'OBJECT'
|
||||
"""
|
||||
id_info = get_id_info()
|
||||
for typ, typ_str, container_str in id_info:
|
||||
if id_type == typ:
|
||||
return typ_str
|
||||
|
||||
|
||||
def get_fundamental_id_type(datablock: bpy.types.ID) -> Any:
|
||||
"""Certain datablocks have very specific types, such as
|
||||
bpy.types.GeometryNodeTree instead of bpy.types.NodeTree.
|
||||
|
||||
This function should return their fundamental type, ie. parent class,
|
||||
by reaching into the python Method Resolution Order (MRO) to find its
|
||||
python class inheritance chain and then step back 4 steps:
|
||||
object->bpy_struct->bpy.types.ID->bpy.types.WhatWeNeed"""
|
||||
|
||||
return type(datablock).mro()[-4]
|
||||
|
||||
|
||||
def get_storage_of_id(datablock: bpy.types.ID) -> 'bpy_prop_collection':
|
||||
"""Return the storage collection property of the datablock.
|
||||
Eg. for an object, returns bpy.data.objects.
|
||||
"""
|
||||
|
||||
fundamental_type = get_fundamental_id_type(datablock)
|
||||
|
||||
id_info = get_id_info()
|
||||
for typ, _typ_str, container_str in id_info:
|
||||
if fundamental_type == typ:
|
||||
return getattr(bpy.data, container_str)
|
||||
assert False, "Failed to find the type of this ID: " + str(datablock) + "with fundamental type: " + str(fundamental_type)
|
||||
|
||||
|
||||
def traverse_collection_tree(
|
||||
collection: bpy.types.Collection,
|
||||
) -> Generator[bpy.types.Collection, None, None]:
|
||||
yield collection
|
||||
for child in collection.children:
|
||||
yield from traverse_collection_tree(child)
|
||||
|
||||
|
||||
def data_type_from_transfer_data_key(obj: bpy.types.Object, td_type: str):
|
||||
"""Returns the data on an object that is referred to by the Transferable Data type"""
|
||||
if td_type == constants.VERTEX_GROUP_KEY:
|
||||
return obj.vertex_groups
|
||||
if td_type == constants.MODIFIER_KEY:
|
||||
return obj.modifiers
|
||||
if td_type == constants.CONSTRAINT_KEY:
|
||||
return obj.constraints
|
||||
if td_type == constants.MATERIAL_SLOT_KEY:
|
||||
return obj.material_slots
|
||||
if td_type == constants.SHAPE_KEY_KEY:
|
||||
return obj.data.shape_keys.key_blocks
|
||||
if td_type == constants.ATTRIBUTE_KEY:
|
||||
return obj.data.attributes
|
||||
if td_type == constants.PARENT_KEY:
|
||||
return obj.parent
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
import time
|
||||
from pathlib import Path
|
||||
from .merge.publish import find_sync_target
|
||||
from .merge.shared_ids import init_shared_ids
|
||||
from .merge.core import (
|
||||
ownership_get,
|
||||
ownership_set,
|
||||
get_invalid_objects,
|
||||
merge_task_layer,
|
||||
)
|
||||
from .merge.transfer_data.transfer_ui import draw_transfer_data
|
||||
from .merge.shared_ids import get_shared_id_icon
|
||||
from .merge.preserve import Perserve
|
||||
from . import config, logging
|
||||
from .hooks import Hooks
|
||||
from .merge.task_layer import draw_task_layer_selection
|
||||
from .asset_catalog import get_asset_id
|
||||
from . import prefs
|
||||
|
||||
|
||||
def sync_poll(cls, context):
|
||||
if any([img.is_dirty for img in bpy.data.images]):
|
||||
cls.poll_message_set("Please save unsaved Images")
|
||||
return False
|
||||
if bpy.data.is_dirty:
|
||||
cls.poll_message_set("Please save current .blend file")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def sync_invoke(self, context):
|
||||
logger = logging.get_logger()
|
||||
logger.info("Loading Transfer Data")
|
||||
self._temp_transfer_data = context.scene.asset_pipeline.temp_transfer_data
|
||||
self._temp_transfer_data.clear()
|
||||
self._invalid_objs.clear()
|
||||
|
||||
asset_pipe = context.scene.asset_pipeline
|
||||
local_col = asset_pipe.asset_collection
|
||||
if not local_col:
|
||||
self.report({'ERROR'}, "Top level collection could not be found")
|
||||
return {'CANCELLED'}
|
||||
|
||||
ownership_get(local_col, context.scene)
|
||||
|
||||
self._invalid_objs = get_invalid_objects(asset_pipe, local_col)
|
||||
self._shared_ids = init_shared_ids(context.scene)
|
||||
|
||||
|
||||
def sync_draw(self, context):
|
||||
layout = self.layout
|
||||
row = layout.row()
|
||||
|
||||
if len(self._invalid_objs) != 0:
|
||||
main_col = layout.column(align=True)
|
||||
main_col.alert = True
|
||||
header, panel = main_col.panel("Invalid Objects")
|
||||
header.label(text="Sync will delete Invalid Objects", icon='TRASH')
|
||||
if panel:
|
||||
col = panel.column(align=True)
|
||||
col.label(text="An object is considered invalid if it's not linked")
|
||||
col.label(text="to the collection of its owning task layer.")
|
||||
col.separator()
|
||||
for obj in self._invalid_objs:
|
||||
panel.label(text=obj.name, icon="OBJECT_DATA")
|
||||
|
||||
if len(self._shared_ids) != 0:
|
||||
header, panel = layout.panel("Shared IDs")
|
||||
header.label(text="New Shared IDs", icon='COLLAPSEMENU')
|
||||
if panel:
|
||||
for id in self._shared_ids:
|
||||
row = panel.row()
|
||||
row.label(text=id.name, icon=get_shared_id_icon(id))
|
||||
draw_task_layer_selection(
|
||||
context,
|
||||
layout=row,
|
||||
data=id,
|
||||
)
|
||||
|
||||
if len(self._temp_transfer_data) == 0:
|
||||
layout.label(text="No new local Transferable Data found")
|
||||
return
|
||||
else:
|
||||
header, panel = layout.panel("New Data")
|
||||
header.label(text="New Data To Push", icon='COLLAPSEMENU')
|
||||
if not panel:
|
||||
return
|
||||
|
||||
objs = [
|
||||
bpy.data.objects.get(transfer_data_item.obj_name)
|
||||
for transfer_data_item in self._temp_transfer_data
|
||||
]
|
||||
|
||||
for obj in sorted(set(objs), key=lambda o: o.name):
|
||||
obj_ownership = [
|
||||
transfer_data_item
|
||||
for transfer_data_item in self._temp_transfer_data
|
||||
if bpy.data.objects.get(transfer_data_item.obj_name) == obj
|
||||
]
|
||||
box = layout.box()
|
||||
header, panel = box.panel(obj.name, default_closed=True)
|
||||
header.label(text=obj.name, icon='OBJECT_DATA')
|
||||
if panel:
|
||||
draw_transfer_data(context, obj_ownership, panel)
|
||||
|
||||
|
||||
def sync_execute_update_ownership(self, context):
|
||||
logger = logging.get_logger()
|
||||
logger.info("Updating Ownership")
|
||||
temp_transfer_data = context.scene.asset_pipeline.temp_transfer_data
|
||||
ownership_set(temp_transfer_data)
|
||||
|
||||
|
||||
def sync_execute_prepare_sync(self, context):
|
||||
asset_pipe = context.scene.asset_pipeline
|
||||
self._current_file = Path(bpy.data.filepath)
|
||||
self._temp_dir = Path(bpy.app.tempdir).parent
|
||||
self._task_layer_keys = asset_pipe.get_local_task_layers()
|
||||
|
||||
self._sync_target = find_sync_target(self._current_file)
|
||||
if not self._sync_target.exists():
|
||||
self.report({'ERROR'}, "Sync Target could not be determined")
|
||||
return {'CANCELLED'}
|
||||
|
||||
for obj in self._invalid_objs:
|
||||
bpy.data.objects.remove(obj)
|
||||
|
||||
|
||||
def create_temp_file_backup(self, context):
|
||||
temp_file = self._temp_dir.joinpath(
|
||||
self._current_file.name.replace(".blend", "") + "_Asset_Pipe_Backup.blend"
|
||||
)
|
||||
context.scene.asset_pipeline.temp_file = temp_file.__str__()
|
||||
return temp_file.__str__()
|
||||
|
||||
|
||||
def update_temp_file_paths(self, context, temp_file_path):
|
||||
asset_pipe = context.scene.asset_pipeline
|
||||
asset_pipe.temp_file = temp_file_path
|
||||
asset_pipe.source_file = self._current_file.__str__()
|
||||
|
||||
|
||||
def sync_execute_pull(self, context):
|
||||
start_time = time.time()
|
||||
profiler = logging.get_profiler()
|
||||
logger = logging.get_logger()
|
||||
logger.info("Pulling Asset")
|
||||
temp_file_path = create_temp_file_backup(self, context)
|
||||
update_temp_file_paths(self, context, temp_file_path)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=temp_file_path, copy=True)
|
||||
logger.debug(f"Creating Backup File at {temp_file_path}")
|
||||
|
||||
preserve_map = Perserve(context.scene.asset_pipeline.asset_collection)
|
||||
|
||||
error_msg = merge_task_layer(
|
||||
context,
|
||||
local_tls=self._task_layer_keys,
|
||||
external_file=self._sync_target,
|
||||
)
|
||||
|
||||
addon_prefs = prefs.get_addon_prefs()
|
||||
if addon_prefs.preserve_action:
|
||||
preserve_map.set_action_map()
|
||||
|
||||
if addon_prefs.preserve_indexes:
|
||||
preserve_map.set_active_index_map()
|
||||
|
||||
if error_msg:
|
||||
context.scene.asset_pipeline.sync_error = True
|
||||
self.report({'ERROR'}, error_msg)
|
||||
return {'CANCELLED'}
|
||||
profiler.add(time.time() - start_time, "TOTAL")
|
||||
|
||||
|
||||
def sync_execute_push(self, context):
|
||||
start_time = time.time()
|
||||
profiler = logging.get_profiler()
|
||||
logger = logging.get_logger()
|
||||
logger.info("Pushing Asset")
|
||||
_catalog_id = None
|
||||
hooks_instance = Hooks()
|
||||
hooks_instance.load_hooks(context)
|
||||
temp_file_path = create_temp_file_backup(self, context)
|
||||
_catalog_id = get_asset_id(context.scene.asset_pipeline.asset_catalog_name)
|
||||
|
||||
file_path = self._sync_target.__str__()
|
||||
bpy.ops.wm.open_mainfile(filepath=file_path)
|
||||
asset_pipe = context.scene.asset_pipeline
|
||||
asset_col = asset_pipe.asset_collection
|
||||
update_temp_file_paths(self, context, temp_file_path)
|
||||
|
||||
local_tls = [
|
||||
task_layer
|
||||
for task_layer in config.TASK_LAYER_TYPES
|
||||
if task_layer not in self._task_layer_keys
|
||||
]
|
||||
|
||||
preserve_map = Perserve(context.scene.asset_pipeline.asset_collection)
|
||||
error_msg = merge_task_layer(
|
||||
context,
|
||||
local_tls=local_tls,
|
||||
external_file=self._current_file,
|
||||
)
|
||||
if error_msg:
|
||||
context.scene.asset_pipeline.sync_error = True
|
||||
self.report({'ERROR'}, error_msg)
|
||||
return {'CANCELLED'}
|
||||
|
||||
preserve_map.unassign_actions()
|
||||
|
||||
if asset_col.asset_data:
|
||||
if _catalog_id:
|
||||
asset_col.asset_data.catalog_id = _catalog_id
|
||||
|
||||
hooks_instance.execute_hooks(
|
||||
merge_mode="push", merge_status='post', asset_col=asset_pipe.asset_collection
|
||||
)
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=file_path)
|
||||
bpy.ops.wm.open_mainfile(filepath=self._current_file.__str__())
|
||||
profiler.add(time.time() - start_time, "TOTAL")
|
||||
@@ -0,0 +1,100 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from . import constants
|
||||
from .logging import get_logger
|
||||
from . import __package__ as base_package
|
||||
|
||||
def get_addon_prefs(context=None):
|
||||
if not context:
|
||||
context = bpy.context
|
||||
if bpy.app.version >= (4, 2, 0) and base_package.startswith('bl_ext'):
|
||||
return context.preferences.addons[base_package].preferences
|
||||
else:
|
||||
return context.preferences.addons[base_package.split(".")[0]].preferences
|
||||
|
||||
def project_root_dir_get():
|
||||
prefs = get_addon_prefs()
|
||||
return prefs.project_root_dir
|
||||
|
||||
|
||||
class ASSET_PIPELINE_addon_preferences(bpy.types.AddonPreferences):
|
||||
bl_idname = __package__
|
||||
|
||||
project_root_dir: bpy.props.StringProperty( # type: ignore
|
||||
name="Project Root Directory",
|
||||
description="Root Directory of the Project, this should be the root directory `your_project_name/ that contains the SVN, Shared and Local folders`",
|
||||
default="/data/our_project/",
|
||||
subtype="DIR_PATH",
|
||||
)
|
||||
|
||||
custom_task_layers_dir: bpy.props.StringProperty( # type: ignore
|
||||
name="Custom Task Layers",
|
||||
description="Specify directory to add additonal Task Layer Presets to use as templates when cerating new assets",
|
||||
default="",
|
||||
subtype="DIR_PATH",
|
||||
)
|
||||
|
||||
save_images_path: bpy.props.StringProperty( # type: ignore
|
||||
name="Save Images Path",
|
||||
description="Path to save un-saved images to, if left blank images will save in a called 'images' folder relative to the asset",
|
||||
default="",
|
||||
subtype="DIR_PATH",
|
||||
)
|
||||
|
||||
def update_logger_level(self, context):
|
||||
logger = get_logger()
|
||||
logger.handlers.clear()
|
||||
|
||||
logger_level: bpy.props.EnumProperty( # type: ignore
|
||||
name="Logging Level",
|
||||
description="Changes the level of detail of print statements in blender's console",
|
||||
default=1,
|
||||
items=constants.LOGGER_LEVEL_ITEMS,
|
||||
update=update_logger_level,
|
||||
)
|
||||
|
||||
is_advanced_mode: bpy.props.BoolProperty( # type: ignore
|
||||
name="Advanced Mode",
|
||||
description="Show Advanced Options in Asset Pipeline Panels",
|
||||
default=False,
|
||||
)
|
||||
|
||||
preserve_action: bpy.props.BoolProperty( # type: ignore
|
||||
name="Preserve Actions in Workfiles",
|
||||
description="Preserve Action Data-Blocks on Armatures in working files during Pull (this data will not be pushed to Sync Target)",
|
||||
default=False,
|
||||
)
|
||||
|
||||
preserve_indexes: bpy.props.BoolProperty( # type: ignore
|
||||
name="Preserve Active Indexes in Workfiles",
|
||||
description=(
|
||||
"Preserve Active Indexes (Vertex Groups, Shape Keys, UV Maps, Color Attributes, Attributes) "
|
||||
"in working files during Pull (this data will not be pushed to Sync Target)"
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
self.layout.prop(self, "project_root_dir")
|
||||
self.layout.prop(self, "custom_task_layers_dir")
|
||||
self.layout.prop(self, "save_images_path")
|
||||
self.layout.prop(self, "logger_level")
|
||||
self.layout.prop(self, "preserve_action")
|
||||
self.layout.prop(self, "preserve_indexes")
|
||||
self.layout.prop(self, "is_advanced_mode")
|
||||
|
||||
|
||||
classes = (ASSET_PIPELINE_addon_preferences,)
|
||||
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
@@ -0,0 +1,247 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from typing import List
|
||||
from . import constants
|
||||
from . import config
|
||||
from pathlib import Path
|
||||
from .prefs import get_addon_prefs
|
||||
from .asset_catalog import get_asset_catalog_items, get_asset_name, get_asset_id
|
||||
|
||||
""" NOTE Items in these properties groups should be generated by a function that finds the
|
||||
avaliable task layers from the task_layer.json file that needs to be created.
|
||||
"""
|
||||
|
||||
|
||||
def get_safely_string_prop(self, name: str) -> str:
|
||||
"""Return Value of String Property, and return "" if value isn't set"""
|
||||
try:
|
||||
return self[name]
|
||||
except KeyError:
|
||||
return ""
|
||||
|
||||
|
||||
def get_task_layer_presets(self, context):
|
||||
prefs = get_addon_prefs()
|
||||
user_tls = Path(prefs.custom_task_layers_dir)
|
||||
|
||||
presets_dir = config.get_task_layer_presets_path()
|
||||
items = []
|
||||
|
||||
for file in presets_dir.glob('*.json'):
|
||||
items.append((file.__str__(), file.name.replace(".json", ""), file.name))
|
||||
if user_tls.exists() and user_tls.is_dir():
|
||||
for file in user_tls.glob('*.json'):
|
||||
items.append((file.__str__(), file.name.replace(".json", ""), file.name))
|
||||
return items
|
||||
|
||||
|
||||
class AssetTransferData(bpy.types.PropertyGroup):
|
||||
"""Properties to track transferable data on an object"""
|
||||
|
||||
owner: bpy.props.StringProperty(name="Owner", default="NONE")
|
||||
type: bpy.props.EnumProperty(
|
||||
name="Transferable Data Type",
|
||||
items=constants.TRANSFER_DATA_TYPES_ENUM_ITEMS,
|
||||
)
|
||||
surrender: bpy.props.BoolProperty(name="Surrender Ownership", default=False)
|
||||
|
||||
@property
|
||||
def obj_name(self):
|
||||
return self.id_data.name
|
||||
|
||||
|
||||
class AssetTransferDataTemp(bpy.types.PropertyGroup):
|
||||
"""Class used when finding new ownership data so it can be drawn
|
||||
with the same method as the existing ownership data from ASSET_TRANSFER_DATA"""
|
||||
|
||||
owner: bpy.props.StringProperty(name="Owner", default="NONE")
|
||||
type: bpy.props.EnumProperty(
|
||||
name="Transferable Data Type",
|
||||
items=constants.TRANSFER_DATA_TYPES_ENUM_ITEMS,
|
||||
)
|
||||
surrender: bpy.props.BoolProperty(name="Surrender Ownership", default=False)
|
||||
obj_name: bpy.props.StringProperty(name="Object Name", default="")
|
||||
|
||||
|
||||
class TaskLayerSettings(bpy.types.PropertyGroup):
|
||||
is_local: bpy.props.BoolProperty(name="Task Layer is Local", default=False)
|
||||
|
||||
|
||||
class AssetPipeline(bpy.types.PropertyGroup):
|
||||
"""Properties to manage the status of asset pipeline files"""
|
||||
|
||||
is_asset_pipeline_file: bpy.props.BoolProperty(
|
||||
name="Asset Pipeline File",
|
||||
description="Asset Pipeline Files are used in the asset pipeline, if file is not asset pipeline file user will be prompted to create a new asset",
|
||||
default=False,
|
||||
)
|
||||
is_depreciated: bpy.props.BoolProperty(
|
||||
name="Depreciated",
|
||||
description="Depreciated files do not recieve any updates when syncing from a task layer",
|
||||
default=False,
|
||||
)
|
||||
|
||||
def get_is_published(self):
|
||||
return bool(Path(bpy.data.filepath).parent.name in constants.PUBLISH_KEYS)
|
||||
|
||||
is_published: bpy.props.BoolProperty(
|
||||
name="Is Published",
|
||||
description="File is Published",
|
||||
get=lambda self: Path(bpy.data.filepath).parent.name in constants.PUBLISH_KEYS,
|
||||
)
|
||||
|
||||
@property
|
||||
def asset_collection(self):
|
||||
return bpy.data.collections.get(self.asset_collection_name) or bpy.data.collections.get(
|
||||
self.asset_collection_name + "." + constants.LOCAL_SUFFIX
|
||||
)
|
||||
|
||||
@asset_collection.setter
|
||||
def asset_collection(self, coll):
|
||||
self.asset_collection_name = coll.name
|
||||
|
||||
asset_collection_name: bpy.props.StringProperty(
|
||||
name="Asset",
|
||||
default="",
|
||||
description="Top Level Collection of the Asset, all other collections of the asset will be children of this collection",
|
||||
)
|
||||
|
||||
# Commented out - Let's use a weak ref for now because this causes the collection to evaluate even when hidden, causing performance nightmares
|
||||
# asset_collection: bpy.props.PointerProperty(
|
||||
# type=bpy.types.Collection,
|
||||
# name="Asset",
|
||||
# description="Top Level Collection of the Asset, all other collections of the
|
||||
# asset will be children of this collection",
|
||||
# )
|
||||
|
||||
temp_transfer_data: bpy.props.CollectionProperty(type=AssetTransferDataTemp)
|
||||
|
||||
def add_temp_transfer_data(self, name, owner, type, obj_name, surrender) -> 'AssetTransferDataTemp':
|
||||
new_transfer_data = self.temp_transfer_data
|
||||
transfer_data_item = new_transfer_data.add()
|
||||
transfer_data_item.name = name
|
||||
transfer_data_item.owner = owner
|
||||
transfer_data_item.type = type
|
||||
transfer_data_item.obj_name = obj_name
|
||||
transfer_data_item.surrender = surrender
|
||||
return transfer_data_item
|
||||
|
||||
## NEW FILE
|
||||
|
||||
new_file_mode: bpy.props.EnumProperty(
|
||||
name="New File Mode",
|
||||
items=(
|
||||
('KEEP', "Current File", "Setup the Existing File/Directory as an Asset"),
|
||||
('BLANK', "Blank File", "Create a New Blank Asset in a New Directory"),
|
||||
),
|
||||
)
|
||||
|
||||
dir: bpy.props.StringProperty(
|
||||
name="Directory",
|
||||
description="Target Path for new asset files",
|
||||
subtype="DIR_PATH",
|
||||
)
|
||||
name: bpy.props.StringProperty(name="Name", description="Name for new Asset")
|
||||
|
||||
prefix: bpy.props.StringProperty(name="Prefix", description="Prefix for new Asset", default="")
|
||||
|
||||
task_layer_config_type: bpy.props.EnumProperty(
|
||||
name="Task Layer Preset",
|
||||
items=get_task_layer_presets,
|
||||
) # type: ignore
|
||||
|
||||
temp_file: bpy.props.StringProperty(name="Pre-Sync Backup")
|
||||
source_file: bpy.props.StringProperty(name="File that started Sync")
|
||||
sync_error: bpy.props.BoolProperty(name="Sync Error", default=False)
|
||||
|
||||
all_task_layers: bpy.props.CollectionProperty(type=TaskLayerSettings)
|
||||
local_task_layers: bpy.props.CollectionProperty(type=TaskLayerSettings)
|
||||
|
||||
def set_local_task_layers(self, task_layer_keys: List[str]):
|
||||
# Update Local Task Layers for New File
|
||||
self.local_task_layers.clear()
|
||||
for task_layer in self.all_task_layers:
|
||||
if task_layer.name in task_layer_keys:
|
||||
new_local_task_layer = self.local_task_layers.add()
|
||||
new_local_task_layer.name = task_layer.name
|
||||
|
||||
def get_local_task_layers(self) -> list[str]:
|
||||
return [task_layer.name for task_layer in self.local_task_layers]
|
||||
|
||||
def set_asset_catalog_name(self, input):
|
||||
task_layer_dict = config.get_task_layer_dict()
|
||||
task_layer_dict["ASSET_CATALOG_ID"] = get_asset_id(input)
|
||||
config.update_task_layer_json_data(task_layer_dict)
|
||||
self['asset_catalog_name'] = input
|
||||
|
||||
def get_asset_catalog_name(self):
|
||||
if config.ASSET_CATALOG_ID != "":
|
||||
asset_name = get_asset_name(config.ASSET_CATALOG_ID)
|
||||
if asset_name is None:
|
||||
return ""
|
||||
return asset_name
|
||||
return get_safely_string_prop(self, 'asset_catalog_name')
|
||||
|
||||
def get_asset_catalogs_search(self, context, edit_text: str):
|
||||
return get_asset_catalog_items()
|
||||
|
||||
asset_catalog_name: bpy.props.StringProperty(
|
||||
name="Catalog",
|
||||
get=get_asset_catalog_name,
|
||||
set=set_asset_catalog_name,
|
||||
search=get_asset_catalogs_search,
|
||||
search_options={'SORT'},
|
||||
description="Select Asset Library Catalog for the current Asset, this value will be updated each time you Push to an 'Active' Publish",
|
||||
) # type: ignore
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def set_asset_collection_name_post_file_load(_):
|
||||
# Version the PointerProperty to the StringProperty, and the left-over pointer.
|
||||
for scene in bpy.data.scenes:
|
||||
if 'asset_collection' not in scene.asset_pipeline:
|
||||
continue
|
||||
coll = scene.asset_pipeline['asset_collection']
|
||||
if coll:
|
||||
scene.asset_pipeline.asset_collection_name = coll.name
|
||||
del scene.asset_pipeline['asset_collection']
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def refresh_asset_catalog(_):
|
||||
get_asset_catalog_items()
|
||||
config.verify_task_layer_json_data()
|
||||
|
||||
|
||||
classes = (
|
||||
AssetTransferData,
|
||||
AssetTransferDataTemp,
|
||||
TaskLayerSettings,
|
||||
AssetPipeline,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
for i in classes:
|
||||
bpy.utils.register_class(i)
|
||||
bpy.types.Object.transfer_data_ownership = bpy.props.CollectionProperty(type=AssetTransferData)
|
||||
bpy.types.Scene.asset_pipeline = bpy.props.PointerProperty(type=AssetPipeline)
|
||||
bpy.types.ID.asset_id_owner = bpy.props.StringProperty(name="Owner", default="NONE")
|
||||
bpy.types.ID.asset_id_surrender = bpy.props.BoolProperty(
|
||||
name="Surrender Ownership", default=False
|
||||
)
|
||||
bpy.app.handlers.load_post.append(set_asset_collection_name_post_file_load)
|
||||
bpy.app.handlers.load_post.append(refresh_asset_catalog)
|
||||
|
||||
|
||||
def unregister():
|
||||
for i in classes:
|
||||
bpy.utils.unregister_class(i)
|
||||
del bpy.types.Object.transfer_data_ownership
|
||||
del bpy.types.Scene.asset_pipeline
|
||||
del bpy.types.ID.asset_id_owner
|
||||
bpy.app.handlers.load_post.remove(set_asset_collection_name_post_file_load)
|
||||
bpy.app.handlers.load_post.remove(refresh_asset_catalog)
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"TASK_LAYER_TYPES": {
|
||||
"Modeling": "MOD",
|
||||
"Rigging": "RIG",
|
||||
"Shading": "SHD"
|
||||
},
|
||||
"TRANSFER_DATA_DEFAULTS": {
|
||||
"GROUP_VERTEX": {
|
||||
"default_owner": "Rigging",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"MODIFIER": {
|
||||
"default_owner": "Rigging",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"CONSTRAINT": {
|
||||
"default_owner": "Rigging",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"CUSTOM_PROP": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"MATERIAL": {
|
||||
"default_owner": "Shading",
|
||||
"auto_surrender": true
|
||||
},
|
||||
"SHAPE_KEY": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"ATTRIBUTE": {
|
||||
"default_owner": "Rigging",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"PARENT": {
|
||||
"default_owner": "Rigging",
|
||||
"auto_surrender": true
|
||||
}
|
||||
},
|
||||
"ATTRIBUTE_DEFAULTS": {
|
||||
"sharp_face": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": true
|
||||
},
|
||||
"UVMap": {
|
||||
"default_owner": "Shading",
|
||||
"auto_surrender": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"TASK_LAYER_TYPES": {
|
||||
"Modeling": "MOD",
|
||||
"Rigging": "RIG",
|
||||
"Shading": "SHD"
|
||||
},
|
||||
"TRANSFER_DATA_DEFAULTS": {
|
||||
"GROUP_VERTEX": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"MODIFIER": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"CONSTRAINT": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"CUSTOM_PROP": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"MATERIAL": {
|
||||
"default_owner": "Shading",
|
||||
"auto_surrender": true
|
||||
},
|
||||
"SHAPE_KEY": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"ATTRIBUTE": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": false
|
||||
},
|
||||
"PARENT": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": true
|
||||
}
|
||||
},
|
||||
"ATTRIBUTE_DEFAULTS": {
|
||||
"sharp_face": {
|
||||
"default_owner": "Modeling",
|
||||
"auto_surrender": true
|
||||
},
|
||||
"UVMap": {
|
||||
"default_owner": "Shading",
|
||||
"auto_surrender": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
from addon_utils import check as check_addon
|
||||
|
||||
from pathlib import Path
|
||||
from .merge.transfer_data.transfer_ui import draw_transfer_data
|
||||
from .merge.task_layer import draw_task_layer_selection
|
||||
from .config import verify_task_layer_json_data
|
||||
from .prefs import get_addon_prefs
|
||||
from . import constants
|
||||
from .merge.publish import is_staged_publish
|
||||
from bpy.types import UILayout, Context, Panel
|
||||
|
||||
|
||||
class ASSETPIPE_PT_sync(bpy.types.Panel):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Assset Pipeline'
|
||||
bl_label = "Asset Management"
|
||||
|
||||
def draw_collection_selection(self, layout: UILayout, context: Context) -> None:
|
||||
layout.prop_search(
|
||||
context.scene.asset_pipeline, 'asset_collection_name', bpy.data, 'collections'
|
||||
)
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
layout = self.layout
|
||||
asset_pipe = context.scene.asset_pipeline
|
||||
if not asset_pipe.is_asset_pipeline_file:
|
||||
layout.prop(asset_pipe, "new_file_mode", expand=True)
|
||||
layout.prop(asset_pipe, "task_layer_config_type")
|
||||
if asset_pipe.new_file_mode == "BLANK":
|
||||
layout.prop(asset_pipe, "name")
|
||||
layout.prop(asset_pipe, "prefix")
|
||||
layout.prop(asset_pipe, "dir")
|
||||
else:
|
||||
layout.prop_search(asset_pipe, 'asset_collection_name', bpy.data, 'collections')
|
||||
layout.operator("assetpipe.create_new_asset")
|
||||
return
|
||||
|
||||
if not Path(bpy.data.filepath).exists:
|
||||
layout.label(text="File is not saved", icon="ERROR")
|
||||
return
|
||||
|
||||
if asset_pipe.asset_collection is not None and (
|
||||
asset_pipe.sync_error
|
||||
or asset_pipe.asset_collection.name.endswith(constants.LOCAL_SUFFIX)
|
||||
):
|
||||
layout.alert = True
|
||||
row = layout.row()
|
||||
row.label(text="Merge Process has Failed", icon='ERROR')
|
||||
row.operator("assetpipe.revert_file", text="Revert", icon="FILE_TICK")
|
||||
return
|
||||
|
||||
# TODO Move this call out of the UI because we keep re-loading this file every draw
|
||||
if not verify_task_layer_json_data() and not asset_pipe.is_published:
|
||||
layout.label(text="Task Layer Config is invalid", icon="ERROR")
|
||||
return
|
||||
if asset_pipe.is_published:
|
||||
layout.label(text="Current File is Published")
|
||||
col = layout.column()
|
||||
col.active = False
|
||||
self.draw_collection_selection(col, context)
|
||||
return
|
||||
|
||||
layout.label(text="Local Task Layers:")
|
||||
box = layout.box()
|
||||
row = box.row(align=True)
|
||||
for task_layer in asset_pipe.local_task_layers:
|
||||
row.label(text=task_layer.name)
|
||||
|
||||
self.draw_collection_selection(layout, context)
|
||||
|
||||
staged = is_staged_publish(Path(bpy.data.filepath))
|
||||
sync_target_name = "Staged" if staged else "Active"
|
||||
layout.operator(
|
||||
"assetpipe.sync_pull",
|
||||
text=f"Pull from {sync_target_name}",
|
||||
icon="TRIA_DOWN",
|
||||
)
|
||||
sync_text = f"Sync from {sync_target_name}"
|
||||
push_text = f"Force Push to {sync_target_name}"
|
||||
if check_addon('blender_log')[1]:
|
||||
log_count = len(list(context.scene.blender_log.all_logs))
|
||||
if log_count > 0:
|
||||
issues_text = f" ({log_count} issues)"
|
||||
sync_text += issues_text
|
||||
push_text += issues_text
|
||||
layout.operator("assetpipe.sync_push", text=sync_text, icon="FILE_REFRESH").pull = True
|
||||
layout.operator("assetpipe.sync_push", text=push_text, icon="TRIA_UP").pull = False
|
||||
|
||||
|
||||
class ASSETPIPE_PT_publish(Panel):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Assset Pipeline'
|
||||
bl_label = "Publish"
|
||||
bl_parent_id = "ASSETPIPE_PT_sync"
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
return bool(not context.scene.asset_pipeline.is_published)
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
staged = is_staged_publish(Path(bpy.data.filepath))
|
||||
layout = self.layout
|
||||
if staged:
|
||||
layout.operator("assetpipe.publish_staged_as_active", icon="LOOP_FORWARDS")
|
||||
layout.operator("assetpipe.publish_new_version", icon="PLUS")
|
||||
layout.operator("assetpipe.open_publish", icon="FILE")
|
||||
|
||||
|
||||
class ASSETPIPE_PT_working_files(Panel):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Assset Pipeline'
|
||||
bl_label = "Working Files"
|
||||
bl_parent_id = "ASSETPIPE_PT_sync"
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
return context.scene.asset_pipeline.is_published
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
for file in Path(bpy.data.filepath).parent.parent.glob("*.blend"):
|
||||
name = f"Open {file.name.strip('.blend')}"
|
||||
self.layout.operator("assetpipe.open_file", text=name).filepath = str(file)
|
||||
|
||||
|
||||
class ASSETPIPE_PT_sync_tools(bpy.types.Panel):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Assset Pipeline'
|
||||
bl_label = "Tools"
|
||||
bl_parent_id = "ASSETPIPE_PT_sync"
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
return bool(not context.scene.asset_pipeline.is_published)
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
layout = self.layout
|
||||
cat_row = layout.row(align=True)
|
||||
cat_row.prop(context.scene.asset_pipeline, 'asset_catalog_name')
|
||||
cat_row.operator("assetpipe.refresh_asset_cat", icon='FILE_REFRESH', text="")
|
||||
layout.operator("assetpipe.batch_ownership_change")
|
||||
layout.operator("assetpipe.revert_file", icon="FILE_TICK")
|
||||
layout.separator()
|
||||
col = layout.column(align=True)
|
||||
col.operator("assetpipe.save_production_hook", text="Create Production Hook").mode = 'PROD'
|
||||
col.operator("assetpipe.save_production_hook", text="Create Asset Hook").mode = 'ASSET'
|
||||
|
||||
|
||||
class ASSETPIPE_PT_sync_advanced(bpy.types.Panel):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Assset Pipeline'
|
||||
bl_label = "Advanced"
|
||||
bl_parent_id = "ASSETPIPE_PT_sync"
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
prefs = get_addon_prefs()
|
||||
return prefs.is_advanced_mode
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
layout = self.layout
|
||||
box = layout.box()
|
||||
box.operator("assetpipe.prepare_sync")
|
||||
box.operator("assetpipe.reset_ownership", icon="LOOP_BACK")
|
||||
box = layout.box()
|
||||
box.operator("assetpipe.fix_prefixes", icon="CHECKMARK")
|
||||
|
||||
# Task Layer Updater
|
||||
box = layout.box()
|
||||
box.label(text="Change Local Task Layers")
|
||||
|
||||
row = box.row()
|
||||
asset_pipe = context.scene.asset_pipeline
|
||||
all_task_layers = asset_pipe.all_task_layers
|
||||
for task_layer in all_task_layers:
|
||||
row.prop(task_layer, "is_local", text=task_layer.name)
|
||||
box.operator("assetpipe.update_local_task_layers")
|
||||
|
||||
|
||||
class ASSETPIPE_PT_ownership_inspector(bpy.types.Panel):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Assset Pipeline'
|
||||
bl_label = "Ownership Inspector"
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
layout = self.layout
|
||||
asset_pipe = context.scene.asset_pipeline
|
||||
if not asset_pipe.is_asset_pipeline_file:
|
||||
layout.label(text="Open valid 'Asset Pipeline' file", icon="ERROR")
|
||||
return
|
||||
|
||||
if (
|
||||
asset_pipe.asset_collection and
|
||||
context.collection in list(asset_pipe.asset_collection.children)
|
||||
):
|
||||
col = context.collection
|
||||
tl_row = layout.row()
|
||||
tl_row.label(
|
||||
text=f"{col.name}: ",
|
||||
icon="OUTLINER_COLLECTION",
|
||||
)
|
||||
draw_task_layer_selection(context, layout=tl_row, data=col)
|
||||
|
||||
if not context.active_object:
|
||||
layout.label(text="Set an Active Object to Inspect", icon="OBJECT_DATA")
|
||||
return
|
||||
obj = context.active_object
|
||||
transfer_data = obj.transfer_data_ownership
|
||||
box = layout.box()
|
||||
main_row = box.row()
|
||||
name_row = main_row.row()
|
||||
name_row.prop(obj, 'name', icon="OBJECT_DATA", text="", emboss=False)
|
||||
|
||||
if obj.asset_id_surrender:
|
||||
name_row.operator("assetpipe.update_surrendered_object")
|
||||
|
||||
draw_task_layer_selection(context, layout=main_row.row(), data=obj)
|
||||
surrender_row = main_row.row()
|
||||
surrender_row.enabled = obj.asset_id_owner in asset_pipe.local_task_layers
|
||||
surrender_row.prop(obj, "asset_id_surrender", text="", icon="ORPHAN_DATA" if obj.asset_id_surrender else "HEART")
|
||||
draw_transfer_data(context, transfer_data, box)
|
||||
|
||||
|
||||
classes = (
|
||||
ASSETPIPE_PT_sync,
|
||||
ASSETPIPE_PT_sync_advanced,
|
||||
ASSETPIPE_PT_working_files,
|
||||
ASSETPIPE_PT_sync_tools,
|
||||
ASSETPIPE_PT_publish,
|
||||
ASSETPIPE_PT_ownership_inspector,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
for i in classes:
|
||||
bpy.utils.register_class(i)
|
||||
|
||||
|
||||
def unregister():
|
||||
for i in classes:
|
||||
bpy.utils.unregister_class(i)
|
||||
Reference in New Issue
Block a user