2025-12-01
This commit is contained in:
@@ -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,
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user