2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
@@ -0,0 +1,92 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.utils import register_class, unregister_class
import importlib
module_names = (
"op_pie_wrappers",
"op_copy_to_selected",
"bs_utils",
"hotkeys",
"prefs",
"sidebar",
"tweak_builtin_pies",
"pie_animation",
"pie_apply_transform",
"pie_camera",
"pie_preferences",
"pie_editor_split_merge",
"pie_editor_switch",
"pie_file",
"pie_manipulator",
"pie_mesh_delete",
"pie_mesh_flatten",
"pie_mesh_merge",
"pie_object_add",
"pie_object_display",
"pie_object_parenting",
"pie_proportional_editing",
"pie_relationship_delete",
"pie_sculpt_brush_select",
"pie_selection",
"pie_set_origin",
"pie_view_3d",
"pie_window",
)
modules = [
__import__(__package__ + "." + submod, {}, {}, submod)
for submod in module_names
]
def register_unregister_modules(modules: list, register: bool):
"""Recursively register or unregister modules by looking for either
un/register() functions or lists named `registry` which should be a list of
registerable classes.
"""
register_func = register_class if register else unregister_class
un = 'un' if not register else ''
for m in modules:
if register:
importlib.reload(m)
if hasattr(m, 'registry'):
for c in m.registry:
try:
register_func(c)
except Exception as e:
print(
f"Warning: Pie Menus failed to {un}register class: {c.__name__}"
)
print(e)
if hasattr(m, 'modules'):
register_unregister_modules(m.modules, register)
if register and hasattr(m, 'register'):
m.register()
elif hasattr(m, 'unregister'):
m.unregister()
def delayed_register(_scene=None):
# Register whole add-on with a slight delay,
# to make sure Keymap data we need already exists on Blender launch.
# Otherwise, keyconfigs.user.keymaps is an empty list, we can't find fallback ops.
register_unregister_modules(modules, True)
def register():
# NOTE: persistent=True must be set, otherwise this doesn't work when opening a .blend file directly from a file browser.
bpy.app.timers.register(delayed_register, first_interval=0.5, persistent=True)
def unregister():
# save add-on prefs to file before unregistering.
from .bs_utils.prefs import get_addon_prefs, update_prefs_on_file
addon_prefs = get_addon_prefs()
if addon_prefs:
if bpy.context.preferences.use_preferences_save:
update_prefs_on_file()
register_unregister_modules(reversed(modules), False)
@@ -0,0 +1,23 @@
schema_version = "1.0.0"
id = "viewport_pie_menus"
name = "3D Viewport Pie Menus"
version = "1.7.0"
tagline = "Various pie menus to speed up your workflow"
maintainer = "Community"
type = "add-on"
tags = ["User Interface"]
blender_version_min = "5.0.0"
license = ["SPDX:GPL-3.0-or-later"]
website = "https://projects.blender.org/extensions/space_view3d_pie_menus"
copyright = [
"2024-2026 Demeter Dzadik",
"2016-2022 meta-androcto",
"2016-2022 pitiwazou",
"2016-2022 chromoly",
"2016-2022 italic",
"2016-2022 kc98",
"2016-2022 saidenka",
"2021 Yashar JafarKhanpour",
]
[permissions]
files = "To load brush icons from disk"
@@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 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.
blender_studio_utils
Copyright (C) 2025 Mets
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:
blender_studio_utils Copyright (C) 2025 Mets
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/philosophy/why-not-lgpl.html>.
@@ -0,0 +1,3 @@
# blender_studio_utils
Generic functions that can be added to Blender add-on projects as a git submodule. These should be considered for inclusion in core Blender.
@@ -0,0 +1,11 @@
from . import (
prefs,
properties,
hotkeys,
)
modules = [
prefs,
properties,
hotkeys,
]
@@ -0,0 +1,404 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
# This file requires (and is made possible by) Blender 5.0 due to the find_match() API call.
import bpy
from bpy.types import KeyMap, KeyMapItem, UILayout
ADDON_KEYMAPS = []
class HotkeyDrawMixin:
"""Expose these functions as a mix-in class so that add-ons can more easily override functionality as needed.
Add-ons should simply inherit this class in their AddonPreferences class.
"""
@staticmethod
def draw_hotkey_list(context, layout, compact=False, debug=False, sort_mode='BY_KEYMAP', ignore_missing=False):
draw_hotkey_list(context, layout, compact, debug, sort_mode, ignore_missing)
@staticmethod
def get_user_kmis_of_addon(context) -> list[tuple[KeyMap, KeyMapItem]]:
return get_user_kmis_of_addon(context)
@staticmethod
def draw_kmi(km: KeyMap, kmi: KeyMapItem, layout: UILayout, compact=False, debug=False):
draw_kmi(km, kmi, layout, compact=compact, debug=debug)
KEYMAP_ICONS = {
'Object Mode': 'OBJECT_DATAMODE',
'Window': 'WINDOW',
'3D View': 'VIEW3D',
'Mesh': 'OUTLINER_DATA_MESH',
'Outliner': 'OUTLINER',
'Object Non-modal': 'OBJECT_DATAMODE',
'Sculpt': 'SCULPTMODE_HLT',
'Armature': 'ARMATURE_DATA',
'Pose': 'POSE_HLT',
'Weight Paint': 'WPAINT_HLT',
}
KEYMAP_UI_NAMES = {
'Armature': "Armature Edit",
'Object Non-modal': "Object Mode",
}
KMI_DEFAULTS = {
prop: KeyMapItem.bl_rna.properties[prop].default
for prop in KeyMapItem.bl_rna.properties.keys()
if hasattr(prop, 'default')
}
def register_hotkey(
bl_idname,
*,
op_kwargs={},
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
keymap_name='Window'
):
global ADDON_KEYMAPS
wm = bpy.context.window_manager
space_type = wm.keyconfigs.default.keymaps[keymap_name].space_type
addon_keyconfig = wm.keyconfigs.addon
if not addon_keyconfig:
# This happens when running Blender in background mode.
return
addon_keymaps = addon_keyconfig.keymaps
addon_km = addon_keymaps.get(keymap_name)
if not addon_km:
addon_km = addon_keymaps.new(name=keymap_name, space_type=space_type)
existing_kmi = find_kmi_in_km_by_data(addon_km, hotkey_kwargs, bl_idname, op_kwargs)
if existing_kmi:
# NOTE: It is extremely important to not register duplicate add-on keymaps, AND
# to NOT remove them on add-on unregister, because once an add-on keymap is registered,
# it is SUPPOSED TO stick around for ever.
# This allows Blender to store the associated user keymap, meaning the user's modifications
# will be stored and restored as expected, whenever the add-on is enabled again.
if (addon_km, existing_kmi) not in ADDON_KEYMAPS:
ADDON_KEYMAPS.append((addon_km, existing_kmi))
return
addon_kmi = addon_km.keymap_items.new(bl_idname, **hotkey_kwargs)
for key in op_kwargs:
value = op_kwargs[key]
setattr(addon_kmi.properties, key, value)
ADDON_KEYMAPS.append((addon_km, addon_kmi))
def draw_hotkey_list(
context,
layout,
*,
compact=False,
debug=False,
sort_mode='BY_KEYMAP',
ignore_missing=False,
button_draw_func: callable=None,
):
"""Draw the list of hotkeys registered by this add-on.
Will find the corresponding User KeyMapItems, which are safe to modify.
Supports two sorting modes:
BY_KEYMAP: Sort by name of containing keymap, then operator name as displayed in the UI.
Operators with a "name" parameter will use that as the name. No grouping.
BY_OPERATOR: Sort by operator name as displayed in the UI. Group identical names together.
"""
if bpy.app.version < (5, 0, 0):
layout.label(text="Only in Blender 5.0 and later.")
return
assert sort_mode in ('BY_KEYMAP', 'BY_OPERATOR')
if sort_mode == 'BY_OPERATOR':
layout = layout.column(align=True)
if compact == None:
sidebar = get_sidebar(context)
if sidebar:
compact = sidebar.width < 600
else:
compact = context.area.width < 600
if not compact:
split = layout.row().split(factor=0.75)
row = split.row()
row.label(text="Operator", icon='BLANK1')
row.label(text="Group", icon='BLANK1')
split.row().label(text="Key Combo")
layout.separator()
user_kmis = get_user_kmis_of_addon(context)
if sort_mode == 'BY_KEYMAP':
user_kmis = sorted(user_kmis, key=lambda tup: "".join(list(get_kmi_ui_info(*tup)[1:])))
elif sort_mode == 'BY_OPERATOR':
user_kmis = sorted(user_kmis, key=lambda tup: "".join(list(get_kmi_ui_info(*tup)[2])))
for i, (user_km, user_kmi) in enumerate(user_kmis):
_, _, kmi_name = get_kmi_ui_info(user_km, user_kmi)
if "Missing" in kmi_name and ignore_missing:
continue
if sort_mode == 'BY_OPERATOR' and i > 0:
prev_km, prev_kmi = user_kmis[i-1]
_, _, prev_kmi_name = get_kmi_ui_info(prev_km, prev_kmi)
if prev_kmi_name != kmi_name:
layout.separator()
draw_kmi(user_km, user_kmi, layout, compact=compact, button_draw_func=button_draw_func, debug=debug)
def get_user_kmis_of_addon(context) -> list[tuple[KeyMap, KeyMapItem]]:
"""Return a list of (KeyMap, KeyMapItem) tuples of user-shortcuts (the ones that can be modified by user)."""
ret = []
assert bpy.app.version >= (5, 0, 0), "This function requires Blender 5.0 or later."
context.window_manager.keyconfigs.update()
for addon_km, addon_kmi in ADDON_KEYMAPS:
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
if not user_km:
# This should never happen.
print("Failed to find User KeyMap: ", addon_km.name)
continue
user_kmi = user_km.keymap_items.find_match(addon_km, addon_kmi)
if not user_kmi:
# This shouldn't really happen, but maybe it can, eg. if user changes idname.
print("Failed to find User KeyMapItem: ", addon_km.name, addon_kmi.idname)
continue
ret.append((user_km, user_kmi))
return ret
def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
km_name = km.name
km_icon = KEYMAP_ICONS.get(km_name, 'BLANK1')
km_name = KEYMAP_UI_NAMES.get(km_name, km_name)
if kmi.properties and 'name' in kmi.properties:
name = kmi.properties.name
if name:
try:
bpy_type = getattr(bpy.types, kmi.properties.name)
kmi_name = bpy_type.bl_label
except:
kmi_name = "Missing (code 1). Try restarting."
else:
kmi_name = "Missing (code 2). Try restarting."
else:
try:
parts = kmi.idname.split(".")
bpy_type = getattr(bpy.ops, parts[0])
bpy_type = getattr(bpy_type, parts[1])
kmi_name = bpy_type.get_rna_type().name
except:
kmi_name = "Missing (code 3). Try restarting."
return km_icon, km_name, kmi_name
def find_kmi_in_km_by_data(km: KeyMap, hotkey_kwargs: dict, op_idname: str, op_kwargs: dict) -> KeyMapItem | None:
"""Loop over KeyMapItems of the provided KeyMap, and return the first entry,
if any, which matches the passed key combo, operator, and operator properties.
"""
def is_kmi_matching(kmi: KeyMapItem, hotkey_kwargs: dict, op_idname: str, op_kwargs: dict) -> bool:
if kmi.idname != op_idname:
return False
if kmi.properties == None:
return False
combined_hotkey = KMI_DEFAULTS.copy()
combined_hotkey.update(hotkey_kwargs)
for key, value in combined_hotkey.items():
if value != getattr(kmi, key):
return False
for key, value in op_kwargs.items():
if key not in kmi.properties:
return False
if value != kmi.properties[key]:
return False
return True
return next((kmi for kmi in km.keymap_items if is_kmi_matching(kmi, hotkey_kwargs, op_idname, op_kwargs)), None)
def draw_kmi(
km: KeyMap,
kmi: KeyMapItem,
layout: UILayout,
compact=False,
button_draw_func: callable=None,
debug=False,
):
"""Draw a KeyMapItem in the provided UI.
This function is designed specifically to be used in an add-on's preferences:
- It does not allow removing the KeyMapItem, since add-on KMIs should never be removed.
- It does not allow changing the operator name or kwargs for similar reasons.
- Caller should pass a KeyMap and KeyMapItem pair from keyconfigs.user.
- Passing data from keyconfigs.addon can be useful for debugging purposes.
"""
if debug:
layout = layout.box()
split = layout.split(factor=0.75)
row1 = split.row(align=True)
if debug:
row1.prop(kmi, "show_expanded", text="", emboss=False)
row1.prop(kmi, "active", text="", emboss=False)
km_icon, km_name, kmi_name = get_kmi_ui_info(km, kmi)
if compact:
km_name = ""
row1.label(text=kmi_name)
row1.label(text=km_name, icon=km_icon or "BLANK1")
row2 = split.row(align=True)
sub = row2.row(align=True)
sub.enabled = kmi.active
if button_draw_func:
button_draw_func(layout=sub, km=km, kmi=kmi, compact=compact)
else:
sub.prop(kmi, "type", text="", full_event=True)
if kmi.is_user_modified:
row2.context_pointer_set("keymap", km) # NOTE: Yes, this actually matters.
row2.operator(
"preferences.keyitem_restore", text="", icon='BACK'
).item_id = kmi.id
if debug and kmi.show_expanded:
layout.template_keymap_item_properties(kmi)
def get_sidebar(context):
if not context.area.type == 'VIEW_3D':
return None
for region in context.area.regions:
if region.type == 'UI':
return region
def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMap or None, KeyMapItem or None]:
target_km = find_matching_keymap(context, target_kc, src_km)
if not target_km:
raise Exception(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
kc_user = context.window_manager.keyconfigs.user
# If we want to find a matching User KeyMapItem, that's easy, because that's what the API was meant for.
if target_kc == kc_user:
return target_km, target_km.keymap_items.find_match(src_km, src_kmi)
user_km, user_kmi = src_km, src_kmi
# If we want to find any other type of KeyMapItem, we have to do it indirectly, since we can only directly check for matches in the User KeyConfig.
# So eg. if we want to find an Addon KeyMapItem based on a User KeyMapItem, we have to loop over all Addon KeyMapItems, and find which one matches with the given User KeyMapItem.
for target_kmi in target_km.keymap_items:
try:
match = user_km.keymap_items.find_match(target_km, target_kmi)
if match == src_kmi:
return target_km, target_kmi
except RuntimeError:
print("Failed to find matching KeyMapItem for: ", target_km.name, target_kmi.to_string())
# raise Exception(f"Failed to find KeyMapItem '{src_kmi.idname}' ({src_kmi.to_string()}) in KeyConfig '{target_kc.name}', KeyMap '{target_km.name}'")
# We will return here eg. when looking for an add-on keymap in the default keyconfig.
return None, None
def find_matching_keymap(context, target_kc, src_km):
"""Find the equivalent keymap in another keyconfig."""
kc_user = context.window_manager.keyconfigs.user
# If we want to find a matching User KeyMap, that's easy, because that's what the API was meant for.
if target_kc == kc_user:
match = target_kc.keymaps.find_match(src_km)
assert match != src_km, "This is the same exact keymap already."
return match
# If we want to find any other type of KeyMap, we have to do it indirectly, since we can only directly check for matches in the User KeyConfig.
# So eg. if we want to find an Addon KeyMap based on a User KeyMap, we have to loop over all Addon KeyMaps, and find which one matches with the given User KeyMap.
for km in target_kc.keymaps:
match = kc_user.keymaps.find_match(km)
if match == src_km:
return km
class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
bl_idname = "window.restore_deleted_hotkeys"
bl_description = "Restore any missing built-in or add-on hotkeys.\n(These should be disabled instead of being deleted.)\nThis operation cannot be undone!"
# Undo flag is omitted, because this operation cannot be un-done.
bl_options = {'REGISTER'}
bl_label = "Restore Deleted Hotkeys"
def execute(self, context):
num_restored = restore_deleted_keymap_items_global(context)
self.report({'INFO'}, f"Restored {num_restored} deleted keymaps.")
return {'FINISHED'}
def restore_deleted_keymap_items_global(context) -> int:
"""Deleting built-in or add-on KeyMapItems should never be done by users, as there's no way to recover them.
Changing the operator name also shouldn't be done, since that makes it impossible to track modifications.
Blender shouldn't even allow either of these things. You can disable instead of delete, and you can disable and add new entry instead of modifying idname.
This function tries to bring them back, by restoring all KeyMaps to their default state, then re-applying any modifications that were there before
(Except these deletions.)
"""
keyconfigs = context.window_manager.keyconfigs
user_kc = keyconfigs.user
total_restored = 0
for user_km in user_kc.keymaps:
total_restored += restore_deleted_keymap_items(context, user_km)
return total_restored
def restore_deleted_keymap_items(context, user_km) -> int:
keyconfigs = context.window_manager.keyconfigs
user_kc = keyconfigs.user
default_kc = keyconfigs.default
addon_kc = keyconfigs.addon
# Step 1: Store modified and added KeyMapItems in a temp keymap.
temp_km = user_kc.keymaps.new("temp_"+user_km.name)
kmis_user_modified = []
kmis_user_defined = []
for user_kmi in user_km.keymap_items:
if user_kmi.is_user_defined:
temp_kmi = temp_km.keymap_items.new_from_item(user_kmi)
kmis_user_defined.append(temp_kmi)
continue
if user_kmi.is_user_modified:
temp_kmi = temp_km.keymap_items.new_from_item(user_kmi)
# Find the original keymap in either the Blender default or Addon KeyConfigs.
# Not sure if this works with presets like Industry Compatible keymap,
# but I assume they change the contents of the "default" keyconfig, so it would work.
default_km, default_kmi = find_matching_km_and_kmi(context, default_kc, user_km, user_kmi)
if not default_kmi:
default_km, default_kmi = find_matching_km_and_kmi(context, addon_kc, user_km, user_kmi)
kmis_user_modified.append(((default_km, default_kmi), (temp_km, temp_kmi)))
# Step 2: Restore User KeyMap to default.
num_kmis = len(user_km.keymap_items)
user_km.restore_to_default()
# Step 3: Restore modified and added KeyMapItems.
for temp_def_kmi in kmis_user_defined:
user_km.keymap_items.new_from_item(temp_def_kmi)
for (default_km, default_kmi), (temp_km, temp_kmi) in kmis_user_modified:
user_km, user_kmi = find_matching_km_and_kmi(context, user_kc, default_km, default_kmi)
for key in ('active', 'alt', 'any', 'ctrl', 'hyper', 'key_modifier', 'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
setattr(user_kmi, key, getattr(temp_kmi, key))
if temp_kmi.properties:
for key in temp_kmi.properties.keys():
temp_value = getattr(temp_kmi.properties, key)
if hasattr(temp_value, 'keys'):
# Operator properties can be PropertyGroups, and this is the case for some node wrangler ops.
temp_propgroup = temp_value
real_propgroup = getattr(user_kmi.properties, key)
for pg_key in temp_propgroup.keys():
pg_val = getattr(temp_propgroup, pg_key)
real_propgroup[pg_key] = pg_val
continue
setattr(user_kmi.properties, key, temp_value)
# Nuke the temp keymap.
user_kc.keymaps.remove(temp_km)
return len(user_km.keymap_items) - num_kmis
registry = [WINDOW_OT_restore_deleted_hotkeys]
@@ -0,0 +1,157 @@
from pathlib import Path
import bpy, json
from bpy.types import PropertyGroup
from rna_prop_ui import IDPropertyGroup
from bpy.types import AddonPreferences
from .. import __package__ as base_package
class PrefsFileSaveLoadMixin:
"""Mix-in class that can be used by any add-on to store their preferences in a file,
so that they don't get lost when the add-on is disabled.
To use it, copy this file to your add-on, and do this in your code:
```
import bpy
from .bs_utils.prefs import PrefsFileSaveLoadMixin, update_prefs_on_file
class MyAddonPrefs(PrefsFileSaveLoadMixin, bpy.types.AddonPreferences):
some_prop: bpy.props.IntProperty(update=update_prefs_on_file)
def register():
bpy.utils.register_class(MyAddonPrefs)
MyAddonPrefs.register_autoload_from_file()
def unregister():
update_prefs_on_file()
```
"""
# List of property names to not write to disk.
omit_from_disk: list[str] = []
loading = False
@staticmethod
def register_autoload_from_file(delay=1.0):
# Preferences cannot be modified during add-on registration, so we have to do it with an arbitrary delay.
# This could still fail if Blender loads too slowly, so it could be better.
# Ideally, Blender would simply save add-on preferences to disk, and none of this should be needed.
def timer_func(_scene=None):
try:
prefs = get_addon_prefs()
except KeyError:
# Add-on got un-registered in the meantime.
return
prefs.load_and_apply_prefs_from_file()
bpy.app.timers.register(timer_func, first_interval=delay)
def apply_prefs_from_dict_recursive(self, propgroup: PropertyGroup, data: dict):
for key, value in data.items():
if not hasattr(propgroup, key):
# Property got removed or renamed in the implementation.
continue
if type(value) == list:
for elem in value:
collprop = getattr(propgroup, key)
entry = collprop.get(elem['name'])
if not entry:
entry = collprop.add()
self.apply_prefs_from_dict_recursive(entry, elem)
elif type(value) == dict:
self.apply_prefs_from_dict_recursive(getattr(propgroup, key), value)
else:
setattr(propgroup, key, value)
def to_dict(self):
return props_to_dict_recursive(self, skip=type(self).omit_from_disk)
def save_prefs_to_file(self, _context=None):
filepath = get_prefs_filepath()
with open(filepath, "w") as f:
json.dump(self.to_dict(), f, indent=4)
def load_and_apply_prefs_from_file(self) -> dict:
type(self).loading = True
addon_data = load_prefs_from_file()
self.apply_prefs_from_dict_recursive(self, addon_data)
type(self).loading = False
return addon_data
def load_prefs_from_file() -> dict:
filepath = get_prefs_filepath()
if not filepath.exists():
return {}
with open(filepath, "r") as f:
try:
return json.load(f)
except json.decoder.JSONDecodeError:
print(f"Failed to load add-on preferences from file: {filepath}")
return {}
def get_prefs_filepath() -> Path:
if "." in base_package:
addon_name = base_package.split(".")[-1]
else:
addon_name = base_package
return Path(bpy.utils.user_resource('CONFIG')) / Path(addon_name + ".json")
def update_prefs_on_file(self=None, context=None):
prefs = get_addon_prefs(context)
if prefs:
if not type(prefs).loading:
prefs.save_prefs_to_file()
else:
print("Couldn't save preferences because the class was already unregistered.")
def props_to_dict_recursive(propgroup: IDPropertyGroup, skip=[]) -> dict:
"""Recursively convert a PropertyGroup or AddonPreferences to a dictionary.
Note that AddonPreferences don't support PointerProperties,
so this function doesn't either."""
ret = {}
for key in propgroup.bl_rna.properties.keys():
if key in skip or key in ['rna_type', 'bl_idname']:
continue
value = getattr(propgroup, key)
if isinstance(value, bpy.types.bpy_prop_collection):
ret[key] = [props_to_dict_recursive(elem) for elem in value]
elif type(value) == IDPropertyGroup or isinstance(value, bpy.types.PropertyGroup):
ret[key] = props_to_dict_recursive(value)
else:
if hasattr(propgroup.bl_rna.properties[key], 'enum_items'):
# Save enum values as string, not int.
ret[key] = propgroup.bl_rna.properties[key].enum_items[value].identifier
else:
ret[key] = value
return ret
def get_addon_prefs(context=None) -> AddonPreferences | None:
if not context:
context = bpy.context
addons = context.preferences.addons
if base_package.startswith('bl_ext'):
# 4.2 and later
addon_key = base_package
else:
# Pre-4.2
addon_key = base_package.split(".")[0]
addon = addons.get(addon_key)
if addon == None:
# print("This happens when packaging the extension, due to the registration delay.")
return
return addon.preferences
@@ -0,0 +1,192 @@
# 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.
# Compatible with the API changes in 5.0, but also older versions.
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 copy_all_custom_properties(src_id, tgt_id, x_mirror=False):
for prop_name in get_custom_prop_names(src_id):
copy_custom_property(src_id, tgt_id, prop_name=prop_name, x_mirror=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_custom_prop_names(owner):
for prop_name in get_all_runtime_prop_names(owner):
if is_custom_prop(owner, prop_name):
yield prop_name
def get_addon_prop_names(owner):
if bpy.app.version >= (5, 0, 0):
sys_props = owner.bl_system_properties_get()
if sys_props == None:
# If there aren't any add-on properties.
return []
return list(sys_props.keys())
else:
return [prop_name for prop_name in owner.keys() if is_addon_prop(owner, prop_name)]
def rename_custom_prop(owner, from_name, to_name):
assert is_custom_prop(owner, from_name), f"Property {from_name} of {owner} is not a Custom Property."
copy_custom_property(owner, owner, from_name, new_name=to_name, x_mirror=False)
remove_property(owner, from_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.
tgt_props = tgt_id.bl_system_properties_get()
if not tgt_props:
# XXX: not sure why, but this happens in one case in a community CloudRig rig (Hela by a$ap berg).
return
src_props = src_id.bl_system_properties_get()
# Remove the existing property before assignment to avoid TypeError when copying IDPropertyGroup
if prop_name in tgt_props:
del tgt_props[prop_name]
tgt_props[prop_name] = src_props[prop_name]
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, x_mirror)
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, new_name="", x_mirror=False):
"""Copy a custom property (one that was created via the UI or via Python dictionary syntax)."""
if not new_name:
new_name = prop_name
try:
src_prop = src_owner.id_properties_ui(prop_name)
except TypeError:
# Python dicts don't support UI data.
tgt_owner[prop_name] = src_owner[prop_name]
return
assert src_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[new_name] = value
new_prop = tgt_owner.id_properties_ui(new_name)
new_prop.update_from(src_prop)
tgt_owner.property_overridable_library_set(f'["{new_name}"]', src_owner.is_property_overridable_library(f'["{prop_name}"]'))
return tgt_owner.id_properties_ui(new_name)
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 get_addon_prop_names(owner)
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]
elif 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}")
@@ -0,0 +1,21 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import UILayout
def aligned_label(layout: UILayout, text: str, icon=None, alert=False, alignment='LEFT', **kwargs):
"""Draw some text in the single-column-layout style, ie. offset by 60%."""
row = layout.split(factor=0.4)
row.separator()
row.alert = alert
row.alignment = alignment
row.label(text=text, icon=icon, **kwargs)
def label_split(layout: UILayout, text: str, icon=None, alert=False, **kwargs) -> UILayout:
"""Return an empty UILayout with a text label to its left in the single-column-layout style."""
split = layout.split(factor=0.4, align=True)
row = split.row(align=True)
row.alignment = 'RIGHT'
row.label(text=text)
return split
@@ -0,0 +1,52 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
class WM_OT_toggle_keymap_item_on_drag(bpy.types.Operator):
"When Drag is enabled, this pie menu will only appear when the mouse is dragged while the assigned key combo is held down"
bl_idname = "wm.toggle_keymap_item_property"
bl_label = "Toggle On Drag"
bl_options = {'REGISTER', 'INTERNAL'}
km_name: bpy.props.StringProperty(options={'SKIP_SAVE'})
kmi_idname: bpy.props.StringProperty(options={'SKIP_SAVE'})
pie_name: bpy.props.StringProperty(options={'SKIP_SAVE'})
prop_name: bpy.props.StringProperty(options={'SKIP_SAVE'})
def execute(self, context):
# Another sign of the fragility of Blender's keymap API.
# The reason for the existence of this property wrapper operator is that
# when we draw the `on_drag` property in the UI directly, Blender's keymap
# system (for some reason??) doesn't realize that a keymap entry has changed,
# and fails to refresh caches, which has disasterous results.
# This operator fires a refreshing of internal keymap data via
# `user_kmi.type = user_kmi.type`
user_kc = context.window_manager.keyconfigs.user
user_km = user_kc.keymaps.get(self.km_name)
if not user_km:
# This really shouldn't happen.
self.report({'ERROR'}, f"Couldn't find KeyMap: {self.km_name}")
return {'CANCELLED'}
for user_kmi in user_km.keymap_items:
if user_kmi.idname == self.kmi_idname and user_kmi.properties and user_kmi.properties.name == self.pie_name:
if hasattr(user_kmi.properties, self.prop_name):
setattr(
user_kmi.properties,
self.prop_name,
not getattr(user_kmi.properties, self.prop_name),
)
# This is the magic line that causes internal keymap data to be kept up to date and not break.
user_kmi.type = user_kmi.type
else:
self.report({'ERROR'}, "Property not in keymap: " + self.prop_name)
return {'CANCELLED'}
return {'FINISHED'}
registry = [
WM_OT_toggle_keymap_item_on_drag,
]
@@ -0,0 +1,67 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Operator
from bpy.props import StringProperty
class VIEW3D_OT_copy_property_to_selected(Operator):
"""Copy a property from active to all selected objects which have that property"""
bl_idname = "view3d.copy_property_to_selected"
bl_label = "Copy Property to Selected"
bl_options = {'REGISTER', 'INTERNAL'}
rna_path: StringProperty(
name="RNA Path",
description="RNA path to the property to be copied, relative to the object",
default="",
options={'SKIP_SAVE'}
)
@classmethod
def poll(cls, context):
if not context.active_object:
cls.poll_message_set("No active object.")
return False
if list(context.selected_objects) == [context.active_object]:
cls.poll_message_set("No selected objects.")
return False
return True
def execute(self, context):
active_obj = context.active_object
try:
prop_value = active_obj.path_resolve(self.rna_path)
except ValueError:
self.report({'ERROR'}, f"Property '{self.rna_path}' not found on active object.")
return {'CANCELLED'}
success_counter = 0
for sel_obj in context.selected_objects:
if sel_obj == active_obj:
continue
try:
sel_obj.path_resolve(self.rna_path)
parts = self.rna_path.split(".")
owner = sel_obj
for part in parts[:-1]:
owner = getattr(sel_obj, part)
setattr(owner, parts[-1], prop_value)
success_counter += 1
except ValueError:
# This object doesn't have this property.
continue
if success_counter > 0:
self.report({'INFO'}, f"Copied property to {success_counter} objects.")
return {'FINISHED'}
else:
self.report({'ERROR'}, f"Property `{self.rna_path}` doesn't exist on any selected objects.")
return {'CANCELLED'}
registry = [
VIEW3D_OT_copy_property_to_selected,
]
@@ -0,0 +1,138 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Operator
from bpy.props import StringProperty, BoolProperty
import json
from .bs_utils.hotkeys import register_hotkey
def idname_to_op_class(idname: str):
parts = idname.split(".")
op = bpy.ops
for part in parts:
op = getattr(op, part)
return op
class WM_OT_call_menu_pie_drag_only(Operator):
"""Summon a pie menu only on mouse drag, otherwise initiate an operator"""
# This class is for cases where we want to overwrite a built-in shortcut which triggers on Press,
# with a pie menu that only appears on mouse drag.
# If the user does not mouse drag, invoke the provided fallback operator.
# In register_drag_hotkey(), the fallback operator is automagically extracted from the keymap,
# at the moment the hotkey is registered.
bl_idname = "wm.call_menu_pie_drag_only"
bl_label = "Pie Menu on Drag"
bl_options = {'REGISTER', 'INTERNAL'}
name: StringProperty(options={'SKIP_SAVE'})
on_drag: BoolProperty(
name="On Drag",
default=True,
description="Only show this pie menu on mouse drag, otherwise execute a default operator",
options={'SKIP_SAVE'},
)
fallback_operator: StringProperty(options={'SKIP_SAVE'})
fallback_op_kwargs: StringProperty(default="{}", options={'SKIP_SAVE'})
def invoke(self, context, event):
if not self.on_drag:
return self.execute(context)
self.init_mouse_x = event.mouse_x
self.init_mouse_y = event.mouse_y
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def invoke_fallback_operator(self):
op_cls = idname_to_op_class(self.fallback_operator)
if not op_cls:
return
fallback_op_kwargs = json.loads(self.fallback_op_kwargs)
if type(fallback_op_kwargs) == str:
# Not sure why json.loads seems to sometimes return a string, but it does
# when eg. setting Shift+C to drag, and using it when first launching Blender.
# After Reload Scripts, it works fine without this workaround. Weird af.
fallback_op_kwargs = json.loads(fallback_op_kwargs)
if op_cls.poll():
try:
return op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
except TypeError:
# This can apparently happen sometimes, see issue #86.
print(f"Pie Menu Fallback Operator failed: {self.fallback_operator}, {self.fallback_op_kwargs}")
def modal(self, context, event):
if event.value == 'RELEASE':
if self.fallback_operator:
self.invoke_fallback_operator()
return {'CANCELLED'}
threshold = context.preferences.inputs.drag_threshold
delta_x = abs(event.mouse_x - self.init_mouse_x)
delta_y = abs(event.mouse_y - self.init_mouse_y)
if delta_x > threshold or delta_y > threshold:
return self.execute(context)
return {'RUNNING_MODAL'}
def execute(self, context):
bpy.ops.wm.call_menu_pie(name=self.name)
return {'FINISHED'}
@classmethod
def register_drag_hotkey(
cls,
*,
keymap_name: str,
pie_name: str,
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
default_fallback_op="",
default_fallback_kwargs={},
on_drag=True,
):
context = bpy.context
fallback_operator = default_fallback_op
fallback_op_kwargs = default_fallback_kwargs
user_kc = context.window_manager.keyconfigs.user
km = user_kc.keymaps.get(keymap_name)
if km:
for kmi in km.keymap_items:
for i, condition in enumerate([
kmi.type == hotkey_kwargs.get('type', ""),
kmi.value == hotkey_kwargs.get('value', "PRESS"),
kmi.ctrl == hotkey_kwargs.get('ctrl', False),
kmi.shift == hotkey_kwargs.get('shift', False),
kmi.alt == hotkey_kwargs.get('alt', False),
kmi.oskey == hotkey_kwargs.get('oskey', False),
kmi.any == hotkey_kwargs.get('any', False),
kmi.key_modifier == hotkey_kwargs.get('key_modifier', 'NONE'),
kmi.active
]):
if not condition:
break
else:
fallback_operator = kmi.idname
if kmi.properties:
fallback_op_kwargs = {k:getattr(kmi.properties, k) if hasattr(kmi.properties, k) else v for k, v in kmi.properties.items()}
break
register_hotkey(
cls.bl_idname,
op_kwargs={
'name': pie_name,
'fallback_operator': fallback_operator,
'fallback_op_kwargs': json.dumps(fallback_op_kwargs),
'on_drag': on_drag,
},
hotkey_kwargs=hotkey_kwargs,
keymap_name=keymap_name,
)
registry = [
WM_OT_call_menu_pie_drag_only,
]
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_animation(Menu):
bl_idname = "PIE_MT_animation"
bl_label = "Animation"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator("screen.frame_jump", text="Jump to Start", icon='REW').end = False
# 6 - RIGHT
pie.operator("screen.frame_jump", text="Jump to End", icon='FF').end = True
# 2 - BOTTOM
pie.operator(
"screen.animation_play", text="Play Reverse", icon='PLAY_REVERSE'
).reverse = True
# 8 - TOP
if not context.screen.is_animation_playing: # Play / Pause
pie.operator("screen.animation_play", text="Play", icon='PLAY')
else:
pie.operator("screen.animation_play", text="Stop", icon='PAUSE')
# 7 - TOP - LEFT
pie.operator(
"screen.keyframe_jump", text="Previous Keyframe", icon='PREV_KEYFRAME'
).next = False
# 9 - TOP - RIGHT
pie.operator(
"screen.keyframe_jump", text="Next Keyframe", icon='NEXT_KEYFRAME'
).next = True
# 1 - BOTTOM - LEFT
pie.prop(context.tool_settings, "use_keyframe_insert_auto", text="Auto Keying")
# 3 - BOTTOM - RIGHT
pie.menu("VIEW3D_MT_object_animation", text="Keyframe Menu", icon="KEYINGSET")
registry = [
PIE_MT_animation,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Object Non-modal",
pie_name=PIE_MT_animation.bl_idname,
hotkey_kwargs={'type': "SPACE", 'value': "PRESS", 'shift': True},
default_fallback_op='screen.animation_play',
on_drag=True,
)
@@ -0,0 +1,146 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_apply_transforms(Menu):
bl_idname = "PIE_MT_apply_transforms"
bl_label = "Apply Transforms"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
props = pie.operator("object.transform_apply", text="Rot/Scale", icon='CON_SIZELIKE')
props.location, props.rotation, props.scale = (False, True, True)
# 6 - RIGHT
props = pie.operator(
"object.transform_apply", text="Loc/Rot/Scale", icon='ORIENTATION_LOCAL'
)
props.location, props.rotation, props.scale = (True, True, True)
# 2 - BOTTOM
pie.operator("object.apply_transforms_of_constraints", text="Soft-Apply Constraints", icon='CONSTRAINT')
# 8 - TOP
props = pie.operator(
"object.transform_apply", text="Rotation", icon='CON_ROTLIKE'
)
props.location, props.rotation, props.scale = (False, True, False)
# 7 - TOP - LEFT
props = pie.operator(
"object.transform_apply", text="Location", icon='CON_LOCLIKE'
)
props.location, props.rotation, props.scale = (True, False, False)
# 9 - TOP - RIGHT
props = pie.operator(
"object.transform_apply", text="Scale", icon='CON_SIZELIKE'
)
props.location, props.rotation, props.scale = (False, False, True)
# 1 - BOTTOM - LEFT
if (
context.active_object
and context.active_object.type == 'EMPTY'
and context.active_object.instance_type == 'COLLECTION'
and context.active_object.instance_collection
):
pie.operator('object.instancer_empty_to_collection', icon='LINKED')
else:
pie.operator(
"object.make_single_user", text="Make Single-User", icon='DUPLICATE'
).obdata = True
# 3 - BOTTOM - RIGHT
pie.menu("PIE_MT_clear_menu", text="Clear Transforms", icon='THREE_DOTS')
class OBJECT_OT_apply_transforms_of_constraints(Operator):
"""Apply constraint transformation results to the object's local transformation matrix, such that the object won't move if its constraints are removed"""
bl_idname = "object.apply_transforms_of_constraints"
bl_label = "Apply Transforms of Constraints"
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
if not any(
[
any([c.enabled and c.influence > 0 for c in obj.constraints])
for obj in context.selected_objects
]
):
cls.poll_message_set("No selected objects with enabled constraints.")
return False
return True
def execute(self, context):
# This is just a wrapper operator, to add a better tooltip and poll message.
return bpy.ops.object.visual_transform_apply()
class OBJECT_OT_make_meshes_single_user(Operator):
"""Make real duplicates of multi-user meshes on selected objects"""
bl_idname = "object.make_meshes_single_user"
bl_label = "Make Meshes Single User"
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
if not any(
[obj.data and obj.data.users > 1 for obj in context.selected_objects]
):
cls.poll_message_set("No selected objects with multi-user meshes.")
return False
return True
def execute(self, context):
# This is just a wrapper operator, to add a better tooltip and poll message.
return bpy.ops.object.duplicates_make_real()
class OBJECT_OT_clear_all_transforms(Operator):
bl_idname = "clear.all"
bl_label = "Clear All Transforms"
bl_description = "Clear All Transforms"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
bpy.ops.object.location_clear()
bpy.ops.object.rotation_clear()
bpy.ops.object.scale_clear()
return {'FINISHED'}
class PIE_MT_clear_transforms(Menu):
bl_idname = "PIE_MT_clear_menu"
bl_label = "Clear Transforms"
def draw(self, context):
layout = self.layout
layout.operator("clear.all", text="Clear All", icon='NONE')
layout.operator("object.location_clear", text="Clear Location", icon='NONE')
layout.operator("object.rotation_clear", text="Clear Rotation", icon='NONE')
layout.operator("object.scale_clear", text="Clear Scale", icon='NONE')
layout.operator("object.origin_clear", text="Clear Origin", icon='NONE')
registry = (
PIE_MT_apply_transforms,
OBJECT_OT_apply_transforms_of_constraints,
OBJECT_OT_make_meshes_single_user,
OBJECT_OT_clear_all_transforms,
PIE_MT_clear_transforms,
)
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Object Mode",
pie_name=PIE_MT_apply_transforms.bl_idname,
hotkey_kwargs={'type': "A", 'value': "PRESS", 'ctrl': True},
on_drag=False,
)
@@ -0,0 +1,255 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
# Some magic numbers that are either hard-coded into Blender,
# or are the indirect product of Blender's viewport implementation.
VIEWPORT_SENSOR_WIDTH = 72
VIEWPORT_CAMERA_ZOOM = 29.0746
class PIE_MT_camera(Menu):
bl_idname = "PIE_MT_camera"
bl_label = "Camera"
def draw(self, context):
pie = self.layout.menu_pie()
scene = context.scene
space = context.area.spaces.active
camera = get_current_camera(context)
# 4 - LEFT
if context.space_data.region_3d.view_perspective == 'CAMERA':
pie.operator("view3d.view_center_camera", icon='PIVOT_BOUNDBOX')
else:
pie.operator(
"view3d.camera_fit_view",
text="Active Camera to View",
icon='CON_CAMERASOLVER',
)
# 6 - RIGHT
text = "View Active Camera"
if context.space_data.region_3d.view_perspective == 'CAMERA':
text = "Return to Viewport"
pie.operator(
'view3d.view_camera_with_poll', text=text, icon='VIEW_CAMERA_UNSELECTED'
)
# 2 - BOTTOM
box = pie.box().column(align=True)
row = box.row()
if space.type == 'VIEW_3D' and space.use_local_camera:
row.prop(space, 'camera', text="Local Cam")
else:
row.prop(scene, 'camera', text="Scene Cam")
if camera:
box.prop(camera.data, 'lens')
box.prop(camera.data, 'sensor_width')
row = box.row(align=True)
row.prop(camera.data, 'show_passepartout', text="")
row.prop(camera.data, 'passepartout_alpha')
row = box.row(align=True)
row.prop(camera.data, 'show_composition_thirds')
row.prop(camera.data, 'show_composition_center')
row.prop(camera.data, 'show_composition_center_diagonal', text="Diagonal")
# 8 - TOP
if context.space_data.region_3d.view_perspective == 'CAMERA':
icon = 'LOCKED' if context.space_data.lock_camera else 'UNLOCKED'
pie.prop(context.space_data, 'lock_camera', icon=icon)
else:
pie.operator("view3d.camera_from_view", icon='ADD')
# 7 - TOP - LEFT
word = "Lock"
icon = 'UNLOCKED'
if camera and all(camera.lock_rotation) and all(camera.lock_location):
word = "Unlock"
icon = 'LOCKED'
pie.operator(
'view3d.lock_active_camera_transforms',
icon=icon,
text=f"{word} Camera Transforms",
)
# 9 - TOP - RIGHT
pie.operator('view3d.set_active_camera', icon='VIEW_CAMERA')
# 1 - BOTTOM - LEFT
# 3 - BOTTOM - RIGHT
class VIEW3D_OT_lock_active_camera_transforms(Operator):
"""Toggle whether the current camera can be transformed"""
bl_idname = "view3d.lock_active_camera_transforms"
bl_label = "Lock Camera Transforms"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not get_current_camera(context):
cls.poll_message_set("No active camera in this viewport.")
return False
return True
def execute(self, context):
camera = get_current_camera(context)
lock = False
if not (all(camera.lock_rotation) and all(camera.lock_location)):
lock = True
camera.lock_rotation = [lock, lock, lock]
camera.lock_rotation_w = lock
camera.lock_location = [lock, lock, lock]
word = "Locked" if lock else "Unlocked"
self.report({'INFO'}, f"{word} {camera.name}.")
return {'FINISHED'}
class VIEW3D_OT_camera_fit_view(Operator):
"""Align and fit selected or scene camera to view, including lens properties"""
bl_idname = "view3d.camera_fit_view"
bl_label = "Snap Camera to View"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not context.space_data or not hasattr(context.space_data, 'region_3d'):
return False
if context.space_data.region_3d.view_perspective == 'CAMERA':
cls.poll_message_set("Already in a camera view.")
return False
camera = get_current_camera(context)
if not camera and not (
context.active_object and context.active_object.type == 'CAMERA'
):
cls.poll_message_set("No active camera.")
return False
if camera and (any(camera.lock_location) or any(camera.lock_rotation)):
cls.poll_message_set("Active camera's transforms are locked.")
return False
return True
def invoke(self, context, _event):
if not context.scene.camera and not (
context.active_object and context.active_object.type == 'CAMERA'
):
self.create_camera = True
return self.execute(context)
def execute(self, context):
cam = get_current_camera(context)
space = context.space_data
if cam:
if space.region_3d.view_perspective == 'ORTHO':
cam.data.type = 'ORTHO'
elif space.region_3d.view_perspective == 'PERSP':
cam.data.type = 'PERSP'
bpy.ops.view3d.camera_to_view()
space.region_3d.view_camera_offset = [0, 0]
space.region_3d.view_camera_zoom = VIEWPORT_CAMERA_ZOOM
cam.data.sensor_width = VIEWPORT_SENSOR_WIDTH
cam.data.lens = space.lens
cam.data.clip_start = space.clip_start
cam.data.clip_end = space.clip_end
return {'FINISHED'}
class VIEW3D_OT_camera_from_view(Operator):
"""Create new camera from this view perspective"""
bl_idname = "view3d.camera_from_view"
bl_label = "New Camera from View"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
if context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.camera_add()
space = context.area.spaces.active
if space.type == 'VIEW_3D' and space.use_local_camera:
space.camera = context.active_object
else:
context.scene.camera = context.active_object
return bpy.ops.view3d.camera_fit_view()
class VIEW3D_OT_set_active_camera(Operator):
"""Set active camera as the scene camera"""
bl_idname = "view3d.set_active_camera"
bl_label = "Set Active Camera"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not context.active_object:
cls.poll_message_set("No active object.")
return False
if context.active_object.type != 'CAMERA':
cls.poll_message_set("Active object must be a camera.")
return False
if context.active_object == context.scene.camera:
cls.poll_message_set("This is already the active camera.")
return False
return True
def execute(self, context):
context.scene.camera = context.active_object
self.report({'INFO'}, f"Set active camera: {context.active_object.name}")
return {'FINISHED'}
class VIEW3D_OT_view_camera_with_poll(Operator):
"""Toggle viewing the active camera"""
bl_idname = "view3d.view_camera_with_poll"
bl_label = "View Active Camera"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not get_current_camera(context):
cls.poll_message_set("No active camera.")
return False
return True
def execute(self, context):
return bpy.ops.view3d.view_camera()
def get_current_camera(context):
space = context.area.spaces.active
if space.type == 'VIEW_3D' and space.use_local_camera:
return space.camera
return context.scene.camera
registry = [
PIE_MT_camera,
VIEW3D_OT_lock_active_camera_transforms,
VIEW3D_OT_camera_fit_view,
VIEW3D_OT_camera_from_view,
VIEW3D_OT_set_active_camera,
VIEW3D_OT_view_camera_with_poll,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="3D View",
pie_name=PIE_MT_camera.bl_idname,
hotkey_kwargs={'type': "C", 'value': "PRESS", 'alt': True},
on_drag=False,
)
@@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from .bs_utils.hotkeys import register_hotkey
def register():
register_hotkey(
'screen.area_join',
hotkey_kwargs={'type': "ACCENT_GRAVE", 'value': "PRESS", 'alt': True},
keymap_name="Window",
)
@@ -0,0 +1,569 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy, sys, json
from bpy.types import Menu, Operator
from bpy.props import StringProperty
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
AVAILABLE_PROPERTIES_EDITORS = []
class PIE_MT_editor_switch(Menu):
bl_idname = "PIE_MT_editor_switch"
bl_label = "Editor Switch"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator(
'wm.call_menu_pie', text="Video Editing...", icon="SEQUENCE"
).name = "PIE_MT_editor_switch_video"
# 6 - RIGHT
pie.operator('wm.call_menu_pie', text="Nodes...", icon="NODETREE").name = (
"PIE_MT_editor_switch_nodes"
)
# 2 - BOTTOM
pie.operator('wm.call_menu_pie', text="Data...", icon="PRESET").name = (
"PIE_MT_editor_switch_data"
)
# 8 - TOP
pie.operator(
WM_OT_set_area_type.bl_idname, text="3D View", icon="VIEW3D"
).ui_type = "VIEW_3D"
# 7 - TOP - LEFT
pie.operator(
'wm.call_menu_pie', text="2D Editors...", icon="FILE_IMAGE"
).name = "PIE_MT_editor_switch_image"
# 9 - TOP - RIGHT
pie.operator('wm.call_menu_pie', text="Script...", icon="SCRIPT").name = (
"PIE_MT_editor_switch_script"
)
# 1 - BOTTOM - LEFT
pie.separator()
# 3 - BOTTOM - RIGHT
pie.operator(
'wm.call_menu_pie', text="Animation...", icon="ARMATURE_DATA"
).name = "PIE_MT_editor_switch_anim"
class PIE_MT_editor_switch_video(Menu):
bl_idname = "PIE_MT_editor_switch_video"
bl_label = "Editor Switch: Video"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname,
text="Sequencer & Preview",
icon="SEQ_SPLITVIEW",
)
op.ui_type = "SEQUENCE_EDITOR"
op.view_type = 'SEQUENCER_PREVIEW'
# 6 - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Movie Clip Editor", icon="SEQUENCE"
)
op.ui_type = "CLIP_EDITOR"
op.mode = 'TRACKING'
op.view = 'CLIP'
# 2 - BOTTOM
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Clip Mask Editor", icon="MOD_MASK"
)
op.ui_type = "CLIP_EDITOR"
op.mode = 'MASK'
# 8 - TOP
pie.separator()
# 7 - TOP - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Preview", icon="SEQ_PREVIEW"
)
op.ui_type = "SEQUENCE_EDITOR"
op.view_type = 'PREVIEW'
# 9 - TOP - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Tracking: Dopesheet", icon="ACTION"
)
op.ui_type = "CLIP_EDITOR"
op.mode = 'TRACKING'
op.view = 'DOPESHEET'
# 1 - BOTTOM - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Sequencer", icon="SEQ_SEQUENCER"
)
op.ui_type = "SEQUENCE_EDITOR"
op.view_type = 'SEQUENCER'
# 3 - BOTTOM - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Tracking: Graph", icon="GRAPH"
)
op.ui_type = "CLIP_EDITOR"
op.mode = 'TRACKING'
op.view = 'GRAPH'
class PIE_MT_editor_switch_nodes(Menu):
bl_idname = "PIE_MT_editor_switch_nodes"
bl_label = "Editor Switch: Nodes"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Compositing", icon="NODE_COMPOSITING"
)
op.ui_type = 'CompositorNodeTree'
# 6 - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Shader", icon="NODE_MATERIAL"
)
op.ui_type = 'ShaderNodeTree'
op.shader_type = 'OBJECT'
# 2 - BOTTOM
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Geometry", icon="GEOMETRY_NODES"
)
op.ui_type = 'GeometryNodeTree'
# 8 - TOP
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Texture", icon="TEXTURE")
op.ui_type = 'TextureNodeTree'
# 7 - TOP - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Line Style", icon="LINE_DATA"
)
op.ui_type = 'ShaderNodeTree'
op.shader_type = 'LINESTYLE'
# 9 - TOP - RIGHT
if get_custom_nodetree_types(context, layout):
pie.menu(
'PIE_MT_editor_switch_nodes_custom',
text="Custom Nodes",
icon='NODETREE',
)
else:
pie.separator()
# 1 - BOTTOM - LEFT
pie.separator()
# 3 - BOTTOM - RIGHT
op = pie.operator(WM_OT_set_area_type.bl_idname, text="World", icon="WORLD")
op.ui_type = 'ShaderNodeTree'
op.shader_type = 'WORLD'
class PIE_MT_editor_switch_nodes_custom(Menu):
bl_idname = "PIE_MT_editor_switch_nodes_custom"
bl_label = "Editor Switch: Custom Nodes"
def draw(self, context):
layout = self.layout
for ui_type, label, icon in get_custom_nodetree_types(context, layout):
layout.operator(
WM_OT_set_area_type.bl_idname, text=label, icon=icon
).ui_type = ui_type
def get_custom_nodetree_types(context, layout):
"""There are no rules In love, war, and Python scripting.
The only way to get custom node tree types is this:
- Try to set the UI type to some nonsense
- The resulting error tells you the possible UI types
- Catch and crunch the error string into a set
- Subtract from this the set of built-in UI types
- The result is a set of the custom node tree type names
- We can get their class with bl_rna_get_subclass_py
"""
builtin_types = set(
[
'VIEW_3D',
'IMAGE_EDITOR',
'UV',
'CompositorNodeTree',
'TextureNodeTree',
'GeometryNodeTree',
'ShaderNodeTree',
'SEQUENCE_EDITOR',
'CLIP_EDITOR',
'DOPESHEET',
'TIMELINE',
'FCURVES',
'DRIVERS',
'NLA_EDITOR',
'TEXT_EDITOR',
'CONSOLE',
'INFO',
'OUTLINER',
'PROPERTIES',
'FILES',
'ASSETS',
'SPREADSHEET',
'PREFERENCES',
]
)
ret = []
try:
context.area.ui_type = "Nonsense."
except Exception as exc:
exc_type, exc_value, tb = sys.exc_info()
if not exc_value:
return []
type_list_str = (
str(exc_value)
.split("not found in ")[-1]
.replace("(", "[")
.replace(")", "]")
.replace("'", '"')
)
type_list = set(json.loads(type_list_str))
custom_types = type_list - builtin_types
for type_name in custom_types:
type_class = bpy.types.NodeTree.bl_rna_get_subclass_py(type_name)
if not type_class:
continue
ret.append((type_name, type_class.bl_label, type_class.bl_icon))
return ret
class PIE_MT_editor_switch_data(Menu):
bl_idname = "PIE_MT_editor_switch_data"
bl_label = "Editor Switch: Data"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator(
WM_OT_set_area_type.bl_idname, text="File Browser", icon="FILEBROWSER"
).ui_type = "FILES"
# 6 - RIGHT
pie.operator(
WM_OT_set_area_type.bl_idname, text="Preferences", icon="PREFERENCES"
).ui_type = "PREFERENCES"
# 2 - BOTTOM
pie.operator(
WM_OT_set_area_type.bl_idname, text="Outliner", icon="OUTLINER"
).ui_type = "OUTLINER"
# 8 - TOP
pie.operator(
WM_OT_set_area_type.bl_idname, text="Properties", icon="PROPERTIES"
).ui_type = "PROPERTIES"
# 7 - TOP - LEFT
pie.operator(
WM_OT_set_area_type.bl_idname, text="Asset Browser", icon="ASSET_MANAGER"
).ui_type = "ASSETS"
# 9 - TOP - RIGHT
pie.menu(
"PIE_MT_editor_switch_properties",
text="Properties Types...",
icon="PROPERTIES",
)
# 1 - BOTTOM - LEFT
pie.operator(
WM_OT_set_area_type.bl_idname, text="Spreadsheet", icon="SPREADSHEET"
).ui_type = "SPREADSHEET"
# 3 - BOTTOM - RIGHT
pie.menu(
"PIE_MT_editor_switch_outliner", text="Outliner Types...", icon="OUTLINER"
)
class PIE_MT_editor_switch_outliner(Menu):
bl_idname = "PIE_MT_editor_switch_outliner"
bl_label = "Editor Switch: Outliner"
def draw(self, context):
pie = self.layout#.menu_pie()
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Scenes", icon="SCENE_DATA"
)
op.ui_type = "OUTLINER"
op.display_mode = 'SCENES'
# 6 - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="View Layer", icon="RENDERLAYERS"
)
op.ui_type = "OUTLINER"
op.display_mode = 'VIEW_LAYER'
# 2 - BOTTOM
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Video Sequencer", icon="SEQUENCE"
)
op.ui_type = "OUTLINER"
op.display_mode = 'SEQUENCE'
# 8 - TOP
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Blender File", icon="FILE_BLEND"
)
op.ui_type = "OUTLINER"
op.display_mode = 'LIBRARIES'
# 7 - TOP - LEFT
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Data API", icon="RNA")
op.ui_type = "OUTLINER"
op.display_mode = 'DATA_API'
# 9 - TOP - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname,
text="Library Overrides",
icon="LIBRARY_DATA_OVERRIDE",
)
op.ui_type = "OUTLINER"
op.display_mode = 'LIBRARY_OVERRIDES'
# 1 - BOTTOM - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Orphan Data", icon="ORPHAN_DATA"
)
op.ui_type = "OUTLINER"
op.display_mode = 'ORPHAN_DATA'
# 3 - BOTTOM - RIGHT
def get_available_properties_editors(context):
"""Similar hack as get_custom_nodetree_types.
Ideally we need an existing Properties editor, otherwise
the list of available types is not up to date.
Although even in that case, we still don't get an error,
so this is fine.
"""
org_ui_type = context.area.ui_type
try:
space = None
for area in context.screen.areas:
if area.ui_type == 'PROPERTIES':
space = area.spaces.active
break
if not space:
context.area.ui_type = 'PROPERTIES'
space = context.space_data
space.context = "Nonsense."
except Exception as exc:
exc_type, exc_value, tb = sys.exc_info()
if not exc_value:
return []
type_list_str = (
str(exc_value)
.split("not found in ")[-1]
.replace("(", "[")
.replace(")", "]")
.replace("'", '"')
)
available_types = set(json.loads(type_list_str))
context.area.ui_type = org_ui_type
return available_types
class PIE_MT_editor_switch_properties(Menu):
bl_idname = "PIE_MT_editor_switch_properties"
bl_label = "Editor Switch: Properties"
def draw(self, context):
layout = self.layout
available_editors = get_available_properties_editors(context)
def safe_draw(pie, text, icon, context_name):
if context_name in available_editors:
op = pie.operator(WM_OT_set_area_type.bl_idname, text=text, icon=icon)
op.ui_type = "PROPERTIES"
op.context_name = context_name
else:
pie.separator()
safe_draw(layout, "Render", 'SCENE', 'RENDER')
safe_draw(layout, "Output", 'OUTPUT', 'OUTPUT')
safe_draw(layout, "View Layer", 'RENDERLAYERS', 'VIEW_LAYER')
safe_draw(layout, "Scene", 'SCENE_DATA', 'SCENE')
safe_draw(layout, "World", 'WORLD', 'WORLD')
layout.separator()
safe_draw(layout, "Object", 'OBJECT_DATAMODE', 'OBJECT')
safe_draw(layout, "Modifiers", 'MODIFIER', 'MODIFIER')
safe_draw(layout, "Particles", 'PARTICLES', 'PARTICLES')
safe_draw(layout, "Physics", 'PHYSICS', 'PHYSICS')
safe_draw(layout, "Constraints", 'CONSTRAINT', 'CONSTRAINT')
safe_draw(layout, "Object Data", 'OUTLINER_DATA_MESH', 'DATA')
safe_draw(layout, "Material", 'MATERIAL', 'MATERIAL')
layout.separator()
safe_draw(layout, "Texture", 'TEXTURE', 'TEXTURE')
class PIE_MT_editor_switch_image(Menu):
bl_idname = "PIE_MT_editor_switch_image"
bl_label = "Editor Switch: 2D Editors"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Image Viewer", icon="SEQ_PREVIEW"
)
op.ui_type = "IMAGE_EDITOR"
op.ui_mode = "VIEW"
# 6 - RIGHT
op = pie.operator(WM_OT_set_area_type.bl_idname, text="UV Editor", icon="UV")
op.ui_type = "UV"
# 2 - BOTTOM
pie.separator()
# 8 - TOP
pie.separator()
# 7 - TOP - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Image Paint", icon="TPAINT_HLT"
)
op.ui_type = "IMAGE_EDITOR"
op.ui_mode = "PAINT"
# 9 - TOP - RIGHT
pie.separator()
# 1 - BOTTOM - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Image Masking", icon="MOD_MASK"
)
op.ui_type = "IMAGE_EDITOR"
op.ui_mode = "PAINT"
# 3 - BOTTOM - RIGHT
class PIE_MT_editor_switch_script(Menu):
bl_idname = "PIE_MT_editor_switch_script"
bl_label = "Editor Switch: Script"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.separator()
# 6 - RIGHT
pie.operator(
WM_OT_set_area_type.bl_idname, text="Text Editor", icon="TEXT"
).ui_type = "TEXT_EDITOR"
# 2 - BOTTOM
pie.operator(
WM_OT_set_area_type.bl_idname, text="Python Console", icon="CONSOLE"
).ui_type = "CONSOLE"
# 8 - TOP
pie.operator(
WM_OT_set_area_type.bl_idname, text="Info", icon="INFO"
).ui_type = "INFO"
# 7 - TOP - LEFT
# 9 - TOP - RIGHT
# 1 - BOTTOM - LEFT
# 3 - BOTTOM - RIGHT
class PIE_MT_editor_switch_anim(Menu):
bl_idname = "PIE_MT_editor_switch_anim"
bl_label = "Editor Switch: Animation"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Grease Pencil", icon="GREASEPENCIL"
)
op.ui_type = 'DOPESHEET'
op.ui_mode = 'GPENCIL'
# 6 - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Dopesheet", icon="ACTION"
)
op.ui_type = 'DOPESHEET'
op.ui_mode = 'DOPESHEET'
# 2 - BOTTOM
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Driver Editor", icon="DRIVER"
)
op.ui_type = 'DRIVERS'
# 8 - TOP
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Timeline", icon="TIME")
op.ui_type = 'TIMELINE'
# 7 - TOP - LEFT
pie.separator()
# 9 - TOP - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Graph Editor", icon="GRAPH"
)
op.ui_type = 'FCURVES'
# 1 - BOTTOM - LEFT
op = pie.operator(WM_OT_set_area_type.bl_idname, text="NLA Editor", icon="NLA")
op.ui_type = 'NLA_EDITOR'
# 3 - BOTTOM - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Action Editor", icon="OBJECT_DATAMODE"
)
op.ui_type = 'DOPESHEET'
op.ui_mode = 'ACTION'
class WM_OT_set_area_type(Operator):
"""Change editor type and sub-type"""
bl_idname = "wm.set_area_type"
bl_label = "Change Editor Type"
bl_options = {'REGISTER', 'INTERNAL'}
ui_type: StringProperty(name="Area Type")
view_type: StringProperty(default="", options={'SKIP_SAVE'})
shader_type: StringProperty(default="", options={'SKIP_SAVE'})
context_name: StringProperty(default="", options={'SKIP_SAVE'})
display_mode: StringProperty(default="", options={'SKIP_SAVE'})
ui_mode: StringProperty(default="", options={'SKIP_SAVE'})
mode: StringProperty(default="", options={'SKIP_SAVE'})
view: StringProperty(default="", options={'SKIP_SAVE'})
def execute(self, context):
context.area.ui_type = self.ui_type
if self.view_type:
context.space_data.view_type = self.view_type
if self.shader_type:
context.space_data.shader_type = self.shader_type
if self.context_name:
context.space_data.context = self.context_name
if self.display_mode:
context.space_data.display_mode = self.display_mode
if self.ui_mode:
context.space_data.ui_mode = self.ui_mode
if self.mode:
context.space_data.mode = self.mode
if self.view:
context.space_data.view = self.view
return {'FINISHED'}
registry = [
PIE_MT_editor_switch,
PIE_MT_editor_switch_video,
PIE_MT_editor_switch_nodes,
PIE_MT_editor_switch_nodes_custom,
PIE_MT_editor_switch_data,
PIE_MT_editor_switch_outliner,
PIE_MT_editor_switch_properties,
PIE_MT_editor_switch_image,
PIE_MT_editor_switch_script,
PIE_MT_editor_switch_anim,
WM_OT_set_area_type,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Window",
pie_name=PIE_MT_editor_switch.bl_idname,
hotkey_kwargs={'type': "S", 'value': "PRESS", 'ctrl': True, 'alt': True},
on_drag=False,
)
@@ -0,0 +1,123 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.props import BoolProperty
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_file(Menu):
bl_idname = "PIE_MT_file"
bl_label = "File"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator("wm.open_mainfile", text="Open", icon='FILEBROWSER')
# 6 - RIGHT
pie.operator("wm.save_as_mainfile", text="Save As", icon='FILE_TICK')
# 2 - BOTTOM
pie.menu("TOPBAR_MT_file_export", text="Export...", icon='EXPORT')
# 8 - TOP
pie.menu("TOPBAR_MT_file_import", text="Import...", icon='IMPORT')
# 7 - TOP - LEFT
pie.operator('wm.call_menu_pie', text="Library...", icon='LINK_BLEND').name = (
"PIE_MT_library"
)
# 9 - TOP - RIGHT
pie.operator(
"wm.save_mainfile", text="Save Incremental", icon='FILE_TICK'
).incremental = True
# 1 - BOTTOM - LEFT
pie.menu("TOPBAR_MT_file_open_recent", icon='FILE_FOLDER')
# 3 - BOTTOM - RIGHT
pie.menu("TOPBAR_MT_file_recover", icon='RECOVER_LAST')
class PIE_MT_library(Menu):
bl_idname = "PIE_MT_library"
bl_label = "Library"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator("wm.link", text="Link", icon='LINK_BLEND')
# 6 - RIGHT
pie.operator("wm.append", text="Append", icon='APPEND_BLEND')
# 2 - BOTTOM
pie.operator(
"object.make_local", text="Make All Local", icon='FILE_BLEND'
).type = 'ALL'
# 8 - TOP
pie.prop(bpy.data, 'use_autopack', text="Auto-pack")
# 7 - TOP - LEFT
pie.operator(
"file.pack_res_and_lib", text="Pack All Local", icon='PACKAGE'
).pack = True
# 9 - TOP - RIGHT
pie.operator(
"file.pack_res_and_lib", text="Unpack All Local", icon='UGLYPACKAGE'
).pack = False
# 1 - BOTTOM - LEFT
pie.operator(
"file.make_paths_relative", icon='FILE', text="Make Paths Relative"
)
# 3 - BOTTOM - RIGHT
pie.operator(
"file.make_paths_absolute",
icon='LIBRARY_DATA_BROKEN',
text="Make Paths Absolute",
)
class WM_OT_pack_res_and_lib(Operator):
"""Pack/Unpack all local resources and libraries.\nNote: Packing indirectly linked data is not possible in Blender"""
bl_idname = "file.pack_res_and_lib"
bl_label = "Pack Resources and Libraries"
bl_options = {'REGISTER', 'UNDO'}
pack: BoolProperty()
def execute(self, context):
if self.pack:
bpy.ops.file.pack_libraries()
bpy.ops.file.pack_all()
else:
bpy.ops.file.unpack_libraries()
bpy.ops.file.unpack_all()
return {'FINISHED'}
registry = [
PIE_MT_file,
PIE_MT_library,
WM_OT_pack_res_and_lib,
]
def draw_revert(self, context):
self.layout.operator('wm.revert_mainfile', icon='LOOP_BACK')
self.layout.separator()
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Window",
pie_name=PIE_MT_file.bl_idname,
hotkey_kwargs={'type': "S", 'value': "PRESS", 'ctrl': True},
default_fallback_op='wm.save_mainfile',
on_drag=True,
)
bpy.types.TOPBAR_MT_file_recover.prepend(draw_revert)
def unregister():
bpy.types.TOPBAR_MT_file_recover.remove(draw_revert)
@@ -0,0 +1,122 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu, Operator
from bpy.props import EnumProperty, BoolProperty
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_manipulator(Menu):
bl_idname = "PIE_MT_manipulator"
bl_label = "Manipulator"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
space = context.space_data
# 4 - LEFT
pie.operator(
'view3d.set_manipulator', text="Rotation", icon='CON_ROTLIKE', depress=space.show_gizmo_object_rotate
).manipulator = 'ROT'
# 6 - RIGHT
pie.operator(
'view3d.set_manipulator', text="Scale", icon='CON_SIZELIKE', depress=space.show_gizmo_object_scale
).manipulator = 'SCALE'
# 2 - BOTTOM
pie.operator(
'view3d.set_manipulator', text="None", icon='X'
).manipulator='NONE'
# 8 - TOP
pie.operator(
'view3d.set_manipulator', text="Location", icon='CON_LOCLIKE', depress=space.show_gizmo_object_translate
).manipulator = 'LOC'
# 7 - TOP-LEFT
pie.operator(
'view3d.set_manipulator', text="Loc/Rot", icon='CON_LOCLIKE', depress=space.show_gizmo_object_translate and space.show_gizmo_object_rotate
).manipulator='LOCROT'
# 9 - TOP-RIGHT
pie.operator(
'view3d.set_manipulator', text="Loc/Scale", icon='CON_SIZELIKE', depress=space.show_gizmo_object_translate and space.show_gizmo_object_scale
).manipulator='LOCSCALE'
# 1 - BOT-LEFT
pie.operator(
'view3d.set_manipulator', text="Loc/Rot/Scale", icon='CON_LOCLIKE', depress=space.show_gizmo_object_translate and space.show_gizmo_object_rotate and space.show_gizmo_object_scale
).manipulator = 'LOCROTSCALE'
class VIEW3D_OT_set_manipulator(Operator):
"""Set manipulator type.\nShift: Toggle this manipulator instead"""
bl_idname = "view3d.set_manipulator"
bl_label = "Set Manipulator Type"
bl_options = {'REGISTER', 'UNDO'}
manipulator: EnumProperty(
name="Manipulator",
items=(
('NONE', "None", "None"),
('LOC', "Translate", "Translate"),
('ROT', "Rotate", "Rotate"),
('SCALE', "Scale", "Scale"),
('LOCROT', "Translate & Rotate", "Translate & Rotate"),
('LOCSCALE', "Translate & Scale", "Translate & Scale"),
('LOCROTSCALE', "All", "All"),
),
description="Set manipulator type",
default='LOC',
)
toggle: BoolProperty(
name="Toggle",
description="Hold Shift to toggle the selected manipulator rather than setting it as the only active one",
default=False,
options={'SKIP_SAVE'}
)
@classmethod
def poll(cls, context):
space = context.space_data
return space and space.type == 'VIEW_3D'
def invoke(self, context, event):
self.toggle = event.shift
return self.execute(context)
def execute(self, context):
space = context.space_data
if self.manipulator != 'NONE':
space.show_gizmo = True
if self.toggle:
if 'SCALE' in self.manipulator:
space.show_gizmo_object_scale = not space.show_gizmo_object_scale
if 'ROT' in self.manipulator:
space.show_gizmo_object_rotate = not space.show_gizmo_object_rotate
if 'LOC' in self.manipulator:
space.show_gizmo_object_translate = not space.show_gizmo_object_translate
else:
space.show_gizmo_object_scale = 'SCALE' in self.manipulator
space.show_gizmo_object_rotate = 'ROT' in self.manipulator
space.show_gizmo_object_translate = 'LOC' in self.manipulator
return {'FINISHED'}
registry = [
PIE_MT_manipulator,
VIEW3D_OT_set_manipulator,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="3D View",
pie_name=PIE_MT_manipulator.bl_idname,
hotkey_kwargs={'type': "SPACE", 'value': "PRESS", 'alt': True},
on_drag=False,
)
@@ -0,0 +1,62 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_mesh_delete(Menu):
bl_idname = "PIE_MT_mesh_delete"
bl_label = "Mesh Delete"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
box = pie.split().column()
box.operator(
"mesh.dissolve_limited", text="Limited Dissolve", icon='STICKY_UVS_LOC'
)
box.operator("mesh.delete_edgeloop", text="Delete Edge Loops", icon='NONE')
box.operator("mesh.edge_collapse", text="Edge Collapse", icon='UV_EDGESEL')
# 6 - RIGHT
box = pie.split().column()
box.operator("mesh.remove_doubles", text="Merge By Distance", icon='NONE')
box.operator("mesh.delete", text="Only Edge & Faces", icon='NONE').type = (
'EDGE_FACE'
)
box.operator("mesh.delete", text="Only Faces", icon='UV_FACESEL').type = (
'ONLY_FACE'
)
# 2 - BOTTOM
pie.operator("mesh.dissolve_edges", text="Dissolve Edges", icon='SNAP_EDGE')
# 8 - TOP
pie.operator("mesh.delete", text="Delete Edges", icon='EDGESEL').type = 'EDGE'
# 7 - TOP - LEFT
pie.operator("mesh.delete", text="Delete Vertices", icon='VERTEXSEL').type = (
'VERT'
)
# 9 - TOP - RIGHT
pie.operator("mesh.delete", text="Delete Faces", icon='FACESEL').type = 'FACE'
# 1 - BOTTOM - LEFT
pie.operator(
"mesh.dissolve_verts", text="Dissolve Vertices", icon='SNAP_VERTEX'
)
# 3 - BOTTOM - RIGHT
pie.operator("mesh.dissolve_faces", text="Dissolve Faces", icon='SNAP_FACE')
registry = [
PIE_MT_mesh_delete,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Mesh",
pie_name=PIE_MT_mesh_delete.bl_idname,
hotkey_kwargs={'type': "X", 'value': "PRESS"},
on_drag=False,
)
@@ -0,0 +1,198 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.props import EnumProperty
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_mesh_flatten(Menu):
bl_idname = "PIE_MT_mesh_flatten"
bl_label = "Mesh Flatten"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
box = pie.box().column(align=True)
box.label(text="Selection Bounds", icon='PIVOT_BOUNDBOX')
for axis in "XYZ":
row = box.row()
op = row.operator("transform.flatten_to_selection_bounding_box", text=f" -{axis}")
op.axis = axis
op.side = 'NEGATIVE'
op = row.operator("transform.flatten_to_selection_bounding_box", text=f" +{axis}")
op.axis = axis
op.side = 'POSITIVE'
# 6 - RIGHT
pie.separator()
# 2 - BOTTOM
pie.operator("transform.flatten_to_origin", text="Origin Y", icon='OBJECT_ORIGIN').axis = 'Y'
# 8 - TOP
pie.operator("transform.flatten_to_center", text="Center Y", icon='ORIENTATION_GLOBAL').axis = 'Y'
# 7 - TOP - LEFT
pie.operator("transform.flatten_to_center", text="Center X", icon='ORIENTATION_GLOBAL').axis = 'X'
# 9 - TOP - RIGHT
pie.operator("transform.flatten_to_center", text="Center Z", icon='ORIENTATION_GLOBAL').axis = 'Z'
# 1 - BOTTOM - LEFT
pie.operator("transform.flatten_to_origin", text="Origin X", icon='OBJECT_ORIGIN').axis = 'X'
# 3 - BOTTOM - RIGHT
pie.operator("transform.flatten_to_origin", text="Origin Z", icon='OBJECT_ORIGIN').axis = 'Z'
class TRANSFORM_OT_flatten_to_center(Operator):
"""Flatten selection along a global axis"""
bl_idname = "transform.flatten_to_center"
bl_label = "Flatten to Center"
bl_options = {'REGISTER', 'UNDO'}
axis: EnumProperty(
name="Axis",
items=(
('X', "X", "X Axis"),
('Y', "Y", "Y Axis"),
('Z', "Z", "Z Axis"),
),
description="Choose an axis for alignment",
default='X',
)
@classmethod
def poll(cls, context):
obj = context.active_object
return obj and obj.type == "MESH"
def execute(self, context):
values = {
'X': [(0, 1, 1), (True, False, False)],
'Y': [(1, 0, 1), (False, True, False)],
'Z': [(1, 1, 0), (False, False, True)],
}
chosen_value = values[self.axis][0]
constraint_value = values[self.axis][1]
bpy.ops.transform.resize(
value=chosen_value,
constraint_axis=constraint_value,
orient_type='GLOBAL',
mirror=False,
use_proportional_edit=False,
)
return {'FINISHED'}
class TRANSFORM_OT_flatten_to_object_origin(Operator):
"""Flatten selection to the object's origin"""
bl_idname = "transform.flatten_to_origin"
bl_label = "Flatten to Origin"
bl_options = {'REGISTER', 'UNDO'}
axis: EnumProperty(
name="Axis",
items=(
('X', "X", "X Axis"),
('Y', "Y", "Y Axis"),
('Z', "Z", "Z Axis"),
),
description="Choose an axis for alignment",
default='X',
)
@classmethod
def poll(cls, context):
obj = context.active_object
return obj and obj.type == "MESH"
def execute(self, context):
bpy.ops.object.mode_set(mode='OBJECT')
index = "XYZ".find(self.axis)
for vert in bpy.context.object.data.vertices:
if vert.select:
vert.co[index] = 0
bpy.ops.object.editmode_toggle()
return {'FINISHED'}
class TRANSFORM_OT_flatten_to_selection_bounding_box(Operator):
"""Flatten to bounding box of the selection"""
bl_idname = "transform.flatten_to_selection_bounding_box"
bl_label = "Flatten to Selection Bounding Box"
bl_options = {'REGISTER', 'UNDO'}
axis: EnumProperty(
name="Axis",
items=(
('X', "X", "X Axis"),
('Y', "Y", "Y Axis"),
('Z', "Z", "Z Axis"),
),
description="Choose an axis for alignment",
default='X',
)
side: EnumProperty(
name="Side",
items=[
('POSITIVE', "Front", "Align on the positive chosen axis"),
('NEGATIVE', "Back", "Align acriss the negative chosen axis"),
],
description="Choose a side for alignment",
default='POSITIVE',
)
@classmethod
def poll(cls, context):
obj = context.active_object
return obj and obj.type == "MESH"
def execute(self, context):
bpy.ops.object.mode_set(mode='OBJECT')
count = 0
axis_idx = "XYZ".find(self.axis)
for vert in bpy.context.object.data.vertices:
if vert.select:
if count == 0:
maxv = vert.co[axis_idx]
count += 1
continue
count += 1
if self.side == 'POSITIVE':
if vert.co[axis_idx] > maxv:
maxv = vert.co[axis_idx]
else:
if vert.co[axis_idx] < maxv:
maxv = vert.co[axis_idx]
bpy.ops.object.mode_set(mode='OBJECT')
for vert in bpy.context.object.data.vertices:
if vert.select:
vert.co[axis_idx] = maxv
bpy.ops.object.mode_set(mode='EDIT')
return {'FINISHED'}
registry = [
PIE_MT_mesh_flatten,
TRANSFORM_OT_flatten_to_center,
TRANSFORM_OT_flatten_to_object_origin,
TRANSFORM_OT_flatten_to_selection_bounding_box,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Mesh",
pie_name=PIE_MT_mesh_flatten.bl_idname,
hotkey_kwargs={'type': "X", 'value': "PRESS", 'alt': True},
on_drag=False,
)
@@ -0,0 +1,69 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_mesh_merge(Menu):
bl_idname = "PIE_MT_mesh_merge"
bl_label = "Mesh Merge"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('mesh.remove_doubles', text="By Distance", icon='PROP_ON')
# 6 - RIGHT
pie.operator('mesh.merge', text="At Center", icon='SNAP_FACE_CENTER').type = (
'CENTER'
)
# 2 - BOTTOM
op = pie.operator('mesh.merge', text="Collapse", icon='FULLSCREEN_EXIT')
op.type = 'COLLAPSE'
# 8 - TOP
pie.separator()
# The implementation of Blender's built-in Merge operator is weird as hell:
# If no geo is selected, the "At First" and "At Last" options cannot be drawn,
# even though in such a case, NONE of the options will do ANYTHING anyways.
# But it is what it is, I choose to be consistent.
try:
# This will raise an error if the option isn't available.
op.type = 'FIRST'
# 7 - TOP - LEFT
pie.operator(
'mesh.merge', text="At First", icon='TRACKING_REFINE_BACKWARDS'
).type = 'FIRST'
# 9 - TOP - RIGHT
pie.operator(
'mesh.merge', text="At Last", icon='TRACKING_REFINE_FORWARDS'
).type = 'LAST'
except:
op.type = 'COLLAPSE'
pie.separator()
pie.separator()
# 1 - BOTTOM - LEFT
pie.separator()
# 3 - BOTTOM - RIGHT
pie.operator('mesh.merge', text="At 3D Cursor", icon='PIVOT_CURSOR').type = (
'CURSOR'
)
registry = [PIE_MT_mesh_merge]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Mesh",
pie_name=PIE_MT_mesh_merge.bl_idname,
hotkey_kwargs={'type': "M", 'value': "PRESS"},
on_drag=False,
)
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Requested by users here: https://projects.blender.org/extensions/space_view3d_pie_menus/issues/67
from bpy.types import Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_object_add(Menu):
bl_idname = "PIE_MT_object_add"
bl_label = "Add Object"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('mesh.primitive_uv_sphere_add', icon='MESH_UVSPHERE', text="UV Sphere")
# 6 - RIGHT
pie.operator('mesh.primitive_cube_add', icon='MESH_CUBE', text="Cube")
# 2 - BOTTOM
pie.operator('mesh.primitive_monkey_add', icon='MESH_MONKEY', text="Suzanne")
# 8 - TOP
pie.operator('mesh.primitive_plane_add', icon='MESH_PLANE', text="Plane")
# 1 - BOTTOM - LEFT
pie.operator('mesh.primitive_cylinder_add', icon='MESH_CYLINDER', text="Cylinder")
# 3 - BOTTOM - RIGHT
pie.operator('mesh.primitive_circle_add', icon='MESH_CIRCLE', text="Circle")
pie.operator('curve.primitive_bezier_curve_add', icon='CURVE_BEZCURVE', text="Bezier Curve")
pie.menu('VIEW3D_MT_add', text="More...", icon='THREE_DOTS')
registry = [PIE_MT_object_add]
def register():
# TODO: Shouldn't this also work in Mesh Edit mode?
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Object Mode",
pie_name=PIE_MT_object_add.bl_idname,
hotkey_kwargs={'type': "A", 'value': "PRESS", 'shift': True, 'ctrl': True},
on_drag=False,
)
@@ -0,0 +1,151 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_object_display(Menu):
bl_idname = "PIE_MT_object_display"
bl_label = "Object Display"
@classmethod
def poll(cls, context):
return context.active_object
def draw(self, context):
pie = self.layout.menu_pie()
obj = context.active_object
# 4 - LEFT
pie.prop(obj, 'show_in_front', icon='XRAY')
# 6 - RIGHT
if obj.type in ('MESH', 'CURVE'):
pie.prop(obj, 'show_wire', text="Wireframe", icon='SHADING_WIRE')
else:
pie.separator()
# 2 - BOTTOM
pie.prop_menu_enum(obj, 'display_type', text="Display Mode", icon='THREE_DOTS')
# 8 - TOP
if obj.type in ('MESH', 'CURVE'):
box = pie.box().column(align=True)
box.label(text="Color")
row = box.row(align=True)
row.prop(obj, 'color', text="")
row.operator('view3d.copy_property_to_selected', text="", icon='LOOP_FORWARDS').rna_path='color'
else:
pie.separator()
# 7 - TOP - LEFT
if obj.type == 'ARMATURE':
pie.prop(obj.data, 'show_names', text="Bone Names", icon='FILE_TEXT')
else:
pie.prop(obj, 'show_name', text="Object Name", icon='FILE_TEXT')
# 9 - TOP - RIGHT
col = pie.box().column(align=True)
col.label(text="Visible:")
icon = 'RESTRICT_VIEW_ON' if obj.hide_viewport else 'RESTRICT_VIEW_OFF'
col.prop(obj, 'hide_viewport', text="Viewport", icon=icon, invert_checkbox=obj.hide_viewport)
icon = 'RESTRICT_RENDER_ON' if obj.hide_render else 'RESTRICT_RENDER_OFF'
col.prop(obj, 'hide_render', text="Render", icon=icon, invert_checkbox=obj.hide_render)
# 1 - BOTTOM - LEFT
if obj.type == 'ARMATURE':
pie.prop(obj.data, 'show_axes', text="Bone Axes", icon='EMPTY_AXIS')
else:
pie.prop(obj, 'show_axis', icon='EMPTY_AXIS')
# 3 - BOTTOM - RIGHT
if obj.type in ('MESH', 'CURVE'):
pie.menu('OBJECT_MT_set_object_shading', icon='THREE_DOTS')
elif obj.type == 'ARMATURE':
pie.prop_menu_enum(obj.data, 'display_type', text="Bone Display", icon='THREE_DOTS')
elif obj.type == 'EMPTY':
pie.prop_menu_enum(obj, 'empty_display_type', icon='THREE_DOTS')
else:
pie.separator()
class OBJECT_MT_set_object_shading(Menu):
bl_idname = "OBJECT_MT_set_object_shading"
bl_label = "Shading Operators"
def draw(self, context):
layout = self.layout
layout.operator('object.shade_flat', icon='MESH_UVSPHERE')
layout.operator('object.shade_smooth', icon='NODE_MATERIAL')
if context.active_object.type == 'MESH':
layout.operator('object.add_weighted_normals', icon='MOD_NORMALEDIT')
layout.operator('object.shade_auto_smooth', icon='MODIFIER')
layout.separator()
layout.operator('OBJECT_OT_reset_normals', icon='LOOP_BACK')
class OBJECT_OT_add_weighted_normals(Operator):
"""Add a Weighted Normal modifier to all selected meshes that don't already have one"""
bl_idname = "object.add_weighted_normals"
bl_label = "Weighted Normals"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
for obj in context.selected_objects:
if obj.type != 'MESH':
continue
if any([m.type == 'WEIGHTED_NORMAL' for m in obj.modifiers]):
continue
obj.modifiers.new(name="WeightedNormal", type='WEIGHTED_NORMAL')
return {'FINISHED'}
class OBJECT_OT_reset_normals(Operator):
"""Reset mesh normals by clearing custom split normals if any, recalculating mesh normals, and setting all faces to smooth shading"""
bl_idname = "object.reset_normals"
bl_label = "Reset Normals"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
for obj in context.selected_objects:
if obj.type != 'MESH':
continue
with context.temp_override(active_object=obj, object=obj):
bpy.ops.mesh.customdata_custom_splitnormals_clear()
obj.data.shade_smooth()
org_mode = context.active_object.mode
if org_mode != 'EDIT':
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.reveal()
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent()
if context.active_object.mode != org_mode:
bpy.ops.object.mode_set(mode=org_mode)
return {'FINISHED'}
registry = [
PIE_MT_object_display,
OBJECT_MT_set_object_shading,
OBJECT_OT_add_weighted_normals,
OBJECT_OT_reset_normals,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="3D View",
pie_name=PIE_MT_object_display.bl_idname,
hotkey_kwargs={'type': "W", 'value': "PRESS", 'shift': True},
on_drag=False,
)
@@ -0,0 +1,657 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.props import BoolProperty, EnumProperty
from mathutils import Matrix
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class OBJECT_MT_parenting_pie(Menu):
bl_label = 'Object Parenting'
bl_idname = 'OBJECT_MT_parenting_pie'
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# <
# Clear Parent (Keep Transform)
pie.operator(
OBJECT_OT_clear_parent.bl_idname,
text="Clear Parent",
icon='X',
).keep_transform = True
# >
# Set Parent (Keep Transform)
pie.operator(OBJECT_OT_parent_set_simple.bl_idname, icon='CON_CHILDOF')
# v
pie.separator()
# ^
pie.separator()
# <^
# Clear Parent
pie.operator(
OBJECT_OT_clear_parent.bl_idname,
text="Clear Parent (Without Correction)",
icon='UNLINKED',
).keep_transform = False
# ^>
# Set Parent (Advanced)
pie.operator(OBJECT_OT_parent_set_advanced.bl_idname, icon='CON_CHILDOF')
# <v
# Clear Inverse
pie.operator(
OBJECT_OT_clear_parent_inverse_matrix.bl_idname,
text="Clear Offset Correction",
icon='DRIVER_DISTANCE',
)
# v>
pie.separator()
def selected_objs_with_parents(context):
return [ob for ob in context.selected_objects if ob.parent]
class OBJECT_OT_clear_parent(Operator):
"""Clear the parent of selected objects"""
bl_idname = "object.parent_clear_py"
bl_label = "Clear Parent"
bl_options = {'REGISTER', 'UNDO'}
keep_transform: BoolProperty(
name="Keep Transform",
description="Whether to preserve the object's world-space transforms by affecting its local space transforms",
)
@classmethod
def description(cls, context, properties):
if properties.keep_transform:
return "Clear the parent of selected objects, while preserving their position in space"
else:
return "Clear the parent of selected objects, without affecting their Loc/Rot/Scale values. This may cause the now parentless children to change position"
@classmethod
def poll(cls, context):
if not selected_objs_with_parents(context):
cls.poll_message_set("No selected objects have parents.")
return False
return set_parent_poll_check_linked(cls, context)
def execute(self, context):
objs = selected_objs_with_parents(context)
op_type = 'CLEAR_KEEP_TRANSFORM' if self.keep_transform else 'CLEAR'
bpy.ops.object.parent_clear(type=op_type)
# Report what was done.
objs_str = objs[0].name if len(objs) == 1 else f"{len(objs)} objects"
self.report({'INFO'}, f"Cleared parent of {objs_str}")
return {'FINISHED'}
class OBJECT_OT_clear_parent_inverse_matrix(Operator):
"""Preserve the parenting relationship, but clear the correction offset, such that when the child's transforms are reset, it will move to the same position as the parent"""
bl_idname = "object.parent_clear_inverse_matrix_py"
bl_label = "Clear Parent Inverse Matrix"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
objs_with_parents = selected_objs_with_parents(context)
if not objs_with_parents:
cls.poll_message_set("No selected objects have parents.")
return False
identity_matrix = Matrix.Identity(4)
if not any(
[obj.matrix_parent_inverse != identity_matrix for obj in objs_with_parents]
):
cls.poll_message_set("No selected objects have a parenting offset set.")
return False
return set_parent_poll_check_linked(cls, context)
def execute(self, context):
objs = selected_objs_with_parents(context)
bpy.ops.object.parent_clear(type='CLEAR_INVERSE')
# Report what was done.
objs_str = objs[0].name if len(objs) == 1 else f"{len(objs)} objects"
self.report({'INFO'}, f"Cleared parenting offset of {objs_str}")
return {'FINISHED'}
class OBJECT_OT_parent_set_simple(Operator):
"""Parent the selected objects to the active one, while preserving their position in space"""
bl_idname = "object.parent_set_simple"
bl_label = "Set Parent"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not len(context.selected_objects) > 1 and context.active_object:
cls.poll_message_set(
"Only one object is selected. You can't parent an object to itself."
)
return False
return set_parent_poll_check_linked(cls, context)
def execute(self, context):
parent_ob = context.active_object
objs_to_parent = [obj for obj in context.selected_objects if obj != parent_ob]
try:
bpy.ops.object.parent_set(type='OBJECT', keep_transform=True)
except Exception as exc:
self.report({'ERROR'}, str(exc))
return {'CANCELLED'}
# Report what was done.
objs_str = (
objs_to_parent[0].name
if len(objs_to_parent) == 1
else f"{len(objs_to_parent)} objects"
)
self.report({'INFO'}, f"Parented {objs_str} to {parent_ob.name}")
return {'FINISHED'}
class OBJECT_OT_parent_set_advanced(Operator):
"""Parent selected objects to the active one"""
bl_idname = "object.parent_set_advanced"
bl_label = "Set Parent (Advanced)"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not len(context.selected_objects) > 1 and context.active_object:
cls.poll_message_set(
"Only one object is selected. You can't parent an object to itself."
)
return False
return set_parent_poll_check_linked(cls, context)
def get_parent_method_items(self, context):
parent_ob = context.active_object
items = [
(
'OBJECT',
"Object",
"Use simple object parenting, with no additional behaviours",
'OBJECT_DATAMODE',
),
(
'CONSTRAINT',
"Constraint",
"Create a constraint-based parenting set-up",
'CONSTRAINT',
),
]
if parent_ob.type == 'ARMATURE':
items.append(
(
'MODIFIER',
"Armature Modifier",
"Add an Armature Modifier to the children, so they are deformed by this armature",
'MODIFIER',
)
)
if parent_ob.data.bones.active:
items.append(
(
'BONE_RELATIVE',
"Bone: " + parent_ob.data.bones.active.name,
"""Parent to the armature's active bone. This option is deprecated, please set the "Parent Method" to "Constraint", and choose the Armature Constraint""",
'BONE_DATA',
)
)
elif parent_ob.type == 'CURVE':
items.append(
(
'MODIFIER',
"Curve Modifier",
"Add a Curve Modifier to the children, so they are deformed by this curve",
'MODIFIER',
)
)
items.append(
(
'FOLLOW',
"(Legacy) Follow Path",
"""Animate the curve's Evaluation Time, causing all children to move along the path over time. This option is deprecated, please set the "Parent Method" to "Constraint", and choose the Follow Path Constraint""",
'CURVE_DATA',
)
)
elif parent_ob.type == 'LATTICE':
items.append(
(
'MODIFIER',
"Lattice Modifier",
"Add a Lattice Modifier to the children, so they are deformed by this lattice",
'MODIFIER',
)
)
if parent_ob.type in ('MESH', 'CURVE', 'LATTICE'):
items.append(
(
'VERTEX',
"Nearest Point",
"Parent to the nearest point (mesh vertex, lattice point, curve point) of the target object",
'VERTEXSEL',
)
)
items.append(
(
'VERTEX_TRI',
"Nearest Triangle",
"Parent to the nearest 3 points of the target object",
'MESH_DATA',
)
)
return [
(item[0], item[1], item[2], item[3], idx) for idx, item in enumerate(items)
]
parent_method: EnumProperty(
name="Parent Method",
description="Type of parenting behaviour",
items=get_parent_method_items,
default=0,
)
def get_constraint_type_items(self, context):
items = [
(
'COPY_TRANSFORMS',
"Copy Transforms",
"In addition to Object parenting, add a Copy Transforms constraint to the children, which snaps and locks them to the parent in world space",
'CON_TRANSLIKE',
0,
),
(
'CHILD_OF',
"Child Of",
"Instead of Object parenting, add a Child Of constraint to the children. This preserves the child's world-space transforms as well as their Loc/Rot/Scale values, by storing a parenting offset in the constraint itself",
'CON_CHILDOF',
1,
),
]
if context.active_pose_bone:
items.append(
(
'ARMATURE',
"Armature",
"In addition to Object parenting, add an Armature constraint to the children. The child objects will only follow the parent bone when it is moved in Pose Mode, but not when it is moved in Edit Mode",
'CON_ARMATURE',
2,
)
)
elif context.active_object.type == 'CURVE':
items.append(
(
'FOLLOW_PATH',
"Follow Path",
"Instead of parenting, add a Follow Path constraint to the children, and animate the curve's Evaluation Time, causing the constrained objects to follow the curve's path over time",
'CON_FOLLOWPATH',
2,
)
)
return items
constraint_type: EnumProperty(
name="Constraint Type",
description="What type of Constraint to use for parenting",
items=get_constraint_type_items,
)
vgroup_init_method: EnumProperty(
name="Initialize Vertex Groups",
items=[
(
'NONE',
"None",
"Do not initialize vertex groups on the child meshes",
'BLANK1',
0,
),
(
'EMPTY_GROUPS',
"Empty Groups",
"On all child meshes, generate empty vertex groups for each deforming bone of the parent Armature",
'GROUP_VERTEX',
1,
),
(
'PROXIMITY_WEIGHTS',
"Auto-Weights",
"On all child meshes, generate Vertex Groups for the parent Armature's deforming bones, based on the mesh surface's proximity to each bone",
'ARMATURE_DATA',
2,
),
(
'ENVELOPE_WEIGHTS',
"Auto-Weights (From Envelopes)",
"On all child meshes, generate vertex groups using the parent Armature's bone envelopes",
'OUTLINER_OB_ARMATURE',
3,
),
],
)
transform_correction: EnumProperty(
name="Keep Transform",
description="This parent is transformed. This will result in the child moving by the same amount when the relationship is created",
items=[
(
'NONE',
"None",
"Simply create the parenting relationship, even if it may cause the children to be moved",
'UNPINNED',
0,
),
(
'MATRIX_LOCAL',
"Normal",
"After creating the relationship, snap the children back to their original positions. This will affect their Loc/Rot/Scale values",
'PINNED',
1,
),
(
'MATRIX_INTERNAL',
"Magic Offset",
"The child will not move and its Loc/Rot/Scale values won't be affected, even if the parent is transformed. This is done by storing an internal parenting offset value",
'SHADERFX',
2,
),
],
default='MATRIX_LOCAL',
)
def invoke(self, context, _event):
parent_ob = context.active_object
if parent_ob.type == 'ARMATURE':
self.parent_method = 'MODIFIER'
return context.window_manager.invoke_props_dialog(self, width=400)
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
parent_ob = context.active_object
layout.prop(self, 'parent_method')
if self.parent_method == 'CONSTRAINT':
layout.prop(self, 'constraint_type', icon='CONSTRAINT')
elif self.parent_method == 'MODIFIER':
if parent_ob.type == 'ARMATURE':
layout.prop(self, 'vgroup_init_method', icon='GROUP_VERTEX')
if self.parent_method == 'CONSTRAINT' and self.constraint_type != 'ARMATURE':
# Skip drawing transform_correction for constraint types where
# it's irrelevant.
return
if parent_ob.matrix_world != Matrix.Identity(4):
# If the parent has any world-space transforms, we offer corrections
# for that to be applied to the children, so they don't move.
layout.prop(self, 'transform_correction')
def execute(self, context):
parent_ob = context.active_object
keep_transform = self.transform_correction == 'MATRIX_INTERNAL'
objs_to_parent = [obj for obj in context.selected_objects if obj != parent_ob]
matrix_backups = [
(obj.matrix_world.copy(), obj.matrix_local.copy()) for obj in objs_to_parent
]
op_parent_method = self.parent_method
if self.parent_method == 'MODIFIER':
if parent_ob.type == 'ARMATURE':
if self.vgroup_init_method == 'NONE':
op_parent_method = 'ARMATURE'
elif self.vgroup_init_method == 'EMPTY_GROUPS':
op_parent_method = 'ARMATURE_NAME'
elif self.vgroup_init_method == 'ENVELOPE_WEIGHTS':
op_parent_method = 'ARMATURE_ENVELOPE'
elif self.vgroup_init_method == 'PROXIMITY_WEIGHTS':
op_parent_method = 'ARMATURE_AUTO'
elif parent_ob.type == 'LATTICE':
op_parent_method = 'LATTICE'
elif parent_ob.type == 'CURVE':
op_parent_method = 'CURVE'
elif self.parent_method == 'CONSTRAINT':
if self.constraint_type == 'FOLLOW_PATH':
op_parent_method = 'PATH_CONST'
elif self.constraint_type == 'ARMATURE':
return self.parent_with_arm_con(context, keep_transform)
elif self.constraint_type == 'CHILD_OF':
return self.parent_with_child_of_con(context)
elif self.constraint_type == 'COPY_TRANSFORMS':
return self.parent_with_copy_transforms_con(context)
try:
bpy.ops.object.parent_set(
type=op_parent_method, keep_transform=keep_transform
)
except Exception as exc:
self.report({'ERROR'}, str(exc))
return {'CANCELLED'}
if self.transform_correction != 'MATRIX_INTERNAL':
for obj in objs_to_parent:
obj.matrix_parent_inverse = Matrix.Identity(4)
if self.transform_correction == 'MATRIX_LOCAL':
for obj, matrices in zip(objs_to_parent, matrix_backups):
# Find expected world matrix of this object:
# Parent world matrix @ inverted self inverse matrix @ basis matrix
obj.matrix_local = matrices[1]
obj.matrix_world = matrices[0]
# Report what was done.
objs_str = (
objs_to_parent[0].name
if len(objs_to_parent) == 1
else f"{len(objs_to_parent)} objects"
)
self.report({'INFO'}, f"Parented {objs_str} to {parent_ob.name}")
return {'FINISHED'}
def parent_with_arm_con(self, context, keep_transform):
"""Parent selected objects to the active bone."""
rig = context.active_object
active_bone = rig.data.bones.active
if not active_bone:
self.report({'ERROR'}, "No active bone.")
return {'CANCELLED'}
try:
bpy.ops.object.parent_set(type='OBJECT', keep_transform=keep_transform)
except Exception as exc:
self.report({'ERROR'}, str(exc))
return {'CANCELLED'}
objs_to_parent = [obj for obj in context.selected_objects if obj != rig]
for obj in objs_to_parent:
arm_con = None
for con in obj.constraints:
if con.type == 'ARMATURE':
con.targets.clear()
arm_con = con
break
if not arm_con:
arm_con = obj.new(type='ARMATURE')
target = arm_con.targets.new()
target.target = rig
target.subtarget = active_bone.name
# Draw a nice info message.
objs_str = (
obj.name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
)
self.report({'INFO'}, f"Constrained {objs_str} to {active_bone.name}")
return {'FINISHED'}
def parent_with_child_of_con(self, context):
"""Parent selected bones to the active object/bone using Child Of constraints."""
parent_ob = context.active_object
objs_to_parent = [obj for obj in context.selected_objects if obj != parent_ob]
for obj in objs_to_parent:
if obj.parent:
self.report(
{'WARNING'},
f"Warning: A Child Of Constraint was added to {obj.name}, which already has a parent. This results in double-parenting. Existing parent should be removed!",
)
for con in obj.constraints:
if con.type == 'CHILD_OF':
self.report(
{'WARNING'},
f"Warning: A Child Of Constraint was added to {obj.name}, which already had a Child Of constraint. This results in double-parenting. Existing constraint should be removed!",
)
childof = obj.constraints.new(type='CHILD_OF')
childof.target = parent_ob
if context.active_pose_bone:
childof.subtarget = context.active_pose_bone.name
childof.inverse_matrix = (
parent_ob.matrix_world @ context.active_pose_bone.matrix
).inverted()
else:
childof.inverse_matrix = parent_ob.matrix_world.inverted()
# Draw a nice info message.
objs_str = (
obj.name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
)
parent_str = (
context.active_pose_bone.name
if context.active_pose_bone
else parent_ob.name
)
self.report({'INFO'}, f"Constrained {objs_str} to {parent_str}")
return {'FINISHED'}
def parent_with_copy_transforms_con(self, context):
"""Parent selected bones to the active object/bone using Copy Transforms constraints."""
parent_ob = context.active_object
objs_to_parent = [obj for obj in context.selected_objects if obj != parent_ob]
for obj in objs_to_parent:
copytrans = obj.constraints.new(type='COPY_TRANSFORMS')
copytrans.target = parent_ob
if context.active_pose_bone:
copytrans.subtarget = context.active_pose_bone.name
# Draw a nice info message.
objs_str = (
obj.name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
)
parent_str = (
context.active_pose_bone.name
if context.active_pose_bone
else parent_ob.name
)
self.report({'INFO'}, f"Constrained {objs_str} to {parent_str}")
return {'FINISHED'}
def set_parent_poll_check_linked(cls, context):
if any([ob.library for ob in context.selected_objects]):
cls.poll_message_set(
"An object is linked. You need to override it to change the parenting."
)
return False
if any([ob.override_library and ob.override_library.is_system_override for ob in context.selected_objects]):
cls.poll_message_set(
"An object is overridden but not editable. You need to make it an editable override to change the parenting."
)
return False
return True
### Header Menu UI
def draw_new_header_menu(self, context):
layout = self.layout
# Set Parent
layout.operator(OBJECT_OT_parent_set_simple.bl_idname, icon='CON_CHILDOF')
# Set Parent (Advanced)
layout.operator(OBJECT_OT_parent_set_advanced.bl_idname, icon='CON_CHILDOF')
layout.separator()
# <
# Clear Parent (Keep Transform)
layout.operator(
OBJECT_OT_clear_parent.bl_idname,
text="Clear Parent",
icon='X',
).keep_transform = True
# Clear Parent (Without Correction)
layout.operator(
OBJECT_OT_clear_parent.bl_idname,
text="Clear Parent (Without Correction)",
icon='UNLINKED',
).keep_transform = False
# Clear Offset Correction (aka Clear Inverse Matrix)
layout.operator(
OBJECT_OT_clear_parent_inverse_matrix.bl_idname,
text="Clear Offset Correction",
icon='DRIVER_DISTANCE',
)
registry = [
OBJECT_MT_parenting_pie,
OBJECT_OT_clear_parent,
OBJECT_OT_clear_parent_inverse_matrix,
OBJECT_OT_parent_set_simple,
OBJECT_OT_parent_set_advanced,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Object Mode",
pie_name=OBJECT_MT_parenting_pie.bl_idname,
hotkey_kwargs={'type': "P", 'value': "PRESS"},
on_drag=False,
)
bpy.types.VIEW3D_MT_object_parent.draw_old = bpy.types.VIEW3D_MT_object_parent.draw
bpy.types.VIEW3D_MT_object_parent.draw = draw_new_header_menu
def unregister():
bpy.types.VIEW3D_MT_object_parent.draw = bpy.types.VIEW3D_MT_object_parent.draw_old
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_preferences(Menu):
bl_idname = "PIE_MT_preferences"
bl_label = "Preferences"
def draw(self, context):
layout = self.layout
prefs = context.preferences
pie = layout.menu_pie()
# 4 - LEFT
pie.operator("wm.read_userpref", text="Revert to Saved Prefs", icon='LOOP_BACK')
# 6 - RIGHT
pie.operator("wm.save_userpref", text="Save User Preferences", icon='FILE_TICK')
# 2 - BOTTOM
pie.separator()
# 8 - TOP
pie.separator()
# 7 - TOP - LEFT
pie.operator(
"wm.read_factory_userpref",
text="Load Factory Preferences",
icon='RECOVER_LAST',
)
# 9 - TOP - RIGHT
pie.operator("wm.save_homefile", text="Save Startup File", icon='FILE_NEW')
# 1 - BOTTOM - LEFT
pie.operator("wm.read_factory_settings", text="Load Factory Startup & Prefs", icon='RECOVER_LAST')
# 3 - BOTTOM - RIGHT
pie.prop(
prefs,
"use_preferences_save",
text="Auto-Save Preferences",
icon='NONE',
)
registry = [
PIE_MT_preferences,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Window",
pie_name=PIE_MT_preferences.bl_idname,
hotkey_kwargs={'type': "U", 'value': "PRESS", 'ctrl': True},
on_drag=False,
)
@@ -0,0 +1,155 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu, Operator
from bpy.props import StringProperty
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_proportional_edit(Menu):
bl_idname = "PIE_MT_proportional_edit"
bl_label = "Proportional Edit"
def draw(self, context):
ts = context.tool_settings
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator(
"object.set_proportional_falloff", text="Smooth", icon='SMOOTHCURVE'
).falloff_shape = 'SMOOTH'
# 6 - RIGHT
if context.mode == 'OBJECT':
pie.menu(
"PIE_MT_proportional_edit_obj_more", text="More...", icon='THREE_DOTS'
)
else:
pie.menu(
"PIE_MT_proportional_edit_mesh_more", text="More...", icon='THREE_DOTS'
)
# 2 - BOTTOM
pie.operator(
"object.set_proportional_falloff", text="Sharp", icon='SHARPCURVE'
).falloff_shape = 'SHARP'
if context.mode == 'OBJECT':
# 8 - TOP
pie.prop(ts, "use_proportional_edit_objects", text="Toggle Proportional")
# 7 - TOP - LEFT
pie.operator(
"object.set_proportional_falloff", text="Root", icon='ROOTCURVE'
).falloff_shape = 'ROOT'
# 9 - TOP - RIGHT
pie.operator(
"object.set_proportional_falloff",
text="Inverse Square",
icon='INVERSESQUARECURVE',
).falloff_shape = 'INVERSE_SQUARE'
else:
# 8 - TOP
pie.prop(
ts, "use_proportional_edit", text="Toggle Proportional", icon='PROP_ON'
)
# 7 - TOP - LEFT
pie.prop(
ts,
"use_proportional_connected",
text="Toggle Connected",
icon='PROP_CON',
)
# 9 - TOP - RIGHT
pie.prop(
ts,
"use_proportional_projected",
text="Toggle Projected",
icon='PROP_PROJECTED',
)
# 1 - BOTTOM - LEFT
pie.operator(
"object.set_proportional_falloff", text="Linear", icon='LINCURVE'
).falloff_shape = 'LINEAR'
# 3 - BOTTOM - RIGHT
pie.operator(
"object.set_proportional_falloff", text="Sphere", icon='SPHERECURVE'
).falloff_shape = 'SPHERE'
class PIE_MT_proportional_edit_mesh_more(Menu):
bl_idname = "PIE_MT_proportional_edit_mesh_more"
bl_label = "More Falloff Shapes"
def draw(self, context):
layout = self.layout
layout.operator(
"object.set_proportional_falloff", text="Root", icon='ROOTCURVE'
).falloff_shape = 'ROOT'
layout.operator(
"object.set_proportional_falloff",
text="Inverse Square",
icon='INVERSESQUARECURVE',
).falloff_shape = 'INVERSE_SQUARE'
layout.operator(
"object.set_proportional_falloff", text="Constant", icon='NOCURVE'
).falloff_shape = 'CONSTANT'
layout.operator(
"object.set_proportional_falloff", text="Random", icon='RNDCURVE'
).falloff_shape = 'RANDOM'
class PIE_MT_proportional_edit_obj_more(Menu):
bl_idname = "PIE_MT_proportional_edit_obj_more"
bl_label = "More Falloff Shapes"
def draw(self, context):
layout = self.layout
layout.operator(
"object.set_proportional_falloff", text="Constant", icon='NOCURVE'
).falloff_shape = 'CONSTANT'
layout.operator(
"object.set_proportional_falloff", text="Random", icon='RNDCURVE'
).falloff_shape = 'RANDOM'
class OBJECT_OT_set_proportional_falloff(Operator):
"""Enable proportional editing and set the falloff curve"""
bl_idname = "object.set_proportional_falloff"
bl_label = "Set Proportional Editing Falloff"
bl_options = {'REGISTER', 'UNDO'}
falloff_shape: StringProperty()
def execute(self, context):
ts = context.tool_settings
if context.mode == 'OBJECT':
ts.use_proportional_edit_objects = True
else:
ts.use_proportional_edit = True
ts.proportional_edit_falloff = self.falloff_shape
return {'FINISHED'}
registry = (
PIE_MT_proportional_edit,
PIE_MT_proportional_edit_mesh_more,
PIE_MT_proportional_edit_obj_more,
OBJECT_OT_set_proportional_falloff,
)
def register():
for km_name, proportional_name in [('Object Mode', 'use_proportional_edit_objects'), ('Mesh', 'use_proportional_edit')]:
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name=km_name,
pie_name=PIE_MT_proportional_edit.bl_idname,
hotkey_kwargs={'type': "O", 'value': "PRESS"},
default_fallback_op='wm.context_toggle',
default_fallback_kwargs={"data_path": f"scene.tool_settings.{proportional_name}"},
)
@@ -0,0 +1,611 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from typing import Any
import bpy
from bpy.types import Object, Collection, Operator, Menu, ID
from bpy.props import StringProperty, CollectionProperty
from bpy_extras import id_map_utils
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class OUTLINER_MT_relationship_pie(Menu):
bl_label = 'Object Relationships'
bl_idname = 'OUTLINER_MT_relationship_pie'
@classmethod
def poll(cls, context):
if context.area.type == 'VIEW_3D':
return True
if context.area.type == 'OUTLINER':
return bool(context.id)
return False
def draw(self, context):
layout = self.layout
active_id = get_active_id(context)
pie = layout.menu_pie()
# 4 - LEFT
if context.area.type == 'VIEW_3D':
pie.operator(
OBJECT_OT_unlink_from_scene.bl_idname,
icon='TRASH',
text="Unlink From Scene",
)
else:
pie.operator(
'outliner.id_operation', icon='TRASH', text='Unlink From Scene'
).type = 'UNLINK'
# 6 - RIGHT
if context.area.type == 'VIEW_3D':
op = pie.operator('object.delete', icon='X', text="Delete From File")
op.use_global = True
op.confirm = False
elif any([type(id) in (bpy.types.Object, bpy.types.Collection) for id in context.selected_ids]):
# NOTE: Objects and other types of IDs cannot be deleted with the same operator, strangely enough.
# If we wanted to support that, we could implement a wrapper operator, but it's a very rare case, so this feels fine.
pie.operator('outliner.delete', icon='X', text='Delete From File')
else:
pie.operator('outliner.id_operation', icon='X', text="Delete IDs From File").type='DELETE'
# 2 - BOTTOM
pie.operator('outliner.pie_purge_orphans', icon='ORPHAN_DATA')
# 8 - TOP
id = get_active_id(context)
if id:
remap = pie.operator(
'outliner.remap_users_ui', icon='FILE_REFRESH', text="Remap Users"
)
remap.id_type = id.id_type
remap.id_name_source = id.name
if id.library:
remap.library_path_source = id.library.filepath
else:
pie.separator()
# 7 - TOP - LEFT
pie.operator(OUTLINER_OT_list_users_of_datablock.bl_idname, icon='LOOP_BACK')
# 9 - TOP - RIGHT
pie.operator(
OUTLINER_OT_list_dependencies_of_datablock.bl_idname, icon='LOOP_FORWARDS'
)
# 1 - BOTTOM - LEFT
if context.area.type == 'OUTLINER' and active_id:
pie.operator(
'outliner.id_operation',
text="Unlink From Collection",
icon="OUTLINER_COLLECTION",
).type = 'UNLINK'
else:
pie.separator()
# 3 - BOTTOM - RIGHT
if context.area.type == 'OUTLINER' and active_id:
pie.operator(
'outliner.delete', text="Delete Hierarchy", icon="OUTLINER"
).hierarchy = True
else:
pie.operator(
OBJECT_OT_instancer_empty_to_collection.bl_idname, icon='LINKED'
)
class OUTLINER_OT_pie_purge_orphans(Operator):
"""Clear all orphaned data-blocks without any users from the file"""
bl_idname = "outliner.pie_purge_orphans"
bl_label = "Purge Unused Data"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
"""Call Blender's purge function, but in a way that works from any editor, not only the 3D View."""
orig_ui = None
for area in context.screen.areas:
if area.type == 'VIEW_3D':
break
else:
orig_ui = context.area.ui_type
context.area.ui_type = 'VIEW_3D'
area = context.area
with context.temp_override(area=area):
ret = bpy.ops.outliner.orphans_purge(
do_local_ids=True, do_linked_ids=True, do_recursive=True
)
if orig_ui:
context.area.ui_type = orig_ui
return ret
class OBJECT_OT_unlink_from_scene(Operator):
"""Unlink selected objects or collections from the current scene"""
bl_idname = "object.unlink_from_scene"
bl_label = "Unlink Selected From Scene"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if context.area.type == 'OUTLINER':
any_selected = bool(
get_objects_to_unlink(context) or get_collections_to_unlink(context)
)
elif context.area.type == 'VIEW_3D':
any_selected = bool(get_objects_to_unlink(context))
else:
any_selected = False
if not any_selected:
cls.poll_message_set("Nothing is selected.")
return any_selected
def execute(self, context):
unlink_collections_from_scene(get_collections_to_unlink(context), context.scene)
unlink_objects_from_scene(get_objects_to_unlink(context), context.scene)
return {'FINISHED'}
class RelationshipOperatorMixin:
datablock_name: StringProperty()
datablock_storage: StringProperty()
library_filepath: StringProperty()
def get_datablock(self, context) -> ID | None:
if self.datablock_name and self.datablock_storage:
storage = getattr(bpy.data, self.datablock_storage)
lib_path = self.library_filepath or None
return storage.get((self.datablock_name, lib_path))
elif context.area.type == 'OUTLINER':
return context.id
else:
return context.active_object
@classmethod
def poll(cls, context):
id = get_active_id(context)
if context.area.type == 'OUTLINER':
if id == None:
cls.poll_message_set("No active ID.")
return False
return True
elif context.area.type == '3D_VIEW':
if id == None:
cls.poll_message_set("No active object.")
return False
return True
def invoke(self, context, _event):
return context.window_manager.invoke_props_dialog(self, width=600)
def get_datablocks_to_display(self, id: ID) -> list[ID]:
raise NotImplementedError
def get_label(self):
return "Listing datablocks that reference this:"
def draw(self, context):
layout = self.layout
layout.use_property_decorate = False
layout.use_property_split = True
datablock = self.get_datablock(context)
if not datablock:
layout.alert = True
layout.label(
text=f"Failed to find datablock: {self.datablock_storage}, {self.datablock_name}, {self.library_filepath}"
)
return
row = layout.row()
split = row.split()
row = split.row()
row.alignment = 'RIGHT'
row.label(text=self.get_label())
id_row = split.row(align=True)
name_row = id_row.row()
name_row.enabled = False
name_row.prop(datablock, 'name', icon=get_datablock_icon(datablock), text="")
fake_user_row = id_row.row()
fake_user_row.prop(datablock, 'use_fake_user', text="")
layout.separator()
datablocks = self.get_datablocks_to_display(datablock)
if not datablocks:
layout.label(text="There are none.")
return
for user in self.get_datablocks_to_display(datablock):
if user == datablock:
# Scenes are users of themself for technical reasons,
# I think it's confusing to display that.
continue
row = layout.row()
name_row = row.row()
name_row.enabled = False
name_row.prop(user, 'name', icon=get_datablock_icon(user), text="")
op_row = row.row()
op = op_row.operator(type(self).bl_idname, text="", icon='LOOP_FORWARDS')
op.datablock_name = user.name
storage = get_id_storage_by_type_str(user.id_type)[1]
if not storage:
print("Error: Can't find storage: ", user.name, user.id_type)
op.datablock_storage = storage
if user.library:
op.library_filepath = user.library.filepath
name_row.prop(
user.library,
'filepath',
icon=get_library_icon(user.library.filepath),
text="",
)
def execute(self, context):
return {'FINISHED'}
class OUTLINER_OT_list_users_of_datablock(RelationshipOperatorMixin, Operator):
"""Show list of users of this datablock"""
bl_idname = "object.list_datablock_users"
bl_label = "List Datablock Users"
datablock_name: StringProperty()
datablock_storage: StringProperty()
library_filepath: StringProperty()
def get_datablocks_to_display(self, datablock: ID) -> list[ID]:
user_map = bpy.data.user_map()
users = user_map[datablock]
return sorted(users, key=lambda u: (str(type(u)), u.name))
class OUTLINER_OT_list_dependencies_of_datablock(RelationshipOperatorMixin, Operator):
"""Show list of dependencies of this datablock"""
bl_idname = "object.list_datablock_dependencies"
bl_label = "List Datablock Dependencies"
def get_label(self):
return "Listing datablocks that are referenced by this:"
def get_datablocks_to_display(self, datablock: ID) -> list[ID]:
dependencies = id_map_utils.get_id_reference_map().get(datablock)
if not dependencies:
return []
return sorted(dependencies, key=lambda u: (str(type(u)), u.name))
class RemapTarget(bpy.types.PropertyGroup):
pass
class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
"""Remap users of a selected ID to any other ID of the same type"""
bl_idname = "outliner.remap_users_ui"
bl_label = "Remap Users"
bl_options = {'INTERNAL', 'UNDO'}
def update_library_path(self, context):
# Prepare the ID selector.
remap_targets = context.scene.remap_targets
remap_targets.clear()
source_id = get_id(self.id_name_source, self.id_type, self.library_path_source)
for id in get_id_storage_by_type_str(self.id_type)[0]:
if id == source_id:
continue
if (self.library_path == 'Local Data' and not id.library) or (
id.library and (self.library_path == id.library.filepath)
):
id_entry = remap_targets.add()
id_entry.name = id.name
library_path: StringProperty(
name="Library",
description="Library path, if we want to remap to a linked ID",
update=update_library_path,
)
id_type: StringProperty(description="ID type, eg. 'OBJECT' or 'MESH'")
library_path_source: StringProperty()
id_name_source: StringProperty(
name="Source ID Name", description="Name of the ID we're remapping the users of"
)
id_name_target: StringProperty(
name="Target ID Name", description="Name of the ID we're remapping users to"
)
def invoke(self, context, _event):
# Populate the remap_targets string list with possible options based on
# what was passed to the operator.
assert (
self.id_type and self.id_name_source
), "Error: UI must provide ID and ID type to this operator."
# Prepare the library selector.
remap_target_libraries = context.scene.remap_target_libraries
remap_target_libraries.clear()
local = remap_target_libraries.add()
local.name = "Local Data"
source_id = get_id(self.id_name_source, self.id_type, self.library_path_source)
for lib in bpy.data.libraries:
for id in lib.users_id:
if type(id) == type(source_id):
lib_entry = remap_target_libraries.add()
lib_entry.name = lib.filepath
break
self.library_path = "Local Data"
if source_id.name[-4] == ".":
storage = get_id_storage_by_type_str(self.id_type)[0]
suggestion = storage.get(source_id.name[:-4])
if suggestion:
self.id_name_target = suggestion.name
if suggestion.library:
self.library_path = suggestion.library.filepath
return context.window_manager.invoke_props_dialog(self, width=600)
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
scene = context.scene
row = layout.row()
id = get_id(self.id_name_source, self.id_type, self.library_path_source)
id_icon = get_datablock_icon(id)
split = row.split()
split.row().label(text="Anything that was referencing this:")
row = split.row()
row.prop(self, 'id_name_source', text="", icon=id_icon)
row.enabled = False
layout.separator()
col = layout.column()
col.label(text="Will now reference this instead: ")
if len(scene.remap_target_libraries) > 1:
col.prop_search(
self,
'library_path',
scene,
'remap_target_libraries',
icon=get_library_icon(self.library_path),
)
col.prop_search(
self,
'id_name_target',
scene,
'remap_targets',
text="Datablock",
icon=id_icon,
)
def execute(self, context):
source_id = get_id(self.id_name_source, self.id_type, self.library_path_source)
target_id = get_id(self.id_name_target, self.id_type, self.library_path)
assert source_id and target_id, "Error: Failed to find source or target."
source_id.user_remap(target_id)
return {'FINISHED'}
class OBJECT_OT_instancer_empty_to_collection(Operator):
"""Replace the Empty that instances a collection, with the collection itself"""
bl_idname = "object.instancer_empty_to_collection"
bl_label = "Instancer Empty To Collection"
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
obj = context.active_object
if context.area.ui_type == 'OUTLINER':
obj = context.id
if not (
obj
and obj.type == 'EMPTY'
and obj.instance_type == 'COLLECTION'
and obj.instance_collection
):
cls.poll_message_set("Active object is not an instancer empty.")
return False
if obj.instance_collection in set(context.scene.collection.children):
cls.poll_message_set(
f'Collection "{obj.instance_collection.name}" is already in the scene.'
)
return False
return True
def execute(self, context):
obj = context.active_object
if context.area.ui_type == 'OUTLINER':
obj = context.id
coll = obj.instance_collection
bpy.data.objects.remove(obj)
context.scene.collection.children.link(coll)
self.report({'INFO'}, f'Added "{coll.name}" directly to the scene.')
return {'FINISHED'}
def get_objects_to_unlink(context) -> list[Object]:
if context.area.type == 'OUTLINER':
selected_objs = [id for id in context.selected_ids if type(id) == Object]
elif context.area.type == 'VIEW_3D':
selected_objs = context.selected_objects
scene_objs = set(context.scene.objects)
return [ob for ob in selected_objs if ob in scene_objs]
def unlink_objects_from_scene(objects, scene):
for obj in objects:
for coll in [scene.collection] + scene.collection.children_recursive:
if obj.name in coll.objects:
coll.objects.unlink(obj)
def get_collections_to_unlink(context) -> list[Collection]:
if context.area.type == 'OUTLINER':
return [id for id in context.selected_ids if type(id) == Collection]
return []
def unlink_collections_from_scene(collections_to_unlink, scene):
for coll_to_unlink in collections_to_unlink:
for coll in scene.collection.children_recursive:
if coll_to_unlink.name in coll.children:
coll.children.unlink(coll_to_unlink)
def get_active_id(context) -> ID | None:
if context.area.ui_type == 'OUTLINER':
return context.id
else:
return context.active_object
# List of datablock type information tuples:
# (type_class, type_enum_string, bpy.data.<collprop_name>)
def get_id_info() -> list[tuple[type, str, str]]:
bpy_prop_collection = type(bpy.data.objects)
id_info = []
for prop_name in dir(bpy.data):
prop = getattr(bpy.data, prop_name)
if type(prop) == bpy_prop_collection:
if len(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
typ = type(prop[0])
if issubclass(typ, bpy.types.NodeTree):
typ = bpy.types.NodeTree
id_info.append((typ, prop[0].id_type, prop_name))
return id_info
def get_id_storage_by_type_str(typ_name: str):
for typ, typ_str, container_str in get_id_info():
if typ_str == typ_name:
return getattr(bpy.data, container_str), container_str
def get_datablock_types_enum_items() -> list[tuple[str, str, str, str, int]]:
"""Return the items needed to define an EnumProperty representing a datablock type selector."""
enum_items = bpy.types.DriverTarget.bl_rna.properties['id_type'].enum_items
ret = []
for i, typ in enumerate(enum_items):
ret.append((typ.identifier, typ.name, typ.name, typ.icon, i))
ret.append(('SCREEN', 'Screen', 'Screen', 'RESTRICT_VIEW_OFF', len(ret)))
ret.append(('METABALL', 'Metaball', 'Metaball', 'OUTLINER_OB_META', len(ret)))
ret.append(('CACHE_FILE', 'Cache File', 'Cache File', 'MOD_MESHDEFORM', len(ret)))
ret.append(
(
'POINT_CLOUD',
'Point Cloud',
'Point Cloud',
'OUTLINER_OB_POINTCLOUD',
len(ret),
)
)
ret.append(
('HAIR_CURVES', 'Hair Curves', 'Hair Curves', 'OUTLINER_OB_CURVES', len(ret))
)
ret.append(('PAINT_CURVE', 'Paint Curve', 'Paint Curve', 'FORCE_CURVE', len(ret)))
ret.append(('MOVIE_CLIP', 'Movie Clip', 'Movie Clip', 'FILE_MOVIE', len(ret)))
return ret
# List of 5-tuples that can be used to define the items of an EnumProperty.
ID_TYPE_ENUM_ITEMS: list[tuple[str, str, str, str, int]] = (
get_datablock_types_enum_items()
)
# Map datablock type enum strings to their name and icon strings.
ID_TYPE_INFO: dict[str, tuple[str, str]] = {
tup[0]: (tup[1], tup[3]) for tup in ID_TYPE_ENUM_ITEMS
}
def get_datablock_icon(id) -> str:
id_type = get_fundamental_id_type(id)[1]
return ID_TYPE_INFO[id_type][1]
def get_fundamental_id_type(datablock: ID) -> tuple[Any, str]:
"""Certain datablocks have very specific types.
This function should return their fundamental type, ie. parent class."""
id_info = get_id_info()
for typ, typ_str, _container_str in id_info:
if isinstance(datablock, typ):
return typ, typ_str
raise Exception(
f"Failed to get fundamental ID type of ID: {datablock.name}, {type(datablock)}"
)
def get_id(id_name: str, id_type: str, lib_path="") -> ID:
container = get_id_storage_by_type_str(id_type)[0]
if lib_path and lib_path != 'Local Data':
return container.get((id_name, lib_path))
return container.get((id_name, None))
def get_library_icon(lib_path: str) -> str:
"""Return the library or the broken library icon, as appropriate."""
if lib_path == 'Local Data':
return 'FILE_BLEND'
filepath = os.path.abspath(bpy.path.abspath(lib_path))
if not os.path.exists(filepath):
return 'LIBRARY_DATA_BROKEN'
return 'LIBRARY_DATA_DIRECT'
registry = [
OUTLINER_MT_relationship_pie,
OUTLINER_OT_pie_purge_orphans,
OBJECT_OT_unlink_from_scene,
OUTLINER_OT_list_users_of_datablock,
OUTLINER_OT_list_dependencies_of_datablock,
RemapTarget,
OUTLINER_OT_remap_users_ui,
OBJECT_OT_instancer_empty_to_collection,
]
def register():
bpy.types.Scene.remap_targets = CollectionProperty(type=RemapTarget)
bpy.types.Scene.remap_target_libraries = CollectionProperty(type=RemapTarget)
for keymap_name in ("Object Mode", "Outliner"):
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name=keymap_name,
pie_name=OUTLINER_MT_relationship_pie.bl_idname,
hotkey_kwargs={'type': "X", 'value': "PRESS"},
on_drag=False,
)
def unregister():
try:
del bpy.types.Scene.remap_targets
del bpy.types.Scene.remap_target_libraries
except AttributeError:
# Blender Log also implements these same things, which can result in errors.
pass
@@ -0,0 +1,244 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from pathlib import Path
import bpy
from bpy.types import Menu
from bl_ui.properties_paint_common import BrushAssetShelf
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_sculpt_brush_select(Menu):
bl_idname = "PIE_MT_sculpt_brush_select"
bl_label = "Sculpt Brush Select"
def draw(self, context):
global brush_icons
layout = self.layout
pie = layout.menu_pie()
pie.scale_y = 1.2
# 4 - LEFT
pie.operator(
'wm.call_menu_pie',
text=" Transform Brushes...",
icon_value=brush_icons['snake_hook'],
).name = PIE_MT_sculpt_brush_select_transform.bl_idname
# 6 - RIGHT
pie.operator(
'wm.call_menu_pie',
text=" Volume Brushes...",
icon_value=brush_icons['blob'],
).name = PIE_MT_sculpt_brush_select_volume.bl_idname
# 2 - BOTTOM
if blender_uses_brush_assets():
sculpt_settings = context.tool_settings.sculpt
brush = sculpt_settings.brush
col = pie.column()
brush_row = col.row()
brush_row.scale_y = 0.75
brush_row.scale_x = 0.15
BrushAssetShelf.draw_popup_selector(
brush_row, context, brush, show_name=False
)
name_row = col.row().box()
if brush:
name_row.label(text=brush.name)
else:
pie.separator()
# 8 - TOP
draw_brush_operator(pie, 'Mask', 'mask')
# 7 - TOP - LEFT
draw_brush_operator(pie, 'Grab', 'grab')
# 9 - TOP - RIGHT
draw_brush_operator(pie, 'Draw', 'draw')
# 1 - BOTTOM - LEFT
pie.operator(
'wm.call_menu_pie',
text=" Contrast Brushes...",
icon_value=brush_icons['flatten'],
).name = PIE_MT_sculpt_brush_select_contrast.bl_idname
# 3 - BOTTOM - RIGHT
pie.operator(
'wm.call_menu_pie',
text=" Special Brushes...",
icon_value=brush_icons['draw_face_sets'],
).name = PIE_MT_sculpt_brush_select_special.bl_idname
class PIE_MT_sculpt_brush_select_contrast(Menu):
bl_idname = "PIE_MT_sculpt_brush_select_contrast"
bl_label = "Contrast Brushes"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
draw_brush_operator(pie, 'Flatten/Contrast', 'flatten')
# 6 - RIGHT
draw_brush_operator(pie, 'Scrape/Fill', 'scrape')
# 2 - BOTTOM
draw_brush_operator(pie, 'Fill/Deepen', 'fill')
# 8 - TOP
draw_brush_operator(pie, 'Scrape Multiplane', 'multiplane_scrape')
# 7 - TOP - LEFT
pie.separator()
# 9 - TOP - RIGHT
pie.separator()
# 1 - BOTTOM - LEFT
draw_brush_operator(pie, 'Smooth', 'smooth')
# 3 - BOTTOM - RIGHT
class PIE_MT_sculpt_brush_select_transform(Menu):
bl_idname = "PIE_MT_sculpt_brush_select_transform"
bl_label = "Transform Brushes"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
draw_brush_operator(pie, 'Elastic Grab', 'elastic_deform')
# 6 - RIGHT
draw_brush_operator(pie, 'Nudge', 'nudge')
# 2 - BOTTOM
draw_brush_operator(pie, 'Relax Slide', 'topology')
# 8 - TOP
draw_brush_operator(pie, 'Snake Hook', 'snake_hook')
# 7 - TOP - LEFT
draw_brush_operator(pie, 'Twist', 'rotate')
# 9 - TOP - RIGHT
draw_brush_operator(pie, 'Pose', 'pose')
# 1 - BOTTOM - LEFT
draw_brush_operator(pie, 'Pinch/Magnify', 'pinch')
# 3 - BOTTOM - RIGHT
draw_brush_operator(pie, 'Thumb', 'thumb')
class PIE_MT_sculpt_brush_select_volume(Menu):
bl_idname = "PIE_MT_sculpt_brush_select_volume"
bl_label = "Volume Brushes"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
draw_brush_operator(pie, 'Blob', 'blob')
# 6 - RIGHT
draw_brush_operator(pie, 'Clay', 'clay')
# 2 - BOTTOM
draw_brush_operator(pie, 'Inflate/Deflate', 'inflate')
# 8 - TOP
draw_brush_operator(pie, 'Draw Sharp', 'draw_sharp')
# 7 - TOP - LEFT
draw_brush_operator(pie, 'Clay Strips', 'clay_strips')
# 9 - TOP - RIGHT
draw_brush_operator(pie, 'Crease Polish', 'crease')
# 1 - BOTTOM - LEFT
draw_brush_operator(pie, 'Clay Thumb', 'clay_thumb')
# 3 - BOTTOM - RIGHT
draw_brush_operator(pie, 'Layer', 'layer')
class PIE_MT_sculpt_brush_select_special(Menu):
bl_idname = "PIE_MT_sculpt_brush_select_special"
bl_label = "Special Brushes"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
draw_brush_operator(pie, 'Grab Cloth', 'cloth')
# 6 - RIGHT
draw_brush_operator(pie, 'Erase Multires Displacement', 'displacement_eraser')
# 2 - BOTTOM
draw_brush_operator(pie, 'Density', 'simplify')
# 8 - TOP
draw_brush_operator(pie, 'Paint Soft', 'paint')
# 7 - TOP - LEFT
draw_brush_operator(pie, 'Smear', 'smear')
# 9 - TOP - RIGHT
draw_brush_operator(pie, 'Face Set Paint', 'draw_face_sets')
# 1 - BOTTOM - LEFT
draw_brush_operator(pie, 'Boundary', 'boundary')
# 3 - BOTTOM - RIGHT
draw_brush_operator(pie, 'Smear Multires Displacement', 'displacement_smear')
def blender_uses_brush_assets():
return 'asset_activate' in dir(bpy.ops.brush)
def draw_brush_operator(layout, brush_name: str, brush_icon: str = ""):
"""Draw a brush select operator in the provided UI element with the pre-4.3 icons.
brush_name must match the name of the Brush Asset.
brush_icon must match the name of a file in this add-on's icons folder.
"""
if blender_uses_brush_assets():
# 4.3
op = layout.operator(
'brush.asset_activate',
text=" " + brush_name,
icon_value=brush_icons.get(brush_icon, 0),
)
op.asset_library_type = 'ESSENTIALS'
op.relative_asset_identifier = os.path.join(
"brushes", "essentials_brushes-mesh_sculpt.blend", "Brush", brush_name
)
else:
# Pre-4.3
if brush_icon:
op = layout.operator(
"paint.brush_select",
text=" " + brush_name,
icon_value=brush_icons[brush_icon],
)
op.sculpt_tool = brush_icon.upper()
else:
layout.separator()
brush_icons = {}
def create_icons():
global brush_icons
icons_directory = Path(__file__).parent / "icons"
for icon_path in icons_directory.iterdir():
icon_value = bpy.app.icons.new_triangles_from_file(icon_path.as_posix())
brush_name = icon_path.stem.split(".")[-1]
brush_icons[brush_name] = icon_value
def release_icons():
global brush_icons
for value in brush_icons.values():
bpy.app.icons.release(value)
registry = (
PIE_MT_sculpt_brush_select,
PIE_MT_sculpt_brush_select_contrast,
PIE_MT_sculpt_brush_select_transform,
PIE_MT_sculpt_brush_select_volume,
PIE_MT_sculpt_brush_select_special,
)
def register():
create_icons()
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name='Sculpt',
pie_name=PIE_MT_sculpt_brush_select.bl_idname,
hotkey_kwargs={'type': "W", 'value': "PRESS"},
on_drag=False,
)
def unregister():
release_icons()
@@ -0,0 +1,680 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import re
import bpy
from bpy.types import Menu, Operator, Constraint, UILayout, Object
from bpy.props import BoolProperty, StringProperty
from bpy.utils import flip_name
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
from .pie_camera import get_current_camera
class PIE_MT_object_selection(Menu):
bl_idname = "PIE_MT_object_selection"
bl_label = "Object Select"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator("object.select_parent_object", text="Parent", icon='FILE_PARENT')
# 6 - RIGHT
pie.operator("object.select_children_of_active", text=f"Direct Children", icon='CON_CHILDOF')
# 2 - BOTTOM
pie.operator("object.select_all", text="Deselect All", icon='OUTLINER_DATA_POINTCLOUD').action='DESELECT'
# 8 - TOP
pie.operator("object.select_all", text="Select All", icon='OUTLINER_OB_POINTCLOUD').action='SELECT'
# 7 - TOP - LEFT
pie.operator("object.select_all", text="Invert Selection", icon='CLIPUV_DEHLT').action='INVERT'
# 9 - TOP - RIGHT
pie.operator("object.select_siblings_of_active", text="Siblings", icon='PARTICLES')
# 1 - BOTTOM - LEFT
text = "Active Camera"
cam = get_current_camera(context)
if cam:
text += f" ({cam.name})"
pie.operator("object.select_active_camera", text=text, icon='OUTLINER_OB_CAMERA')
# 3 - BOTTOM - RIGHT
pie.menu("PIE_MT_object_selection_more", text="Select Menu", icon='THREE_DOTS')
class PIE_MT_object_selection_more(Menu):
bl_idname = "PIE_MT_object_selection_more"
bl_label = "More Object Select"
def draw(self, context):
layout = self.layout
# Modal selection operators.
layout.separator()
layout.operator("view3d.select_circle", text="Circle Select", icon='MESH_CIRCLE')
layout.operator("view3d.select_box", text="Box Select", icon='SELECT_SET')
layout.operator_menu_enum("view3d.select_lasso", "mode", icon='MOD_CURVE')
# Select based on the active object.
layout.separator()
layout.operator_menu_enum("object.select_grouped", "type", text="Select Grouped", icon='OUTLINER_COLLECTION')
layout.operator_menu_enum("object.select_linked", "type", text="Select Linked", icon='DUPLICATE')
# Select based on parameters.
layout.separator()
layout.operator_menu_enum("object.select_by_type", "type", text="Select All by Type", icon='OUTLINER_OB_MESH')
layout.operator("object.select_random", text="Select Random", icon='RNDCURVE')
layout.operator("object.select_pattern", text="Select Pattern...", icon='FILTER')
layout.operator('object.select_by_name_search', icon='VIEWZOOM')
layout.separator()
layout.menu("VIEW3D_MT_select_any_camera", text="Select Camera", icon='OUTLINER_OB_CAMERA')
class VIEW3D_MT_select_any_camera(Menu):
bl_idname = "VIEW3D_MT_select_any_camera"
bl_label = "Select Camera"
def draw(self, context):
layout = self.layout
active_cam = get_current_camera(context)
all_cams = [obj for obj in sorted(context.scene.objects, key=lambda o: o.name) if obj.type == 'CAMERA']
for cam in all_cams:
icon = 'OUTLINER_DATA_CAMERA'
if cam == active_cam:
icon = 'OUTLINER_OB_CAMERA'
layout.operator('object.select_object_by_name', text=cam.name, icon=icon).obj_name=cam.name
### Object relationship selection operators.
def get_selected_parents(context) -> list:
"""Return objects which are selected or active, and have children.
Active object is always first."""
objects = context.selected_objects or [context.active_object]
parents = [o for o in objects if o.children]
if context.active_object and context.active_object in parents:
idx = parents.index(context.active_object)
if idx > -1:
parents.pop(idx)
parents.insert(0, context.active_object)
return parents
def deselect_all_objects(context):
for obj in context.selected_objects:
obj.select_set(False)
class ObjectSelectOperatorMixin:
extend_selection: BoolProperty(
name="Extend Selection",
description="Objects that are already selected will remain selected",
)
def invoke(self, context, event):
if event.shift:
self.extend_selection = True
else:
self.extend_selection = False
return self.execute(context)
@classmethod
def poll(cls, context):
if context.mode != 'OBJECT':
cls.poll_message_set("Must be in Object Mode.")
return False
return True
def execute(self, context):
if not self.extend_selection:
deselect_all_objects(context)
return {'FINISHED'}
class OBJECT_OT_select_children_of_active(Operator, ObjectSelectOperatorMixin):
"""Select the children of the active object"""
bl_idname = "object.select_children_of_active"
bl_label = "Select Children"
bl_options = {'REGISTER', 'UNDO'}
recursive: BoolProperty(default=False, options={'SKIP_SAVE'})
@classmethod
def poll(cls, context):
if not super().poll(context):
return False
obj = context.active_object
if not (obj and obj.children):
cls.poll_message_set("Active object has no children.")
return False
return True
@classmethod
def description(cls, context, props):
recursively = " recursively" if props.recursive else ""
return f"Select children of active object{recursively}." + "\n\nShift: Extend current selection"
def execute(self, context):
super().execute(context)
counter = 0
children = context.active_object.children
if self.recursive:
children = context.active_object.children_recursive
for child in children:
if not child.select_get():
counter += 1
child.select_set(True)
self.report({'INFO'}, f"Selected {counter} objects.")
return {'FINISHED'}
class OBJECT_OT_select_siblings_of_active(Operator, ObjectSelectOperatorMixin):
"""Select siblings of active object.\n\nShift: Extend current selection"""
bl_idname = "object.select_siblings_of_active"
bl_label = "Select Siblings"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not super().poll(context):
return False
obj = context.active_object
if not (obj and obj.parent):
cls.poll_message_set("Active object has no parent.")
return False
if not len(obj.parent.children) > 1:
cls.poll_message_set("Active object has no siblings.")
return False
return True
def execute(self, context):
super().execute(context)
counter = 0
for child in context.active_object.parent.children:
if not child.select_get():
counter += 1
child.select_set(True)
self.report({'INFO'}, f"Selected {counter} objects.")
return {'FINISHED'}
class OBJECT_OT_select_parent_object(Operator, ObjectSelectOperatorMixin):
"""Select parent of the active object.\n\nShift: Extend current selection"""
bl_idname = "object.select_parent_object"
bl_label = "Select Parent Object"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not super().poll(context):
return False
obj = context.active_object
if not (obj and obj.parent):
cls.poll_message_set("Active object has no parent.")
return False
return True
def execute(self, context):
super().execute(context)
active_obj = context.active_object
active_obj.parent.select_set(True)
context.view_layer.objects.active = active_obj.parent
return {'FINISHED'}
class OBJECT_OT_select_active_camera(Operator, ObjectSelectOperatorMixin):
"""Select the active camera of the scene or viewport"""
bl_idname = "object.select_active_camera"
bl_label = "Select Active Camera"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
cam = get_current_camera(context)
if not cam:
cls.poll_message_set("No active camera.")
return False
if not cam.visible_get():
cls.poll_message_set("Camera is hidden, it cannot be selected.")
return False
return True
def execute(self, context):
super().execute(context)
cam = get_current_camera(context)
if context.active_object and context.mode != 'MODE':
bpy.ops.object.mode_set(mode='OBJECT')
cam.select_set(True)
context.view_layer.objects.active = cam
return {'FINISHED'}
### Object name-based selection operators.
class PIE_MT_select_object_name_relation(Menu):
bl_idname = 'PIE_MT_select_object_name_relation'
bl_label = "Select by Name"
@classmethod
def poll(cls, context):
return context.active_object
def draw(self, context):
layout = self.layout
active_obj = context.active_object
pie = layout.menu_pie()
# 4 - LEFT
pie.operator(
OBJECT_OT_select_symmetry_object.bl_idname,
text="Flip Selection",
icon='MOD_MIRROR',
)
# 6 - RIGHT
pie.operator('object.select_by_name_search', icon='VIEWZOOM')
# 2 - BOTTOM
lower_obj = context.scene.objects.get(increment_name(active_obj.name, increment=-1))
if lower_obj:
op = pie.operator('object.select_object_by_name', text=lower_obj.name, icon='TRIA_DOWN')
op.obj_name = lower_obj.name
else:
pie.separator()
# 8 - TOP
higher_obj = context.scene.objects.get(increment_name(active_obj.name, increment=1)) or context.scene.objects.get(active_obj.name + ".001")
if higher_obj:
op = pie.operator('object.select_object_by_name', text=higher_obj.name, icon='TRIA_UP')
op.obj_name = higher_obj.name
else:
pie.separator()
# 7 - TOP - LEFT
constraints = OBJECT_MT_PIE_constrained_objects.get_dependent_constraints(context)
if len(constraints) == 1:
draw_select_constraint_owner(pie, constraints[0])
elif len(constraints) > 1:
pie.menu('OBJECT_MT_PIE_constrained_objects', icon='THREE_DOTS')
else:
pie.separator()
# 9 - TOP - RIGHT
constraints = OBJECT_MT_PIE_obj_constraint_targets.get_constraints_with_target(context)
if len(constraints) == 1:
draw_select_constraint_target(pie, constraints[0])
elif len(constraints) > 1:
pie.menu('OBJECT_MT_PIE_obj_constraint_targets', icon='THREE_DOTS')
else:
pie.separator()
# 1 - BOTTOM - LEFT
pie.operator("object.select_pattern", text=f"Select Pattern...", icon='FILTER')
# 3 - BOTTOM - RIGHT
pie.separator()
class OBJECT_OT_select_symmetry_object(Operator, ObjectSelectOperatorMixin):
"""Select opposite objects.\n\nShift: Extend current selection"""
bl_idname = "object.select_opposite"
bl_label = "Select Opposite Object"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
scene_obs = context.scene.objects
sel_obs = context.selected_objects[:]
active_obj = context.active_object
if not active_obj:
cls.poll_message_set("No active object.")
return False
flipped_objs = [scene_obs.get(flip_name(ob.name)) for ob in sel_obs]
flipped_active = scene_obs.get(flip_name(active_obj.name))
if not (flipped_active or any(flipped_objs)):
cls.poll_message_set("No selected objects with corresponding opposite objects.")
return False
return True
def execute(self, context):
scene_obs = context.scene.objects
sel_obs = context.selected_objects[:]
active_obj = context.active_object
flipped_objs = [scene_obs.get(flip_name(ob.name)) for ob in sel_obs]
flipped_active = scene_obs.get(flip_name(active_obj.name))
notflipped = sum([flipped_obj in (obj, None) for obj, flipped_obj in zip(sel_obs, flipped_objs)])
if notflipped > 0:
self.report({'WARNING'}, f"{notflipped} objects had no opposite.")
super().execute(context)
for ob in flipped_objs:
if not ob:
continue
ob.select_set(True)
if flipped_active:
context.view_layer.objects.active = flipped_active
return {'FINISHED'}
class OBJECT_OT_select_object_by_name_search(Operator, ObjectSelectOperatorMixin):
"""Select an object via a search box"""
bl_idname = "object.select_by_name_search"
bl_label = "Search Object..."
bl_options = {'REGISTER', 'UNDO'}
obj_name: StringProperty(name="Object")
def invoke(self, context, _event):
obj = context.active_object
if obj:
self.obj_name = obj.name
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
layout.prop_search(
self, 'obj_name', context.scene, 'objects', icon='OBJECT_DATA'
)
layout.prop(self, 'extend_selection')
def execute(self, context):
obj = context.scene.objects.get(self.obj_name)
if not self.extend_selection:
deselect_all_objects(context)
obj.select_set(True)
context.view_layer.objects.active = obj
return {'FINISHED'}
class OBJECT_OT_select_object_by_name(Operator, ObjectSelectOperatorMixin):
"""Select this object.\n\nShift: Extend current selection"""
bl_idname = "object.select_object_by_name"
bl_label = "Select Object By Name"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
obj_name: StringProperty(
name="Object Name", description="Name of the object to select"
)
def execute(self, context):
obj = context.scene.objects.get(self.obj_name)
if not obj:
self.report({'ERROR'}, "Object name not found in scene: " + self.obj_name)
return {'CANCELLED'}
if not obj.visible_get():
self.report({'ERROR'}, "Object is hidden, so it cannot be selected.")
return {'CANCELLED'}
super().execute(context)
obj.select_set(True)
context.view_layer.objects.active = obj
return {'FINISHED'}
def increment_name(name: str, increment=1, default_zfill=1) -> str:
# Increment LAST number in the name.
# Negative numbers will be clamped to 0.
# Digit length will be preserved, so 10 will decrement to 09.
# 99 will increment to 100, not 00.
# If no number was found, one will be added at the end of the base name.
# The length of this in digits is set with the `default_zfill` param.
# This is not meant to be able to add a whole .001 suffix at the end of a name,
# although we can account for the inverse of that easily enough.
if name.endswith(".001") and increment==-1:
# Special case.
return name[:-4]
numbers_in_name = re.findall(r'\d+', name)
if not numbers_in_name:
return name + "_" + str(max(0, increment)).zfill(default_zfill)
last = numbers_in_name[-1]
incremented = str(max(0, int(last) + increment)).zfill(len(last))
split = name.rsplit(last, 1)
return incremented.join(split)
class OBJECT_MT_PIE_obj_constraint_targets(Menu):
bl_label = "Constraint Targets"
@staticmethod
def get_constraints_with_target(context):
return [con for con in context.active_object.constraints if hasattr(con, 'target') and con.target in set(context.scene.objects)]
@classmethod
def poll(cls, context):
return cls.get_constraints_with_target(context)
def draw(self, context):
layout = self.layout
for constraint in self.get_constraint_targets(context):
draw_select_constraint_target(layout, constraint)
class OBJECT_MT_PIE_constrained_objects(Menu):
bl_label = "Constrained Objects"
@staticmethod
def get_dependent_constraints(context):
dependent_ids = bpy.data.user_map()[context.active_object]
dependent_objects = [id for id in dependent_ids if type(id)==Object and id in set(context.scene.objects)]
ret = []
for dependent_object in dependent_objects:
for con in dependent_object.constraints:
if hasattr(con, 'target') and con.target == context.active_object:
ret.append(con)
break
return ret
@classmethod
def poll(cls, context):
return cls.get_dependent_constraints(context)
def draw(self, context):
layout = self.layout
for constraint in self.get_dependent_constraints(context):
draw_select_constraint_owner(layout, constraint)
def draw_select_constraint_target(layout, constraint):
icon = get_constraint_icon(constraint)
layout.operator(
'object.select_object_by_name',
text=f"Constraint Target: {constraint.target.name} ({constraint.name})",
icon=icon,
).obj_name=constraint.target.name
def draw_select_constraint_owner(layout, constraint):
icon = get_constraint_icon(constraint)
layout.operator(
'object.select_object_by_name',
text=f"Constrained Object: {constraint.id_data.name} ({constraint.name})",
icon=icon,
).obj_name=constraint.id_data.name
def get_constraint_icon(constraint: Constraint) -> str:
"""We do not ask questions about this function. We accept it."""
if constraint.type == 'ACTION':
return 'ACTION'
icons = UILayout.bl_rna.functions["prop"].parameters["icon"].enum_items.keys()
# This magic number can change between blender versions. Last updated: 4.1.1
constraint_icon_magic_offset = 42
return icons[UILayout.icon(constraint) - constraint_icon_magic_offset]
### Mesh selection UI.
class PIE_MT_mesh_selection(Menu):
bl_idname = "PIE_MT_mesh_selection"
bl_label = "Mesh Select"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator("mesh.select_less", text="Select Less", icon='REMOVE')
# 6 - RIGHT
pie.operator("mesh.select_more", text="Select More", icon='ADD')
# 2 - BOTTOM
pie.operator("mesh.select_all", text="Deselect All", icon='OUTLINER_DATA_POINTCLOUD').action='DESELECT'
# 8 - TOP
pie.operator("mesh.select_all", text="Select All", icon='OUTLINER_OB_POINTCLOUD').action='SELECT'
# 7 - TOP - LEFT
pie.operator("mesh.select_all", text="Invert Selection", icon='CLIPUV_DEHLT').action='INVERT'
# 9 - TOP - RIGHT
pie.operator("mesh.select_linked", text="Select Linked", icon='FILE_3D')
# 1 - BOTTOM - LEFT
pie.operator('wm.call_menu_pie', text='Select Loops...', icon='MOD_WAVE').name='PIE_MT_mesh_selection_loops'
# 3 - BOTTOM - RIGHT
pie.menu("PIE_MT_mesh_selection_more", text="More...", icon='THREE_DOTS')
class PIE_MT_mesh_selection_more(Menu):
bl_idname = "PIE_MT_mesh_selection_more"
bl_label = "Mesh Selection Mode"
def draw(self, context):
layout = self.layout
vert_mode, edge_mode, face_mode = context.tool_settings.mesh_select_mode
# Selection modes.
layout.operator("mesh.select_mode", text="Vertex Select Mode", icon='VERTEXSEL', depress=vert_mode).type = 'VERT'
layout.operator("mesh.select_mode", text="Edge Select Mode", icon='EDGESEL', depress=edge_mode).type = 'EDGE'
layout.operator("mesh.select_mode", text="Face Select Mode", icon='FACESEL', depress=face_mode).type = 'FACE'
# Modal selection operators.
layout.separator()
layout.operator("view3d.select_circle", text="Circle Select", icon='MESH_CIRCLE')
layout.operator("view3d.select_box", text="Box Select", icon='SELECT_SET')
layout.operator_menu_enum("view3d.select_lasso", "mode", icon='MOD_CURVE')
# Select based on what's active.
layout.separator()
layout.menu("VIEW3D_MT_edit_mesh_select_linked", icon='FILE_3D')
layout.menu("VIEW3D_MT_edit_mesh_select_similar", icon='CON_TRANSLIKE')
layout.operator_menu_enum("mesh.select_axis", "axis", text="Select Side", icon='AXIS_SIDE')
layout.operator("mesh.select_nth", text="Checker Deselect", icon='TEXTURE')
# Select based on some parameter.
layout.separator()
layout.menu("VIEW3D_MT_edit_mesh_select_by_trait")
layout.operator("mesh.select_by_attribute", text="Select All By Attribute", icon='GEOMETRY_NODES')
# User-defined GeoNode Select operations.
layout.template_node_operator_asset_menu_items(catalog_path="Select")
class PIE_MT_mesh_selection_loops(Menu):
bl_idname = "PIE_MT_mesh_selection_loops"
bl_label = "Select Loop"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('mesh.loop_multi_select', text="Edge Loops", icon='MOD_WAVE').ring=False
# 6 - RIGHT
pie.operator('mesh.loop_multi_select', text="Edge Rings", icon='CURVES').ring=True
# 2 - BOTTOM
pie.operator('mesh.loop_to_region', text="Loop Inner Region", icon='SHADING_SOLID')
# 8 - TOP
pie.operator('mesh.region_to_loop', text="Boundary Loop", icon='MESH_CIRCLE')
registry = [
PIE_MT_object_selection,
PIE_MT_object_selection_more,
VIEW3D_MT_select_any_camera,
OBJECT_OT_select_children_of_active,
OBJECT_OT_select_siblings_of_active,
OBJECT_OT_select_parent_object,
OBJECT_OT_select_active_camera,
PIE_MT_select_object_name_relation,
OBJECT_OT_select_symmetry_object,
OBJECT_OT_select_object_by_name_search,
OBJECT_OT_select_object_by_name,
OBJECT_MT_PIE_obj_constraint_targets,
OBJECT_MT_PIE_constrained_objects,
PIE_MT_mesh_selection,
PIE_MT_mesh_selection_more,
PIE_MT_mesh_selection_loops,
]
def draw_edge_sharpness_in_traits_menu(self, context):
self.layout.operator("mesh.edges_select_sharp", text="Edge Sharpness")
def register():
# Weird that this built-in operator is not included in this built-in menu.
bpy.types.VIEW3D_MT_edit_mesh_select_by_trait.prepend(draw_edge_sharpness_in_traits_menu)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name='Object Mode',
pie_name=PIE_MT_object_selection.bl_idname,
hotkey_kwargs={'type': "A", 'value': "PRESS"},
on_drag=True,
default_fallback_op='object.select_all',
default_fallback_kwargs={"action": "TOGGLE"}
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name='Object Mode',
pie_name=PIE_MT_select_object_name_relation.bl_idname,
hotkey_kwargs={'type': "F", 'value': "PRESS", 'ctrl': True},
on_drag=False,
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name='Mesh',
pie_name=PIE_MT_mesh_selection.bl_idname,
hotkey_kwargs={'type': "A", 'value': "PRESS"},
on_drag=True,
default_fallback_op='mesh.select_all',
default_fallback_kwargs={"action": "TOGGLE"}
)
def unregister():
bpy.types.VIEW3D_MT_edit_mesh_select_by_trait.remove(draw_edge_sharpness_in_traits_menu)
@@ -0,0 +1,202 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.props import StringProperty
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_set_origin(Menu):
bl_idname = "PIE_MT_set_origin"
bl_label = "Set Origin"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator(
"object.origin_set_any_mode",
text="Geometry -> Origin",
icon='TRANSFORM_ORIGINS',
).type = 'GEOMETRY_ORIGIN'
# 6 - RIGHT
pie.operator(
"object.origin_set_any_mode",
text="Origin -> Geometry",
icon='SNAP_PEEL_OBJECT',
).type = 'ORIGIN_GEOMETRY'
# 2 - BOTTOM
pie.operator(
"object.origin_set_to_bottom", text="Origin -> Bottom", icon='TRIA_DOWN'
)
# 8 - TOP
pie.operator(
"object.origin_set_to_selection",
text="Origin -> Selection",
icon='RESTRICT_SELECT_OFF',
)
# 7 - TOP - LEFT
pie.operator(
"object.origin_set_any_mode", text="Origin -> Cursor", icon='PIVOT_CURSOR'
).type = 'ORIGIN_CURSOR'
# 9 - TOP - RIGHT
pie.operator(
"object.origin_set_any_mode", text="Origin -> Mass", icon='SNAP_FACE_CENTER'
).type = 'ORIGIN_CENTER_OF_MASS'
# 1 - BOTTOM - LEFT
pie.separator()
# 3 - BOTTOM - RIGHT
pie.operator(
"object.origin_set_any_mode",
text="Origin -> Volume",
icon='SNAP_FACE_CENTER',
).type = 'ORIGIN_CENTER_OF_MASS'
class OBJECT_OT_set_origin_to_selection(Operator):
bl_idname = "object.origin_set_to_selection"
bl_label = "Origin To Selection"
bl_description = "Snap the origin to the selection"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
org_mode = 'OBJECT'
if context.active_object:
org_mode = context.active_object.mode
saved_location = context.scene.cursor.location.copy()
bpy.ops.view3d.snap_cursor_to_selected()
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
bpy.ops.object.mode_set(mode=org_mode)
context.scene.cursor.location = saved_location
self.report({'INFO'}, "Snapped the origin point to the selection.")
return {'FINISHED'}
class OBJECT_OT_set_origin_to_bottom(Operator):
bl_idname = "object.origin_set_to_bottom"
bl_label = "Origin To Bottom"
bl_description = "Apply transforms and set the Object Origin to the centered lowest point of each selected object"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not any(
[ob.type in {'MESH', 'ARMATURE'} for ob in context.selected_objects]
):
cls.poll_message_set("No mesh or armature objects selected.")
return False
return True
def execute(self, context):
org_active_obj = context.active_object
for obj in context.selected_objects:
if obj.type not in ('MESH', 'ARMATURE'):
obj.select_set(False)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY')
counter = 0
for obj in context.selected_objects:
counter += int(self.origin_to_bottom(context, obj))
context.view_layer.objects.active = org_active_obj
self.report(
{'INFO'}, f"Moved the origins of {counter} objects to their lowest point."
)
return {'FINISHED'}
@staticmethod
def origin_to_bottom(context, obj) -> bool:
if obj.type not in {'MESH', 'ARMATURE'}:
return False
org_mode = obj.mode
try:
if obj.type == 'MESH':
bpy.ops.object.mode_set(mode='OBJECT')
min_z = min([v.co.z for v in obj.data.vertices])
elif obj.type == 'ARMATURE':
context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
min_z = min(
[min([bone.head.z, bone.tail.z]) for bone in obj.data.edit_bones]
)
else:
return False
except ValueError:
# min([]) would result in this error, so if the object is empty.
return False
if obj.type == 'MESH':
for vert in obj.data.vertices:
vert.co.z -= min_z
elif obj.type == 'ARMATURE':
for bone in obj.data.edit_bones:
bone.head.z -= min_z
bone.tail.z -= min_z
obj.location.z += min_z
bpy.ops.object.mode_set(mode=org_mode)
return True
class OBJECT_OT_set_origin_any_mode(Operator):
bl_idname = "object.origin_set_any_mode"
bl_label = "Set Origin"
bl_description = "Set the Object Origin"
bl_options = {'REGISTER', 'UNDO'}
type: StringProperty()
messages = {
'GEOMETRY_ORIGIN': "Snapped the geometry to the object's origin.",
'ORIGIN_CURSOR': "Snapped the object's origin to the 3D cursor.",
'ORIGIN_CENTER_OF_MASS': "Snapped the origin to the geometry's center of mass.",
'ORIGIN_CENTER_OF_VOLUME': "Snapped the origin to the geometry's center of volume.",
'ORIGIN_GEOMETRY': "Snapped the origin to the geometry.",
}
@classmethod
def description(cls, context, params):
return cls.messages[params.type].replace("Snapped", "Snap")[:-1]
def execute(self, context):
org_mode = 'OBJECT'
if context.active_object:
org_mode = context.active_object.mode
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.origin_set(type=self.type)
bpy.ops.object.mode_set(mode=org_mode)
self.report({'INFO'}, self.messages[self.type])
return {'FINISHED'}
registry = [
PIE_MT_set_origin,
OBJECT_OT_set_origin_to_selection,
OBJECT_OT_set_origin_to_bottom,
OBJECT_OT_set_origin_any_mode,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name='3D View',
pie_name=PIE_MT_set_origin.bl_idname,
hotkey_kwargs={'type': "X", 'value': "PRESS", 'ctrl': True, 'alt': True},
on_drag=False,
)
@@ -0,0 +1,51 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_view_pie(Menu):
bl_idname = "PIE_MT_view_pie"
bl_label = "3D View"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('view3d.render_border', text="Set Render Border", icon='OBJECT_HIDDEN')
# 6 - RIGHT
pie.operator('view3d.view_selected', text="Frame Selected", icon='ZOOM_SELECTED')
# 2 - BOTTOM
pie.operator('view3d.view_persportho', text="Toggle Orthographic", icon='VIEW_ORTHO')
# 8 - TOP
pie.operator("screen.region_quadview", text="Toggle Quad View", icon='SNAP_VERTEX')
# 7 - TOP - LEFT
pie.operator('view3d.clip_border', text="Toggle Clipping Border", icon='MOD_BEVEL')
# 9 - TOP - RIGHT
pie.operator('view3d.view_all', text="Frame All", icon='VIEWZOOM').center=False
# 1 - BOTTOM - LEFT
pie.operator('view3d.clear_render_border', text="Clear Render Border", icon='X')
# 3 - BOTTOM - RIGHT
space = context.area.spaces.active
if space.local_view:
pie.operator("view3d.localview", text="Leave Local View", icon='SOLO_OFF').frame_selected=False
else:
pie.operator("view3d.localview", text="Enter Local View", icon='SOLO_ON').frame_selected=False
registry = [
PIE_MT_view_pie,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="3D View",
pie_name='PIE_MT_view_pie',
hotkey_kwargs={'type': "C", 'value': "PRESS", 'shift': True},
default_fallback_op='view3d.view_all',
default_fallback_kwargs='{"center":true}',
on_drag=True,
)
@@ -0,0 +1,84 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from sys import platform
import bpy
from bpy.types import Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_window(Menu):
bl_idname = "PIE_MT_window"
bl_label = "Window"
def draw(self, context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('screen.userpref_show', text="Preferences", icon='PREFERENCES')
# 6 - RIGHT
if not context.screen.show_fullscreen:
pie.operator(
"screen.screen_full_area", text="Maximize Area", icon='PIVOT_BOUNDBOX'
)
else:
pie.operator("wm.exit_area_fullscreen", icon='SCREEN_BACK')
# 2 - BOTTOM
pie.prop(context.screen, 'show_statusbar', text="Status Bar")
# 8 - TOP
pie.operator(
"wm.window_fullscreen_toggle",
text="Window Fullscreen",
icon='FULLSCREEN_ENTER',
)
# 7 - TOP - LEFT
pie.operator("screen.area_dupli", text="Pop Out Window", icon='WINDOW')
# 9 - TOP - RIGHT
if not context.screen.show_fullscreen:
pie.operator(
"screen.screen_full_area", text="Fullscreen Area", icon='PIVOT_BOUNDBOX'
).use_hide_panels = True
else:
pie.separator()
# 1 - BOTTOM - LEFT
if platform == 'win32':
pie.operator("wm.console_toggle", icon='CONSOLE')
else:
pie.separator()
# 3 - BOTTOM - RIGHT
pie.operator('screen.area_join', text="Move/Split Area", icon='AREA_DOCK')
class WM_OT_exit_area_fullscreen(Operator):
"""Exit area fullscreen"""
bl_idname = "wm.exit_area_fullscreen"
bl_label = "Exit Area Fullscreen"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
if context.screen.show_fullscreen:
bpy.ops.screen.screen_full_area()
if context.screen.show_fullscreen:
bpy.ops.screen.screen_full_area(use_hide_panels=True)
return {'FINISHED'}
registry = [
PIE_MT_window,
WM_OT_exit_area_fullscreen,
]
def register():
# NOTE: This sadly results in a console warning from wm_gizmo_map.c, but it seems harmless.
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Window",
pie_name='PIE_MT_window',
hotkey_kwargs={'type': "SPACE", 'value': "PRESS", 'ctrl': True},
default_fallback_op='screen.screen_full_area',
)
@@ -0,0 +1,168 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import platform, struct, urllib
import bpy
import addon_utils
from bpy.types import AddonPreferences, Operator, KeyMap, KeyMapItem
from bpy.props import BoolProperty
from bl_ui.space_userpref import USERPREF_PT_interface_menus_pie
from .bs_utils.prefs import PrefsFileSaveLoadMixin, update_prefs_on_file, get_addon_prefs
from .bs_utils.hotkeys import HotkeyDrawMixin, get_sidebar, draw_hotkey_list
class ExtraPies_AddonPrefs(
PrefsFileSaveLoadMixin,
HotkeyDrawMixin,
AddonPreferences,
USERPREF_PT_interface_menus_pie, # We use this class's `draw_centered` function to draw built-in pie settings.
):
bl_idname = __package__
show_in_sidebar: BoolProperty(
name="Show in Sidebar",
default=False,
description="Show a compact version of the preferences in the sidebar. Useful when picking up the add-on for the first time to learn and customize it to your liking",
)
debug: BoolProperty(
name="Debug Mode",
default=False,
description="Enable some debugging UI",
)
def draw(self, context):
draw_prefs(self.layout, context, compact=False)
def draw_prefs(layout, context, compact=False):
prefs = get_addon_prefs(context)
sidebar = get_sidebar(context)
if sidebar:
compact = sidebar.width < 600
layout.use_property_split = True
layout.use_property_decorate = False
if not compact:
col = layout.column()
row = col.row()
row.operator('wm.url_open', text="Report Bug", icon='URL').url = (
get_bug_report_url()
)
row.prop(prefs, 'debug', icon='BLANK1', text="", emboss=False)
col.prop(prefs, 'show_in_sidebar')
header, builtins_panel = layout.panel(idname="Extra Pies Builtin Prefs")
header.label(text="Pie Preferences")
if builtins_panel:
prefs.draw_centered(context, layout)
if not sidebar:
# In the Preferences UI, still draw compact hotkeys list if the window is quite small.
compact = context.area.width < 600
header, hotkeys_panel = layout.panel(idname="Extra Pies Hotkeys")
header.label(text="Hotkeys")
if hotkeys_panel:
hotkeys_panel.operator('window.restore_deleted_hotkeys', icon='KEY_RETURN')
draw_hotkey_list(context, hotkeys_panel, sort_mode='BY_OPERATOR', compact=compact, button_draw_func=button_draw_func)
def button_draw_func(layout, km: KeyMap, kmi: KeyMapItem, compact=False):
"""This function is passed as a callback to draw_hotkey_list, which will in turn call it
with these parameters to draw the key combo UI, where we want to insert a "Drag" button."""
split = layout.split(factor=0.65, align=True)
split.prop(kmi, "type", text="", full_event=True)
if kmi.idname != 'wm.call_menu_pie_drag_only':
return
if not kmi.properties:
sub.label(text="Missing properties. This should never happen!")
return
text = "" if compact else "Drag"
sub = split.row(align=True)
sub.enabled = kmi.active
op = sub.operator(
'wm.toggle_keymap_item_property',
text=text,
icon='MOUSE_MOVE',
depress=kmi.properties.on_drag,
)
op.km_name = km.name
op.kmi_idname = kmi.idname
op.pie_name = kmi.properties.name
op.prop_name = 'on_drag'
def get_bug_report_url():
op_sys = "%s %d Bits\n" % (
platform.platform(),
struct.calcsize("P") * 8,
)
blender_version = "%s, branch: %s, commit: [%s](https://projects.blender.org/blender/blender/commit/%s)\n" % (
bpy.app.version_string,
bpy.app.build_branch.decode('utf-8', 'replace'),
bpy.app.build_commit_date.decode('utf-8', 'replace'),
bpy.app.build_hash.decode('ascii'),
)
addon_ver = ""
for addon_module in addon_utils.modules():
if addon_module.bl_info['name'] == '3D Viewport Pie Menus':
addon_ver = str(addon_module.bl_info['version'])
return (
"https://projects.blender.org/extensions/space_view3d_pie_menus/issues/new?template=.gitea/issue_template/bug.yaml"
+ "&field:body="
+ urllib.parse.quote("Description of the problem: \n\n\nSteps to reproduce: \n\n\nBlend file:")
+ "&field:addon_ver="
+ urllib.parse.quote(addon_ver)
+ "&field:blender_ver="
+ urllib.parse.quote(blender_version)
+ "&field:op_sys="
+ urllib.parse.quote(op_sys)
)
class WINDOW_OT_extra_pies_prefs_save(Operator):
"""Save Extra Pies add-on preferences"""
bl_idname = "window.extra_pies_prefs_save"
bl_label = "Save Pie Hotkeys"
bl_options = {'REGISTER'}
def execute(self, context):
filepath, data = update_prefs_on_file(context)
self.report({'INFO'}, f"Saved Pie Prefs to {filepath}.")
return {'FINISHED'}
class WINDOW_OT_extra_pies_prefs_load(Operator):
"""Load Extra Pies add-on preferences"""
bl_idname = "window.extra_pies_prefs_load"
bl_label = "Load Pie Hotkeys"
bl_options = {'REGISTER'}
def execute(self, context):
prefs = get_addon_prefs(context)
filepath = prefs.get_prefs_filepath()
success = prefs.load_and_apply_prefs_from_file()
if success:
self.report({'INFO'}, f"Loaded pie preferences from {filepath}.")
else:
self.report({'ERROR'}, "Failed to load Pie preferences.")
return {'FINISHED'}
registry = [
ExtraPies_AddonPrefs,
WINDOW_OT_extra_pies_prefs_save,
WINDOW_OT_extra_pies_prefs_load,
]
def register():
ExtraPies_AddonPrefs.register_autoload_from_file()
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .bs_utils.prefs import get_addon_prefs
class VIEW3D_PT_extra_pies(bpy.types.Panel):
bl_label = "Extra Pies"
bl_idname = "VIEW3D_PT_extra_pies"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Extra Pies'
@classmethod
def poll(cls, context):
prefs = get_addon_prefs(context)
return prefs.show_in_sidebar
def draw(self, context):
layout = self.layout
draw_prefs(layout, context, compact=True)
registry = [
VIEW3D_PT_extra_pies,
]
@@ -0,0 +1,67 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .bs_utils.hotkeys import register_hotkey
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
def register():
context = bpy.context
kc_active = context.window_manager.keyconfigs.active
prefs = kc_active.preferences
if prefs and hasattr(prefs, 'spacebar_action'):
# This is a rough way to check if user is on the default "Blender"
# keymap preset, so they actually have preferences.
# In this case, we don't need to give them the built-in pie menus,
# they can configure it themselves thorugh said preferences.
return
print("Extra Pies: Register built-in pies for use with non-standard Keymap.")
# Mode Pie with "Tab for Pie Menu" disabled because it would conflict with Search on Industry Compatible.
# So, it's on Ctrl+Tab.
register_hotkey(
'view3d.object_mode_pie_or_toggle',
hotkey_kwargs={'type': 'TAB', 'value': 'PRESS', 'ctrl': True},
keymap_name='Object Non-modal',
)
# Shading Pie with "Extra Shading Pie Menu Items" enabled but "Pie Menu on Drag" disabled.
register_hotkey(
'wm.call_menu_pie',
op_kwargs={'name': 'VIEW3D_MT_shading_ex_pie'},
hotkey_kwargs={'type': 'Z', 'value': 'PRESS'},
keymap_name='3D View',
)
# View Pie with "Pie Menu on Drag" replicated.
register_hotkey(
'wm.call_menu_pie',
op_kwargs={
'name': 'VIEW3D_MT_view_pie',
},
hotkey_kwargs={'type': 'ACCENT_GRAVE', 'value': 'CLICK_DRAG'},
keymap_name='3D View',
)
register_hotkey(
'view3d.navigate',
hotkey_kwargs={'type': 'ACCENT_GRAVE', 'value': 'CLICK'},
keymap_name='3D View',
)
# Region Toggle Pie with "Pie Menu on Drag"-type functionality,
# so that if tapped, it toggles the sidebar, but if held and dragged,
# summons this pie menu.
# Note that in 4.2, this is actually only a built-in pie with Developer Extras,
# for some reason.
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="3D View Generic",
pie_name='WM_MT_region_toggle_pie',
hotkey_kwargs={'type': 'N', 'value': 'PRESS'},
default_fallback_op='wm.context_toggle',
default_fallback_kwargs={"data_path":"space_data.show_region_ui"},
on_drag=True,
)