Skip to content

Instantly share code, notes, and snippets.

@zvodd
Last active May 20, 2025 13:30
Show Gist options
  • Save zvodd/5120a102f7a995d840ff585a7e750827 to your computer and use it in GitHub Desktop.
Save zvodd/5120a102f7a995d840ff585a7e750827 to your computer and use it in GitHub Desktop.
blender tagging addon WIP
bl_info = {
"name": "Object Tagger",
"author": "zvodd",
"version": (1, 1, 4),
"blender": (4, 2, 0),
"location": "3D View > Sidebar (N Panel) > Tagger Tab | V for Pie Menu",
"description": "Adds, removes, and manages tags on objects using custom properties. Includes a customizable Pie Menu.",
"warning": "",
"doc_url": "",
"category": "Object",
}
import bpy
from bpy.props import (
StringProperty,
BoolProperty,
EnumProperty,
CollectionProperty,
IntProperty,
PointerProperty,
)
from bpy.types import (
PropertyGroup,
UIList,
Operator,
Panel,
Menu,
AddonPreferences
)
# --- Constants ---
TAG_PREFIX = "tag_" # Optional prefix for custom properties to identify them as tags
last_active_object = None
# --- Helper Functions ---
def get_target_objects(context):
"""Gets selected objects. If none, tries to get objects from active collection."""
# Consider all common object types that can have custom properties and are selectable in 3D view
relevant_types = {'MESH', 'EMPTY', 'CURVE', 'SURFACE', 'META', 'FONT',
'ARMATURE', 'LATTICE', 'LIGHT', 'CAMERA', 'SPEAKER',
'LIGHT_PROBE', 'GPENCIL'} # Added Grease Pencil and Light Probe
selected_obs = [obj for obj in context.selected_objects if obj.type in relevant_types]
if selected_obs:
return selected_obs
return []
def get_all_tags_in_file(context):
"""Scans all objects and returns a set of unique tag names."""
all_tags = set()
for obj in bpy.data.objects:
for k in obj.keys():
# Filter out Blender's internal properties
if k == "_RNA_UI" or k.startswith("cycles") or k.startswith("cycles_"):
continue
is_tag_property = False
# Check for prefix if TAG_PREFIX is defined
if TAG_PREFIX:
if k.startswith(TAG_PREFIX):
# Further ensure it's a boolean True, as per our tagging convention
if isinstance(obj[k], int) and obj[k] == 1: # Blender booleans are often ints
is_tag_property = True
all_tags.add(k[len(TAG_PREFIX):])
elif isinstance(obj[k], bool) and obj[k] is True:
is_tag_property = True
all_tags.add(k[len(TAG_PREFIX):])
else:
# If no prefix, rely on the convention that the property value is boolean True
if isinstance(obj[k], int) and obj[k] == 1:
is_tag_property = True
all_tags.add(k)
elif isinstance(obj[k], bool) and obj[k] is True:
is_tag_property = True
all_tags.add(k)
return sorted(list(all_tags))
def get_tags_on_selected_objects(context):
"""
Returns a dictionary of tags present on the selected objects.
Key: tag_name
Value: 'ALL' if on all selected, 'SOME' if on some.
Also returns a set of common tags (tags present on ALL selected objects).
"""
target_objects = get_target_objects(context)
if not target_objects:
return {}, set()
object_tags_list = []
for obj in target_objects:
current_obj_tags = set()
for k in obj.keys():
tag_name_candidate = None
is_actual_tag = False
if TAG_PREFIX:
if k.startswith(TAG_PREFIX):
tag_name_candidate = k[len(TAG_PREFIX):]
if (isinstance(obj[k], int) and obj[k] == 1) or \
(isinstance(obj[k], bool) and obj[k] is True):
is_actual_tag = True
elif k != "_RNA_UI" and not k.startswith("cycles"): # No prefix, check general custom props
if (isinstance(obj[k], int) and obj[k] == 1) or \
(isinstance(obj[k], bool) and obj[k] is True):
tag_name_candidate = k
is_actual_tag = True
if is_actual_tag and tag_name_candidate:
current_obj_tags.add(tag_name_candidate)
object_tags_list.append(current_obj_tags)
if not object_tags_list: # Should not happen if target_objects is not empty
return {}, set()
# Find all unique tags across selected objects
all_selected_tags = set.union(*object_tags_list) if object_tags_list else set()
# Find common tags (present on ALL selected objects)
common_tags = set.intersection(*object_tags_list) if object_tags_list else set()
tags_status = {}
for tag in all_selected_tags:
count = sum(1 for obj_tags in object_tags_list if tag in obj_tags)
if count == len(target_objects):
tags_status[tag] = 'ALL'
elif count > 0: # count < len(target_objects) but > 0
tags_status[tag] = 'SOME'
return tags_status, common_tags
def add_tag_to_objects(objects, tag_name):
"""Adds a tag (custom property set to True) to a list of objects."""
if not tag_name:
return
# Sanitize tag_name: remove leading/trailing whitespace, replace spaces with underscores
# Blender custom property names cannot contain spaces.
sanitized_tag_name = tag_name.strip().replace(" ", "_")
if not sanitized_tag_name:
# Handle case where tag_name becomes empty after sanitization
print(f"Warning: Tag name '{tag_name}' became empty after sanitization. Tag not added.")
return
full_tag_name = f"{TAG_PREFIX}{sanitized_tag_name}" if TAG_PREFIX else sanitized_tag_name
for obj in objects:
obj[full_tag_name] = True
def remove_tag_from_objects(objects, tag_name):
"""Removes a tag (custom property) from a list of objects."""
if not tag_name:
return
sanitized_tag_name = tag_name.strip().replace(" ", "_") # Ensure consistency
if not sanitized_tag_name:
return
full_tag_name = f"{TAG_PREFIX}{sanitized_tag_name}" if TAG_PREFIX else sanitized_tag_name
for obj in objects:
if full_tag_name in obj:
del obj[full_tag_name]
def toggle_tag_on_objects(objects, tag_name):
"""Toggles a tag on objects. If any object has it, remove from all. Else, add to all."""
if not tag_name or not objects:
return
sanitized_tag_name = tag_name.strip().replace(" ", "_")
if not sanitized_tag_name:
print(f"Warning: Tag name '{tag_name}' became empty after sanitization. Tag not toggled.")
return
full_tag_name = f"{TAG_PREFIX}{sanitized_tag_name}" if TAG_PREFIX else sanitized_tag_name
any_has_tag = any(full_tag_name in obj and ( (isinstance(obj[full_tag_name], int) and obj[full_tag_name] == 1) or \
(isinstance(obj[full_tag_name], bool) and obj[full_tag_name] is True) )
for obj in objects)
if any_has_tag:
for obj in objects:
if full_tag_name in obj:
del obj[full_tag_name]
else:
for obj in objects:
obj[full_tag_name] = True
# --- Property Groups ---
class TTAGS_ListItem(PropertyGroup):
"""Helper for CollectionProperties used in UILists."""
name: StringProperty(name="Name", default="Unknown")
class TTAGS_PieMenuItem(PropertyGroup):
"""Represents a tag configured for the Pie Menu."""
name: StringProperty(name="Tag Name", default="")
class TTAGS_SceneProperties(PropertyGroup):
"""Properties stored per scene for the addon."""
new_tag_name: StringProperty(
name="New Tag",
description="Name for a new tag to be created (spaces will be replaced with underscores)",
default=""
)
selected_object_tags: CollectionProperty(type=TTAGS_ListItem)
selected_object_tags_index: IntProperty()
available_tags_in_file: CollectionProperty(type=TTAGS_ListItem)
available_tags_in_file_index: IntProperty()
available_tags_filter: StringProperty(
name="Search Tags",
description="Filter available tags by name",
default="",
update=lambda self, context: TTAGS_OT_UpdateAvailableTagsList.execute_direct(context)
)
pie_menu_tags: CollectionProperty(type=TTAGS_PieMenuItem)
active_pie_tag_index: IntProperty(name="Active Pie Tag Index")
pie_config_available_tags: CollectionProperty(type=TTAGS_ListItem)
pie_config_available_tags_index: IntProperty()
pie_config_filter: StringProperty(
name="Search All Tags",
description="Filter all tags for pie menu configuration",
default="",
update=lambda self, context: TTAGS_OT_UpdatePieConfigAvailableTagsList.execute_direct(context)
)
# --- Operators ---
class TTAGS_OT_UpdateSelectedObjectTagsList(Operator):
"""Internal operator to refresh the list of tags on selected objects."""
bl_idname = "ttags.update_selected_tags_list"
bl_label = "Update Selected Tags List"
bl_options = {'REGISTER', 'INTERNAL'}
@staticmethod
def _update_logic(context):
"""Core logic for updating the selected object tags list."""
scene_props = context.scene.ttags_props
scene_props.selected_object_tags.clear()
tags_status, _ = get_tags_on_selected_objects(context)
for tag_name, status in sorted(tags_status.items()):
item = scene_props.selected_object_tags.add()
item.name = tag_name
return {'FINISHED'}
def execute(self, context):
"""Standard operator execution method."""
return TTAGS_OT_UpdateSelectedObjectTagsList._update_logic(context)
@classmethod
def execute_direct(cls, context):
"""Directly calls the update logic, bypassing full operator invocation."""
cls._update_logic(context)
class TTAGS_OT_UpdateAvailableTagsList(Operator):
"""Internal operator to refresh the list of all available tags in the file."""
bl_idname = "ttags.update_available_tags_list"
bl_label = "Update Available Tags List"
bl_options = {'REGISTER', 'INTERNAL'}
@staticmethod
def _update_logic(context):
"""Core logic for updating the available tags list."""
scene_props = context.scene.ttags_props
scene_props.available_tags_in_file.clear()
all_tags = get_all_tags_in_file(context)
current_filter = scene_props.available_tags_filter.lower()
for tag_name in all_tags:
if not current_filter or current_filter in tag_name.lower():
item = scene_props.available_tags_in_file.add()
item.name = tag_name
return {'FINISHED'}
def execute(self, context):
"""Standard operator execution method."""
return TTAGS_OT_UpdateAvailableTagsList._update_logic(context)
@classmethod
def execute_direct(cls, context):
"""Directly calls the update logic."""
cls._update_logic(context)
class TTAGS_OT_UpdatePieConfigAvailableTagsList(Operator):
"""Internal operator to refresh the list of all tags for pie config."""
bl_idname = "ttags.update_pie_config_available_tags"
bl_label = "Update Pie Config Available Tags"
bl_options = {'REGISTER', 'INTERNAL'}
@staticmethod
def _update_logic(context):
"""Core logic for updating the pie config available tags list."""
scene_props = context.scene.ttags_props
scene_props.pie_config_available_tags.clear()
all_tags = get_all_tags_in_file(context)
current_filter = scene_props.pie_config_filter.lower()
for tag_name in all_tags:
if not current_filter or current_filter in tag_name.lower():
item = scene_props.pie_config_available_tags.add()
item.name = tag_name
return {'FINISHED'}
def execute(self, context):
"""Standard operator execution method."""
return TTAGS_OT_UpdatePieConfigAvailableTagsList._update_logic(context)
@classmethod
def execute_direct(cls, context):
"""Directly calls the update logic."""
cls._update_logic(context)
class TTAGS_OT_AddTagToSelection(Operator):
"""Creates a new tag and applies it to the current selection."""
bl_idname = "ttags.add_tag_to_selection"
bl_label = "Add New Tag"
bl_description = "Create and apply a new tag to selected objects. Spaces in tag name will be replaced by underscores"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return bool(get_target_objects(context)) and bool(context.scene.ttags_props.new_tag_name.strip())
def execute(self, context):
scene_props = context.scene.ttags_props
new_tag_raw = scene_props.new_tag_name.strip()
if not new_tag_raw:
self.report({'WARNING'}, "New tag name cannot be empty.")
return {'CANCELLED'}
# Sanitize tag name (replace spaces, etc.)
new_tag_sanitized = new_tag_raw.replace(" ", "_")
if not new_tag_sanitized: # If tag name was only spaces
self.report({'WARNING'}, "Tag name cannot consist only of spaces.")
return {'CANCELLED'}
target_objects = get_target_objects(context)
if not target_objects:
self.report({'WARNING'}, "No suitable objects selected.")
return {'CANCELLED'}
add_tag_to_objects(target_objects, new_tag_sanitized) # Use sanitized name
self.report({'INFO'}, f"Tag '{new_tag_sanitized}' added to {len(target_objects)} object(s).")
TTAGS_OT_UpdateSelectedObjectTagsList.execute_direct(context)
TTAGS_OT_UpdateAvailableTagsList.execute_direct(context)
TTAGS_OT_UpdatePieConfigAvailableTagsList.execute_direct(context)
scene_props.new_tag_name = ""
return {'FINISHED'}
class TTAGS_OT_ToggleTagOnSelection(Operator):
"""Toggles a specific tag on the current selection. From UI lists or Pie menu."""
bl_idname = "ttags.toggle_tag_on_selection"
bl_label = "Toggle Tag on Selection"
bl_description = "If any selected has the tag, remove from all. Else, add to all"
bl_options = {'REGISTER', 'UNDO'}
tag_name: StringProperty(name="Tag Name")
@classmethod
def poll(cls, context):
return bool(get_target_objects(context)) #and hasattr(cls, "tag_name")
def execute(self, context):
target_objects = get_target_objects(context)
if not target_objects:
self.report({'WARNING'}, "No suitable objects selected.")
return {'CANCELLED'}
if not self.tag_name: # Should be sanitized by caller if needed
self.report({'WARNING'}, "No tag name provided.")
return {'CANCELLED'}
toggle_tag_on_objects(target_objects, self.tag_name)
TTAGS_OT_UpdateSelectedObjectTagsList.execute_direct(context)
# These might be needed if a tag is completely removed from the scene or newly created by toggle
TTAGS_OT_UpdateAvailableTagsList.execute_direct(context)
TTAGS_OT_UpdatePieConfigAvailableTagsList.execute_direct(context)
return {'FINISHED'}
class TTAGS_OT_RemoveTagFromSelection(Operator):
"""Removes a specific tag from the current selection."""
bl_idname = "ttags.remove_tag_from_selection"
bl_label = "Remove Tag from Selection"
bl_description = "Remove this tag from all selected objects"
bl_options = {'REGISTER', 'UNDO'}
tag_name: StringProperty(name="Tag Name")
@classmethod
def poll(cls, context):
return bool(get_target_objects(context)) #and hasattr(cls, "tag_name")
def execute(self, context):
target_objects = get_target_objects(context)
if not target_objects:
self.report({'WARNING'}, "No suitable objects selected.")
return {'CANCELLED'}
if not self.tag_name: # Should be sanitized by caller
self.report({'WARNING'}, "No tag name provided.")
return {'CANCELLED'}
remove_tag_from_objects(target_objects, self.tag_name)
self.report({'INFO'}, f"Tag '{self.tag_name}' removed from selection.")
TTAGS_OT_UpdateSelectedObjectTagsList.execute_direct(context)
TTAGS_OT_UpdateAvailableTagsList.execute_direct(context)
TTAGS_OT_UpdatePieConfigAvailableTagsList.execute_direct(context)
return {'FINISHED'}
class TTAGS_OT_SelectByTag(Operator):
"""Selects objects based on a tag."""
bl_idname = "ttags.select_by_tag"
bl_label = "Select by Tag"
bl_description = "Select objects that have the specified tag"
bl_options = {'REGISTER', 'UNDO'}
tag_name: StringProperty(name="Tag Name")
mode: EnumProperty(
name="Selection Mode",
items=[
('SET', "Set", "Replace current selection"),
('ADD', "Add", "Add to current selection"),
('SUBTRACT', "Subtract", "Remove from current selection (objects with this tag)"),
('FILTER_AND', "Filter (AND)", "Intersect with current selection (keep selected objects that also have this tag)"),
('FILTER_NAND', "Filter (NAND)", "From current selection, keep only those that DO NOT have this tag"),
],
default='SET'
)
@classmethod
def poll(cls, context):
return hasattr(cls, "tag_name")
def execute(self, context):
if not self.tag_name:
self.report({'WARNING'}, "No tag name provided.")
return {'CANCELLED'}
# Assume tag_name from UI is already sanitized (e.g. no spaces)
# If it could have spaces, sanitize it here: self.tag_name.replace(" ", "_")
full_tag_name = f"{TAG_PREFIX}{self.tag_name}" if TAG_PREFIX else self.tag_name
initial_selection = list(context.selected_objects)
if self.mode == 'SET':
bpy.ops.object.select_all(action='DESELECT')
# After deselecting, initial_selection for filtering purposes becomes empty for 'SET'
# However, for 'SET', we iterate all objects, so initial_selection isn't used for filtering.
# For modes that modify the current selection, we operate on initial_selection
# For 'SET' and 'ADD' (when adding new objects not currently selected), we iterate all scene objects
objects_to_change_selection_state = [] # Tuples of (object, should_be_selected_boolean)
if self.mode in ('SET', 'ADD'):
for obj in bpy.data.objects:
has_tag = full_tag_name in obj and ( (isinstance(obj[full_tag_name], int) and obj[full_tag_name] == 1) or \
(isinstance(obj[full_tag_name], bool) and obj[full_tag_name] is True) )
if has_tag:
if self.mode == 'SET' or (self.mode == 'ADD' and not obj.select_get()):
objects_to_change_selection_state.append((obj, True))
elif self.mode == 'SUBTRACT':
for obj in initial_selection: # Only consider currently selected objects
has_tag = full_tag_name in obj and ( (isinstance(obj[full_tag_name], int) and obj[full_tag_name] == 1) or \
(isinstance(obj[full_tag_name], bool) and obj[full_tag_name] is True) )
if has_tag:
objects_to_change_selection_state.append((obj, False))
elif self.mode == 'FILTER_AND': # Intersect: keep selected that HAVE the tag
for obj in initial_selection:
has_tag = full_tag_name in obj and ( (isinstance(obj[full_tag_name], int) and obj[full_tag_name] == 1) or \
(isinstance(obj[full_tag_name], bool) and obj[full_tag_name] is True) )
if not has_tag: # If it's selected but doesn't have the tag
objects_to_change_selection_state.append((obj, False))
elif self.mode == 'FILTER_NAND': # Keep selected that DO NOT HAVE the tag
for obj in initial_selection:
has_tag = full_tag_name in obj and ( (isinstance(obj[full_tag_name], int) and obj[full_tag_name] == 1) or \
(isinstance(obj[full_tag_name], bool) and obj[full_tag_name] is True) )
if has_tag: # If it's selected and has the tag
objects_to_change_selection_state.append((obj, False))
# Apply selection changes
for obj, select_state in objects_to_change_selection_state:
obj.select_set(select_state)
if context.selected_objects:
if context.view_layer.objects.active not in context.selected_objects:
context.view_layer.objects.active = context.selected_objects[0]
else:
context.view_layer.objects.active = None
self.report({'INFO'}, f"Selection updated for tag '{self.tag_name}' with mode '{self.mode}'.")
TTAGS_OT_UpdateSelectedObjectTagsList.execute_direct(context)
return {'FINISHED'}
class TTAGS_OT_AddTagToPieConfig(Operator):
"""Adds a tag from the 'Available Tags' list to the 'Pie Menu Tags' list."""
bl_idname = "ttags.add_tag_to_pie_config"
bl_label = "Add to Pie Menu"
bl_description = "Add selected available tag to the Pie Menu configuration"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
scene_props = context.scene.ttags_props
return scene_props.pie_config_available_tags_index >= 0 and \
len(scene_props.pie_config_available_tags) > scene_props.pie_config_available_tags_index
def execute(self, context):
scene_props = context.scene.ttags_props
source_list = scene_props.pie_config_available_tags
source_index = scene_props.pie_config_available_tags_index
if not (0 <= source_index < len(source_list)):
self.report({'WARNING'}, "No valid tag selected from available list.")
return {'CANCELLED'}
tag_to_add = source_list[source_index].name
if any(pt.name == tag_to_add for pt in scene_props.pie_menu_tags):
self.report({'INFO'}, f"Tag '{tag_to_add}' is already in the Pie Menu.")
return {'CANCELLED'}
if len(scene_props.pie_menu_tags) >= 8:
self.report({'WARNING'}, "Pie Menu can have a maximum of 8 items.")
return {'CANCELLED'}
new_pie_item = scene_props.pie_menu_tags.add()
new_pie_item.name = tag_to_add
self.report({'INFO'}, f"Tag '{tag_to_add}' added to Pie Menu configuration.")
return {'FINISHED'}
class TTAGS_OT_RemoveTagFromPieConfig(Operator):
"""Removes the selected tag from the 'Pie Menu Tags' list."""
bl_idname = "ttags.remove_tag_from_pie_config"
bl_label = "Remove from Pie Menu"
bl_description = "Remove selected tag from the Pie Menu configuration"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
scene_props = context.scene.ttags_props
return scene_props.active_pie_tag_index >= 0 and \
len(scene_props.pie_menu_tags) > scene_props.active_pie_tag_index
def execute(self, context):
scene_props = context.scene.ttags_props
target_index = scene_props.active_pie_tag_index
if not (0 <= target_index < len(scene_props.pie_menu_tags)):
self.report({'WARNING'}, "No valid tag selected from Pie Menu list.")
return {'CANCELLED'}
tag_removed = scene_props.pie_menu_tags[target_index].name
scene_props.pie_menu_tags.remove(target_index)
if scene_props.active_pie_tag_index >= len(scene_props.pie_menu_tags) and len(scene_props.pie_menu_tags) > 0:
scene_props.active_pie_tag_index = len(scene_props.pie_menu_tags) - 1
elif not scene_props.pie_menu_tags:
scene_props.active_pie_tag_index = 0 # Or -1 if appropriate for no selection
self.report({'INFO'}, f"Tag '{tag_removed}' removed from Pie Menu configuration.")
return {'FINISHED'}
class TTAGS_OT_MovePieTag(Operator):
"""Moves the selected Pie Menu tag up or down in the list."""
bl_idname = "ttags.move_pie_tag"
bl_label = "Move Pie Tag"
bl_description = "Move selected Pie Menu tag up or down"
bl_options = {'REGISTER', 'UNDO'}
direction: EnumProperty(
name="Direction",
items=[('UP', "Up", "Move tag up"), ('DOWN', "Down", "Move tag down")],
default='UP'
)
@classmethod
def poll(cls, context):
scene_props = context.scene.ttags_props
idx = scene_props.active_pie_tag_index
pie_tags_len = len(scene_props.pie_menu_tags)
if not (0 <= idx < pie_tags_len): # Check if index is valid
return False
if cls.direction == 'UP' and idx == 0: # Cannot move top item up
return False
if cls.direction == 'DOWN' and idx == pie_tags_len - 1: # Cannot move bottom item down
return False
return True
def execute(self, context):
scene_props = context.scene.ttags_props
idx = scene_props.active_pie_tag_index
pie_tags = scene_props.pie_menu_tags
if self.direction == 'UP':
if idx > 0:
pie_tags.move(idx, idx - 1)
scene_props.active_pie_tag_index -= 1
elif self.direction == 'DOWN':
if idx < len(pie_tags) - 1:
pie_tags.move(idx, idx + 1)
scene_props.active_pie_tag_index += 1
return {'FINISHED'}
# --- UI Lists ---
class TTAGS_UL_SelectedObjectTagsList(UIList):
"""UIList for displaying tags on the current selection."""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
scene_props = context.scene.ttags_props # data is scene_props
tag_name = item.name # item is TTAGS_ListItem
if self.layout_type in {'DEFAULT', 'COMPACT'}:
row = layout.row(align=True)
tags_status, _ = get_tags_on_selected_objects(context)
status_icon = 'NONE' # Default icon
tag_status_text = ""
current_tag_status = tags_status.get(tag_name)
if current_tag_status == 'ALL':
status_icon = 'CHECKBOX_HLT' # All selected objects have this tag
tag_status_text = tag_name
elif current_tag_status == 'SOME':
status_icon = 'CHECKBOX_DEHLT' # Some selected objects have this tag (partial)
tag_status_text = f"{tag_name} (Some)"
else: # Tag not on selection (shouldn't appear here if list is accurate)
status_icon = 'QUESTION'
tag_status_text = f"{tag_name} (?)"
row.label(text=tag_status_text, icon=status_icon)
op_toggle = row.operator(TTAGS_OT_ToggleTagOnSelection.bl_idname, text="", icon='UV_SYNC_SELECT')
op_toggle.tag_name = tag_name
# Remove button should always remove, not toggle.
op_remove = row.operator(TTAGS_OT_RemoveTagFromSelection.bl_idname, text="", icon='X')
op_remove.tag_name = tag_name
elif self.layout_type == 'GRID':
layout.alignment = 'CENTER'
layout.label(text=tag_name, icon_value=icon) # Default icon for grid
class TTAGS_UL_AvailableTagsInFileList(UIList):
"""UIList for displaying all unique tags in the file (for applying/selecting)."""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
# scene_props = context.scene.ttags_props (data is scene_props)
tag_name = item.name
if self.layout_type in {'DEFAULT', 'COMPACT'}:
row = layout.row(align=True)
row.label(text=tag_name, icon='TAG')
op_toggle = row.operator(TTAGS_OT_ToggleTagOnSelection.bl_idname, text="Toggle", icon='UV_SYNC_SELECT')
op_toggle.tag_name = tag_name
# Select buttons container
select_row = row.row(align=True)
select_row.scale_x = 0.8 # Make buttons smaller to fit
op_sel_set = select_row.operator(TTAGS_OT_SelectByTag.bl_idname, text="Set", icon='RESTRICT_SELECT_OFF')
op_sel_set.tag_name = tag_name
op_sel_set.mode = 'SET'
op_sel_add = select_row.operator(TTAGS_OT_SelectByTag.bl_idname, text="+", icon='ADD')
op_sel_add.tag_name = tag_name
op_sel_add.mode = 'ADD'
op_sel_sub = select_row.operator(TTAGS_OT_SelectByTag.bl_idname, text="-", icon='REMOVE')
op_sel_sub.tag_name = tag_name
op_sel_sub.mode = 'SUBTRACT'
# Filter buttons could be added here if desired
# op_filter_and = select_row.operator(TTAGS_OT_SelectByTag.bl_idname, text="&&", icon='FILTER')
# op_filter_and.tag_name = tag_name
# op_filter_and.mode = 'FILTER_AND'
elif self.layout_type == 'GRID':
layout.alignment = 'CENTER'
layout.label(text=tag_name, icon_value=icon) # Default icon for grid
def draw_filter(self, context, layout):
scene_props = context.scene.ttags_props
row = layout.row()
row.prop(scene_props, "available_tags_filter", text="") # Empty text for search field
# No need for explicit refresh button if update on prop change works well
class TTAGS_UL_PieMenuConfigAvailableTagsList(UIList):
"""UIList for displaying all tags available for Pie Menu configuration."""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
tag_name = item.name
if self.layout_type in {'DEFAULT', 'COMPACT'}:
layout.label(text=tag_name, icon='TAG')
elif self.layout_type == 'GRID':
layout.alignment = 'CENTER'
layout.label(text=tag_name, icon_value=icon)
def draw_filter(self, context, layout):
scene_props = context.scene.ttags_props
row = layout.row()
row.prop(scene_props, "pie_config_filter", text="") # Empty text for search field
class TTAGS_UL_PieMenuTagsList(UIList):
"""UIList for displaying tags configured for the Pie Menu."""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
tag_name = item.name
if self.layout_type in {'DEFAULT', 'COMPACT'}:
layout.label(text=tag_name, icon='DOT')
elif self.layout_type == 'GRID':
layout.alignment = 'CENTER'
layout.label(text=tag_name, icon_value=icon)
# --- Panels ---
class TTAGS_PT_MainPanel(Panel):
bl_label = "Object Tagger"
bl_idname = "TTAGS_PT_main_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Tagger'
def draw_header(self, context):
# Optional: Add an icon or a master refresh button to the panel header
layout = self.layout
layout.operator(TTAGS_OT_UpdateAllLists.bl_idname, text="", icon='FILE_REFRESH')
def draw(self, context):
layout = self.layout
scene_props = context.scene.ttags_props
target_objects = get_target_objects(context)
box = layout.box()
box.label(text="Create New Tag:")
row = box.row(align=True)
row.prop(scene_props, "new_tag_name", text="")
col = row.column()
op_add_new = col.operator(TTAGS_OT_AddTagToSelection.bl_idname, text="Add to Selection", icon='ADD')
col.enabled = bool(target_objects and scene_props.new_tag_name.strip())
box = layout.box()
row = box.row(align=True)
row.label(text="Tags on Current Selection:")
# Removed individual refresh, relying on header refresh or selection change handler (if implemented)
# row.operator(TTAGS_OT_UpdateSelectedObjectTagsList.bl_idname, text="", icon='FILE_REFRESH')
if target_objects:
box.template_list(
"TTAGS_UL_SelectedObjectTagsList", "selected_tags", # Unique ID for this list instance
scene_props, "selected_object_tags",
scene_props, "selected_object_tags_index",
rows=max(1, min(len(scene_props.selected_object_tags), 3)), # Dynamic rows
maxrows=5
)
if not scene_props.selected_object_tags:
box.label(text="No tags on current selection.")
else:
box.label(text="Select object(s) to see their tags.")
box = layout.box()
row = box.row(align=True)
row.label(text="Manage & Select by All Tags:")
# row.operator(TTAGS_OT_UpdateAvailableTagsList.bl_idname, text="", icon='FILE_REFRESH')
box.prop(scene_props, "available_tags_filter", text="Search", icon='VIEWZOOM')
box.template_list(
"TTAGS_UL_AvailableTagsInFileList", "available_tags", # Unique ID
scene_props, "available_tags_in_file",
scene_props, "available_tags_in_file_index",
rows=5, maxrows=10
)
# Provide feedback based on filter and available tags
if not get_all_tags_in_file(context): # Check actual source, not filtered list
box.label(text="No tags found in the file yet.")
elif not scene_props.available_tags_in_file and scene_props.available_tags_filter:
box.label(text="No tags match filter.")
# Select by Tag - Advanced Filter Options (could be a sub-panel or separate operator)
# For now, keeping it simple with buttons in the list.
# Could add a dedicated section for more complex selection logic if needed.
box = layout.box()
box.label(text="Configure Pie Menu (Max 8 Tags):")
row = box.row(align=True)
# row.label(text="Available Tags for Pie:") # Label might be redundant due to filter box
# row.operator(TTAGS_OT_UpdatePieConfigAvailableTagsList.bl_idname, text="", icon='FILE_REFRESH')
box.prop(scene_props, "pie_config_filter", text="Search All Tags", icon='VIEWZOOM')
split = box.split(factor=0.2) # Adjusted factor for better balance
col1 = split.column()
col1.label(text="Available:")
col1.template_list(
"TTAGS_UL_PieMenuConfigAvailableTagsList", "pie_available", # Unique ID
scene_props, "pie_config_available_tags",
scene_props, "pie_config_available_tags_index",
rows=5, maxrows=8
)
col_mid = split.column(align=True)
col_mid.separator(factor=0.8) # Add some space
op_row = col_mid.row()
op_add_to_pie = op_row.operator(TTAGS_OT_AddTagToPieConfig.bl_idname, text="", icon='TRIA_RIGHT')
idx_avail = scene_props.pie_config_available_tags_index
list_avail_len = len(scene_props.pie_config_available_tags)
op_row.enabled = (0 <= idx_avail < list_avail_len) and (len(scene_props.pie_menu_tags) < 8)
op_row = col_mid.row()
op_remove_from_pie = op_row.operator(TTAGS_OT_RemoveTagFromPieConfig.bl_idname, text="", icon='TRIA_LEFT')
#set enabled
idx_pie = scene_props.active_pie_tag_index
list_pie_len = len(scene_props.pie_menu_tags)
op_row.enabled = 0 <= idx_pie < list_pie_len
col_mid.separator(factor=0.2)
col2 = split.column()
col2.label(text="In Pie Menu:")
col2.template_list(
"TTAGS_UL_PieMenuTagsList", "pie_configured", # Unique ID
scene_props, "pie_menu_tags",
scene_props, "active_pie_tag_index",
rows=5, maxrows=8
)
sub_row = col2.row(align=True)
op_move_up = sub_row.operator(TTAGS_OT_MovePieTag.bl_idname, text="", icon='TRIA_UP')
op_move_up.direction = 'UP'
op_move_down = sub_row.operator(TTAGS_OT_MovePieTag.bl_idname, text="", icon='TRIA_DOWN')
op_move_down.direction = 'DOWN'
# Poll logic for move buttons is handled in TTAGS_OT_MovePieTag.poll
# op_move_up.enabled = (0 < idx_pie < list_pie_len)
# op_move_down.enabled = (0 <= idx_pie < list_pie_len - 1)
if len(scene_props.pie_menu_tags) >= 8 and op_add_to_pie.enabled:
# This specific check might be redundant if op_add_to_pie.enabled already covers it
col_mid.label(text="Pie Full!", icon='ERROR') # Or show near the add button
# --- Master Refresh Operator ---
class TTAGS_OT_UpdateAllLists(Operator):
"""Manually refreshes all tag lists in the panel."""
bl_idname = "ttags.update_all_lists"
bl_label = "Refresh All Tag Lists"
bl_description = "Reloads all tag-related lists from the scene data"
bl_options = {'REGISTER', 'INTERNAL'} # Internal as it's UI-triggered mostly
def execute(self, context):
TTAGS_OT_UpdateSelectedObjectTagsList.execute_direct(context)
TTAGS_OT_UpdateAvailableTagsList.execute_direct(context)
TTAGS_OT_UpdatePieConfigAvailableTagsList.execute_direct(context)
# self.report({'INFO'}, "Tag lists refreshed.") # Optional feedback
return {'FINISHED'}
# --- Pie Menu ---
class TTAGS_MT_ApplyTagPie(Menu):
bl_label = "Apply/Toggle Tags"
bl_idname = "TTAGS_MT_apply_tag_pie"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
scene_props = context.scene.ttags_props
if not scene_props.pie_menu_tags:
# Provide a more helpful message or direct action
pie.label(text="No tags configured for Pie Menu.")
# Could add an operator to open preferences or jump to the panel section
# pie.operator("screen.userpref_show", text="Open Add-on Preferences") # If settings were in addon prefs
return
for i, pie_item in enumerate(scene_props.pie_menu_tags):
if i >= 8: break
op = pie.operator(TTAGS_OT_ToggleTagOnSelection.bl_idname, text=pie_item.name, icon='TAG')
op.tag_name = pie_item.name
# --- Registration ---
reg_classes = (
TTAGS_ListItem,
TTAGS_PieMenuItem,
TTAGS_SceneProperties,
TTAGS_OT_UpdateSelectedObjectTagsList,
TTAGS_OT_UpdateAvailableTagsList,
TTAGS_OT_UpdatePieConfigAvailableTagsList,
TTAGS_OT_AddTagToSelection,
TTAGS_OT_ToggleTagOnSelection,
TTAGS_OT_RemoveTagFromSelection,
TTAGS_OT_SelectByTag,
TTAGS_OT_AddTagToPieConfig,
TTAGS_OT_RemoveTagFromPieConfig,
TTAGS_OT_MovePieTag,
TTAGS_UL_SelectedObjectTagsList,
TTAGS_UL_AvailableTagsInFileList,
TTAGS_UL_PieMenuConfigAvailableTagsList,
TTAGS_UL_PieMenuTagsList,
TTAGS_PT_MainPanel,
TTAGS_MT_ApplyTagPie,
TTAGS_OT_UpdateAllLists, # Register the master refresh operator
)
addon_keymaps = []
# --- Add-on Preferences (Example, not used in current scene_props setup) ---
# class TTAGS_AddonPreferences(AddonPreferences):
# bl_idname = __name__ # Should be bl_info["name"] or the module name
# # Define addon preferences here if needed, e.g., for TAG_PREFIX
# tag_prefix_pref: StringProperty(
# name="Tag Prefix",
# description="Optional prefix for custom properties to identify them as tags (e.g., 'tag_'). Leave empty for no prefix.",
# default="tag_"
# )
# def draw(self, context):
# layout = self.layout
# layout.prop(self, "tag_prefix_pref")
# --- Scene Update Handler ---
# This handler will try to refresh lists when the selection changes.
# Be cautious with handlers, they can impact performance if not implemented carefully.
# @bpy.app.handlers.depsgraph_update_post
# def ttags_depsgraph_update_post_handler(scene, depsgraph):
# # Check if the active view layer's selection has changed
# # This is a common way to detect selection changes, but might not cover all cases
# # or might trigger too often.
# # For simplicity, we'll call the update operators.
# # A more refined approach would check specific depsgraph updates related to selection.
# # This can be too aggressive. Consider if this is truly needed or if manual refresh is better.
# # If enabling, ensure the operators are efficient.
# # For now, let's keep it commented out to avoid potential performance issues without more testing.
# # if bpy.context.scene: # Ensure context.scene is available
# # try:
# # # Check if our properties are available (addon loaded and scene props set up)
# # if hasattr(bpy.context.scene, 'ttags_props'):
# # # This is a very broad trigger.
# # # A better way would be to track previous selection and compare.
# # # For now, let's assume it's okay for demonstration.
# # TTAGS_OT_UpdateSelectedObjectTagsList.execute_direct(bpy.context)
# # # Updating all lists on every depsgraph update is too much.
# # # TTAGS_OT_UpdateAvailableTagsList.execute_direct(bpy.context)
# # # TTAGS_OT_UpdatePieConfigAvailableTagsList.execute_direct(bpy.context)
# # except Exception as e:
# # print(f"Error in ttags_depsgraph_update_post_handler: {e}")
# pass
# This handler updates UI panel in sync with the active object changes.
def msgbus_activelayer_observer(*arg):
obj = bpy.context.active_object
if not(last_active_object != obj and obj == obj):
return
last_active_object = obj
windows = (w for w in bpy.context.window_manager.windows)
areas = (area for window in windows for area in window.screen.areas)
for area in areas:
if a.type == 'TTAGS_PT_main_panel':
area.tag_redraw()
def subscribe_message_bus():
bpy.msgbus.subscribe_rna(
key=(bpy.types.LayerObjects, 'active'),
owner=owner,
args=(1,2,3),
notify=msgbus_activelayer_observer,
options={'PERSISTENT'}
)
# Ensure the message bus handler resubs on file changes
@bpy.app.handlers.persistent
def resub_mb_on_loadfile(dummy):
subscribe_message_bus()
def register():
# bpy.utils.register_class(TTAGS_AddonPreferences) # If using addon prefs
for cls in reg_classes:
bpy.utils.register_class(cls)
bpy.types.Scene.ttags_props = PointerProperty(type=TTAGS_SceneProperties)
wm = bpy.context.window_manager
kc = wm.keyconfigs.addon
if kc:
km = kc.keymaps.new(name='3D View', space_type='VIEW_3D')
kmi = km.keymap_items.new(TTAGS_MT_ApplyTagPie.bl_idname, 'V', 'PRESS')
addon_keymaps.append((km, kmi))
# bpy.app.handlers.depsgraph_update_post.append(ttags_depsgraph_update_post_handler)
subscribe_message_bus()
if resub_mb_on_loadfile not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(resub_mb_on_loadfile)
# Initial population of lists when addon is enabled
# Need to ensure a context is available, typically Blender handles this during startup
# Deferring this to when the panel is first drawn or a manual refresh is safer.
def unregister():
# bpy.app.handlers.depsgraph_update_post.remove(ttags_depsgraph_update_post_handler)
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
#del bpy.types.Scene.ttags_props
if resub_mb_on_loadfile in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(resub_mb_on_loadfile)
for cls in reversed(reg_classes):
bpy.utils.unregister_class(cls)
# bpy.utils.unregister_class(TTAGS_AddonPreferences) # If using addon prefs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment