2025-12-01
This commit is contained in:
@@ -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'}
|
||||
Reference in New Issue
Block a user