update addons (for 5.2)

This commit is contained in:
Nathan
2026-07-14 14:44:58 -06:00
parent a232ea1c9c
commit e3310263f3
246 changed files with 21097 additions and 9607 deletions
@@ -2,9 +2,10 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
import bpy
from bpy.utils import register_class, unregister_class
import importlib
_VPM_AGENT_IMMEDIATE_REGISTER_DONE = locals().get("_VPM_AGENT_IMMEDIATE_REGISTER_DONE", False)
@@ -15,16 +16,19 @@ module_names = (
"prefs",
"sidebar",
"tweak_builtin_pies",
"pie_animation",
"pie_playback",
"pie_keyframes",
"pie_copy_attributes",
"pie_apply_transform",
"pie_camera",
"pie_curve_delete",
"pie_preferences",
"pie_editor_split_merge",
"pie_editor_switch",
"pie_file",
"pie_manipulator",
"pie_mesh_delete",
"pie_mesh_edge",
"pie_mesh_flatten",
"pie_mesh_merge",
"pie_object_add",
@@ -33,16 +37,15 @@ module_names = (
"pie_proportional_editing",
"pie_relationship_delete",
"pie_sculpt_brush_select",
"pie_select_similar",
"pie_selection",
"pie_set_origin",
"pie_view_3d",
"pie_window",
)
modules = [
__import__(__package__ + "." + submod, {}, {}, submod)
for submod in module_names
]
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
@@ -71,12 +74,14 @@ def register_unregister_modules(modules: list, register: bool):
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():
"""
We prefer an *immediate* register during startup, because other add-ons may touch
@@ -96,9 +101,10 @@ def register():
# Keep behavior unchanged (fallback to timer), but avoid raising during registration.
pass
# NOTE: persistent=True must be set, otherwise this doesn't work when opening
# 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.0, persistent=True)
def unregister():
register_unregister_modules(reversed(modules), False)
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "viewport_pie_menus"
name = "3D Viewport Pie Menus"
version = "1.7.3"
version = "1.7.4"
tagline = "Various pie menus to speed up your workflow"
maintainer = "Community"
type = "add-on"
@@ -5,10 +5,12 @@
# This file requires (and is made possible by) Blender 5.0 due to the find_match() API call.
import hashlib
from typing import Callable, Any
import json
from typing import Any, Callable
import bpy
import json
from bpy.app.translations import pgettext_n as n_
from bpy.app.translations import pgettext_rpt as rpt_
from bpy.types import KeyMap, KeyMapItem, UILayout
# Preserve across Reload Scripts (module reload) when possible, but also keep
@@ -27,11 +29,14 @@ KEYMAP_ICONS = {
'Armature': 'ARMATURE_DATA',
'Pose': 'POSE_HLT',
'Weight Paint': 'WPAINT_HLT',
'Graph Editor': 'GRAPH',
'Dopesheet': 'ACTION',
'Curve': 'CURVE_DATA',
}
KEYMAP_UI_NAMES = {
'Armature': "Armature Edit",
'Object Non-modal': "Object Mode",
'Armature': n_("Armature Edit"),
'Object Non-modal': n_("Object Mode"),
}
KMI_DEFAULTS = {
@@ -75,9 +80,9 @@ def register_hotkey(
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,
# 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
# 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))
@@ -213,7 +218,7 @@ def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
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,
"""Loop over KeyMapItems of the provided KeyMap, and return the first entry,
if any, which matches the passed key combo, operator, and operator properties.
"""
@@ -369,7 +374,7 @@ class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
def execute(self, context):
num_restored = restore_deleted_keymap_items_global(context)
self.report({'INFO'}, f"Restored {num_restored} deleted keymaps.")
self.report({'INFO'}, rpt_("Restored {num_restored} deleted keymaps.").format(num_restored=num_restored))
return {'FINISHED'}
@@ -175,7 +175,7 @@ def x_mirror_value(value):
if isinstance(value, str):
return flip_name(value)
elif isinstance(value, Object):
get_opposite_obj(value)
return get_opposite_obj(value)
else:
return value
@@ -1,39 +1,35 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import os
from bpy.types import Operator
from bpy.props import StringProperty, BoolProperty
import json
import os
from typing import Any
import bpy
from bpy.props import BoolProperty, StringProperty
from bpy.types import Context, Event, Operator
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.
# 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'}
bl_options = {'INTERNAL'}
def update_kmi(self, context):
def update_kmi(self, context: Context) -> None:
if not hasattr(context, 'keymapitem'):
return
kmi = context.keymapitem # Set via UILayout.context_pointer_set().
kmi = context.keymapitem # Set via UILayout.context_pointer_set().
kmi.type = kmi.type
name: StringProperty(options={'SKIP_SAVE'})
@@ -47,7 +43,7 @@ class WM_OT_call_menu_pie_drag_only(Operator):
fallback_operator: StringProperty(options={'SKIP_SAVE'})
fallback_op_kwargs: StringProperty(default="{}", options={'SKIP_SAVE'})
def invoke(self, context, event):
def invoke(self, context: Context, event: Event) -> set[str]:
if not self.on_drag:
return self.execute(context)
self.init_mouse_x = event.mouse_x
@@ -55,33 +51,30 @@ class WM_OT_call_menu_pie_drag_only(Operator):
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def invoke_fallback_operator(self):
def invoke_fallback_operator(self) -> None:
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.
if isinstance(fallback_op_kwargs, str):
# Not sure why json.loads sometimes returns a string, but it does
# when eg. setting Shift+C to drag and using it on first launch.
# After Reload Scripts it works fine without this. Weird.
fallback_op_kwargs = json.loads(fallback_op_kwargs)
if op_cls.poll():
try:
# 1. Execute the original operator and capture the result
result = op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
# 2. [Added] Check if it's a Save operation and report the message manually
if 'FINISHED' in result and self.fallback_operator == 'wm.save_mainfile':
# Get current filename, or default to untitled
filename = os.path.basename(bpy.data.filepath) if bpy.data.filepath else "untitled.blend"
# HACK: Fix save notification not appearing when tapping Ctrl+S.
filename = os.path.basename(bpy.data.filepath)
self.report({'INFO'}, f'Saved "{filename}"')
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):
def modal(self, context: Context, event: Event) -> set[str]:
if event.value == 'RELEASE':
if self.fallback_operator:
self.invoke_fallback_operator()
@@ -91,10 +84,9 @@ class WM_OT_call_menu_pie_drag_only(Operator):
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):
def execute(self, _context: Context) -> set[str]:
bpy.ops.wm.call_menu_pie(name=self.name)
return {'FINISHED'}
@@ -104,16 +96,16 @@ class WM_OT_call_menu_pie_drag_only(Operator):
*,
keymap_name: str,
pie_name: str,
hotkey_kwargs=None,
default_fallback_op="",
default_fallback_kwargs=None,
on_drag=True,
):
hotkey_kwargs: dict[str, Any] | None = None,
default_fallback_op: str = "",
default_fallback_kwargs: dict[str, Any] | None = None,
on_drag: bool = True,
) -> None:
if hotkey_kwargs is None:
hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"}
context = bpy.context
fallback_operator = default_fallback_op
fallback_op_kwargs = default_fallback_kwargs if default_fallback_kwargs is not None else {}
fallback_op_kwargs: dict[str, Any] = default_fallback_kwargs if default_fallback_kwargs is not None else {}
# IMPORTANT:
# Do NOT derive fallback operator/kwargs from the USER keyconfig.
@@ -137,21 +129,24 @@ class WM_OT_call_menu_pie_drag_only(Operator):
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
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()}
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,
'name': pie_name,
'fallback_operator': fallback_operator,
# Deterministic JSON (sort_keys=True) helps keep KMI identity stable across sessions.
'fallback_op_kwargs': json.dumps(fallback_op_kwargs, sort_keys=True),
'on_drag': on_drag,
@@ -160,10 +155,20 @@ class WM_OT_call_menu_pie_drag_only(Operator):
keymap_name=keymap_name,
)
def register():
def idname_to_op_class(idname: str) -> Any:
parts = idname.split(".")
op = bpy.ops
for part in parts:
op = getattr(op, part)
return op
def register() -> None:
bpy.utils.register_class(WM_OT_call_menu_pie_drag_only)
def unregister():
def unregister() -> None:
# HACK: As a workaround to https://projects.blender.org/blender/blender/issues/150229, we do not unregister
# this operator when the add-on is uninstalled, which is pretty bad.
# bpy.utils.unregister_class(WM_OT_call_menu_pie_drag_only)
@@ -1,9 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.types import Context, Event, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -11,34 +12,26 @@ class PIE_MT_apply_transforms(Menu):
bl_idname = "PIE_MT_apply_transforms"
bl_label = "Apply Transforms"
def draw(self, context):
def draw(self, context: 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 = 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 = 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 = 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 = pie.operator("object.transform_apply", text="Scale", icon='CON_SIZELIKE')
props.location, props.rotation, props.scale = (False, False, True)
# 1 - BOTTOM - LEFT
if (
@@ -49,11 +42,10 @@ class PIE_MT_apply_transforms(Menu):
):
pie.operator('object.instancer_empty_to_collection', icon='LINKED')
else:
pie.operator(
"object.make_single_user", text="Make Single-User", icon='DUPLICATE'
).obdata = True
pie.operator("object.apply_modifiers_shapekeys", icon='MODIFIER_DATA')
# 3 - BOTTOM - RIGHT
pie.menu("PIE_MT_clear_menu", text="Clear Transforms", icon='THREE_DOTS')
pie.menu("PIE_MT_apply_transforms_extras", text="More...", icon='THREE_DOTS')
class OBJECT_OT_apply_transforms_of_constraints(Operator):
@@ -64,18 +56,13 @@ class OBJECT_OT_apply_transforms_of_constraints(Operator):
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
]
):
def poll(cls, context: Context) -> bool:
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):
def execute(self, _context: Context):
# This is just a wrapper operator, to add a better tooltip and poll message.
return bpy.ops.object.visual_transform_apply()
@@ -88,15 +75,13 @@ class OBJECT_OT_make_meshes_single_user(Operator):
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
if not any(
[obj.data and obj.data.users > 1 for obj in context.selected_objects]
):
def poll(cls, context: Context) -> bool:
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):
def execute(self, _context: Context):
# This is just a wrapper operator, to add a better tooltip and poll message.
return bpy.ops.object.duplicates_make_real()
@@ -107,25 +92,63 @@ class OBJECT_OT_clear_all_transforms(Operator):
bl_description = "Clear All Transforms"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, _context: Context):
bpy.ops.object.location_clear()
bpy.ops.object.rotation_clear()
bpy.ops.object.scale_clear()
return {'FINISHED'}
class OBJECT_OT_apply_modifiers_shapekeys(Operator):
"""Apply the object's modifier stack and shape keys. Same as calling "Convert To -> Mesh".
Shift: Keep the original object as a duplicate."""
bl_idname = "object.apply_modifiers_shapekeys"
bl_label = "Apply Modifiers & Shape Keys"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
return bool(context.active_object)
def invoke(self, _context: Context, event: Event):
bpy.ops.object.convert(target='MESH', keep_original=event.shift)
return {'FINISHED'}
class PIE_MT_clear_transforms(Menu):
bl_idname = "PIE_MT_clear_menu"
bl_label = "Clear Transforms"
bl_idname = "PIE_MT_apply_transforms_extras"
bl_label = "Apply Transforms Extras"
def draw(self, context):
def draw(self, _context: 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')
layout.label(text="Clear Transforms")
layout.operator("clear.all", text="Clear All", icon='X')
layout.operator("object.location_clear", text="Clear Location", icon='CON_LOCLIKE')
layout.operator("object.rotation_clear", text="Clear Rotation", icon='CON_ROTLIKE')
layout.operator("object.scale_clear", text="Clear Scale", icon='CON_SIZELIKE')
layout.operator("object.origin_clear", text="Clear Origin", icon='PIVOT_CURSOR')
layout.separator()
layout.label(text="To Deltas")
props = layout.operator("object.transforms_to_deltas", text="Location to Deltas", icon='CON_LOCLIKE')
props.mode = 'LOC'
props = layout.operator("object.transforms_to_deltas", text="Rotation to Deltas", icon='CON_ROTLIKE')
props.mode = 'ROT'
props = layout.operator("object.transforms_to_deltas", text="Scale to Deltas", icon='CON_SIZELIKE')
props.mode = 'SCALE'
props = layout.operator(
"object.transforms_to_deltas", text="All Transforms to Deltas", icon='ORIENTATION_LOCAL'
)
props.mode = 'ALL'
layout.operator("object.anim_transforms_to_deltas", text="Animated Transforms to Deltas", icon='KEYINGSET')
layout.separator()
layout.label(text="Mesh")
make_single = layout.operator("object.make_single_user", text="Make Single-User", icon='DUPLICATE')
make_single.obdata = True
layout.operator("object.duplicates_make_real", text="Make Instances Real", icon='DUPLICATE')
registry = (
@@ -133,6 +156,7 @@ registry = (
OBJECT_OT_apply_transforms_of_constraints,
OBJECT_OT_make_meshes_single_user,
OBJECT_OT_clear_all_transforms,
OBJECT_OT_apply_modifiers_shapekeys,
PIE_MT_clear_transforms,
)
@@ -1,9 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.types import Context, Event, Menu, Object, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
# Some magic numbers that are either hard-coded into Blender,
@@ -16,7 +17,7 @@ class PIE_MT_camera(Menu):
bl_idname = "PIE_MT_camera"
bl_label = "Camera"
def draw(self, context):
def draw(self, context: Context):
pie = self.layout.menu_pie()
scene = context.scene
space = context.area.spaces.active
@@ -35,9 +36,7 @@ class PIE_MT_camera(Menu):
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'
)
pie.operator('view3d.view_camera_with_poll', text=text, icon='VIEW_CAMERA_UNSELECTED')
# 2 - BOTTOM
box = pie.box().column(align=True)
row = box.row()
@@ -48,9 +47,20 @@ class PIE_MT_camera(Menu):
if camera:
box.prop(camera.data, 'lens')
box.prop(camera.data, 'sensor_width')
row = box.row(align=True)
row.prop(camera.data.dof, 'use_dof', text="")
fstop_row = row.row()
fstop_row.enabled = camera.data.dof.use_dof
fstop_row.prop(camera.data.dof, 'aperture_fstop', text="Depth of Field F-Stop")
box.separator()
row = box.row(align=True)
row.prop(camera.data, 'show_passepartout', text="")
row.prop(camera.data, 'passepartout_alpha')
row_alpha = row.row()
row_alpha.enabled = camera.data.show_passepartout
row_alpha.prop(camera.data, 'passepartout_alpha')
row = box.row(align=True)
row.prop(camera.data, 'show_composition_thirds')
row.prop(camera.data, 'show_composition_center')
@@ -88,13 +98,13 @@ class VIEW3D_OT_lock_active_camera_transforms(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if not get_current_camera(context):
cls.poll_message_set("No active camera in this viewport.")
return False
return True
def execute(self, context):
def execute(self, context: Context):
camera = get_current_camera(context)
lock = False
@@ -119,16 +129,14 @@ class VIEW3D_OT_camera_fit_view(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
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'
):
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)):
@@ -136,14 +144,12 @@ class VIEW3D_OT_camera_fit_view(Operator):
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'
):
def invoke(self, context: Context, _event: 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):
def execute(self, context: Context):
cam = get_current_camera(context)
space = context.space_data
@@ -173,7 +179,7 @@ class VIEW3D_OT_camera_from_view(Operator):
bl_label = "New Camera from View"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, context: Context):
if context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.camera_add()
@@ -193,7 +199,7 @@ class VIEW3D_OT_set_active_camera(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if not context.active_object:
cls.poll_message_set("No active object.")
return False
@@ -205,7 +211,7 @@ class VIEW3D_OT_set_active_camera(Operator):
return False
return True
def execute(self, context):
def execute(self, context: Context):
context.scene.camera = context.active_object
self.report({'INFO'}, f"Set active camera: {context.active_object.name}")
return {'FINISHED'}
@@ -219,17 +225,17 @@ class VIEW3D_OT_view_camera_with_poll(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if not get_current_camera(context):
cls.poll_message_set("No active camera.")
return False
return True
def execute(self, context):
def execute(self, _context: Context):
return bpy.ops.view3d.view_camera()
def get_current_camera(context):
def get_current_camera(context: Context) -> Object | None:
space = context.area.spaces.active
if space.type == 'VIEW_3D' and space.use_local_camera:
return space.camera
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Context, Menu, Object, Operator, Spline
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_curve_delete(Menu):
bl_idname = "PIE_MT_curve_delete"
bl_label = "Curve Delete"
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator(CURVE_OT_dissolve_verts.bl_idname, text="Dissolve Points")
# 6 - RIGHT
pie.operator(CURVE_OT_delete_verts.bl_idname, text="Delete Points")
# 2 - BOTTOM
pie.operator(CURVE_OT_delete_segment.bl_idname, text="Delete Segments")
# 8 - TOP
# 7 - TOP - LEFT
# 9 - TOP - RIGHT
# 1 - BOTTOM - LEFT
# 3 - BOTTOM - RIGHT
class CURVE_OT_delete_verts(Operator):
"""Delete selected curve points"""
bl_idname = "curve.delete_verts"
bl_label = "Delete Points"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
curve_objects = _curve_objects_in_edit_mode(context)
if not curve_objects:
cls.poll_message_set("Must be editing a curve.")
return False
if any(spline_has_any_selected(spline) for obj in curve_objects for spline in obj.data.splines):
return True
cls.poll_message_set("No points are selected.")
return False
def execute(self, _context: Context):
return bpy.ops.curve.delete(type='VERT')
class CURVE_OT_dissolve_verts(Operator):
"""Dissolve selected Bezier points, preserving the curve shape"""
bl_idname = "curve.dissolve_verts_py"
bl_label = "Dissolve Points"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
curve_objects = _curve_objects_in_edit_mode(context)
if not curve_objects:
cls.poll_message_set("Must be editing a curve.")
return False
if any(
spline_has_dissolvable_bezier_point(spline) #
for obj in curve_objects
for spline in obj.data.splines
):
return True
cls.poll_message_set("No selected Bezier points with neighbours on both sides.")
return False
def execute(self, _context: Context):
return bpy.ops.curve.dissolve_verts()
class CURVE_OT_delete_segment(Operator):
"""Delete segments between selected consecutive curve points"""
bl_idname = "curve.delete_segment"
bl_label = "Delete Segments"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
curve_objects = _curve_objects_in_edit_mode(context)
if not curve_objects:
cls.poll_message_set("Must be editing a curve.")
return False
if any(
spline_has_consecutive_selected(spline) #
for obj in curve_objects
for spline in obj.data.splines
):
return True
cls.poll_message_set("No two consecutive points are selected.")
return False
def execute(self, _context: Context):
return bpy.ops.curve.delete(type='SEGMENT')
def _curve_objects_in_edit_mode(context: Context) -> list[Object]:
return [obj for obj in (context.objects_in_mode or []) if obj.type == 'CURVE' and obj.data]
def spline_has_dissolvable_bezier_point(spline: Spline) -> bool:
if spline.type != 'BEZIER':
return False
points = spline.bezier_points
point_count = len(points)
if spline.use_cyclic_u:
return point_count > 2 and spline_has_any_selected(spline)
return point_count >= 3 and any(point.select_control_point for point in points[1 : point_count - 1])
def spline_has_any_selected(spline: Spline) -> bool:
if spline.type == 'BEZIER':
return any(point.select_control_point for point in spline.bezier_points)
return any(point.select for point in spline.points)
def spline_has_consecutive_selected(spline: Spline) -> bool:
if spline.type == 'BEZIER':
sel = [point.select_control_point for point in spline.bezier_points]
else:
sel = [point.select for point in spline.points]
for prev, curr in zip(sel, sel[1:]):
if prev and curr:
return True
return spline.use_cyclic_u and len(sel) >= 2 and sel[-1] and sel[0]
registry = [
CURVE_OT_delete_verts,
CURVE_OT_dissolve_verts,
CURVE_OT_delete_segment,
PIE_MT_curve_delete,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Curve",
pie_name=PIE_MT_curve_delete.bl_idname,
hotkey_kwargs={'type': "X", 'value': "PRESS"},
on_drag=True,
)
@@ -1,109 +1,79 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy, sys, json
from bpy.types import Menu, Operator
import json
import bpy
from bpy.props import StringProperty
from bpy.types import Context, Menu, Operator
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):
def draw(self, _context: 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"
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"
)
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"
)
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"
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"
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"
)
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"
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):
def draw(self, _context: 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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'
@@ -113,42 +83,30 @@ class PIE_MT_editor_switch_nodes(Menu):
bl_idname = "PIE_MT_editor_switch_nodes"
bl_label = "Editor Switch: Nodes"
def draw(self, context):
def draw(self, context: 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 = 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 = 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 = 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 = 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',
)
if get_custom_nodetree_types(context):
pie.menu('PIE_MT_editor_switch_nodes_custom', text="Custom Nodes", icon='NODETREE')
else:
pie.separator()
# 1 - BOTTOM - LEFT
@@ -163,253 +121,117 @@ 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):
def draw(self, context: 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
for ui_type, label, icon in get_custom_nodetree_types(context):
layout.operator(WM_OT_set_area_type.bl_idname, text=label, icon=icon).ui_type = ui_type
class PIE_MT_editor_switch_data(Menu):
bl_idname = "PIE_MT_editor_switch_data"
bl_label = "Editor Switch: Data"
def draw(self, context):
def draw(self, _context: 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"
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"
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"
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"
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"
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",
)
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"
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"
)
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()
def draw(self, _context: Context):
layout = self.layout
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Scenes", icon="SCENE_DATA"
)
op = layout.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 = layout.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 = layout.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 = layout.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 = layout.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 = layout.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 = layout.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):
def draw(self, context: Context):
layout = self.layout
available_editors = get_available_properties_editors(context)
def safe_draw(pie, text, icon, context_name):
def draw_entry(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 = layout.operator(WM_OT_set_area_type.bl_idname, text=text, icon=icon)
op.ui_type = "PROPERTIES"
op.context_name = context_name
else:
pie.separator()
layout.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')
draw_entry("Render", 'SCENE', 'RENDER')
draw_entry("Output", 'OUTPUT', 'OUTPUT')
draw_entry("View Layer", 'RENDERLAYERS', 'VIEW_LAYER')
draw_entry("Scene", 'SCENE_DATA', 'SCENE')
draw_entry("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')
draw_entry("Object", 'OBJECT_DATAMODE', 'OBJECT')
draw_entry("Modifiers", 'MODIFIER', 'MODIFIER')
draw_entry("Particles", 'PARTICLES', 'PARTICLES')
draw_entry("Physics", 'PHYSICS', 'PHYSICS')
draw_entry("Constraints", 'CONSTRAINT', 'CONSTRAINT')
draw_entry("Object Data", 'OUTLINER_DATA_MESH', 'DATA')
draw_entry("Material", 'MATERIAL', 'MATERIAL')
layout.separator()
safe_draw(layout, "Texture", 'TEXTURE', 'TEXTURE')
draw_entry("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):
def draw(self, _context: 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 = 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
@@ -420,17 +242,13 @@ class PIE_MT_editor_switch_image(Menu):
# 8 - TOP
pie.separator()
# 7 - TOP - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Image Paint", icon="TPAINT_HLT"
)
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 = 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
@@ -440,23 +258,17 @@ class PIE_MT_editor_switch_script(Menu):
bl_idname = "PIE_MT_editor_switch_script"
bl_label = "Editor Switch: Script"
def draw(self, context):
def draw(self, _context: 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"
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"
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"
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
@@ -467,26 +279,20 @@ class PIE_MT_editor_switch_anim(Menu):
bl_idname = "PIE_MT_editor_switch_anim"
bl_label = "Editor Switch: Animation"
def draw(self, context):
def draw(self, _context: 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 = 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 = 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 = 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")
@@ -494,17 +300,13 @@ class PIE_MT_editor_switch_anim(Menu):
# 7 - TOP - LEFT
pie.separator()
# 9 - TOP - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Graph Editor", icon="GRAPH"
)
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 = pie.operator(WM_OT_set_area_type.bl_idname, text="Action Editor", icon="OBJECT_DATAMODE")
op.ui_type = 'DOPESHEET'
op.ui_mode = 'ACTION'
@@ -525,7 +327,7 @@ class WM_OT_set_area_type(Operator):
mode: StringProperty(default="", options={'SKIP_SAVE'})
view: StringProperty(default="", options={'SKIP_SAVE'})
def execute(self, context):
def execute(self, context: Context):
context.area.ui_type = self.ui_type
if self.view_type:
context.space_data.view_type = self.view_type
@@ -541,10 +343,66 @@ class WM_OT_set_area_type(Operator):
context.space_data.mode = self.mode
if self.view:
context.space_data.view = self.view
return {'FINISHED'}
def get_custom_nodetree_types(context: Context) -> list[tuple[str, str, str]]:
"""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 = {
'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:
type_list_str = str(exc).split("not found in ")[-1].replace("(", "[").replace(")", "]").replace("'", '"')
custom_types = set(json.loads(type_list_str)) - 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
def get_available_properties_editors(context: Context) -> set[str]:
"""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:
type_list_str = str(exc).split("not found in ")[-1].replace("(", "[").replace(")", "]").replace("'", '"')
context.area.ui_type = org_ui_type
return set(json.loads(type_list_str))
return set()
registry = [
PIE_MT_editor_switch,
PIE_MT_editor_switch_video,
@@ -1,11 +1,11 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.props import BoolProperty
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -13,7 +13,7 @@ class PIE_MT_file(Menu):
bl_idname = "PIE_MT_file"
bl_label = "File"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
@@ -25,13 +25,9 @@ class PIE_MT_file(Menu):
# 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"
)
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
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
@@ -42,7 +38,7 @@ class PIE_MT_library(Menu):
bl_idname = "PIE_MT_library"
bl_label = "Library"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
@@ -50,29 +46,17 @@ class PIE_MT_library(Menu):
# 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'
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
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
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"
)
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",
)
pie.operator("file.make_paths_absolute", icon='LIBRARY_DATA_BROKEN', text="Make Paths Absolute")
class WM_OT_pack_res_and_lib(Operator):
@@ -84,17 +68,21 @@ class WM_OT_pack_res_and_lib(Operator):
pack: BoolProperty()
def execute(self, context):
def execute(self, _context: 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'}
def draw_revert(self, _context: Context):
self.layout.operator('wm.revert_mainfile', icon='LOOP_BACK')
self.layout.separator()
registry = [
PIE_MT_file,
PIE_MT_library,
@@ -102,11 +90,6 @@ registry = [
]
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",
@@ -115,7 +98,6 @@ def register():
default_fallback_op='wm.save_mainfile',
on_drag=True,
)
bpy.types.TOPBAR_MT_file_recover.prepend(draw_revert)
@@ -0,0 +1,191 @@
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Context, Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_keyframe_type(Menu):
bl_idname = "PIE_MT_keyframe_type"
bl_label = "Keyframe Type"
def draw(self, context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('action.keyframe_type', text="Keyframe", icon='KEYTYPE_KEYFRAME_VEC').type = 'KEYFRAME'
# 6 - RIGHT
pie.operator('action.keyframe_type', text="Extreme", icon='KEYTYPE_EXTREME_VEC').type = 'EXTREME'
# 2 - BOTTOM
pie.operator('action.keyframe_type', text="Breakdown", icon='KEYTYPE_BREAKDOWN_VEC').type = 'BREAKDOWN'
# 8 - TOP
pie.operator('action.keyframe_type', text="Moving Hold", icon='KEYTYPE_MOVING_HOLD_VEC').type = 'MOVING_HOLD'
# 7 - TOP - LEFT
pie.separator()
# 9 - TOP - RIGHT
pie.separator()
# 1 - BOTTOM - LEFT
pie.operator('action.keyframe_type', text="Generated", icon='KEYTYPE_GENERATED_VEC').type = 'GENERATED'
# 3 - BOTTOM - RIGHT
pie.operator('action.keyframe_type', text="Jitter", icon='KEYTYPE_JITTER_VEC').type = 'JITTER'
class PIE_MT_handle_type(Menu):
bl_idname = "PIE_MT_handle_type"
bl_label = "Handle Type"
def draw(self, context: Context):
pie = self.layout.menu_pie()
base = 'graph' if context.area.type == 'GRAPH_EDITOR' else 'action'
# 4 - LEFT
pie.operator(f'{base}.handle_type', text="Free", icon='HANDLE_FREE').type = 'FREE'
# 6 - RIGHT
pie.operator(f'{base}.handle_type', text="Aligned", icon='HANDLE_ALIGNED').type = 'ALIGNED'
# 2 - BOTTOM
pie.operator(f'{base}.handle_type', text="Vector", icon='HANDLE_VECTOR').type = 'VECTOR'
# 8 - TOP
pie.operator(f'{base}.handle_type', text="Automatic", icon='HANDLE_AUTO').type = 'AUTO'
# 7 - TOP - LEFT
pie.operator(f'{base}.handle_type', text="Auto Clamped", icon='HANDLE_AUTOCLAMPED').type = 'AUTO_CLAMPED'
class PIE_MT_interpolation_mode(Menu):
bl_idname = "PIE_MT_interpolation_mode"
bl_label = "Interpolation Mode"
def draw(self, context: Context):
pie = self.layout.menu_pie()
base = 'graph' if context.area.type == 'GRAPH_EDITOR' else 'action'
# 4 - LEFT
pie.operator(f'{base}.interpolation_type', text="Constant", icon='IPO_CONSTANT').type = 'CONSTANT'
# 6 - RIGHT
pie.operator(f'{base}.interpolation_type', text="Bezier", icon='IPO_BEZIER').type = 'BEZIER'
# 2 - BOTTOM
pie.separator()
# 8 - TOP
pie.operator(f'{base}.interpolation_type', text="Linear", icon='IPO_LINEAR').type = 'LINEAR'
# 7 - TOP - LEFT
pie.separator()
# 9 - TOP - RIGHT
pie.menu("PIE_MT_interpolation_dynamic", text="Dynamic...", icon='IPO_BACK')
# 1 - BOTTOM - LEFT
pie.separator()
# 3 - BOTTOM - RIGHT
pie.menu("PIE_MT_interpolation_easing", text="Easing...", icon='IPO_SINE')
class PIE_MT_interpolation_easing(Menu):
bl_idname = "PIE_MT_interpolation_easing"
bl_label = "Easing Interpolation"
def draw(self, context: Context):
layout = self.layout
base = 'graph' if context.area.type == 'GRAPH_EDITOR' else 'action'
layout.operator(f'{base}.interpolation_type', text="Sinusoidal", icon='IPO_SINE').type = 'SINE'
layout.operator(f'{base}.interpolation_type', text="Quadratic", icon='IPO_QUAD').type = 'QUAD'
layout.operator(f'{base}.interpolation_type', text="Cubic", icon='IPO_CUBIC').type = 'CUBIC'
layout.operator(f'{base}.interpolation_type', text="Quartic", icon='IPO_QUART').type = 'QUART'
layout.operator(f'{base}.interpolation_type', text="Quintic", icon='IPO_QUINT').type = 'QUINT'
layout.operator(f'{base}.interpolation_type', text="Exponential", icon='IPO_EXPO').type = 'EXPO'
layout.operator(f'{base}.interpolation_type', text="Circular", icon='IPO_CIRC').type = 'CIRC'
class PIE_MT_interpolation_dynamic(Menu):
bl_idname = "PIE_MT_interpolation_dynamic"
bl_label = "Dynamic Effects"
def draw(self, context: Context):
layout = self.layout
base = 'graph' if context.area.type == 'GRAPH_EDITOR' else 'action'
layout.operator(f'{base}.interpolation_type', text="Back", icon='IPO_BACK').type = 'BACK'
layout.operator(f'{base}.interpolation_type', text="Bounce", icon='IPO_BOUNCE').type = 'BOUNCE'
layout.operator(f'{base}.interpolation_type', text="Elastic", icon='IPO_ELASTIC').type = 'ELASTIC'
class PIE_MT_easing_mode(Menu):
bl_idname = "PIE_MT_easing_mode"
bl_label = "Easing Mode"
def draw(self, context: Context):
pie = self.layout.menu_pie()
base = 'graph' if context.area.type == 'GRAPH_EDITOR' else 'action'
# 4 - LEFT
pie.operator(f'{base}.easing_type', text="Ease In", icon='IPO_EASE_IN').type = 'EASE_IN'
# 6 - RIGHT
pie.operator(f'{base}.easing_type', text="Ease Out", icon='IPO_EASE_OUT').type = 'EASE_OUT'
# 2 - BOTTOM
pie.operator(f'{base}.easing_type', text="Ease In and Out", icon='IPO_EASE_IN_OUT').type = 'EASE_IN_OUT'
# 8 - TOP
pie.operator(f'{base}.easing_type', text="Automatic", icon='AUTO').type = 'AUTO'
class PIE_MT_extrapolation_mode(Menu):
bl_idname = "PIE_MT_extrapolation_mode"
bl_label = "Extrapolation Mode"
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('graph.extrapolation_type', text="Constant", icon='IPO_CONSTANT').type = 'CONSTANT'
# 6 - RIGHT
pie.operator('graph.extrapolation_type', text="Linear", icon='IPO_LINEAR').type = 'LINEAR'
# 2 - BOTTOM
pie.operator('graph.extrapolation_type', text="Clear Cyclic", icon='X').type = 'CLEAR_CYCLIC'
# 8 - TOP
pie.operator('graph.extrapolation_type', text="Make Cyclic", icon='FILE_REFRESH').type = 'MAKE_CYCLIC'
registry = [
PIE_MT_keyframe_type,
PIE_MT_handle_type,
PIE_MT_interpolation_mode,
PIE_MT_interpolation_easing,
PIE_MT_interpolation_dynamic,
PIE_MT_easing_mode,
PIE_MT_extrapolation_mode,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name='Dopesheet',
pie_name=PIE_MT_keyframe_type.bl_idname,
hotkey_kwargs={'type': "R", 'value': "PRESS"},
on_drag=False,
)
for keymap in ('Dopesheet', 'Graph Editor'):
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name=keymap,
pie_name=PIE_MT_handle_type.bl_idname,
hotkey_kwargs={'type': "V", 'value': "PRESS"},
on_drag=False,
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name=keymap,
pie_name=PIE_MT_interpolation_mode.bl_idname,
hotkey_kwargs={'type': "T", 'value': "PRESS"},
on_drag=False,
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name=keymap,
pie_name=PIE_MT_easing_mode.bl_idname,
hotkey_kwargs={'type': "E", 'value': "PRESS", 'ctrl': True},
on_drag=False,
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name=keymap,
pie_name=PIE_MT_extrapolation_mode.bl_idname,
hotkey_kwargs={'type': "E", 'value': "PRESS", 'shift': True},
on_drag=False,
)
@@ -1,9 +1,9 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu, Operator
from bpy.props import EnumProperty, BoolProperty
from bpy.props import BoolProperty, EnumProperty
from bpy.types import Context, Event, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -12,10 +12,9 @@ class PIE_MT_manipulator(Menu):
bl_idname = "PIE_MT_manipulator"
bl_label = "Manipulator"
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
space = context.space_data
# 4 - LEFT
@@ -27,25 +26,31 @@ class PIE_MT_manipulator(Menu):
'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'
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
# 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
'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
'view3d.set_manipulator',
text="Loc/Scale",
icon='CON_SIZELIKE',
depress=space.show_gizmo_object_translate and space.show_gizmo_object_scale,
).manipulator = 'LOCSCALE'
# 1 - BOTTOM - 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
'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'
@@ -75,19 +80,19 @@ class VIEW3D_OT_set_manipulator(Operator):
name="Toggle",
description="Hold Shift to toggle the selected manipulator rather than setting it as the only active one",
default=False,
options={'SKIP_SAVE'}
options={'SKIP_SAVE'},
)
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
space = context.space_data
return space and space.type == 'VIEW_3D'
def invoke(self, context, event):
def invoke(self, context: Context, event: Event):
self.toggle = event.shift
return self.execute(context)
def execute(self, context):
def execute(self, context: Context):
space = context.space_data
if self.manipulator != 'NONE':
@@ -1,8 +1,8 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from bpy.types import Context, Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -11,39 +11,29 @@ class PIE_MT_mesh_delete(Menu):
bl_idname = "PIE_MT_mesh_delete"
bl_label = "Mesh Delete"
def draw(self, context):
def draw(self, _context: 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.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'
)
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'
)
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'
)
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')
@@ -0,0 +1,179 @@
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bmesh
import bpy
from bmesh.types import BMEdge
from bpy.types import Context, Menu, Object, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
# Above this edge count the state check is skipped to avoid poll lag.
_EDGE_POLL_LIMIT = 100_000
class PIE_MT_mesh_edge(Menu):
bl_idname = "PIE_MT_mesh_edge"
bl_label = "Mesh Edge"
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator(MESH_OT_pie_clear_seam.bl_idname, text="Clear Seam", icon='EDGE_SEAM')
# 6 - RIGHT
pie.operator(MESH_OT_pie_mark_seam.bl_idname, text="Mark Seam", icon='EDGE_SEAM')
# 2 - BOTTOM
pie.operator(MESH_OT_pie_clear_sharp.bl_idname, text="Clear Sharp", icon='EDGE_SHARP')
# 8 - TOP
pie.operator(MESH_OT_pie_mark_sharp.bl_idname, text="Mark Sharp", icon='EDGE_SHARP')
# 7 - TOP - LEFT
pie.operator(MESH_OT_pie_clear_freestyle_edge.bl_idname, text="Clear Freestyle Edge", icon='EDGESEL')
# 9 - TOP - RIGHT
if context.tool_settings.mesh_select_mode[0]:
pie.operator("transform.vert_crease", text="Vertex Crease", icon='VERTEX_CREASE')
else:
pie.operator("transform.edge_crease", text="Edge Crease", icon='EDGE_CREASE')
# 1 - BOTTOM - LEFT
pie.operator(MESH_OT_pie_mark_freestyle_edge.bl_idname, text="Mark Freestyle Edge", icon='EDGESEL')
# 3 - BOTTOM - RIGHT
pie.operator("transform.edge_bevelweight", text="Edge Bevel Weight", icon='EDGE_BEVEL')
class _EdgeMarkBase(Operator):
bl_options = {'REGISTER', 'UNDO'}
edge_data_type: str
do_clear: bool
@classmethod
def poll(cls, context: Context) -> bool:
obj = context.active_object
if not (obj and obj.type == 'MESH' and obj.mode == 'EDIT'):
cls.poll_message_set("Must be in Mesh Edit mode")
return False
if len(obj.data.edges) > _EDGE_POLL_LIMIT:
return True
any_have, all_have = selected_edge_data_state(obj, cls.edge_data_type)
if not any_have and all_have:
cls.poll_message_set("No edges selected")
return False
if cls.do_clear:
if not any_have:
cls.poll_message_set("No selected edges are marked")
return False
else:
if all_have:
cls.poll_message_set("All selected edges are already marked")
return False
return True
def execute(self, _context: Context):
if self.edge_data_type == 'SEAM':
bpy.ops.mesh.mark_seam(clear=self.do_clear)
elif self.edge_data_type == 'SHARP':
bpy.ops.mesh.mark_sharp(clear=self.do_clear)
elif self.edge_data_type == 'FREESTYLE':
bpy.ops.mesh.mark_freestyle_edge(clear=self.do_clear)
return {'FINISHED'}
class MESH_OT_pie_mark_seam(_EdgeMarkBase):
bl_idname = "mesh.pie_mark_seam"
bl_label = "Mark Seam"
bl_description = "Mark selected edges as seams for UV unwrapping"
edge_data_type = 'SEAM'
do_clear = False
class MESH_OT_pie_clear_seam(_EdgeMarkBase):
bl_idname = "mesh.pie_clear_seam"
bl_label = "Clear Seam"
bl_description = "Remove seam marks from selected edges"
edge_data_type = 'SEAM'
do_clear = True
class MESH_OT_pie_mark_sharp(_EdgeMarkBase):
bl_idname = "mesh.pie_mark_sharp"
bl_label = "Mark Sharp"
bl_description = "Mark selected edges as sharp"
edge_data_type = 'SHARP'
do_clear = False
class MESH_OT_pie_clear_sharp(_EdgeMarkBase):
bl_idname = "mesh.pie_clear_sharp"
bl_label = "Clear Sharp"
bl_description = "Remove sharp marks from selected edges"
edge_data_type = 'SHARP'
do_clear = True
class MESH_OT_pie_mark_freestyle_edge(_EdgeMarkBase):
bl_idname = "mesh.pie_mark_freestyle_edge"
bl_label = "Mark Freestyle Edge"
bl_description = "Mark selected edges as Freestyle edges"
edge_data_type = 'FREESTYLE'
do_clear = False
class MESH_OT_pie_clear_freestyle_edge(_EdgeMarkBase):
bl_idname = "mesh.pie_clear_freestyle_edge"
bl_label = "Clear Freestyle Edge"
bl_description = "Remove Freestyle marks from selected edges"
edge_data_type = 'FREESTYLE'
do_clear = True
def _edge_has_data(edge: BMEdge, edge_data_type: str, freestyle_layer) -> bool:
if edge_data_type == 'SEAM':
return edge.seam
if edge_data_type == 'SHARP':
return not edge.smooth
if edge_data_type == 'FREESTYLE':
return edge[freestyle_layer] if freestyle_layer else False
return False
def selected_edge_data_state(obj: Object, edge_data_type: str) -> tuple[bool, bool]:
"""Return (any_have, all_have) for selected edges of the given data type."""
bm = bmesh.from_edit_mesh(obj.data)
freestyle_layer = bm.edges.layers.bool.get("freestyle_edge") if edge_data_type == 'FREESTYLE' else None
any_have = False
all_have = True
found = False
for edge in bm.edges:
if not edge.select:
continue
found = True
has = _edge_has_data(edge, edge_data_type, freestyle_layer)
any_have |= has
all_have &= has
if any_have and not all_have:
break # Both states seen, result can't change.
if not found:
return False, False
return any_have, all_have
registry = [
PIE_MT_mesh_edge,
MESH_OT_pie_mark_seam,
MESH_OT_pie_clear_seam,
MESH_OT_pie_mark_sharp,
MESH_OT_pie_clear_sharp,
MESH_OT_pie_mark_freestyle_edge,
MESH_OT_pie_clear_freestyle_edge,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Mesh",
pie_name=PIE_MT_mesh_edge.bl_idname,
hotkey_kwargs={'type': "E", 'value': "PRESS", 'shift': True},
on_drag=True,
)
@@ -1,10 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.props import EnumProperty
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -13,7 +13,7 @@ class PIE_MT_mesh_flatten(Menu):
bl_idname = "PIE_MT_mesh_flatten"
bl_label = "Mesh Flatten"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
@@ -65,11 +65,11 @@ class TRANSFORM_OT_flatten_to_center(Operator):
)
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
obj = context.active_object
return obj and obj.type == "MESH"
def execute(self, context):
def execute(self, _context: Context):
values = {
'X': [(0, 1, 1), (True, False, False)],
'Y': [(1, 0, 1), (False, True, False)],
@@ -106,11 +106,11 @@ class TRANSFORM_OT_flatten_to_object_origin(Operator):
)
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
obj = context.active_object
return obj and obj.type == "MESH"
def execute(self, context):
def execute(self, _context: Context):
bpy.ops.object.mode_set(mode='OBJECT')
index = "XYZ".find(self.axis)
for vert in bpy.context.object.data.vertices:
@@ -149,11 +149,11 @@ class TRANSFORM_OT_flatten_to_selection_bounding_box(Operator):
)
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
obj = context.active_object
return obj and obj.type == "MESH"
def execute(self, context):
def execute(self, _context: Context):
bpy.ops.object.mode_set(mode='OBJECT')
count = 0
axis_idx = "XYZ".find(self.axis)
@@ -1,8 +1,8 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from bpy.types import Context, Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -11,16 +11,14 @@ class PIE_MT_mesh_merge(Menu):
bl_idname = "PIE_MT_mesh_merge"
bl_label = "Mesh Merge"
def draw(self, context):
def draw(self, _context: 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'
)
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')
@@ -37,13 +35,9 @@ class PIE_MT_mesh_merge(Menu):
# 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'
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'
pie.operator('mesh.merge', text="At Last", icon='TRACKING_REFINE_FORWARDS').type = 'LAST'
except:
op.type = 'COLLAPSE'
pie.separator()
@@ -52,9 +46,7 @@ class PIE_MT_mesh_merge(Menu):
# 1 - BOTTOM - LEFT
pie.separator()
# 3 - BOTTOM - RIGHT
pie.operator('mesh.merge', text="At 3D Cursor", icon='PIVOT_CURSOR').type = (
'CURSOR'
)
pie.operator('mesh.merge', text="At 3D Cursor", icon='PIVOT_CURSOR').type = 'CURSOR'
registry = [PIE_MT_mesh_merge]
@@ -1,10 +1,11 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 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 bpy.types import Context, Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -12,7 +13,7 @@ class PIE_MT_object_add(Menu):
bl_idname = "PIE_MT_object_add"
bl_label = "Add Object"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
@@ -1,9 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -12,10 +13,10 @@ class PIE_MT_object_display(Menu):
bl_label = "Object Display"
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
return context.active_object
def draw(self, context):
def draw(self, context: Context):
pie = self.layout.menu_pie()
obj = context.active_object
@@ -37,7 +38,7 @@ class PIE_MT_object_display(Menu):
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'
row.operator('view3d.copy_property_to_selected', text="", icon='LOOP_FORWARDS').rna_path = 'color'
else:
pie.separator()
@@ -95,7 +96,7 @@ class OBJECT_OT_add_weighted_normals(Operator):
bl_label = "Weighted Normals"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, context: Context):
for obj in context.selected_objects:
if obj.type != 'MESH':
continue
@@ -113,7 +114,7 @@ class OBJECT_OT_reset_normals(Operator):
bl_label = "Reset Normals"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, context: Context):
for obj in context.selected_objects:
if obj.type != 'MESH':
continue
@@ -1,10 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 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 bpy.types import Context, Menu, Operator, OperatorProperties
from mathutils import Matrix
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -15,10 +15,10 @@ class OBJECT_MT_parenting_pie(Menu):
bl_idname = 'OBJECT_MT_parenting_pie'
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
return context.mode == 'OBJECT'
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
@@ -64,7 +64,7 @@ class OBJECT_MT_parenting_pie(Menu):
pie.separator()
def selected_objs_with_parents(context):
def selected_objs_with_parents(context: Context):
return [ob for ob in context.selected_objects if ob.parent]
@@ -81,21 +81,21 @@ class OBJECT_OT_clear_parent(Operator):
)
@classmethod
def description(cls, context, properties):
def description(cls, _context: Context, properties: OperatorProperties):
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):
def poll(cls, context: 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):
def execute(self, context: Context):
objs = selected_objs_with_parents(context)
op_type = 'CLEAR_KEEP_TRANSFORM' if self.keep_transform else 'CLEAR'
@@ -115,22 +115,20 @@ class OBJECT_OT_clear_parent_inverse_matrix(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: 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]
):
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):
def execute(self, context: Context):
objs = selected_objs_with_parents(context)
bpy.ops.object.parent_clear(type='CLEAR_INVERSE')
@@ -148,16 +146,14 @@ class OBJECT_OT_parent_set_simple(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: 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."
)
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):
def execute(self, context: Context):
parent_ob = context.active_object
objs_to_parent = [obj for obj in context.selected_objects if obj != parent_ob]
@@ -168,11 +164,7 @@ class OBJECT_OT_parent_set_simple(Operator):
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"
)
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'}
@@ -185,16 +177,14 @@ class OBJECT_OT_parent_set_advanced(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: 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."
)
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):
def get_parent_method_items(self, context: Context):
parent_ob = context.active_object
items = [
(
@@ -273,9 +263,7 @@ class OBJECT_OT_parent_set_advanced(Operator):
)
)
return [
(item[0], item[1], item[2], item[3], idx) for idx, item in enumerate(items)
]
return [(item[0], item[1], item[2], item[3], idx) for idx, item in enumerate(items)]
parent_method: EnumProperty(
name="Parent Method",
@@ -428,9 +416,7 @@ class OBJECT_OT_parent_set_advanced(Operator):
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
]
matrix_backups = [(obj.matrix_world.copy(), obj.matrix_local.copy()) for obj in objs_to_parent]
op_parent_method = self.parent_method
@@ -459,9 +445,7 @@ class OBJECT_OT_parent_set_advanced(Operator):
return self.parent_with_copy_transforms_con(context)
try:
bpy.ops.object.parent_set(
type=op_parent_method, keep_transform=keep_transform
)
bpy.ops.object.parent_set(type=op_parent_method, keep_transform=keep_transform)
except Exception as exc:
self.report({'ERROR'}, str(exc))
return {'CANCELLED'}
@@ -477,11 +461,7 @@ class OBJECT_OT_parent_set_advanced(Operator):
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"
)
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'}
@@ -515,9 +495,7 @@ class OBJECT_OT_parent_set_advanced(Operator):
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"
)
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'}
@@ -543,21 +521,13 @@ class OBJECT_OT_parent_set_advanced(Operator):
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()
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
)
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'}
@@ -573,23 +543,15 @@ class OBJECT_OT_parent_set_advanced(Operator):
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
)
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):
def set_parent_poll_check_linked(cls, context: 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."
)
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(
@@ -598,8 +560,9 @@ def set_parent_poll_check_linked(cls, context):
return False
return True
### Header Menu UI
def draw_new_header_menu(self, context):
def draw_new_header_menu(self, _context: Context):
layout = self.layout
# Set Parent
@@ -1,16 +1,17 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from bpy.types import Context, 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"
class PIE_MT_playback(Menu):
bl_idname = "PIE_MT_playback"
bl_label = "Playback"
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
@@ -18,22 +19,16 @@ class PIE_MT_animation(Menu):
# 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
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
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
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
@@ -41,14 +36,14 @@ class PIE_MT_animation(Menu):
registry = [
PIE_MT_animation,
PIE_MT_playback,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Object Non-modal",
pie_name=PIE_MT_animation.bl_idname,
pie_name=PIE_MT_playback.bl_idname,
hotkey_kwargs={'type': "SPACE", 'value': "PRESS", 'shift': True},
default_fallback_op='screen.animation_play',
on_drag=True,
@@ -3,6 +3,7 @@
# 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
@@ -2,8 +2,9 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu, Operator
from bpy.props import StringProperty
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -11,38 +12,28 @@ class PIE_MT_proportional_edit(Menu):
bl_idname = "PIE_MT_proportional_edit"
bl_label = "Proportional Edit"
def draw(self, context):
def draw(self, context: 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'
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'
)
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'
)
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'
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'
pie.operator("object.set_proportional_falloff", text="Root", icon='ROOTCURVE').falloff_shape = 'ROOT'
# 9 - TOP - RIGHT
pie.operator(
"object.set_proportional_falloff",
@@ -51,9 +42,7 @@ class PIE_MT_proportional_edit(Menu):
).falloff_shape = 'INVERSE_SQUARE'
else:
# 8 - TOP
pie.prop(
ts, "use_proportional_edit", text="Toggle Proportional", icon='PROP_ON'
)
pie.prop(ts, "use_proportional_edit", text="Toggle Proportional", icon='PROP_ON')
# 7 - TOP - LEFT
pie.prop(
ts,
@@ -70,50 +59,36 @@ class PIE_MT_proportional_edit(Menu):
)
# 1 - BOTTOM - LEFT
pie.operator(
"object.set_proportional_falloff", text="Linear", icon='LINCURVE'
).falloff_shape = 'LINEAR'
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'
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):
def draw(self, _context: Context):
layout = self.layout
layout.operator(
"object.set_proportional_falloff", text="Root", icon='ROOTCURVE'
).falloff_shape = 'ROOT'
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'
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):
def draw(self, _context: 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'
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):
@@ -125,7 +100,7 @@ class OBJECT_OT_set_proportional_falloff(Operator):
falloff_shape: StringProperty()
def execute(self, context):
def execute(self, context: Context):
ts = context.tool_settings
if context.mode == 'OBJECT':
ts.use_proportional_edit_objects = True
@@ -145,7 +120,10 @@ registry = (
def register():
for km_name, proportional_name in [('Object Mode', 'use_proportional_edit_objects'), ('Mesh', 'use_proportional_edit')]:
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,
@@ -3,11 +3,23 @@
# 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.props import BoolProperty, CollectionProperty, StringProperty
from bpy.types import (
ID,
Collection,
Context,
Event,
Menu,
NodeTree,
Object,
Operator,
OperatorProperties,
PropertyGroup,
Scene,
bpy_prop_collection,
)
from bpy_extras import id_map_utils
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -18,88 +30,95 @@ class OUTLINER_MT_relationship_pie(Menu):
bl_idname = 'OUTLINER_MT_relationship_pie'
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if context.area.type == 'VIEW_3D':
return True
if context.area.type == 'OUTLINER':
return bool(context.id)
return False
def draw(self, context):
def draw(self, context: Context) -> None:
layout = self.layout
active_id = get_active_id(context)
active_override = OUTLINER_OT_delete_override_library_hierarchy.poll(context)
pie = layout.menu_pie()
# 4 - LEFT
if context.area.type == 'VIEW_3D':
if active_override:
pie.operator(
OUTLINER_OT_delete_override_library_hierarchy.bl_idname,
icon='TRASH',
text="Unlink Override Hierarchy",
).remove_from_file = False
else:
pie.operator(
OBJECT_OT_unlink_from_scene.bl_idname,
icon='TRASH',
text="Unlink From Scene",
)
# 6 - RIGHT
if active_override:
pie.operator(
OUTLINER_OT_delete_override_library_hierarchy.bl_idname,
icon='X',
text='Delete Override Hierarchy',
).remove_from_file = True
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"
OBJECT_OT_pie_delete.bl_idname,
icon='X',
text="Delete From File",
)
if id.library:
remap.library_path_source = id.library.filepath
# 2 - BOTTOM
pie.operator(
'outliner.pie_purge_orphans',
icon='ORPHAN_DATA',
)
# 8 - TOP
if active_id:
remap = pie.operator(
'outliner.remap_users_ui',
icon='FILE_REFRESH',
text="Remap Users",
)
if active_id.library:
remap.library_path_source = active_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'
)
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",
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"
'outliner.delete',
text="Delete Hierarchy",
icon='OUTLINER',
).hierarchy = True
else:
pie.operator(
OBJECT_OT_instancer_empty_to_collection.bl_idname, icon='LINKED'
)
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_idname = 'outliner.pie_purge_orphans'
bl_label = "Purge Unused Data"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, context: Context) -> set[str]:
"""Call Blender's purge function, but in a way that works from any editor, not only the 3D View."""
orig_ui = None
@@ -112,9 +131,7 @@ class OUTLINER_OT_pie_purge_orphans(Operator):
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
)
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
@@ -123,18 +140,16 @@ class OUTLINER_OT_pie_purge_orphans(Operator):
class OBJECT_OT_unlink_from_scene(Operator):
"""Unlink selected objects or collections from the current scene"""
"""Unlink selected objects or collections from all collections of the current scene"""
bl_idname = "object.unlink_from_scene"
bl_idname = 'object.unlink_from_scene'
bl_label = "Unlink Selected From Scene"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if context.area.type == 'OUTLINER':
any_selected = bool(
get_objects_to_unlink(context) or get_collections_to_unlink(context)
)
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:
@@ -144,7 +159,7 @@ class OBJECT_OT_unlink_from_scene(Operator):
cls.poll_message_set("Nothing is selected.")
return any_selected
def execute(self, context):
def execute(self, context: Context) -> set[str]:
unlink_collections_from_scene(get_collections_to_unlink(context), context.scene)
unlink_objects_from_scene(get_objects_to_unlink(context), context.scene)
@@ -156,7 +171,7 @@ class RelationshipOperatorMixin:
datablock_storage: StringProperty()
library_filepath: StringProperty()
def get_datablock(self, context) -> ID | None:
def get_datablock(self, context: 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
@@ -167,30 +182,30 @@ class RelationshipOperatorMixin:
return context.active_object
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
id = get_active_id(context)
if context.area.type == 'OUTLINER':
if id == None:
if id is None:
cls.poll_message_set("No active ID.")
return False
return True
elif context.area.type == '3D_VIEW':
if id == None:
if id is None:
cls.poll_message_set("No active object.")
return False
return True
def invoke(self, context, _event):
def invoke(self, context: Context, _event: Event) -> set[str]:
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):
def get_label(self) -> str:
return "Listing datablocks that reference this:"
def draw(self, context):
def draw(self, context: Context) -> None:
layout = self.layout
layout.use_property_decorate = False
layout.use_property_split = True
@@ -211,9 +226,9 @@ class RelationshipOperatorMixin:
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="")
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="")
fake_user_row.prop(datablock, 'use_fake_user', text='')
layout.separator()
@@ -230,14 +245,15 @@ class RelationshipOperatorMixin:
row = layout.row()
name_row = row.row()
name_row.enabled = False
name_row.prop(user, 'name', icon=get_datablock_icon(user), text="")
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 = 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]
storage = get_id_storage_by_type_str(user.id_type)
if not storage:
print("Error: Can't find storage: ", user.name, user.id_type)
op.datablock_storage = storage
else:
op.datablock_storage = storage[1]
if user.library:
op.library_filepath = user.library.filepath
name_row.prop(
@@ -247,14 +263,22 @@ class RelationshipOperatorMixin:
text="",
)
def execute(self, context):
def execute(self, _context: Context) -> set[str]:
return {'FINISHED'}
def get_datablock_users(datablock: ID) -> list[ID]:
user_map = bpy.data.user_map()
users = user_map[datablock]
if datablock in users:
users.remove(datablock)
return sorted(users, key=lambda u: (str(type(u)), u.name))
class OUTLINER_OT_list_users_of_datablock(RelationshipOperatorMixin, Operator):
"""Show list of users of this datablock"""
bl_idname = "object.list_datablock_users"
bl_idname = 'object.list_datablock_users'
bl_label = "List Datablock Users"
datablock_name: StringProperty()
@@ -262,18 +286,16 @@ class OUTLINER_OT_list_users_of_datablock(RelationshipOperatorMixin, Operator):
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))
return get_datablock_users(datablock)
class OUTLINER_OT_list_dependencies_of_datablock(RelationshipOperatorMixin, Operator):
"""Show list of dependencies of this datablock"""
bl_idname = "object.list_datablock_dependencies"
bl_idname = 'object.list_datablock_dependencies'
bl_label = "List Datablock Dependencies"
def get_label(self):
def get_label(self) -> str:
return "Listing datablocks that are referenced by this:"
def get_datablocks_to_display(self, datablock: ID) -> list[ID]:
@@ -283,23 +305,26 @@ class OUTLINER_OT_list_dependencies_of_datablock(RelationshipOperatorMixin, Oper
return sorted(dependencies, key=lambda u: (str(type(u)), u.name))
class RemapTarget(bpy.types.PropertyGroup):
class RemapTarget(PropertyGroup):
pass
class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
class OUTLINER_OT_remap_users_ui(Operator):
"""Remap users of selected IDs to any other ID of the same type"""
bl_idname = "outliner.remap_users_ui"
bl_idname = 'outliner.remap_users_ui'
bl_label = "Remap Users"
bl_options = {'INTERNAL', 'UNDO'}
def update_library_path(self, context):
def update_library_path(self, context: Context) -> None:
# Prepare the ID selector.
remap_targets = context.scene.remap_targets
remap_targets.clear()
source_ids = get_selected_ids_of_active_type(context)
for id in get_id_storage_by_type_str(source_ids[0].id_type)[0]:
storage_info = get_id_storage_by_type_str(source_ids[0].id_type)
if not storage_info:
return
for id in storage_info[0]:
if id in source_ids:
continue
if (self.library_path == 'Local Data' and not id.library) or (
@@ -314,19 +339,20 @@ class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
update=update_library_path,
)
library_path_source: StringProperty()
id_name_target: StringProperty(
name="Target ID Name", description="Name of the ID we're remapping users to"
)
id_name_target: StringProperty(name="Target ID Name", description="Name of the ID we're remapping users to")
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
source_ids = get_selected_ids_of_active_type(context)
if not source_ids:
cls.poll_message_set("No selected IDs.")
return False
if any(src_id.override_library for src_id in source_ids if src_id):
cls.poll_message_set("Cannot remap users of library overrides.")
return False
return True
def invoke(self, context, _event):
def invoke(self, context: Context, _event: Event) -> set[str]:
# Populate the remap_targets string list with possible options based on
# selection context.
@@ -334,19 +360,21 @@ class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
remap_target_libraries = context.scene.remap_target_libraries
remap_target_libraries.clear()
local = remap_target_libraries.add()
local.name = "Local Data"
local.name = 'Local Data'
source_ids = get_selected_ids_of_active_type(context)
for lib in bpy.data.libraries:
for id in lib.users_id:
if type(id) == type(source_ids[0]):
if isinstance(id, type(source_ids[0])):
lib_entry = remap_target_libraries.add()
lib_entry.name = lib.filepath
break
container = get_id_storage_by_type_str(source_ids[0].id_type)[0]
storage_info = get_id_storage_by_type_str(source_ids[0].id_type)
assert storage_info
container = storage_info[0]
self.library_path = "Local Data"
suffixed_id = next((id for id in source_ids if id.name[-4] == "."), None)
self.library_path = 'Local Data'
suffixed_id = next((id for id in source_ids if id.name[-4] == '.'), None)
if suffixed_id:
default_target = container.get(suffixed_id.name[:-4])
@@ -355,12 +383,12 @@ class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
if default_target.library:
self.library_path = default_target.library.filepath
else:
self.id_name_target = ""
self.id_name_target = ''
self.library_path = 'Local Data'
return context.window_manager.invoke_props_dialog(self, width=600)
def draw(self, context):
def draw(self, context: Context) -> None:
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
@@ -371,7 +399,7 @@ class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
for i, source_id in enumerate(source_ids):
row = layout.row()
split = row.split()
if i==0:
if i == 0:
split.row().label(text="Anything that was referencing these:")
else:
split.row()
@@ -399,7 +427,7 @@ class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
icon=id_icon,
)
def execute(self, context):
def execute(self, context: Context) -> set[str]:
source_ids = get_selected_ids_of_active_type(context)
target_id = get_id(self.id_name_target, source_ids[0].id_type, self.library_path)
assert source_ids and target_id, "Error: Failed to find source or target."
@@ -409,33 +437,154 @@ class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
return {'FINISHED'}
class OBJECT_OT_pie_delete(Operator):
"""Delete selected objects or collections from the file.\n\nShift: Show dependent datablocks before confirming deletion"""
bl_idname = 'object.pie_delete'
bl_label = "Delete From File"
bl_options = {'REGISTER', 'UNDO'}
@staticmethod
def _get_items_to_delete(context: Context) -> list[ID]:
if context.area and context.area.type == 'OUTLINER':
return list(context.selected_ids or [])
return list(context.selected_objects or [])
@classmethod
def poll(cls, context: Context) -> bool:
if not cls._get_items_to_delete(context):
cls.poll_message_set("Nothing selected.")
return False
return True
def invoke(self, context: Context, event: Event) -> set[str]:
if not event or not event.shift:
return self.execute(context)
self._users_per_obj: list[tuple[ID, list[ID]]] = []
for item in self._get_items_to_delete(context):
users = get_datablock_users(item)
if context.scene in users:
users.remove(context.scene)
self._users_per_obj.append((item, users))
return context.window_manager.invoke_props_dialog(self, width=400) # type: ignore[union-attr]
def draw(self, _context: Context) -> None:
layout = self.layout
if not layout:
return
layout.use_property_decorate = False
for obj, users in self._users_per_obj:
if len(self._users_per_obj) > 1 and users:
header, body = layout.panel(
f'delete_dialog_{obj.name}',
default_closed=len(self._users_per_obj) > 3,
)
header_row = header.row(align=True)
else:
body = layout
header_row = body.row(align=True)
if len(self._users_per_obj) > 1:
header_row.label(text='', icon='BLANK1')
header_row.prop(obj, 'name', icon=get_datablock_icon(obj), text='', emboss=False)
header_row.label(text=f'{len(users)} users')
if body and users:
for user in users:
split = body.split(factor=0.15)
split.row()
row = split.row()
row.prop(
user,
'name',
icon=get_datablock_icon(user),
text='',
emboss=False,
)
def execute(self, context: Context) -> set[str]:
count = len(self._get_items_to_delete(context))
if context.area and context.area.type == 'OUTLINER':
items = self._get_items_to_delete(context)
if any(isinstance(id, (Object, Collection)) for id in items):
ret = bpy.ops.outliner.delete()
else:
ret = bpy.ops.outliner.id_operation(type='DELETE')
else:
ret = bpy.ops.object.delete(use_global=True, confirm=False)
if ret == {'FINISHED'}:
self.report({'INFO'}, f"Deleted {count} datablock(s).")
return ret
class OUTLINER_OT_delete_override_library_hierarchy(Operator):
"""Delete the library override hierarchy that the active datablock belongs to"""
bl_idname = 'outliner.delete_override_library_hierarchy'
bl_label = "Delete Override Hierarchy"
bl_options = {'REGISTER', 'UNDO'}
remove_from_file: BoolProperty(
name="Remove from File",
default=True,
)
@classmethod
def description(cls, _context: Context, properties: OperatorProperties) -> str:
if properties.remove_from_file:
return cls.bl_description
return "Unlink the library override hierarchy that the active datablock belongs to from the active Scene"
@classmethod
def poll(cls, context: Context) -> bool:
active_id = get_active_id(context)
if not active_id:
cls.poll_message_set("No active datablock.")
return False
if not active_id.override_library:
cls.poll_message_set("Active datablock is not a library override.")
return False
return True
def execute(self, context: Context) -> set[str]:
active_id = get_active_id(context)
assert active_id and active_id.override_library
root = active_id.override_library.hierarchy_root
assert root
root_name = root.name
count = remove_override_hierarchy(root, remove_from_file=self.remove_from_file, scene=context.scene)
word = "Deleted" if self.remove_from_file else "Unlinked"
self.report(
{'INFO'},
f"{word} {count} override datablock(s) from hierarchy of {root_name!r}.",
)
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_idname = 'object.instancer_empty_to_collection'
bl_label = "Instancer Empty To Collection"
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
obj = get_active_id(context)
if not (
obj
and obj.type == 'EMPTY'
and obj.instance_type == 'COLLECTION'
and obj.instance_collection
):
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.'
)
cls.poll_message_set(f'Collection "{obj.instance_collection.name}" is already in the scene.')
return False
return True
def execute(self, context):
def execute(self, context: Context) -> set[str]:
obj = get_active_id(context)
coll = obj.instance_collection
@@ -447,66 +596,97 @@ class OBJECT_OT_instancer_empty_to_collection(Operator):
return {'FINISHED'}
def get_objects_to_unlink(context) -> list[Object]:
def get_objects_to_unlink(context: Context) -> list[Object]:
if context.area.type == 'OUTLINER':
selected_objs = [id for id in context.selected_ids if type(id) == Object]
selected_objs = [id for id in context.selected_ids if isinstance(id, Object)]
elif context.area.type == 'VIEW_3D':
selected_objs = context.selected_objects
selected_objs = list(context.selected_objects)
else:
return []
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):
def unlink_objects_from_scene(objects: list[Object], scene: Scene) -> None:
for obj in objects:
for coll in [scene.collection] + scene.collection.children_recursive:
if obj.name in coll.objects:
if coll.override_library:
continue
if obj in set(coll.objects):
coll.objects.unlink(obj)
def get_collections_to_unlink(context) -> list[Collection]:
def get_collections_to_unlink(context: Context) -> list[Collection]:
if context.area.type == 'OUTLINER':
return [id for id in context.selected_ids if type(id) == Collection]
return [id for id in context.selected_ids if isinstance(id, Collection)]
return []
def unlink_collections_from_scene(collections_to_unlink, scene):
def unlink_collections_from_scene(collections_to_unlink: list[Collection], scene: Scene) -> None:
for coll_to_unlink in collections_to_unlink:
for coll in scene.collection.children_recursive:
if coll_to_unlink.name in coll.children:
for coll in scene.collection.children_recursive + [scene.collection]:
if coll_to_unlink in set(coll.children):
coll.children.unlink(coll_to_unlink)
def get_active_id(context) -> ID | None:
def remove_override_hierarchy(root: ID, *, remove_from_file: bool, scene: Scene | None = None) -> int:
"""Find and remove all datablocks belonging to the override hierarchy rooted at `root`.
If `remove_from_file` is True, permanently deletes from bpy.data via batch_remove.
Otherwise unlinks objects and collections from `scene` (required in this case).
Returns the number of datablocks found in the hierarchy.
"""
root_ptr = root.as_pointer()
hierarchy_ids = [
datablock
for *_, container_str in get_id_info()
for datablock in getattr(bpy.data, container_str)
if (ol := datablock.override_library) and (hr := ol.hierarchy_root) and hr.as_pointer() == root_ptr
]
if remove_from_file:
bpy.data.batch_remove(ids=hierarchy_ids)
else:
assert scene is not None
unlink_objects_from_scene([db for db in hierarchy_ids if isinstance(db, Object)], scene)
unlink_collections_from_scene([db for db in hierarchy_ids if isinstance(db, Collection)], scene)
return len(hierarchy_ids)
def get_active_id(context: 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)
"""Return a list of datablock type information tuples:
(type_class, type_enum_string, bpy.data.<collprop_name>)
"""
id_info = []
for prop_name in dir(bpy.data):
prop = getattr(bpy.data, prop_name)
if type(prop) == bpy_prop_collection:
if isinstance(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
if issubclass(typ, NodeTree):
typ = 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():
def get_id_storage_by_type_str(typ_name: str) -> tuple[bpy_prop_collection, str] | None:
for _typ, typ_str, container_str in get_id_info():
if typ_str == typ_name:
return getattr(bpy.data, container_str), container_str
return None
def get_datablock_types_enum_items() -> list[tuple[str, str, str, str, int]]:
@@ -518,40 +698,26 @@ def get_datablock_types_enum_items() -> list[tuple[str, str, str, str, int]]:
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(('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()
)
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
}
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:
def get_datablock_icon(id: 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]:
def get_fundamental_id_type(datablock: ID) -> tuple[type[ID], str]:
"""Certain datablocks have very specific types.
This function should return their fundamental type, ie. parent class."""
id_info = get_id_info()
@@ -559,20 +725,19 @@ def get_fundamental_id_type(datablock: ID) -> tuple[Any, str]:
if isinstance(datablock, typ):
return typ, typ_str
raise Exception(
f"Failed to get fundamental ID type of ID: {datablock.name}, {type(datablock)}"
)
raise Exception(f"Failed to get fundamental ID type of ID: {datablock.name}, {type(datablock)}")
def get_selected_ids_of_active_type(context):
def get_selected_ids_of_active_type(context: Context) -> list[ID]:
active_id = get_active_id(context)
return [
id for id in context.selected_ids
if type(id) == type(active_id)
]
return [id for id in (context.selected_ids or []) if active_id and isinstance(id, type(active_id))]
def get_id(id_name: str, id_type: str, lib_path="") -> ID:
container = get_id_storage_by_type_str(id_type)[0]
def get_id(id_name: str, id_type: str, lib_path: str = '') -> ID | None:
storage_info = get_id_storage_by_type_str(id_type)
if not storage_info:
return None
container = storage_info[0]
if lib_path and lib_path != 'Local Data':
return container.get((id_name, lib_path))
return container.get((id_name, None))
@@ -591,33 +756,35 @@ def get_library_icon(lib_path: str) -> str:
registry = [
OUTLINER_MT_relationship_pie,
OBJECT_OT_pie_delete,
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,
OUTLINER_OT_delete_override_library_hierarchy,
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)
def register() -> None:
Scene.remap_targets = CollectionProperty(type=RemapTarget)
Scene.remap_target_libraries = CollectionProperty(type=RemapTarget)
for keymap_name in ("Object Mode", "Outliner"):
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"},
hotkey_kwargs={'type': 'X', 'value': 'PRESS'},
on_drag=False,
)
def unregister():
def unregister() -> None:
try:
del bpy.types.Scene.remap_targets
del bpy.types.Scene.remap_target_libraries
del Scene.remap_targets
del Scene.remap_target_libraries
except AttributeError:
# Blender Log also implements these same things, which can result in errors.
pass
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
@@ -6,8 +6,8 @@ import os
from pathlib import Path
import bpy
from bpy.types import Menu
from bl_ui.properties_paint_common import BrushAssetShelf
from bpy.types import Context, Menu, UILayout
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -16,8 +16,7 @@ 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
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
pie.scale_y = 1.2
@@ -42,9 +41,7 @@ class PIE_MT_sculpt_brush_select(Menu):
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
)
BrushAssetShelf.draw_popup_selector(brush_row, context, brush, show_name=False)
name_row = col.row().box()
if brush:
name_row.label(text=brush.name)
@@ -75,7 +72,7 @@ class PIE_MT_sculpt_brush_select_contrast(Menu):
bl_idname = "PIE_MT_sculpt_brush_select_contrast"
bl_label = "Contrast Brushes"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
@@ -99,7 +96,7 @@ class PIE_MT_sculpt_brush_select_transform(Menu):
bl_idname = "PIE_MT_sculpt_brush_select_transform"
bl_label = "Transform Brushes"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
@@ -124,7 +121,7 @@ class PIE_MT_sculpt_brush_select_volume(Menu):
bl_idname = "PIE_MT_sculpt_brush_select_volume"
bl_label = "Volume Brushes"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
draw_brush_operator(pie, 'Blob', 'blob')
@@ -148,7 +145,7 @@ class PIE_MT_sculpt_brush_select_special(Menu):
bl_idname = "PIE_MT_sculpt_brush_select_special"
bl_label = "Special Brushes"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
draw_brush_operator(pie, 'Grab Cloth', 'cloth')
@@ -168,11 +165,11 @@ class PIE_MT_sculpt_brush_select_special(Menu):
draw_brush_operator(pie, 'Smear Multires Displacement', 'displacement_smear')
def blender_uses_brush_assets():
def blender_uses_brush_assets() -> bool:
return 'asset_activate' in dir(bpy.ops.brush)
def draw_brush_operator(layout, brush_name: str, brush_icon: str = ""):
def draw_brush_operator(layout: UILayout, 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.
@@ -205,7 +202,6 @@ brush_icons = {}
def create_icons():
global brush_icons
icons_directory = Path(__file__).parent / "icons"
for icon_path in icons_directory.iterdir():
@@ -215,7 +211,6 @@ def create_icons():
def release_icons():
global brush_icons
for value in brush_icons.values():
bpy.app.icons.release(value)
@@ -0,0 +1,402 @@
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Bone, Context, EditBone, Menu, Object, Operator, PoseBone
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
SEL_SIMILAR = "mesh.select_similar"
class PIE_MT_select_similar_object(Menu):
bl_idname = "PIE_MT_select_similar_object"
bl_label = "Select Similar"
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator("object.select_grouped", text="Collection", icon='OUTLINER_COLLECTION').type = 'COLLECTION'
# 6 - RIGHT
pie.operator("object.select_grouped", text="Type", icon='OBJECT_DATA').type = 'TYPE'
# 2 - BOTTOM
pie.operator("object.select_grouped", text="Colour", icon='COLOR').type = 'COLOR'
# 8 - TOP
pie.operator("object.select_grouped", text="Pass", icon='RENDERLAYERS').type = 'PASS'
# 7 - TOP - LEFT
pie.operator("object.select_grouped", text="Keying Set", icon='KEYINGSET').type = 'KEYINGSET'
# 9 - TOP - RIGHT
if context.active_object and context.active_object.type == 'LIGHT':
pie.operator("object.select_grouped", text="Light Type", icon='LIGHT').type = 'LIGHT_TYPE'
else:
pie.separator()
# 1 - BOTTOM - LEFT
pie.operator("object.select_grouped", text="Hook", icon='HOOK').type = 'HOOK'
# 3 - BOTTOM - RIGHT
pie.separator()
class PIE_MT_select_similar_mesh(Menu):
bl_idname = "PIE_MT_select_similar_mesh"
bl_label = "Select Similar"
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
mode = context.tool_settings.mesh_select_mode
if mode[0]:
_draw_vert_slots(pie)
elif mode[1]:
_draw_edge_slots(pie)
elif mode[2]:
_draw_face_slots(pie)
class PIE_MT_select_similar_mesh_edge_more(Menu):
bl_idname = "PIE_MT_select_similar_mesh_edge_more"
bl_label = "Select Similar: More"
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
mode = context.tool_settings.mesh_select_mode
if mode[1]:
_draw_edge_more_slots(pie)
def _draw_vert_slots(pie):
# 4 - LEFT
pie.operator(SEL_SIMILAR, text="Connected Edge Count", icon='EDGESEL').type = 'VERT_EDGES'
# 6 - RIGHT
pie.separator()
# 2 - BOTTOM
pie.operator('mesh.select_similar_region', text="Face Regions", icon='FACE_MAPS')
# 8 - TOP
pie.operator(SEL_SIMILAR, text="Vertex Groups", icon='GROUP_VERTEX').type = 'VERT_GROUPS'
# 7 - TOP - LEFT
pie.operator(SEL_SIMILAR, text="Normal", icon='NORMALS_VERTEX').type = 'VERT_NORMAL'
# 9 - TOP - RIGHT
pie.operator(SEL_SIMILAR, text="Vertex Crease", icon='EDGE_CREASE').type = 'VERT_CREASE'
# 1 - BOTTOM - LEFT
pie.operator(SEL_SIMILAR, text="Connected Face Count", icon='FACESEL').type = 'VERT_FACES'
# 3 - BOTTOM - RIGHT
def _draw_edge_slots(pie):
# 4 - LEFT
pie.operator('wm.call_menu_pie', text="More...", icon='THREE_DOTS').name = (
PIE_MT_select_similar_mesh_edge_more.bl_idname
)
# 6 - RIGHT
pie.operator(SEL_SIMILAR, text="Seam", icon='EDGE_SEAM').type = 'EDGE_SEAM'
# 2 - BOTTOM
pie.operator(SEL_SIMILAR, text="Length", icon='DRIVER_DISTANCE').type = 'EDGE_LENGTH'
# 8 - TOP
pie.operator(SEL_SIMILAR, text="Sharp", icon='EDGE_SHARP').type = 'EDGE_SHARP'
# 7 - TOP - LEFT
pie.operator(SEL_SIMILAR, text="Direction", icon='ORIENTATION_NORMAL').type = 'EDGE_DIR'
# 9 - TOP - RIGHT
pie.operator(SEL_SIMILAR, text="Edge Crease", icon='EDGE_CREASE').type = 'EDGE_CREASE'
# 1 - BOTTOM - LEFT
pie.operator(SEL_SIMILAR, text="Connected Face Count", icon='FACESEL').type = 'EDGE_FACES'
# 3 - BOTTOM - RIGHT
pie.operator(SEL_SIMILAR, text="Bevel Weight", icon='EDGE_BEVEL').type = 'EDGE_BEVEL'
def _draw_edge_more_slots(pie):
# 4 - LEFT
# 6 - RIGHT
pie.operator(SEL_SIMILAR, text="Freestyle Edge Marks", icon='EDGESEL').type = 'EDGE_FREESTYLE'
# 2 - BOTTOM
pie.operator(SEL_SIMILAR, text="Face Angle", icon='NORMALS_FACE').type = 'EDGE_FACE_ANGLE'
# 8 - TOP
pie.operator('mesh.select_similar_region', text="Face Regions", icon='FACE_MAPS')
# 7 - TOP - LEFT
# 9 - TOP - RIGHT
# 1 - BOTTOM - LEFT
# 3 - BOTTOM - RIGHT
def _draw_face_slots(pie):
# 4 - LEFT
pie.operator(SEL_SIMILAR, text="Flat/Smooth", icon='SHADING_SOLID').type = 'FACE_SMOOTH'
# 6 - RIGHT
pie.operator(SEL_SIMILAR, text="Material", icon='MATERIAL').type = 'FACE_MATERIAL'
# 2 - BOTTOM
pie.operator('mesh.select_similar_region', text="Face Regions", icon='FACE_MAPS')
# 8 - TOP
pie.operator(SEL_SIMILAR, text="Coplanar", icon='GRID').type = 'FACE_COPLANAR'
# 7 - TOP - LEFT
pie.operator(SEL_SIMILAR, text="Area", icon='SNAP_FACE').type = 'FACE_AREA'
# 9 - TOP - RIGHT
pie.operator(SEL_SIMILAR, text="Sides", icon='MOD_EDGESPLIT').type = 'FACE_SIDES'
# 1 - BOTTOM - LEFT
pie.operator(SEL_SIMILAR, text="Perimeter", icon='MOD_CURVE_TO_TUBE').type = 'FACE_PERIMETER'
# 3 - BOTTOM - RIGHT
pie.operator(SEL_SIMILAR, text="Normal", icon='NORMALS_FACE').type = 'FACE_NORMAL'
class PIE_MT_select_similar_pose(Menu):
bl_idname = "PIE_MT_select_similar_pose"
bl_label = "Select Similar"
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator(POSE_OT_select_parent_bone.bl_idname, text="Parent", icon='FILE_PARENT')
# 6 - RIGHT
pie.operator("pose.select_grouped", text="Direct Children", icon='CON_CHILDOF').type = 'CHILDREN_IMMEDIATE'
# 2 - BOTTOM
pie.operator("pose.select_grouped", text="Colour", icon='COLOR').type = 'COLOR'
# 8 - TOP
pie.operator(POSE_OT_select_similar_custom_shape.bl_idname, text="Custom Shape", icon='OBJECT_DATA')
# 7 - TOP - LEFT
pie.operator("pose.select_grouped", text="Collection", icon='OUTLINER_COLLECTION').type = 'COLLECTION'
# 9 - TOP - RIGHT
pie.operator("pose.select_grouped", text="Siblings", icon='PARTICLES').type = 'SIBLINGS'
# 1 - BOTTOM - LEFT
pie.operator(POSE_OT_select_similar_deform.bl_idname, text="Deform", icon='MOD_ARMATURE')
# 3 - BOTTOM - RIGHT
pie.operator("pose.select_grouped", text="All Children", icon='CON_CHILDOF').type = 'CHILDREN'
class PIE_MT_select_similar_armature(Menu):
bl_idname = "PIE_MT_select_similar_armature"
bl_label = "Select Similar"
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator(POSE_OT_select_parent_bone.bl_idname, text="Parent", icon='FILE_PARENT')
# 6 - RIGHT
pie.operator("armature.select_similar", text="Direct Children", icon='CON_CHILDOF').type = 'CHILDREN_IMMEDIATE'
# 2 - BOTTOM
pie.operator("armature.select_similar", text="Colour", icon='COLOR').type = 'COLOR'
# 8 - TOP
pie.operator("armature.select_similar", text="Shape", icon='OBJECT_DATA').type = 'SHAPE'
# 7 - TOP - LEFT
pie.operator("armature.select_similar", text="Collection", icon='OUTLINER_COLLECTION').type = 'BONE_COLLECTION'
# 9 - TOP - RIGHT
pie.operator("armature.select_similar", text="Siblings", icon='PARTICLES').type = 'SIBLINGS'
# 1 - BOTTOM - LEFT
pie.separator()
# 3 - BOTTOM - RIGHT
pie.operator("armature.select_similar", text="All Children", icon='CON_CHILDOF').type = 'CHILDREN'
class POSE_OT_select_similar_deform(Operator):
bl_idname = "pose.select_similar_deform"
bl_label = "Select Similar Deform"
bl_description = "Select all visible bones that share the active bone's Deform setting"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
if not context.active_pose_bone:
cls.poll_message_set("No active pose bone")
return False
return True
def execute(self, context: Context):
target = context.active_pose_bone.bone.use_deform
for pose_bone in context.visible_pose_bones:
pose_bone.select = (pose_bone.bone.use_deform == target)
return {'FINISHED'}
class POSE_OT_select_similar_custom_shape(Operator):
bl_idname = "pose.select_similar_custom_shape"
bl_label = "Select Similar Custom Shape"
bl_description = "Select all visible bones that share the active bone's custom shape object"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
if not context.active_pose_bone:
cls.poll_message_set("No active pose bone")
return False
return True
def execute(self, context: Context):
target = context.active_pose_bone.custom_shape
for pose_bone in context.visible_pose_bones:
pose_bone.select = (pose_bone.custom_shape == target)
return {'FINISHED'}
class POSE_OT_select_parent_bone(Operator):
"""Select parent of the current bone"""
bl_idname = "pose.select_parent_bone"
bl_label = "Select Parent Bone"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
bones = (context.selected_pose_bones or context.selected_bones or [])[:]
if not bones:
cls.poll_message_set("No selected bones.")
return False
if not any((bone.parent for bone in bones)):
cls.poll_message_set("No selected bones with parents.")
return False
if context.mode not in ('POSE', 'PAINT_WEIGHT', 'EDIT_ARMATURE'):
cls.poll_message_set("Must be in Pose, Weight Paint, or Armature Edit mode.")
return False
if context.mode == 'PAINT_WEIGHT' and not context.pose_object:
cls.poll_message_set("No pose mode armature.")
return False
return True
def execute(self, context):
bones = (context.selected_pose_bones or context.selected_bones or [])[:]
deselect_all_bones(context)
for bone in bones:
if bone.parent:
print("Parent: ", bone.parent)
reveal_and_select_bone(context, bone.parent, extend_selection=True)
else:
print("No parent!")
return {'FINISHED'}
def reveal_and_select_bone(context, bone: Bone | EditBone | PoseBone, extend_selection=False, set_active=True):
rig = active_rig(context)
reveal_bone(bone)
if not extend_selection:
deselect_all_bones(context)
if context.mode == 'EDIT_ARMATURE':
if isinstance(bone, PoseBone):
ebone = bone.id_data.data.edit_bones[bone.name]
else:
ebone = bone.id_data.edit_bones[bone.name]
ebone.select = True
ebone.select_head = True
ebone.select_tail = True
else:
if isinstance(bone, PoseBone):
pbone = bone
bone = bone.bone
elif isinstance(bone, Bone):
pbone = rig.pose.bones.get(bone.name)
pbone.hide = False
pbone.select = True
if set_active:
set_active_bone(context, bone)
def active_rig(context) -> Object | None:
"""Return the active rig even if we're in weight paint mode."""
rig = context.pose_object or context.active_object
if not rig:
return
if rig.type == 'ARMATURE':
return rig
def deselect_all_bones(context):
if context.mode == 'EDIT_ARMATURE':
bones = context.selected_editable_bones
else:
bones = [pb for pb in context.selected_pose_bones]
for bone in bones:
bone.select = False
if context.mode == 'EDIT_ARMATURE':
bone.select_head = False
bone.select_tail = False
def reveal_bone(bone: Bone | EditBone | PoseBone):
ensure_visible_bone_collection(bone)
bone.hide = False
def ensure_visible_bone_collection(bone: Bone | EditBone | PoseBone):
"""If target bone not in any enabled collections, enable first one."""
if isinstance(bone, PoseBone):
bone = bone.bone
armature = bone.id_data
collections = armature.collections
if len(bone.collections) == 0:
return
if not any([coll.is_visible_effectively for coll in bone.collections]):
coll = bone.collections[0]
while coll:
if collections.is_solo_active:
coll.is_solo = True
else:
coll.is_visible = True
coll = coll.parent
def set_active_bone(context, bone: Bone | EditBone | PoseBone):
"""Set the active bone, regardless of if we're in edit mode or not.
Also account for active vertex group when in weight paint mode.
"""
if not bone:
return
if isinstance(bone, PoseBone):
armature = bone.id_data.data
bone = bone.bone
else:
armature = bone.id_data
if context.mode == 'EDIT_ARMATURE':
edit_bone = armature.edit_bones.get(bone.name)
armature.edit_bones.active = edit_bone
else:
armature.bones.active = bone
if context.mode == 'PAINT_WEIGHT':
if bone.name in context.active_object.vertex_groups:
context.active_object.vertex_groups.active = context.active_object.vertex_groups[bone.name]
registry = [
PIE_MT_select_similar_object,
PIE_MT_select_similar_mesh,
PIE_MT_select_similar_mesh_edge_more,
PIE_MT_select_similar_pose,
PIE_MT_select_similar_armature,
POSE_OT_select_similar_deform,
POSE_OT_select_similar_custom_shape,
POSE_OT_select_parent_bone,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Object Mode",
pie_name=PIE_MT_select_similar_object.bl_idname,
hotkey_kwargs={'type': "G", 'value': "PRESS", 'shift': True},
on_drag=True,
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Mesh",
pie_name=PIE_MT_select_similar_mesh.bl_idname,
hotkey_kwargs={'type': "G", 'value': "PRESS", 'shift': True},
on_drag=True,
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Pose",
pie_name=PIE_MT_select_similar_pose.bl_idname,
hotkey_kwargs={'type': "G", 'value': "PRESS", 'shift': True},
on_drag=True,
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Armature",
pie_name=PIE_MT_select_similar_armature.bl_idname,
hotkey_kwargs={'type': "G", 'value': "PRESS", 'shift': True},
on_drag=True,
)
@@ -1,12 +1,12 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 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.types import Constraint, Context, Event, Menu, Object, Operator, OperatorProperties, UILayout
from bpy.utils import flip_name
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -17,19 +17,19 @@ class PIE_MT_object_selection(Menu):
bl_idname = "PIE_MT_object_selection"
bl_label = "Object Select"
def draw(self, context):
def draw(self, context: 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')
pie.operator("object.select_children_of_active", text="Direct Children", icon='CON_CHILDOF')
# 2 - BOTTOM
pie.operator("object.select_all", text="Deselect All", icon='OUTLINER_DATA_POINTCLOUD').action='DESELECT'
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'
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'
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
@@ -46,9 +46,9 @@ class PIE_MT_object_selection_more(Menu):
bl_idname = "PIE_MT_object_selection_more"
bl_label = "More Object Select"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
# Modal selection operators.
layout.separator()
layout.operator("view3d.select_circle", text="Circle Select", icon='MESH_CIRCLE')
@@ -75,36 +75,33 @@ class VIEW3D_MT_select_any_camera(Menu):
bl_idname = "VIEW3D_MT_select_any_camera"
bl_label = "Select Camera"
def draw(self, context):
def draw(self, context: 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
layout.operator('object.select_object_by_name', text=cam.name, icon=icon).obj_name = cam.name
### Object relationship selection operators.
# Object relationship selection operators.
def get_selected_parents(context) -> list:
def get_selected_parents(context: Context) -> list[Object]:
"""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.remove(context.active_object)
parents.insert(0, context.active_object)
return parents
def deselect_all_objects(context):
def deselect_all_objects(context: Context):
for obj in context.selected_objects:
obj.select_set(False)
@@ -115,25 +112,20 @@ class ObjectSelectOperatorMixin:
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
def invoke(self, context: Context, event: Event):
self.extend_selection = event.shift
return self.execute(context)
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if context.mode != 'OBJECT':
cls.poll_message_set("Must be in Object Mode.")
return False
return True
def execute(self, context):
def execute(self, context: Context):
if not self.extend_selection:
deselect_all_objects(context)
return {'FINISHED'}
@@ -147,7 +139,7 @@ class OBJECT_OT_select_children_of_active(Operator, ObjectSelectOperatorMixin):
recursive: BoolProperty(default=False, options={'SKIP_SAVE'})
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if not super().poll(context):
return False
obj = context.active_object
@@ -157,11 +149,11 @@ class OBJECT_OT_select_children_of_active(Operator, ObjectSelectOperatorMixin):
return True
@classmethod
def description(cls, context, props):
def description(cls, _context: Context, props: OperatorProperties) -> str:
recursively = " recursively" if props.recursive else ""
return f"Select children of active object{recursively}." + "\n\nShift: Extend current selection"
def execute(self, context):
def execute(self, context: Context):
super().execute(context)
counter = 0
@@ -185,7 +177,7 @@ class OBJECT_OT_select_siblings_of_active(Operator, ObjectSelectOperatorMixin):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if not super().poll(context):
return False
obj = context.active_object
@@ -197,7 +189,7 @@ class OBJECT_OT_select_siblings_of_active(Operator, ObjectSelectOperatorMixin):
return False
return True
def execute(self, context):
def execute(self, context: Context):
super().execute(context)
counter = 0
@@ -218,7 +210,7 @@ class OBJECT_OT_select_parent_object(Operator, ObjectSelectOperatorMixin):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if not super().poll(context):
return False
obj = context.active_object
@@ -227,14 +219,11 @@ class OBJECT_OT_select_parent_object(Operator, ObjectSelectOperatorMixin):
return False
return True
def execute(self, context):
def execute(self, context: 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'}
@@ -246,7 +235,7 @@ class OBJECT_OT_select_active_camera(Operator, ObjectSelectOperatorMixin):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
cam = get_current_camera(context)
if not cam:
cls.poll_message_set("No active camera.")
@@ -256,17 +245,17 @@ class OBJECT_OT_select_active_camera(Operator, ObjectSelectOperatorMixin):
return False
return True
def execute(self, context):
def execute(self, context: Context):
super().execute(context)
cam = get_current_camera(context)
if context.active_object and context.mode != 'MODE':
if context.active_object and context.mode != 'MODE': # TODO: 'MODE' looks like a typo, should be 'OBJECT'?
bpy.ops.object.mode_set(mode='OBJECT')
cam.select_set(True)
context.view_layer.objects.active = cam
return {'FINISHED'}
### Object name-based selection operators.
# Object name-based selection operators.
class PIE_MT_select_object_name_relation(Menu):
@@ -274,35 +263,30 @@ class PIE_MT_select_object_name_relation(Menu):
bl_label = "Select by Name"
@classmethod
def poll(cls, context):
return context.active_object
def poll(cls, context: Context) -> bool:
return bool(context.active_object)
def draw(self, context):
def draw(self, context: 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',
)
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
pie.operator('object.select_object_by_name', text=lower_obj.name, icon='TRIA_DOWN').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")
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
pie.operator('object.select_object_by_name', text=higher_obj.name, icon='TRIA_UP').obj_name = higher_obj.name
else:
pie.separator()
# 7 - TOP - LEFT
@@ -322,7 +306,7 @@ class PIE_MT_select_object_name_relation(Menu):
else:
pie.separator()
# 1 - BOTTOM - LEFT
pie.operator("object.select_pattern", text=f"Select Pattern...", icon='FILTER')
pie.operator("object.select_pattern", text="Select Pattern...", icon='FILTER')
# 3 - BOTTOM - RIGHT
pie.separator()
@@ -335,7 +319,7 @@ class OBJECT_OT_select_symmetry_object(Operator, ObjectSelectOperatorMixin):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
scene_obs = context.scene.objects
sel_obs = context.selected_objects[:]
active_obj = context.active_object
@@ -349,7 +333,7 @@ class OBJECT_OT_select_symmetry_object(Operator, ObjectSelectOperatorMixin):
return False
return True
def execute(self, context):
def execute(self, context: Context):
scene_obs = context.scene.objects
sel_obs = context.selected_objects[:]
active_obj = context.active_object
@@ -357,16 +341,15 @@ class OBJECT_OT_select_symmetry_object(Operator, ObjectSelectOperatorMixin):
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)])
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 ob:
ob.select_set(True)
if flipped_active:
context.view_layer.objects.active = flipped_active
@@ -383,30 +366,25 @@ class OBJECT_OT_select_object_by_name_search(Operator, ObjectSelectOperatorMixin
obj_name: StringProperty(name="Object")
def invoke(self, context, _event):
def invoke(self, context: Context, _event: Event):
obj = context.active_object
if obj:
self.obj_name = obj.name
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
def draw(self, context: 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_search(self, 'obj_name', context.scene, 'objects', icon='OBJECT_DATA')
layout.prop(self, 'extend_selection')
def execute(self, context):
def execute(self, context: 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'}
@@ -417,11 +395,9 @@ class OBJECT_OT_select_object_by_name(Operator, ObjectSelectOperatorMixin):
bl_label = "Select Object By Name"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
obj_name: StringProperty(
name="Object Name", description="Name of the object to select"
)
obj_name: StringProperty(name="Object Name", description="Name of the object to select")
def execute(self, context):
def execute(self, context: Context):
obj = context.scene.objects.get(self.obj_name)
if not obj:
@@ -433,27 +409,19 @@ class OBJECT_OT_select_object_by_name(Operator, ObjectSelectOperatorMixin):
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.
def increment_name(name: str, increment: int = 1, default_zfill: int = 1) -> str:
"""Increment the last number in name.
# 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.
Negative numbers are clamped to 0. Digit length is preserved (10 decrements to 09),
except on overflow (99 increments to 100). If name contains no number, one is appended
with default_zfill digit width. Special case: decrementing a '.001' suffix strips it.
"""
if name.endswith(".001") and increment == -1:
return name[:-4]
numbers_in_name = re.findall(r'\d+', name)
@@ -462,25 +430,27 @@ def increment_name(name: str, increment=1, default_zfill=1) -> str:
last = numbers_in_name[-1]
incremented = str(max(0, int(last) + increment)).zfill(len(last))
split = name.rsplit(last, 1)
return incremented.join(split)
return incremented.join(name.rsplit(last, 1))
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)]
def get_constraints_with_target(context: Context) -> list[Constraint]:
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 poll(cls, context: Context) -> bool:
return bool(cls.get_constraints_with_target(context))
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
for constraint in self.get_constraint_targets(context):
for constraint in self.get_constraints_with_target(context):
draw_select_constraint_target(layout, constraint)
@@ -488,9 +458,11 @@ class OBJECT_MT_PIE_constrained_objects(Menu):
bl_label = "Constrained Objects"
@staticmethod
def get_dependent_constraints(context):
def get_dependent_constraints(context: Context) -> list[Constraint]:
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)]
dependent_objects = [
id for id in dependent_ids if isinstance(id, Object) and id in set(context.scene.objects)
]
ret = []
for dependent_object in dependent_objects:
for con in dependent_object.constraints:
@@ -500,32 +472,31 @@ class OBJECT_MT_PIE_constrained_objects(Menu):
return ret
@classmethod
def poll(cls, context):
return cls.get_dependent_constraints(context)
def poll(cls, context: Context) -> bool:
return bool(cls.get_dependent_constraints(context))
def draw(self, context):
def draw(self, context: 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):
def draw_select_constraint_target(layout: UILayout, constraint: 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
).obj_name = constraint.target.name
def draw_select_constraint_owner(layout, constraint):
def draw_select_constraint_owner(layout: UILayout, constraint: 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
).obj_name = constraint.id_data.name
def get_constraint_icon(constraint: Constraint) -> str:
@@ -534,18 +505,19 @@ def get_constraint_icon(constraint: Constraint) -> str:
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
# 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.
# Mesh selection UI.
class PIE_MT_mesh_selection(Menu):
bl_idname = "PIE_MT_mesh_selection"
bl_label = "Mesh Select"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
@@ -553,15 +525,15 @@ class PIE_MT_mesh_selection(Menu):
# 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'
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'
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'
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'
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')
@@ -570,9 +542,8 @@ class PIE_MT_mesh_selection_more(Menu):
bl_idname = "PIE_MT_mesh_selection_more"
bl_label = "Mesh Selection Mode"
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
vert_mode, edge_mode, face_mode = context.tool_settings.mesh_select_mode
# Selection modes.
@@ -606,13 +577,20 @@ class PIE_MT_mesh_selection_loops(Menu):
bl_idname = "PIE_MT_mesh_selection_loops"
bl_label = "Select Loop"
def draw(self, context):
def draw(self, _context: 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
if bpy.app.version < (5, 1, 0):
# 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
else:
# 4 - LEFT
pie.operator('mesh.select_edge_loop_multi', text="Edge Loops", icon='MOD_WAVE')
# 6 - RIGHT
pie.operator('mesh.select_edge_ring_multi', text="Edge Rings", icon='CURVES')
# 2 - BOTTOM
pie.operator('mesh.loop_to_region', text="Loop Inner Region", icon='SHADING_SOLID')
# 8 - TOP
@@ -620,31 +598,26 @@ class PIE_MT_mesh_selection_loops(Menu):
registry = [
PIE_MT_object_selection,
PIE_MT_select_object_name_relation,
PIE_MT_object_selection_more,
PIE_MT_object_selection,
PIE_MT_mesh_selection_loops,
PIE_MT_mesh_selection_more,
PIE_MT_mesh_selection,
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):
def draw_edge_sharpness_in_traits_menu(self, _context: Context):
self.layout.operator("mesh.edges_select_sharp", text="Edge Sharpness")
@@ -658,7 +631,7 @@ def register():
hotkey_kwargs={'type': "A", 'value': "PRESS"},
on_drag=True,
default_fallback_op='object.select_all',
default_fallback_kwargs={"action": "TOGGLE"}
default_fallback_kwargs={"action": "TOGGLE"},
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name='Object Mode',
@@ -672,7 +645,7 @@ def register():
hotkey_kwargs={'type': "A", 'value': "PRESS"},
on_drag=True,
default_fallback_op='mesh.select_all',
default_fallback_kwargs={"action": "TOGGLE"}
default_fallback_kwargs={"action": "TOGGLE"},
)
@@ -1,10 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.props import StringProperty
from bpy.types import Context, Menu, Object, Operator, OperatorProperties
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -13,7 +13,7 @@ class PIE_MT_set_origin(Menu):
bl_idname = "PIE_MT_set_origin"
bl_label = "Set Origin"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
@@ -29,9 +29,7 @@ class PIE_MT_set_origin(Menu):
icon='SNAP_PEEL_OBJECT',
).type = 'ORIGIN_GEOMETRY'
# 2 - BOTTOM
pie.operator(
"object.origin_set_to_bottom", text="Origin -> Bottom", icon='TRIA_DOWN'
)
pie.operator("object.origin_set_to_bottom", text="Origin -> Bottom", icon='TRIA_DOWN')
# 8 - TOP
pie.operator(
"object.origin_set_to_selection",
@@ -39,9 +37,7 @@ class PIE_MT_set_origin(Menu):
icon='RESTRICT_SELECT_OFF',
)
# 7 - TOP - LEFT
pie.operator(
"object.origin_set_any_mode", text="Origin -> Cursor", icon='PIVOT_CURSOR'
).type = 'ORIGIN_CURSOR'
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'
@@ -53,7 +49,7 @@ class PIE_MT_set_origin(Menu):
"object.origin_set_any_mode",
text="Origin -> Volume",
icon='SNAP_FACE_CENTER',
).type = 'ORIGIN_CENTER_OF_MASS'
).type = 'ORIGIN_CENTER_OF_VOLUME'
class OBJECT_OT_set_origin_to_selection(Operator):
@@ -62,7 +58,7 @@ class OBJECT_OT_set_origin_to_selection(Operator):
bl_description = "Snap the origin to the selection"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, context: Context):
org_mode = 'OBJECT'
if context.active_object:
org_mode = context.active_object.mode
@@ -85,15 +81,13 @@ class OBJECT_OT_set_origin_to_bottom(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not any(
[ob.type in {'MESH', 'ARMATURE'} for ob in context.selected_objects]
):
def poll(cls, context: Context) -> bool:
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):
def execute(self, context: Context):
org_active_obj = context.active_object
for obj in context.selected_objects:
@@ -108,14 +102,12 @@ class OBJECT_OT_set_origin_to_bottom(Operator):
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."
)
self.report({'INFO'}, f"Moved the origins of {counter} objects to their lowest point.")
return {'FINISHED'}
@staticmethod
def origin_to_bottom(context, obj) -> bool:
def origin_to_bottom(context: Context, obj: Object) -> bool:
if obj.type not in {'MESH', 'ARMATURE'}:
return False
@@ -124,13 +116,11 @@ class OBJECT_OT_set_origin_to_bottom(Operator):
try:
if obj.type == 'MESH':
bpy.ops.object.mode_set(mode='OBJECT')
min_z = min([v.co.z for v in obj.data.vertices])
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]
)
min_z = min(min(bone.head.z, bone.tail.z) for bone in obj.data.edit_bones)
else:
return False
except ValueError:
@@ -168,10 +158,10 @@ class OBJECT_OT_set_origin_any_mode(Operator):
}
@classmethod
def description(cls, context, params):
def description(cls, _context: Context, params: OperatorProperties) -> str:
return cls.messages[params.type].replace("Snapped", "Snap")[:-1]
def execute(self, context):
def execute(self, context: Context):
org_mode = 'OBJECT'
if context.active_object:
org_mode = context.active_object.mode
@@ -1,8 +1,8 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from bpy.types import Context, Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -11,7 +11,7 @@ class PIE_MT_view_pie(Menu):
bl_idname = "PIE_MT_view_pie"
bl_label = "3D View"
def draw(self, context):
def draw(self, context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('view3d.render_border', text="Set Render Border", icon='OBJECT_HIDDEN')
@@ -24,15 +24,15 @@ class PIE_MT_view_pie(Menu):
# 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
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
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
pie.operator("view3d.localview", text="Enter Local View", icon='SOLO_ON').frame_selected = False
registry = [
@@ -5,7 +5,7 @@
from sys import platform
import bpy
from bpy.types import Menu, Operator
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -14,16 +14,14 @@ class PIE_MT_window(Menu):
bl_idname = "PIE_MT_window"
bl_label = "Window"
def draw(self, context):
def draw(self, context: 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'
)
pie.operator("screen.screen_full_area", text="Maximize Area", icon='PIVOT_BOUNDBOX')
else:
pie.operator("wm.exit_area_fullscreen", icon='SCREEN_BACK')
# 2 - BOTTOM
@@ -59,7 +57,7 @@ class WM_OT_exit_area_fullscreen(Operator):
bl_label = "Exit Area Fullscreen"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, context: Context):
if context.screen.show_fullscreen:
bpy.ops.screen.screen_full_area()
if context.screen.show_fullscreen:
@@ -1,21 +1,24 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import platform, struct, urllib
import platform
import struct
import urllib.parse
import bpy
import addon_utils
from bpy.types import AddonPreferences, KeyMap, KeyMapItem
from bpy.props import BoolProperty
import bpy
from bl_ui.space_userpref import USERPREF_PT_interface_menus_pie
from bpy.props import BoolProperty
from bpy.types import AddonPreferences, Context, KeyMap, KeyMapItem, UILayout
from .bs_utils.hotkeys import draw_hotkey_list, get_sidebar
from .bs_utils.prefs import get_addon_prefs
from .bs_utils.hotkeys import get_sidebar, draw_hotkey_list
class ExtraPies_AddonPrefs(
AddonPreferences,
USERPREF_PT_interface_menus_pie, # We use this class's `draw_centered` function to draw built-in pie settings.
USERPREF_PT_interface_menus_pie, # We use this class's `draw_centered` function to draw built-in pie settings.
):
bl_idname = __package__
@@ -30,10 +33,11 @@ class ExtraPies_AddonPrefs(
description="Enable some debugging UI",
)
def draw(self, context):
def draw(self, context: Context):
draw_prefs(self.layout, context, compact=False)
def draw_prefs(layout, context, compact=False):
def draw_prefs(layout: UILayout, context: Context, compact: bool = False):
prefs = get_addon_prefs(context)
sidebar = get_sidebar(context)
@@ -46,9 +50,7 @@ def draw_prefs(layout, context, compact=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.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')
@@ -64,9 +66,12 @@ def draw_prefs(layout, context, compact=False):
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)
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):
def button_draw_func(layout: UILayout, km: KeyMap, kmi: KeyMapItem, compact: bool = 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)
@@ -75,17 +80,18 @@ def button_draw_func(layout, km: KeyMap, kmi: KeyMapItem, compact=False):
if kmi.idname != 'wm.call_menu_pie_drag_only':
return
if not kmi.properties:
sub.label(text="Missing properties. This should never happen!")
layout.label(text="Missing properties. This should never happen!")
return
text = "" if compact else "Drag"
sub = split.row(align=True)
sub.enabled = kmi.active
sub.context_pointer_set("keymapitem", kmi)
sub.use_property_split=False
sub.use_property_split = False
sub.prop(kmi.properties, 'on_drag', icon='MOUSE_MOVE', text=text)
def get_bug_report_url():
def get_bug_report_url() -> str:
op_sys = "%s %d Bits\n" % (
platform.platform(),
struct.calcsize("P") * 8,
@@ -114,4 +120,5 @@ def get_bug_report_url():
+ urllib.parse.quote(op_sys)
)
registry = [ExtraPies_AddonPrefs]
@@ -2,11 +2,13 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Context, Panel
from .bs_utils.prefs import get_addon_prefs
from .prefs import draw_prefs
class VIEW3D_PT_extra_pies(bpy.types.Panel):
class VIEW3D_PT_extra_pies(Panel):
bl_label = "Extra Pies"
bl_idname = "VIEW3D_PT_extra_pies"
bl_space_type = 'VIEW_3D'
@@ -14,15 +16,16 @@ class VIEW3D_PT_extra_pies(bpy.types.Panel):
bl_category = 'Extra Pies'
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
prefs = get_addon_prefs(context)
return prefs.show_in_sidebar
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
draw_prefs(layout, context, compact=True)
registry = [
VIEW3D_PT_extra_pies,
]
]