2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
@@ -1,7 +1,12 @@
import bpy
import os
import re
from ..panels.bulk_path_management import get_image_extension, bulk_remap_paths, set_image_paths
from ..panels.bulk_path_management import (
get_image_extension,
bulk_remap_paths,
set_image_paths,
ensure_directory_for_path,
)
class AUTOMAT_OT_summary_dialog(bpy.types.Operator):
"""Show AutoMat Extractor operation summary"""
@@ -91,6 +96,10 @@ class AutoMatExtractor(bpy.types.Operator):
self.overwrite_skipped = []
self.failed_list = []
self.path_mapping = {}
self.udim_summary = {
"found": 0,
"saved": 0,
}
# Start timer for processing
bpy.app.timers.register(self._process_step)
@@ -202,26 +211,34 @@ class AutoMatExtractor(bpy.types.Operator):
if img.name.startswith('#'):
# Flat colors go to FlatColors subfolder
path = f"//textures\\{common_path_part}\\FlatColors\\{filename}"
base_folder = f"//textures\\{common_path_part}\\FlatColors"
else:
# Check material usage for this image
materials_using_image = self.material_mapping.get(img.name, [])
if not materials_using_image:
# No materials found, put in common folder
path = f"//textures\\{common_path_part}\\{filename}"
base_folder = f"//textures\\{common_path_part}"
print(f"DEBUG: {img.name} - No materials found, using common folder")
elif len(materials_using_image) == 1:
# Used by exactly one material, organize by material name
material_name = self.sanitize_filename(materials_using_image[0])
path = f"//textures\\{blend_name}\\{material_name}\\{filename}"
base_folder = f"//textures\\{blend_name}\\{material_name}"
print(f"DEBUG: {img.name} - Used by {material_name}, organizing by material")
else:
# Used by multiple materials, put in common folder
path = f"//textures\\{common_path_part}\\{filename}"
base_folder = f"//textures\\{common_path_part}"
print(f"DEBUG: {img.name} - Used by multiple materials: {materials_using_image}, using common folder")
self.path_mapping[img.name] = path
is_udim = self.is_udim_image(img)
if is_udim:
udim_mapping = self.build_udim_mapping(base_folder, sanitized_base_name, extension, img)
self.path_mapping[img.name] = udim_mapping
self.udim_summary["found"] += 1
print(f"DEBUG: {img.name} - UDIM detected with {len(udim_mapping.get('tiles', {}))} tiles")
else:
path = f"{base_folder}\\{filename}"
self.path_mapping[img.name] = path
self.current_index += 1
progress = 50.0 + (self.current_index / len(self.selected_images)) * 20.0
@@ -239,10 +256,17 @@ class AutoMatExtractor(bpy.types.Operator):
# Remap current image
img_name = list(self.path_mapping.keys())[self.current_index]
new_path = self.path_mapping[img_name]
mapping_entry = self.path_mapping[img_name]
props.operation_status = f"Remapping {img_name}..."
success = set_image_paths(img_name, new_path)
if isinstance(mapping_entry, dict) and mapping_entry.get("udim"):
success = set_image_paths(
img_name,
mapping_entry.get("template", ""),
tile_paths=mapping_entry.get("tiles", {})
)
else:
success = set_image_paths(img_name, mapping_entry)
if success:
self.success_count += 1
else:
@@ -281,9 +305,10 @@ class AutoMatExtractor(bpy.types.Operator):
flat_colors = 0
for img_name, path in self.path_mapping.items():
if "FlatColors" in path:
current_path = path["template"] if isinstance(path, dict) else path
if "FlatColors" in current_path:
flat_colors += 1
elif "common" in path:
elif "common" in current_path:
common_organized += 1
else:
material_organized += 1
@@ -300,6 +325,8 @@ class AutoMatExtractor(bpy.types.Operator):
for img_name, path in self.path_mapping.items():
if "FlatColors" not in path and "common" not in path:
# Extract material name from path
if isinstance(path, dict):
continue
path_parts = path.split('\\')
if len(path_parts) >= 3:
material_name = path_parts[-2]
@@ -311,6 +338,8 @@ class AutoMatExtractor(bpy.types.Operator):
print(f" {material_name}: {len(images)} images")
print(f"=====================================\n")
if self.udim_summary["found"]:
print(f"UDIM images processed: {self.udim_summary['found']} (saved successfully: {self.udim_summary['saved']})")
# Force UI update
for area in bpy.context.screen.areas:
@@ -322,11 +351,11 @@ class AutoMatExtractor(bpy.types.Operator):
img = self.selected_images[self.current_index]
props.operation_status = f"Saving {img.name}..."
try:
if hasattr(img, 'save'):
img.save()
except Exception as e:
pass # Continue even if saving fails
mapping_entry = self.path_mapping.get(img.name)
if isinstance(mapping_entry, dict) and mapping_entry.get("udim"):
self.save_udim_image(img, mapping_entry)
else:
self.save_standard_image(img)
self.current_index += 1
progress = 85.0 + (self.current_index / len(self.selected_images)) * 15.0
@@ -402,6 +431,99 @@ class AutoMatExtractor(bpy.types.Operator):
return image_to_materials
def is_udim_image(self, image):
"""Return True when the image contains UDIM/tiled data"""
has_tiles = hasattr(image, "source") and image.source == 'TILED'
tiles_attr = getattr(image, "tiles", None)
if tiles_attr and len(tiles_attr) > 1:
return True
return has_tiles
def build_udim_mapping(self, base_folder, base_name, extension, image):
"""Create a path mapping structure for UDIM images"""
udim_token = "<UDIM>"
template_filename = f"{base_name}.{udim_token}{extension}"
template_path = f"{base_folder}\\{template_filename}"
tile_paths = {}
tiles = getattr(image, "tiles", [])
for tile in tiles:
tile_number = str(getattr(tile, "number", "1001"))
tile_filename = f"{base_name}.{tile_number}{extension}"
tile_paths[tile_number] = f"{base_folder}\\{tile_filename}"
return {
"udim": True,
"template": template_path,
"tiles": tile_paths,
}
def save_udim_image(self, image, mapping):
"""Attempt to save each tile for a UDIM image"""
success = False
try:
image.save()
success = True
except Exception as e:
print(f"DEBUG: UDIM bulk save failed for {image.name}: {e}")
success = self._save_udim_tiles_individually(image, mapping)
if success:
self.udim_summary["saved"] += 1
return success
def save_standard_image(self, image):
"""Save a non-UDIM image safely"""
try:
if hasattr(image, 'save'):
image.save()
return True
except Exception as e:
print(f"DEBUG: Failed to save image {image.name}: {e}")
return False
def _save_udim_tiles_individually(self, image, mapping):
"""Fallback saving routine when image.save() fails on UDIMs"""
tile_paths = mapping.get("tiles", {})
any_saved = False
for tile in getattr(image, "tiles", []):
tile_number = str(getattr(tile, "number", "1001"))
target_path = tile_paths.get(tile_number)
if not target_path:
continue
try:
ensure_directory_for_path(target_path)
self._save_tile_via_image_editor(image, tile_number, target_path)
any_saved = True
except Exception as e:
print(f"DEBUG: Failed to save UDIM tile {tile_number} for {image.name}: {e}")
return any_saved
def _save_tile_via_image_editor(self, image, tile_number, filepath):
"""Use an IMAGE_EDITOR override to save a specific tile"""
# Try to find an existing image editor to reuse Blender UI context
for area in bpy.context.screen.areas:
if area.type != 'IMAGE_EDITOR':
continue
override = bpy.context.copy()
override['area'] = area
override['space_data'] = area.spaces.active
region = next((r for r in area.regions if r.type == 'WINDOW'), None)
if region is None:
continue
override['region'] = region
space = area.spaces.active
space.image = image
if hasattr(space, "image_user"):
space.image_user.tile = int(tile_number)
bpy.ops.image.save(override, filepath=filepath)
return
# Fallback: attempt to set filepath and invoke save without override
image.filepath = filepath
image.save()
# Must register the new dialog class as well
classes = (
AUTOMAT_OT_summary_dialog,
@@ -27,5 +27,3 @@ class NoSubdiv(bpy.types.Operator):
removed_count += 1
self.report({'INFO'}, f"Subdivision Surface modifiers removed from {'selected' if self.only_selected else 'all'} objects. ({removed_count} removed)")
return {'FINISHED'}
print("Subdivision Surface modifiers removed from all objects.")
@@ -0,0 +1,87 @@
import bpy
class ConvertRelationsToConstraint(bpy.types.Operator):
"""Convert regular parenting to Child Of constraints for all selected objects"""
bl_idname = "bst.convert_relations_to_constraint"
bl_label = "Convert Relations to Constraint"
bl_description = "Convert regular parenting relationships to Child Of constraints for selected objects"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
result = convert_relations_to_constraint()
if result:
self.report({'INFO'}, f"Converted {result} objects to Child Of constraints")
else:
self.report({'WARNING'}, "No objects with parents found in selection")
return {'FINISHED'}
def convert_relations_to_constraint():
"""Convert regular parenting to Child Of constraints for all selected objects"""
# Get all selected objects
selected_objects = bpy.context.selected_objects
if not selected_objects:
print("No objects selected!")
return 0
print(f"Converting parenting to Child Of constraints for {len(selected_objects)} objects...")
converted_count = 0
for obj in selected_objects:
# Check if object has a parent
if obj.parent is None:
print(f"Skipping {obj.name}: No parent found")
continue
# Store bone information if parented to a bone
parent_bone = obj.parent_bone if obj.parent_bone else None
bone_info = f" (bone: {parent_bone})" if parent_bone else ""
print(f"Processing {obj.name} -> {obj.parent.name}{bone_info}")
# Store original parent and current world matrix
original_parent = obj.parent
world_matrix = obj.matrix_world.copy()
# Remove the parent relationship
obj.parent = None
obj.parent_bone = "" # Clear the bone reference
# Add Child Of constraint
child_of_constraint = obj.constraints.new(type='CHILD_OF')
child_of_constraint.name = f"Child_Of_{original_parent.name}"
child_of_constraint.target = original_parent
# Transfer bone information to constraint subtarget
if parent_bone:
child_of_constraint.subtarget = parent_bone
print(f" ✓ Transferred bone target: {parent_bone}")
# Set the inverse matrix properly to maintain world position
# This is equivalent to clicking "Set Inverse" in the UI
child_of_constraint.inverse_matrix = original_parent.matrix_world.inverted()
# Restore the original world position
obj.matrix_world = world_matrix
# Set the constraint to be active
child_of_constraint.influence = 1.0
converted_count += 1
print(f" ✓ Converted {obj.name} to Child Of constraint")
print(f"\nConversion complete! Converted {converted_count} objects.")
# Report remaining parented objects
remaining_parented = [obj for obj in bpy.context.selected_objects if obj.parent is not None]
if remaining_parented:
print(f"\nObjects that still have parents (not converted):")
for obj in remaining_parented:
print(f" - {obj.name} -> {obj.parent.name}")
return converted_count
# Run the conversion
if __name__ == "__main__":
convert_relations_to_constraint()
@@ -0,0 +1,39 @@
import bpy
class DeleteSingleKeyframeActions(bpy.types.Operator):
"""Delete actions that have no keyframes, only one keyframe, or all keyframes on the same frame"""
bl_idname = "bst.delete_single_keyframe_actions"
bl_label = "Delete Single Keyframe Actions"
bl_description = "Delete actions with unwanted keyframe patterns (no keyframes, single keyframe, or all keyframes on same frame)"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
actions = bpy.data.actions
actions_to_delete = []
for action in actions:
keyframe_frames = set()
total_keyframes = 0
for fcurve in action.fcurves:
for kf in fcurve.keyframe_points:
keyframe_frames.add(kf.co[0])
total_keyframes += 1
# No keyframes
if total_keyframes == 0:
actions_to_delete.append(action)
# Only one keyframe
elif total_keyframes == 1:
actions_to_delete.append(action)
# All keyframes on the same frame
elif len(keyframe_frames) == 1:
actions_to_delete.append(action)
deleted_count = 0
for action in actions_to_delete:
print(f"Deleting action '{action.name}' (unwanted keyframe pattern)")
bpy.data.actions.remove(action)
deleted_count += 1
self.report({'INFO'}, f"Deleted {deleted_count} unwanted actions")
return {'FINISHED'}
@@ -0,0 +1,157 @@
import bpy
class MATERIAL_USERS_OT_summary_dialog(bpy.types.Operator):
"""Show material users analysis in a popup dialog"""
bl_idname = "bst.material_users_summary_dialog"
bl_label = "Material Users Summary"
bl_options = {'REGISTER', 'INTERNAL'}
# Properties to store summary data
material_name: bpy.props.StringProperty(default="")
users_count: bpy.props.IntProperty(default=0)
fake_user: bpy.props.BoolProperty(default=False)
object_users: bpy.props.StringProperty(default="")
node_users: bpy.props.StringProperty(default="")
material_node_users: bpy.props.StringProperty(default="")
total_user_count: bpy.props.IntProperty(default=0)
def draw(self, context):
layout = self.layout
# Title
layout.label(text=f"Material Users - '{self.material_name}'", icon='MATERIAL')
layout.separator()
# Basic info box
box = layout.box()
col = box.column(align=True)
col.label(text=f"Blender Users Count: {self.users_count}")
col.label(text=f"Fake User: {'Yes' if self.fake_user else 'No'}")
col.label(text=f"Total Found Users: {self.total_user_count}")
layout.separator()
# Object users section
if self.object_users:
layout.label(text="Object Users:", icon='OBJECT_DATA')
objects_box = layout.box()
objects_col = objects_box.column(align=True)
for obj_name in self.object_users.split('|'):
if obj_name.strip():
objects_col.label(text=f"{obj_name}", icon='RIGHTARROW_THIN')
else:
layout.label(text="Object Users: None", icon='OBJECT_DATA')
# Node tree users section
if self.node_users:
layout.separator()
layout.label(text="Node Tree Users:", icon='NODETREE')
nodes_box = layout.box()
nodes_col = nodes_box.column(align=True)
for node_ref in self.node_users.split('|'):
if node_ref.strip():
nodes_col.label(text=f"{node_ref}", icon='RIGHTARROW_THIN')
# Material node tree users section
if self.material_node_users:
layout.separator()
layout.label(text="Material Node Tree Users:", icon='MATERIAL')
mat_nodes_box = layout.box()
mat_nodes_col = mat_nodes_box.column(align=True)
for mat_node_ref in self.material_node_users.split('|'):
if mat_node_ref.strip():
mat_nodes_col.label(text=f"{mat_node_ref}", icon='RIGHTARROW_THIN')
layout.separator()
def execute(self, context):
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_popup(self, width=500)
class FindMaterialUsers(bpy.types.Operator):
"""Find all users of a specified material and display detailed information"""
bl_idname = "bst.find_material_users"
bl_label = "Find Material Users"
bl_description = "Find and display all users of a specified material"
bl_options = {'REGISTER'}
material_name: bpy.props.StringProperty(
name="Material",
description="Name of the material to analyze",
default="",
)
def draw(self, context):
layout = self.layout
# Set the material if we have a name
if self.material_name and self.material_name in bpy.data.materials:
context.scene.bst_temp_material = bpy.data.materials[self.material_name]
# Use template_ID to get the proper material selector (without new button)
layout.template_ID(context.scene, "bst_temp_material", text="Material")
def execute(self, context):
# Get the material from the temp property
material = getattr(context.scene, 'bst_temp_material', None)
if not material:
self.report({'ERROR'}, "No material selected")
return {'CANCELLED'}
# Update our material_name property
self.material_name = material.name
# Check objects
object_users = []
for obj in bpy.data.objects:
if obj.material_slots:
for slot in obj.material_slots:
if slot.material == material:
object_users.append(obj.name)
break
# Check node groups more thoroughly
node_users = []
for node_tree in bpy.data.node_groups:
for node in node_tree.nodes:
# Check material nodes
if hasattr(node, 'material') and node.material == material:
node_users.append(f"{node_tree.name}.{node.name}")
# Check material input sockets
for input_socket in node.inputs:
if hasattr(input_socket, 'default_value') and hasattr(input_socket.default_value, 'name'):
if input_socket.default_value.name == material.name:
node_users.append(f"{node_tree.name}.{node.name}.{input_socket.name}")
# Check material node trees
material_node_users = []
for mat in bpy.data.materials:
if mat.node_tree:
for node in mat.node_tree.nodes:
if hasattr(node, 'material') and node.material == material:
material_node_users.append(f"{mat.name}.{node.name}")
# Show summary dialog
self.show_summary_dialog(context, material, object_users, node_users, material_node_users)
return {'FINISHED'}
def show_summary_dialog(self, context, material, object_users, node_users, material_node_users):
"""Show the material users summary in a popup dialog"""
total_user_count = len(object_users) + len(node_users) + len(material_node_users)
# Create and configure the summary dialog
dialog_op = bpy.ops.bst.material_users_summary_dialog
dialog_op('INVOKE_DEFAULT',
material_name=material.name,
users_count=material.users,
fake_user=material.use_fake_user,
object_users='|'.join(object_users),
node_users='|'.join(node_users),
material_node_users='|'.join(material_node_users),
total_user_count=total_user_count)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
@@ -104,6 +104,32 @@ def clean_empty_collections():
print(f"Removed {removed_collections} empty collections")
return removed_collections
def is_object_used_by_scene_instance_collections(obj):
"""Check if object is in a collection that's being instanced by objects in scenes"""
# Find all collections that contain this object
obj_collections = []
for collection in bpy.data.collections:
if obj in collection.objects.values():
obj_collections.append(collection)
if not obj_collections:
return False
# Check if any of these collections are being instanced by objects in scenes
for collection in obj_collections:
# Find objects that instance this collection
for other_obj in bpy.data.objects:
if (other_obj.instance_type == 'COLLECTION' and
other_obj.instance_collection == collection):
# Check if the instancing object is in any scene
for scene in bpy.data.scenes:
if other_obj in scene.objects.values():
return True
return False
def is_object_legitimate_outside_scene(obj):
"""Check if an object has legitimate reasons to exist outside scenes"""
@@ -115,36 +141,64 @@ def is_object_legitimate_outside_scene(obj):
if obj.instance_type == 'COLLECTION' and obj.instance_collection is not None:
return True
# Objects that are being used by instance collections in scenes are legitimate
if is_object_used_by_scene_instance_collections(obj):
return True
# Objects used as curve modifiers, constraints targets, etc.
# Check if object is used by modifiers on other objects
# Check if object is used by modifiers on other objects that are in scenes
for other_obj in bpy.data.objects:
for modifier in other_obj.modifiers:
if hasattr(modifier, 'object') and modifier.object == obj:
return True
if hasattr(modifier, 'target') and modifier.target == obj:
return True
# Check if object is used by constraints on other objects
for other_obj in bpy.data.objects:
for constraint in other_obj.constraints:
if hasattr(constraint, 'target') and constraint.target == obj:
return True
if hasattr(constraint, 'subtarget') and constraint.subtarget == obj.name:
return True
# Check if object is used in particle systems
for other_obj in bpy.data.objects:
for modifier in other_obj.modifiers:
if modifier.type == 'PARTICLE_SYSTEM':
settings = modifier.particle_system.settings
if hasattr(settings, 'object') and settings.object == obj:
# Check if the other object is in any scene
in_scene = False
for scene in bpy.data.scenes:
if other_obj in scene.objects.values():
in_scene = True
break
if in_scene:
for modifier in other_obj.modifiers:
if hasattr(modifier, 'object') and modifier.object == obj:
return True
if hasattr(settings, 'instance_object') and settings.instance_object == obj:
if hasattr(modifier, 'target') and modifier.target == obj:
return True
# Check if object is used by constraints on other objects that are in scenes
for other_obj in bpy.data.objects:
# Check if the other object is in any scene
in_scene = False
for scene in bpy.data.scenes:
if other_obj in scene.objects.values():
in_scene = True
break
if in_scene:
for constraint in other_obj.constraints:
if hasattr(constraint, 'target') and constraint.target == obj:
return True
if hasattr(constraint, 'subtarget') and constraint.subtarget == obj.name:
return True
# Check if object is used in particle systems on objects that are in scenes
for other_obj in bpy.data.objects:
# Check if the other object is in any scene
in_scene = False
for scene in bpy.data.scenes:
if other_obj in scene.objects.values():
in_scene = True
break
if in_scene:
for modifier in other_obj.modifiers:
if modifier.type == 'PARTICLE_SYSTEM':
settings = modifier.particle_system.settings
if hasattr(settings, 'object') and settings.object == obj:
return True
if hasattr(settings, 'instance_object') and settings.instance_object == obj:
return True
return False
def clean_object_ghosts():
def clean_object_ghosts(delete_low_priority=False):
"""Remove objects that are not in any scene and have no legitimate purpose (potential ghosts)"""
print("\n" + "="*80)
@@ -180,11 +234,24 @@ def clean_object_ghosts():
print(f" Preserving object: {obj.name} (legitimate use outside scene)")
continue
# Additional conservative check - only remove if it seems truly orphaned
# Objects with 2+ users might be referenced in ways we don't detect
# If not legitimate, it's a ghost - but be conservative with low user count objects
should_remove = False
removal_reason = ""
if obj.users >= 2:
# Higher user count ghosts are definitely safe to remove
should_remove = True
removal_reason = "ghost (users >= 2, no legitimate use found)"
elif obj.users < 2 and delete_low_priority:
# Low user count ghosts only if user enables the option
should_remove = True
removal_reason = "low priority ghost (users < 2, no legitimate use found)"
elif obj.users < 2:
print(f" Skipping low priority object: {obj.name} (users < 2, enable 'Delete Low Priority' to remove)")
if should_remove:
ghosts_to_remove.append(obj)
print(f" Marking ghost for removal: {obj.name} (type: {obj.type})")
print(f" Marking ghost for removal: {obj.name} (type: {obj.type}) - {removal_reason}")
# Remove the ghost objects
for obj in ghosts_to_remove:
@@ -245,14 +312,14 @@ def manual_object_analysis():
# Show recommendation
if is_object_legitimate_outside_scene(obj):
print(f" -> LEGITIMATE: Has valid use outside scenes")
elif obj.users < 2:
print(f" -> LOW PRIORITY: Only 1 user (collection reference)")
elif obj.users >= 2:
print(f" -> GHOST: Multiple users but not in scenes (will be removed)")
print(f" -> GHOST: No legitimate use found, users >= 2 (will be removed)")
elif obj.users < 2:
print(f" -> LOW PRIORITY: No legitimate use found, users < 2 (needs option enabled)")
else:
print(f" -> UNCLEAR: Manual review needed")
def main():
def main(delete_low_priority=False):
"""Main conservative cleanup function"""
print("CONSERVATIVE GHOST DATA CLEANUP")
@@ -260,7 +327,11 @@ def main():
print("This script removes:")
print("1. Unused local WGT widget objects")
print("2. Empty unlinked collections")
print("3. Objects not in any scene (conservative ghost detection)")
print("3. Objects not in any scene with no legitimate use")
if delete_low_priority:
print(" - Including low priority ghosts (no legitimate use, users < 2)")
else:
print(" - Excluding low priority ghosts (no legitimate use, users < 2)")
print("="*80)
initial_objects = len(list(bpy.data.objects))
@@ -269,7 +340,7 @@ def main():
# Safe operations only
wgts_removed = safe_wgt_removal()
collections_removed = clean_empty_collections()
object_ghosts_removed = clean_object_ghosts()
object_ghosts_removed = clean_object_ghosts(delete_low_priority)
# Show remaining object analysis
manual_object_analysis()
@@ -305,8 +376,11 @@ class GhostBuster(bpy.types.Operator):
def execute(self, context):
try:
# Get the delete low priority setting from scene properties
delete_low_priority = getattr(context.scene, "ghost_buster_delete_low_priority", False)
# Call the main ghost buster function
main()
main(delete_low_priority)
self.report({'INFO'}, "Ghost data cleanup completed")
return {'FINISHED'}
except Exception as e:
@@ -428,12 +502,12 @@ class GhostDetector(bpy.types.Operator):
if is_object_legitimate_outside_scene(obj):
legitimate += 1
status = "LEGITIMATE (has valid use outside scenes)"
elif obj.users < 2:
low_priority += 1
status = "LOW PRIORITY (only collection reference)"
elif obj.users >= 2:
potential_ghosts += 1
status = "GHOST (will be removed)"
status = "GHOST (no legitimate use found, users >= 2)"
elif obj.users < 2:
low_priority += 1
status = "LOW PRIORITY (no legitimate use found, users < 2)"
else:
status = "UNCLEAR"
@@ -493,11 +567,11 @@ class GhostDetector(bpy.types.Operator):
col.label(text=f"Objects not in scenes: {self.ghost_objects}")
if self.ghost_objects > 0:
if self.ghost_potential > 0:
col.label(text=f"Potential ghosts: {self.ghost_potential}", icon='ERROR')
col.label(text=f"Ghosts (users >= 2): {self.ghost_potential}", icon='ERROR')
if self.ghost_legitimate > 0:
col.label(text=f"Legitimate objects: {self.ghost_legitimate}", icon='CHECKMARK')
if self.ghost_low_priority > 0:
col.label(text=f"Low priority: {self.ghost_low_priority}", icon='QUESTION')
col.label(text=f"Low priority (users < 2): {self.ghost_low_priority}", icon='QUESTION')
if self.ghost_details:
box.separator()
@@ -514,10 +588,14 @@ class GhostDetector(bpy.types.Operator):
summary_box.label(text="Summary", icon='INFO')
total_issues = self.unused_wgt_objects + self.empty_collections + self.ghost_potential
if total_issues > 0:
summary_box.label(text=f"Found {total_issues} potential ghost data issues", icon='ERROR')
summary_box.label(text=f"Found {total_issues} ghost data issues that will be removed", icon='ERROR')
if self.ghost_low_priority > 0:
summary_box.label(text=f"+ {self.ghost_low_priority} low priority issues (optional)", icon='QUESTION')
summary_box.label(text="Use Ghost Buster to clean up safely")
else:
summary_box.label(text="No ghost data issues detected!", icon='CHECKMARK')
if self.ghost_low_priority > 0:
summary_box.label(text=f"({self.ghost_low_priority} low priority issues available)", icon='INFO')
def execute(self, context):
return {'FINISHED'}
@@ -0,0 +1,57 @@
import bpy
class RemoveUnusedMaterialSlots(bpy.types.Operator):
"""Remove unused material slots from all mesh objects"""
bl_idname = "bst.remove_unused_material_slots"
bl_label = "Remove Unused Material Slots"
bl_description = "Remove unused material slots from all mesh objects in the scene"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
processed_objects = 0
# Store original active object and selection
original_active = context.view_layer.objects.active
original_selection = [obj for obj in context.selected_objects]
try:
# Remove unused material slots from all mesh objects
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.material_slots and obj.library is None:
# Temporarily ensure object is in view layer by linking to master collection
was_linked = False
if obj.name not in context.view_layer.objects:
context.scene.collection.objects.link(obj)
was_linked = True
# Store original selection state
original_obj_selection = obj.select_get()
# Select the object and make it active
obj.select_set(True)
context.view_layer.objects.active = obj
# Remove unused material slots
bpy.ops.object.material_slot_remove_unused()
processed_objects += 1
# Restore original selection state
obj.select_set(original_obj_selection)
# Unlink if we linked it
if was_linked:
context.scene.collection.objects.unlink(obj)
finally:
# Restore original active object and selection
context.view_layer.objects.active = original_active
# Clear all selections first
for obj in context.selected_objects:
obj.select_set(False)
# Restore original selection
for obj in original_selection:
if obj.name in context.view_layer.objects:
obj.select_set(True)
self.report({'INFO'}, f"Removed unused material slots from {processed_objects} mesh objects")
return {'FINISHED'}