diff --git a/source/pie_selection.py b/source/pie_selection.py index ae598d6..a9b0be0 100644 --- a/source/pie_selection.py +++ b/source/pie_selection.py @@ -2,8 +2,12 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -from bpy.types import Menu +import bpy, re +from bpy.types import Menu, Operator, Constraint, UILayout, Object from .hotkeys import register_hotkey +from bpy.props import BoolProperty, StringProperty +from bpy.utils import flip_name +from .pie_camera import get_current_camera class PIE_MT_object_selection(Menu): @@ -14,25 +18,517 @@ class PIE_MT_object_selection(Menu): layout = self.layout pie = layout.menu_pie() # 4 - LEFT - pie.operator("object.select_grouped", text="Select Grouped") + pie.operator("object.select_parent_object", text="Parent", icon='FILE_PARENT') # 6 - RIGHT - pie.operator("object.select_by_type", text="Select By Type") + pie.operator("object.select_children_of_active", text=f"Direct Children", icon='CON_CHILDOF') # 2 - BOTTOM - pie.operator( - "object.select_all", text="Invert Selection", icon='ZOOM_PREVIOUS' - ).action = 'INVERT' + pie.operator("object.select_all", text="Deselect All", icon='OUTLINER_DATA_POINTCLOUD').action='DESELECT' # 8 - TOP - pie.operator( - "object.select_all", text="Select All Toggle", icon='NONE' - ).action = 'TOGGLE' + pie.operator("object.select_all", text="Select All", icon='OUTLINER_OB_POINTCLOUD').action='SELECT' # 7 - TOP - LEFT - pie.operator("view3d.select_circle", text="Circle Select") + pie.operator("object.select_all", text="Invert Selection", icon='CLIPUV_DEHLT').action='INVERT' # 9 - TOP - RIGHT - pie.operator("view3d.select_box", text="Box Select") + pie.operator("object.select_siblings_of_active", text="Siblings", icon='PARTICLES') # 1 - BOTTOM - LEFT - pie.operator("object.select_camera", text="Select Camera") + text = "Active Camera" + cam = get_current_camera(context) + if cam: + text += f" ({cam.name})" + pie.operator("object.select_active_camera", text=text, icon='OUTLINER_OB_CAMERA') # 3 - BOTTOM - RIGHT - pie.menu("PIE_MT_object_selection_more", text="Select Menu") + pie.menu("PIE_MT_object_selection_more", text="Select Menu", icon='THREE_DOTS') + + +class PIE_MT_object_selection_more(Menu): + bl_idname = "PIE_MT_object_selection_more" + bl_label = "More Object Select" + + def draw(self, context): + layout = self.layout + + # Modal selection operators. + layout.separator() + layout.operator("view3d.select_circle", text="Circle Select", icon='MESH_CIRCLE') + layout.operator("view3d.select_box", text="Box Select", icon='SELECT_SET') + layout.operator_menu_enum("view3d.select_lasso", "mode", icon='MOD_CURVE') + + # Select based on the active object. + layout.separator() + layout.operator_menu_enum("object.select_grouped", "type", text="Select Grouped", icon='OUTLINER_COLLECTION') + layout.operator_menu_enum("object.select_linked", "type", text="Select Linked", icon='DUPLICATE') + + # Select based on parameters. + layout.separator() + layout.operator_menu_enum("object.select_by_type", "type", text="Select All by Type", icon='OUTLINER_OB_MESH') + layout.operator("object.select_random", text="Select Random", icon='RNDCURVE') + layout.operator("object.select_pattern", text="Select Pattern...", icon='FILTER') + layout.operator('object.select_by_name_search', icon='VIEWZOOM') + + layout.separator() + layout.menu("VIEW3D_MT_select_any_camera", text="Select Camera", icon='OUTLINER_OB_CAMERA') + + +class VIEW3D_MT_select_any_camera(Menu): + bl_idname = "VIEW3D_MT_select_any_camera" + bl_label = "Select Camera" + + def draw(self, context): + layout = self.layout + active_cam = get_current_camera(context) + + all_cams = [obj for obj in sorted(context.scene.objects, key=lambda o: o.name) if obj.type == 'CAMERA'] + + for cam in all_cams: + icon = 'OUTLINER_DATA_CAMERA' + if cam == active_cam: + icon = 'OUTLINER_OB_CAMERA' + layout.operator('object.select_object_by_name', text=cam.name, icon=icon).obj_name=cam.name + + +### Object relationship selection operators. + + +def get_selected_parents(context) -> list: + """Return objects which are selected or active, and have children. + Active object is always first.""" + objects = context.selected_objects or [context.active_object] + parents = [o for o in objects if o.children] + if context.active_object and context.active_object in parents: + idx = parents.index(context.active_object) + if idx > -1: + parents.pop(idx) + parents.insert(0, context.active_object) + return parents + + +def deselect_all_objects(context): + for obj in context.selected_objects: + obj.select_set(False) + + +class ObjectSelectOperatorMixin: + extend_selection: BoolProperty( + name="Extend Selection", + description="Objects that are already selected will remain selected", + ) + + def invoke(self, context, event): + if event.shift: + self.extend_selection = True + else: + self.extend_selection = False + + return self.execute(context) + + @classmethod + def poll(cls, context): + if context.mode != 'OBJECT': + cls.poll_message_set("Must be in Object Mode.") + return False + return True + + def execute(self, context): + if not self.extend_selection: + deselect_all_objects(context) + + return {'FINISHED'} + + +class OBJECT_OT_select_children_of_active(Operator, ObjectSelectOperatorMixin): + """Select the children of the active object""" + + bl_idname = "object.select_children_of_active" + bl_label = "Select Children" + bl_options = {'REGISTER', 'UNDO'} + + recursive: BoolProperty(default=False, options={'SKIP_SAVE'}) + + @classmethod + def poll(cls, context): + if not super().poll(context): + return False + obj = context.active_object + if not (obj and obj.children): + cls.poll_message_set("Active object has no children.") + return False + return True + + @classmethod + def description(cls, context, props): + recursively = " recursively" if props.recursive else "" + return f"Select children of active object{recursively}." + "\n\nShift: Extend current selection" + + def execute(self, context): + super().execute(context) + + counter = 0 + children = context.active_object.children + if self.recursive: + children = context.active_object.children_recursive + for child in children: + if not child.select_get(): + counter += 1 + child.select_set(True) + + self.report({'INFO'}, f"Selected {counter} objects.") + return {'FINISHED'} + + +class OBJECT_OT_select_siblings_of_active(Operator, ObjectSelectOperatorMixin): + """Select siblings of active object.\n\nShift: Extend current selection""" + + bl_idname = "object.select_siblings_of_active" + bl_label = "Select Siblings" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + if not super().poll(context): + return False + obj = context.active_object + if not (obj and obj.parent): + cls.poll_message_set("Active object has no parent.") + return False + if not len(obj.parent.children) > 1: + cls.poll_message_set("Active object has no siblings.") + return False + return True + + def execute(self, context): + super().execute(context) + + counter = 0 + for child in context.active_object.parent.children: + if not child.select_get(): + counter += 1 + child.select_set(True) + + self.report({'INFO'}, f"Selected {counter} objects.") + return {'FINISHED'} + + +class OBJECT_OT_select_parent_object(Operator, ObjectSelectOperatorMixin): + """Select parent of the active object.\n\nShift: Extend current selection""" + + bl_idname = "object.select_parent_object" + bl_label = "Select Parent Object" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + if not super().poll(context): + return False + obj = context.active_object + if not (obj and obj.parent): + cls.poll_message_set("Active object has no parent.") + return False + return True + + def execute(self, context): + super().execute(context) + + active_obj = context.active_object + + active_obj.parent.select_set(True) + context.view_layer.objects.active = active_obj.parent + + return {'FINISHED'} + + +class OBJECT_OT_select_active_camera(Operator, ObjectSelectOperatorMixin): + """Select the active camera of the scene or viewport""" + + bl_idname = "object.select_active_camera" + bl_label = "Select Active Camera" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + cam = get_current_camera(context) + if not cam: + cls.poll_message_set("No active camera.") + return False + if not cam.visible_get(): + cls.poll_message_set("Camera is hidden, it cannot be selected.") + return False + return True + + def execute(self, context): + super().execute(context) + cam = get_current_camera(context) + if context.active_object and context.mode != 'MODE': + bpy.ops.object.mode_set(mode='OBJECT') + cam.select_set(True) + context.view_layer.objects.active = cam + return {'FINISHED'} + + +### Object name-based selection operators. + + +class PIE_MT_select_object_name_relation(Menu): + bl_idname = 'PIE_MT_select_object_name_relation' + bl_label = "Select by Name" + + def draw(self, context): + layout = self.layout + active_obj = context.active_object + + pie = layout.menu_pie() + + # 4 - LEFT + pie.operator( + OBJECT_OT_select_symmetry_object.bl_idname, + text="Flip Selection", + icon='MOD_MIRROR', + ) + # 6 - RIGHT + pie.operator('object.select_by_name_search', icon='VIEWZOOM') + # 2 - BOTTOM + lower_obj = context.scene.objects.get(increment_name(active_obj.name, increment=-1)) + if lower_obj: + op = pie.operator('object.select_object_by_name', text=lower_obj.name, icon='TRIA_DOWN') + op.obj_name = lower_obj.name + else: + pie.separator() + # 8 - TOP + higher_obj = context.scene.objects.get(increment_name(active_obj.name, increment=1)) or context.scene.objects.get(active_obj.name + ".001") + if higher_obj: + op = pie.operator('object.select_object_by_name', text=higher_obj.name, icon='TRIA_UP') + op.obj_name = higher_obj.name + else: + pie.separator() + # 7 - TOP - LEFT + constraints = OBJECT_MT_PIE_constrained_objects.get_dependent_constraints(context) + if len(constraints) == 1: + draw_select_constraint_owner(pie, constraints[0]) + elif len(constraints) > 1: + pie.menu('OBJECT_MT_PIE_constrained_objects', icon='THREE_DOTS') + else: + pie.separator() + # 9 - TOP - RIGHT + constraints = OBJECT_MT_PIE_obj_constraint_targets.get_constraints_with_target(context) + if len(constraints) == 1: + draw_select_constraint_target(pie, constraints[0]) + elif len(constraints) > 1: + pie.menu('OBJECT_MT_PIE_obj_constraint_targets', icon='THREE_DOTS') + else: + pie.separator() + # 1 - BOTTOM - LEFT + pie.operator("object.select_pattern", text=f"Select Pattern...", icon='FILTER') + # 3 - BOTTOM - RIGHT + pie.separator() + + +class OBJECT_OT_select_symmetry_object(Operator, ObjectSelectOperatorMixin): + """Select opposite objects.\n\nShift: Extend current selection""" + + bl_idname = "object.select_opposite" + bl_label = "Select Opposite Object" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + scene_obs = context.scene.objects + sel_obs = context.selected_objects[:] + active_obj = context.active_object + flipped_objs = [scene_obs.get(flip_name(ob.name)) for ob in sel_obs] + flipped_active = scene_obs.get(flip_name(active_obj.name)) + if not (flipped_active or any(flipped_objs)): + cls.poll_message_set("No selected objects with corresponding opposite objects.") + return False + return True + + def execute(self, context): + scene_obs = context.scene.objects + sel_obs = context.selected_objects[:] + active_obj = context.active_object + + flipped_objs = [scene_obs.get(flip_name(ob.name)) for ob in sel_obs] + flipped_active = scene_obs.get(flip_name(active_obj.name)) + + notflipped = sum([flipped_obj in (obj, None) for obj, flipped_obj in zip(sel_obs, flipped_objs)]) + if notflipped > 0: + self.report({'WARNING'}, f"{notflipped} objects had no opposite.") + + super().execute(context) + + for ob in flipped_objs: + if not ob: + continue + ob.select_set(True) + + if flipped_active: + context.view_layer.objects.active = flipped_active + + return {'FINISHED'} + + +class OBJECT_OT_select_object_by_name_search(Operator, ObjectSelectOperatorMixin): + """Select an object via a search box""" + + bl_idname = "object.select_by_name_search" + bl_label = "Search Object..." + bl_options = {'REGISTER', 'UNDO'} + + obj_name: StringProperty(name="Object") + + def invoke(self, context, _event): + obj = context.active_object + if obj: + self.obj_name = obj.name + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False + + layout.prop_search( + self, 'obj_name', context.scene, 'objects', icon='OBJECT_DATA' + ) + layout.prop(self, 'extend_selection') + + def execute(self, context): + obj = context.scene.objects.get(self.obj_name) + if not self.extend_selection: + deselect_all_objects(context) + + obj.select_set(True) + context.view_layer.objects.active = obj + + return {'FINISHED'} + + +class OBJECT_OT_select_object_by_name(Operator, ObjectSelectOperatorMixin): + """Select this object.\n\nShift: Extend current selection""" + + bl_idname = "object.select_object_by_name" + bl_label = "Select Object By Name" + bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} + + obj_name: StringProperty( + name="Object Name", description="Name of the object to select" + ) + + def execute(self, context): + obj = context.scene.objects.get(self.obj_name) + + if not obj: + self.report({'ERROR'}, "Object name not found in scene: " + self.obj_name) + return {'CANCELLED'} + + if not obj.visible_get(): + self.report({'ERROR'}, "Object is hidden, so it cannot be selected.") + return {'CANCELLED'} + + super().execute(context) + + obj.select_set(True) + context.view_layer.objects.active = obj + + return {'FINISHED'} + + +def increment_name(name: str, increment=1, default_zfill=1) -> str: + # Increment LAST number in the name. + # Negative numbers will be clamped to 0. + # Digit length will be preserved, so 10 will decrement to 09. + # 99 will increment to 100, not 00. + + # If no number was found, one will be added at the end of the base name. + # The length of this in digits is set with the `default_zfill` param. + + # This is not meant to be able to add a whole .001 suffix at the end of a name, + # although we can account for the inverse of that easily enough. + + if name.endswith(".001") and increment==-1: + # Special case. + return name[:-4] + + numbers_in_name = re.findall(r'\d+', name) + if not numbers_in_name: + return name + "_" + str(max(0, increment)).zfill(default_zfill) + + last = numbers_in_name[-1] + incremented = str(max(0, int(last) + increment)).zfill(len(last)) + split = name.rsplit(last, 1) + return incremented.join(split) + + +class OBJECT_MT_PIE_obj_constraint_targets(Menu): + bl_label = "Constraint Targets" + + @staticmethod + def get_constraints_with_target(context): + return [con for con in context.active_object.constraints if hasattr(con, 'target') and con.target in set(context.scene.objects)] + + @classmethod + def poll(cls, context): + return cls.get_constraints_with_target(context) + + def draw(self, context): + layout = self.layout + + for constraint in self.get_constraint_targets(context): + draw_select_constraint_target(layout, constraint) + + +class OBJECT_MT_PIE_constrained_objects(Menu): + bl_label = "Constrained Objects" + + @staticmethod + def get_dependent_constraints(context): + dependent_ids = bpy.data.user_map()[context.active_object] + dependent_objects = [id for id in dependent_ids if type(id)==Object and id in set(context.scene.objects)] + ret = [] + for dependent_object in dependent_objects: + for con in dependent_object.constraints: + if hasattr(con, 'target') and con.target == context.active_object: + ret.append(con) + break + return ret + + @classmethod + def poll(cls, context): + return cls.get_dependent_constraints(context) + + def draw(self, context): + layout = self.layout + + for constraint in self.get_dependent_constraints(context): + draw_select_constraint_owner(layout, constraint) + + +def draw_select_constraint_target(layout, constraint): + icon = get_constraint_icon(constraint) + layout.operator( + 'object.select_object_by_name', + text=f"Constraint Target: {constraint.target.name} ({constraint.name})", + icon=icon, + ).obj_name=constraint.target.name + + +def draw_select_constraint_owner(layout, constraint): + icon = get_constraint_icon(constraint) + layout.operator( + 'object.select_object_by_name', + text=f"Constrained Object: {constraint.id_data.name} ({constraint.name})", + icon=icon, + ).obj_name=constraint.id_data.name + + +def get_constraint_icon(constraint: Constraint) -> str: + """We do not ask questions about this function. We accept it.""" + if constraint.type == 'ACTION': + return 'ACTION' + + icons = UILayout.bl_rna.functions["prop"].parameters["icon"].enum_items.keys() + # This magic number can change between blender versions. Last updated: 4.1.1 + constraint_icon_magic_offset = 42 + return icons[UILayout.icon(constraint) - constraint_icon_magic_offset] + +### Mesh selection UI. class PIE_MT_mesh_selection(Menu): @@ -43,89 +539,125 @@ class PIE_MT_mesh_selection(Menu): layout = self.layout pie = layout.menu_pie() # 4 - LEFT - pie.operator("mesh.select_less", text="Select Less") + pie.operator("mesh.select_less", text="Select Less", icon='REMOVE') # 6 - RIGHT - pie.operator("mesh.select_more", text="Select More") + pie.operator("mesh.select_more", text="Select More", icon='ADD') # 2 - BOTTOM - pie.menu("VIEW3D_MT_edit_mesh_select_loops") + pie.operator("mesh.select_all", text="Deselect All", icon='OUTLINER_DATA_POINTCLOUD').action='DESELECT' # 8 - TOP - pie.operator("mesh.select_all", text="Select All Toggle").action = 'TOGGLE' + pie.operator("mesh.select_all", text="Select All", icon='OUTLINER_OB_POINTCLOUD').action='SELECT' # 7 - TOP - LEFT - pie.operator("view3d.select_circle", text="Circle Select") + pie.operator("mesh.select_all", text="Invert Selection", icon='CLIPUV_DEHLT').action='INVERT' # 9 - TOP - RIGHT - pie.operator("view3d.select_box", text="Box Select") + pie.operator("mesh.select_linked", text="Select Linked", icon='FILE_3D') # 1 - BOTTOM - LEFT - pie.operator("mesh.select_all", text="Invert Selection").action = 'INVERT' + 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_mode", text="Edit Modes", icon='VERTEXSEL') + pie.menu("PIE_MT_mesh_selection_more", text="More...", icon='THREE_DOTS') -class PIE_MT_object_selection_more(Menu): - bl_idname = "PIE_MT_object_selection_more" - bl_label = "More Object Select" - - def draw(self, context): - layout = self.layout - pie = layout.menu_pie() - box = pie.split().column() - box.operator("object.select_random", text="Select Random") - box.operator("object.select_linked", text="Select Linked") - box.separator() - - box.operator("object.select_more", text="More") - box.operator("object.select_less", text="Less") - box.separator() - - props = box.operator("object.select_hierarchy", text="Parent") - props.extend = False - props.direction = 'PARENT' - - props = box.operator("object.select_hierarchy", text="Child") - props.extend = False - props.direction = 'CHILD' - box.separator() - - props = box.operator("object.select_hierarchy", text="Extend Parent") - props.extend = True - props.direction = 'PARENT' - - props = box.operator("object.select_hierarchy", text="Extend Child") - props.extend = True - props.direction = 'CHILD' - - -class PIE_MT_mesh_selection_mode(Menu): - bl_idname = "PIE_MT_mesh_selection_mode" +class PIE_MT_mesh_selection_more(Menu): + bl_idname = "PIE_MT_mesh_selection_more" bl_label = "Mesh Selection Mode" def draw(self, context): layout = self.layout - pie = layout.menu_pie() - box = pie.split().column() - box.operator("mesh.select_mode", text="Vertex", icon='VERTEXSEL').type = 'VERT' - box.operator("mesh.select_mode", text="Edge", icon='EDGESEL').type = 'EDGE' - box.operator("mesh.select_mode", text="Face", icon='FACESEL').type = 'FACE' + vert_mode, edge_mode, face_mode = context.tool_settings.mesh_select_mode + + # Selection modes. + layout.operator("mesh.select_mode", text="Vertex Select Mode", icon='VERTEXSEL', depress=vert_mode).type = 'VERT' + layout.operator("mesh.select_mode", text="Edge Select Mode", icon='EDGESEL', depress=edge_mode).type = 'EDGE' + layout.operator("mesh.select_mode", text="Face Select Mode", icon='FACESEL', depress=face_mode).type = 'FACE' + + # Modal selection operators. + layout.separator() + layout.operator("view3d.select_circle", text="Circle Select", icon='MESH_CIRCLE') + layout.operator("view3d.select_box", text="Box Select", icon='SELECT_SET') + layout.operator_menu_enum("view3d.select_lasso", "mode", icon='MOD_CURVE') + + # Select based on what's active. + layout.separator() + layout.menu("VIEW3D_MT_edit_mesh_select_linked", icon='FILE_3D') + layout.menu("VIEW3D_MT_edit_mesh_select_similar", icon='CON_TRANSLIKE') + layout.operator_menu_enum("mesh.select_axis", "axis", text="Select Side", icon='AXIS_SIDE') + layout.operator("mesh.select_nth", text="Checker Deselect", icon='TEXTURE') + + # Select based on some parameter. + layout.separator() + layout.menu("VIEW3D_MT_edit_mesh_select_by_trait") + layout.operator("mesh.select_by_attribute", text="Select All By Attribute", icon='GEOMETRY_NODES') + + # User-defined GeoNode Select operations. + layout.template_node_operator_asset_menu_items(catalog_path="Select") + + +class PIE_MT_mesh_selection_loops(Menu): + bl_idname = "PIE_MT_mesh_selection_loops" + bl_label = "Select Loop" + + def draw(self, context): + pie = self.layout.menu_pie() + + # 4 - LEFT + pie.operator('mesh.loop_multi_select', text="Edge Loops", icon='MOD_WAVE').ring=False + # 6 - RIGHT + pie.operator('mesh.loop_multi_select', text="Edge Rings", icon='CURVES').ring=True + # 2 - BOTTOM + pie.operator('mesh.loop_to_region', text="Loop Inner Region", icon='SHADING_SOLID') + # 8 - TOP + pie.operator('mesh.region_to_loop', text="Boundary Loop", icon='MESH_CIRCLE') registry = [ PIE_MT_object_selection, - PIE_MT_mesh_selection, PIE_MT_object_selection_more, - PIE_MT_mesh_selection_mode, + 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, + + PIE_MT_mesh_selection, + PIE_MT_mesh_selection_more, + PIE_MT_mesh_selection_loops, ] +def draw_edge_sharpness_in_traits_menu(self, context): + self.layout.operator("mesh.edges_select_sharp", text="Edge Sharpness") + + def register(): + # Weird that this built-in operator is not included in this built-in menu. + bpy.types.VIEW3D_MT_edit_mesh_select_by_trait.prepend(draw_edge_sharpness_in_traits_menu) + register_hotkey( 'wm.call_menu_pie', op_kwargs={'name': 'PIE_MT_object_selection'}, hotkey_kwargs={'type': "A", 'value': "PRESS"}, key_cat="Object Mode", ) + register_hotkey( + 'wm.call_menu_pie', + op_kwargs={'name': 'PIE_MT_select_object_name_relation'}, + hotkey_kwargs={'type': "F", 'value': "PRESS", 'ctrl': True}, + key_cat="Object Mode", + ) register_hotkey( 'wm.call_menu_pie', op_kwargs={'name': 'PIE_MT_mesh_selection'}, hotkey_kwargs={'type': "A", 'value': "PRESS"}, key_cat="Mesh", ) + + +def unregister(): + bpy.types.VIEW3D_MT_edit_mesh_select_by_trait.remove(draw_edge_sharpness_in_traits_menu)