2025-07-01
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
import bpy
|
||||
import os
|
||||
import re
|
||||
from ..panels.bulk_path_management import get_image_extension, bulk_remap_paths, set_image_paths
|
||||
|
||||
class AUTOMAT_OT_summary_dialog(bpy.types.Operator):
|
||||
"""Show AutoMat Extractor operation summary"""
|
||||
bl_idname = "bst.automat_summary_dialog"
|
||||
bl_label = "AutoMat Extractor Summary"
|
||||
bl_options = {'REGISTER', 'INTERNAL'}
|
||||
|
||||
# Properties to store summary data
|
||||
total_selected: bpy.props.IntProperty(default=0)
|
||||
success_count: bpy.props.IntProperty(default=0)
|
||||
overwrite_skipped_count: bpy.props.IntProperty(default=0)
|
||||
failed_remap_count: bpy.props.IntProperty(default=0)
|
||||
|
||||
overwrite_details: bpy.props.StringProperty(default="")
|
||||
failed_remap_details: bpy.props.StringProperty(default="")
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
layout.label(text="AutoMat Extractor - Summary", icon='INFO')
|
||||
layout.separator()
|
||||
|
||||
box = layout.box()
|
||||
col = box.column(align=True)
|
||||
col.label(text=f"Total selected images: {self.total_selected}")
|
||||
col.label(text=f"Successfully extracted: {self.success_count}", icon='CHECKMARK')
|
||||
|
||||
if self.overwrite_skipped_count > 0:
|
||||
col.label(text=f"Skipped to prevent overwrite: {self.overwrite_skipped_count}", icon='ERROR')
|
||||
if self.failed_remap_count > 0:
|
||||
col.label(text=f"Failed to remap (path issue): {self.failed_remap_count}", icon='ERROR')
|
||||
|
||||
if self.overwrite_details:
|
||||
layout.separator()
|
||||
box = layout.box()
|
||||
box.label(text="Overwrite Conflicts (Skipped):", icon='FILE_TEXT')
|
||||
for line in self.overwrite_details.split('\n'):
|
||||
if line.strip():
|
||||
box.label(text=line)
|
||||
|
||||
if self.failed_remap_details:
|
||||
layout.separator()
|
||||
box = layout.box()
|
||||
box.label(text="Failed Remaps:", icon='FILE_TEXT')
|
||||
for line in self.failed_remap_details.split('\n'):
|
||||
if line.strip():
|
||||
box.label(text=line)
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_popup(self, width=500)
|
||||
|
||||
class AutoMatExtractor(bpy.types.Operator):
|
||||
bl_idname = "bst.automatextractor"
|
||||
bl_label = "AutoMatExtractor"
|
||||
bl_description = "Pack selected images and extract them with organized paths by blend file and material"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
# Get addon preferences
|
||||
addon_name = __package__.split('.')[0]
|
||||
prefs = context.preferences.addons.get(addon_name).preferences
|
||||
common_outside = prefs.automat_common_outside_blend
|
||||
|
||||
# Get selected images
|
||||
selected_images = [img for img in bpy.data.images if hasattr(img, "bst_selected") and img.bst_selected]
|
||||
|
||||
if not selected_images:
|
||||
self.report({'WARNING'}, "No images selected for extraction")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Set up progress tracking
|
||||
props = context.scene.bst_path_props
|
||||
props.is_operation_running = True
|
||||
props.operation_progress = 0.0
|
||||
props.operation_status = f"Preparing AutoMat extraction for {len(selected_images)} images..."
|
||||
|
||||
# Store data for timer processing
|
||||
self.selected_images = selected_images
|
||||
self.common_outside = common_outside
|
||||
self.current_step = 0
|
||||
self.current_index = 0
|
||||
self.packed_count = 0
|
||||
self.success_count = 0
|
||||
self.overwrite_skipped = []
|
||||
self.failed_list = []
|
||||
self.path_mapping = {}
|
||||
|
||||
# Start timer for processing
|
||||
bpy.app.timers.register(self._process_step)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def _process_step(self):
|
||||
"""Process AutoMat extraction in steps to avoid blocking the UI"""
|
||||
props = bpy.context.scene.bst_path_props
|
||||
|
||||
# Check for cancellation
|
||||
if props.cancel_operation:
|
||||
props.is_operation_running = False
|
||||
props.operation_progress = 0.0
|
||||
props.operation_status = "Operation cancelled"
|
||||
props.cancel_operation = False
|
||||
return None
|
||||
|
||||
if self.current_step == 0:
|
||||
# Step 1: Pack images
|
||||
if self.current_index >= len(self.selected_images):
|
||||
# Packing complete, move to next step
|
||||
self.current_step = 1
|
||||
self.current_index = 0
|
||||
props.operation_status = "Removing extensions from image names..."
|
||||
props.operation_progress = 25.0
|
||||
return 0.01
|
||||
|
||||
# Pack current image
|
||||
img = self.selected_images[self.current_index]
|
||||
props.operation_status = f"Packing {img.name}..."
|
||||
|
||||
if not img.packed_file:
|
||||
try:
|
||||
img.pack()
|
||||
self.packed_count += 1
|
||||
except Exception as e:
|
||||
# Continue even if packing fails
|
||||
pass
|
||||
|
||||
self.current_index += 1
|
||||
progress = (self.current_index / len(self.selected_images)) * 25.0
|
||||
props.operation_progress = progress
|
||||
|
||||
elif self.current_step == 1:
|
||||
# Step 2: Remove extensions (this is a quick operation)
|
||||
try:
|
||||
bpy.ops.bst.remove_extensions()
|
||||
except Exception as e:
|
||||
pass # Continue even if this fails
|
||||
|
||||
self.current_step = 2
|
||||
self.current_index = 0
|
||||
props.operation_status = "Analyzing material usage..."
|
||||
props.operation_progress = 30.0
|
||||
|
||||
elif self.current_step == 2:
|
||||
# Step 3: Organize images by material usage
|
||||
if self.current_index >= len(self.selected_images):
|
||||
# Analysis complete, move to path building
|
||||
self.current_step = 3
|
||||
self.current_index = 0
|
||||
props.operation_status = "Building path mapping..."
|
||||
props.operation_progress = 50.0
|
||||
return 0.01
|
||||
|
||||
# Get material mapping for all selected images
|
||||
if self.current_index == 0:
|
||||
self.material_mapping = self.get_image_material_mapping(self.selected_images)
|
||||
print(f"DEBUG: Material mapping created for {len(self.selected_images)} images")
|
||||
|
||||
# This step is quick, just mark progress
|
||||
self.current_index += 1
|
||||
progress = 30.0 + (self.current_index / len(self.selected_images)) * 20.0
|
||||
props.operation_progress = progress
|
||||
|
||||
elif self.current_step == 3:
|
||||
# Step 4: Build path mapping
|
||||
if self.current_index >= len(self.selected_images):
|
||||
# Path building complete, move to remapping
|
||||
self.current_step = 4
|
||||
self.current_index = 0
|
||||
props.operation_status = "Remapping image paths..."
|
||||
props.operation_progress = 70.0
|
||||
return 0.01
|
||||
|
||||
# Build path for current image
|
||||
img = self.selected_images[self.current_index]
|
||||
props.operation_status = f"Building path for {img.name}..."
|
||||
|
||||
# Get blend file name
|
||||
blend_name = bpy.path.basename(bpy.data.filepath)
|
||||
if blend_name:
|
||||
blend_name = os.path.splitext(blend_name)[0]
|
||||
else:
|
||||
blend_name = "untitled"
|
||||
blend_name = self.sanitize_filename(blend_name)
|
||||
|
||||
# Determine common path
|
||||
if self.common_outside:
|
||||
common_path_part = "common"
|
||||
else:
|
||||
common_path_part = f"{blend_name}\\common"
|
||||
|
||||
# Get extension and build path
|
||||
extension = get_image_extension(img)
|
||||
sanitized_base_name = self.sanitize_filename(img.name)
|
||||
filename = f"{sanitized_base_name}{extension}"
|
||||
|
||||
if img.name.startswith('#'):
|
||||
# Flat colors go to FlatColors subfolder
|
||||
path = f"//textures\\{common_path_part}\\FlatColors\\{filename}"
|
||||
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}"
|
||||
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}"
|
||||
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}"
|
||||
print(f"DEBUG: {img.name} - Used by multiple materials: {materials_using_image}, using common folder")
|
||||
|
||||
self.path_mapping[img.name] = path
|
||||
|
||||
self.current_index += 1
|
||||
progress = 50.0 + (self.current_index / len(self.selected_images)) * 20.0
|
||||
props.operation_progress = progress
|
||||
|
||||
elif self.current_step == 4:
|
||||
# Step 5: Remap paths
|
||||
if self.current_index >= len(self.path_mapping):
|
||||
# Remapping complete, move to saving
|
||||
self.current_step = 5
|
||||
self.current_index = 0
|
||||
props.operation_status = "Saving images to new locations..."
|
||||
props.operation_progress = 85.0
|
||||
return 0.01
|
||||
|
||||
# Remap current image
|
||||
img_name = list(self.path_mapping.keys())[self.current_index]
|
||||
new_path = self.path_mapping[img_name]
|
||||
props.operation_status = f"Remapping {img_name}..."
|
||||
|
||||
success = set_image_paths(img_name, new_path)
|
||||
if success:
|
||||
self.success_count += 1
|
||||
else:
|
||||
self.failed_list.append(img_name)
|
||||
|
||||
self.current_index += 1
|
||||
progress = 70.0 + (self.current_index / len(self.path_mapping)) * 15.0
|
||||
props.operation_progress = progress
|
||||
|
||||
elif self.current_step == 5:
|
||||
# Step 6: Save images
|
||||
if self.current_index >= len(self.selected_images):
|
||||
# Operation complete
|
||||
props.is_operation_running = False
|
||||
props.operation_progress = 100.0
|
||||
props.operation_status = f"Completed! Extracted {self.success_count} images{f', {len(self.failed_list)} failed' if self.failed_list else ''}"
|
||||
|
||||
# Show summary dialog
|
||||
self.show_summary_dialog(
|
||||
bpy.context,
|
||||
total_selected=len(self.selected_images),
|
||||
success_count=self.success_count,
|
||||
overwrite_skipped_list=self.overwrite_skipped,
|
||||
failed_remap_list=self.failed_list
|
||||
)
|
||||
|
||||
# Console summary
|
||||
print(f"\n=== AUTOMAT EXTRACTION SUMMARY ===")
|
||||
print(f"Total images processed: {len(self.selected_images)}")
|
||||
print(f"Successfully extracted: {self.success_count}")
|
||||
print(f"Failed to remap: {len(self.failed_list)}")
|
||||
|
||||
# Show organization breakdown
|
||||
material_organized = 0
|
||||
common_organized = 0
|
||||
flat_colors = 0
|
||||
|
||||
for img_name, path in self.path_mapping.items():
|
||||
if "FlatColors" in path:
|
||||
flat_colors += 1
|
||||
elif "common" in path:
|
||||
common_organized += 1
|
||||
else:
|
||||
material_organized += 1
|
||||
|
||||
print(f"\nOrganization breakdown:")
|
||||
print(f" Material-specific folders: {material_organized}")
|
||||
print(f" Common folder: {common_organized}")
|
||||
print(f" Flat colors: {flat_colors}")
|
||||
|
||||
# Show material organization details
|
||||
if material_organized > 0:
|
||||
print(f"\nMaterial organization details:")
|
||||
material_folders = {}
|
||||
for img_name, path in self.path_mapping.items():
|
||||
if "FlatColors" not in path and "common" not in path:
|
||||
# Extract material name from path
|
||||
path_parts = path.split('\\')
|
||||
if len(path_parts) >= 3:
|
||||
material_name = path_parts[-2]
|
||||
if material_name not in material_folders:
|
||||
material_folders[material_name] = []
|
||||
material_folders[material_name].append(img_name)
|
||||
|
||||
for material_name, images in material_folders.items():
|
||||
print(f" {material_name}: {len(images)} images")
|
||||
|
||||
print(f"=====================================\n")
|
||||
|
||||
# Force UI update
|
||||
for area in bpy.context.screen.areas:
|
||||
area.tag_redraw()
|
||||
|
||||
return None
|
||||
|
||||
# Save current image
|
||||
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
|
||||
|
||||
self.current_index += 1
|
||||
progress = 85.0 + (self.current_index / len(self.selected_images)) * 15.0
|
||||
props.operation_progress = progress
|
||||
|
||||
# Force UI update
|
||||
for area in bpy.context.screen.areas:
|
||||
area.tag_redraw()
|
||||
|
||||
# Continue processing
|
||||
return 0.01
|
||||
|
||||
def show_summary_dialog(self, context, total_selected, success_count, overwrite_skipped_list, failed_remap_list):
|
||||
"""Show a popup dialog with the extraction summary"""
|
||||
overwrite_details = ""
|
||||
if overwrite_skipped_list:
|
||||
for name, path in overwrite_skipped_list:
|
||||
overwrite_details += f"'{name}' -> '{path}'\n"
|
||||
|
||||
failed_remap_details = ""
|
||||
if failed_remap_list:
|
||||
for name, path in failed_remap_list:
|
||||
failed_remap_details += f"'{name}' -> '{path}'\n"
|
||||
|
||||
bpy.ops.bst.automat_summary_dialog('INVOKE_DEFAULT',
|
||||
total_selected=total_selected,
|
||||
success_count=success_count,
|
||||
overwrite_skipped_count=len(overwrite_skipped_list),
|
||||
failed_remap_count=len(failed_remap_list),
|
||||
overwrite_details=overwrite_details.strip(),
|
||||
failed_remap_details=failed_remap_details.strip()
|
||||
)
|
||||
|
||||
def sanitize_filename(self, filename):
|
||||
"""Sanitize filename/folder name for filesystem compatibility"""
|
||||
# First, remove potential file extensions, including numerical ones like .001
|
||||
base_name = re.sub(r'\.\d{3}$', '', filename) # Remove .001, .002 etc.
|
||||
base_name = os.path.splitext(base_name)[0] # Remove standard extensions
|
||||
|
||||
# Remove or replace invalid characters for Windows/Mac/Linux
|
||||
sanitized = re.sub(r'[<>:"/\\|?*]', '_', base_name)
|
||||
# Remove leading/trailing spaces and dots
|
||||
sanitized = sanitized.strip(' .')
|
||||
# Ensure it's not empty
|
||||
if not sanitized:
|
||||
sanitized = "unnamed"
|
||||
return sanitized
|
||||
|
||||
def get_image_material_mapping(self, images):
|
||||
"""Create mapping of image names to materials that use them"""
|
||||
image_to_materials = {}
|
||||
|
||||
# Initialize mapping
|
||||
for img in images:
|
||||
image_to_materials[img.name] = []
|
||||
|
||||
# Check all materials for image usage
|
||||
for material in bpy.data.materials:
|
||||
if not material.use_nodes:
|
||||
continue
|
||||
|
||||
material_images = set()
|
||||
|
||||
# Find all image texture nodes in this material
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == 'TEX_IMAGE' and node.image:
|
||||
material_images.add(node.image.name)
|
||||
|
||||
# Add this material to each image's usage list
|
||||
for img_name in material_images:
|
||||
if img_name in image_to_materials:
|
||||
image_to_materials[img_name].append(material.name)
|
||||
|
||||
return image_to_materials
|
||||
|
||||
# Must register the new dialog class as well
|
||||
classes = (
|
||||
AUTOMAT_OT_summary_dialog,
|
||||
AutoMatExtractor,
|
||||
)
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import bpy
|
||||
|
||||
class BST_FreeGPU(bpy.types.Operator):
|
||||
bl_idname = "bst.free_gpu"
|
||||
bl_label = "Free VRAM"
|
||||
bl_description = "Unallocate all material images from VRAM"
|
||||
|
||||
def execute(self, context):
|
||||
for mat in bpy.data.materials:
|
||||
if mat.use_nodes:
|
||||
for node in mat.node_tree.nodes:
|
||||
if hasattr(node, 'image') and node.image:
|
||||
node.image.gl_free()
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,31 @@
|
||||
import bpy
|
||||
|
||||
class NoSubdiv(bpy.types.Operator):
|
||||
"""Remove all subdivision surface modifiers from objects"""
|
||||
bl_idname = "bst.no_subdiv"
|
||||
bl_label = "No Subdiv"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
only_selected: bpy.props.BoolProperty(
|
||||
name="Only Selected Objects",
|
||||
description="Apply only to selected objects",
|
||||
default=True
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
# Choose objects based on the property
|
||||
if self.only_selected:
|
||||
objects = context.selected_objects
|
||||
else:
|
||||
objects = bpy.data.objects
|
||||
removed_count = 0
|
||||
for obj in objects:
|
||||
if obj.modifiers:
|
||||
subdiv_mods = [mod for mod in obj.modifiers if mod.type == 'SUBSURF']
|
||||
for mod in subdiv_mods:
|
||||
obj.modifiers.remove(mod)
|
||||
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,513 @@
|
||||
import bpy
|
||||
import re
|
||||
|
||||
class RENAME_OT_summary_dialog(bpy.types.Operator):
|
||||
"""Show rename operation summary"""
|
||||
bl_idname = "bst.rename_summary_dialog"
|
||||
bl_label = "Rename Summary"
|
||||
bl_options = {'REGISTER', 'INTERNAL'}
|
||||
|
||||
# Properties to store summary data
|
||||
total_selected: bpy.props.IntProperty(default=0)
|
||||
renamed_count: bpy.props.IntProperty(default=0)
|
||||
shared_count: bpy.props.IntProperty(default=0)
|
||||
unused_count: bpy.props.IntProperty(default=0)
|
||||
cc3iid_count: bpy.props.IntProperty(default=0)
|
||||
flatcolor_count: bpy.props.IntProperty(default=0)
|
||||
already_correct_count: bpy.props.IntProperty(default=0)
|
||||
unrecognized_suffix_count: bpy.props.IntProperty(default=0)
|
||||
rename_details: bpy.props.StringProperty(default="")
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Title
|
||||
layout.label(text="Rename by Material - Summary", icon='INFO')
|
||||
layout.separator()
|
||||
|
||||
# Statistics box
|
||||
box = layout.box()
|
||||
col = box.column(align=True)
|
||||
col.label(text=f"Total selected images: {self.total_selected}")
|
||||
col.label(text=f"Successfully renamed: {self.renamed_count}", icon='CHECKMARK')
|
||||
|
||||
if self.already_correct_count > 0:
|
||||
col.label(text=f"Already correctly named: {self.already_correct_count}", icon='CHECKMARK')
|
||||
if self.shared_count > 0:
|
||||
col.label(text=f"Shared images skipped: {self.shared_count}", icon='RADIOBUT_OFF')
|
||||
if self.unused_count > 0:
|
||||
col.label(text=f"Unused images skipped: {self.unused_count}", icon='RADIOBUT_OFF')
|
||||
if self.cc3iid_count > 0:
|
||||
col.label(text=f"CC3 ID textures skipped: {self.cc3iid_count}", icon='RADIOBUT_OFF')
|
||||
if self.flatcolor_count > 0:
|
||||
col.label(text=f"Flat colors skipped: {self.flatcolor_count}", icon='RADIOBUT_OFF')
|
||||
if self.unrecognized_suffix_count > 0:
|
||||
col.label(text=f"Unrecognized suffixes skipped: {self.unrecognized_suffix_count}", icon='RADIOBUT_OFF')
|
||||
|
||||
# Show detailed rename information if available
|
||||
if self.rename_details:
|
||||
layout.separator()
|
||||
box = layout.box()
|
||||
box.label(text="Renamed Images:", icon='FILE_TEXT')
|
||||
|
||||
# Split the details by lines and show each one
|
||||
lines = self.rename_details.split('\n')
|
||||
for line in lines[:10]: # Limit to first 10 to avoid overly long dialogs
|
||||
if line.strip():
|
||||
box.label(text=line)
|
||||
|
||||
if len(lines) > 10:
|
||||
box.label(text=f"... and {len(lines) - 10} more")
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_popup(self, width=500)
|
||||
|
||||
class Rename_images_by_mat(bpy.types.Operator):
|
||||
bl_idname = "bst.rename_images_by_mat"
|
||||
bl_label = "Rename Images by Material"
|
||||
bl_description = "Rename selected images based on their material usage, preserving texture type suffixes"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
# Get selected images
|
||||
selected_images = [img for img in bpy.data.images if hasattr(img, "bst_selected") and img.bst_selected]
|
||||
|
||||
if not selected_images:
|
||||
self.report({'WARNING'}, "No images selected for renaming")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Get image to material mapping
|
||||
image_to_materials = self.get_image_material_mapping(selected_images)
|
||||
|
||||
renamed_count = 0
|
||||
shared_count = 0
|
||||
unused_count = 0
|
||||
cc3iid_count = 0 # Track CC3 ID textures
|
||||
flatcolor_count = 0 # Track flat color textures
|
||||
already_correct_count = 0 # Track images already correctly named
|
||||
unrecognized_suffix_count = 0 # Track images with unrecognized suffixes
|
||||
renamed_list = [] # Track renamed images for debug
|
||||
unrecognized_list = [] # Track images with unrecognized suffixes
|
||||
|
||||
for img in selected_images:
|
||||
# Skip CC3 ID textures (ignore case)
|
||||
if img.name.lower().startswith('cc3iid'):
|
||||
cc3iid_count += 1
|
||||
print(f"DEBUG: Skipped CC3 ID texture: {img.name}")
|
||||
continue
|
||||
|
||||
# Skip flat color textures (start with #)
|
||||
if img.name.startswith('#'):
|
||||
flatcolor_count += 1
|
||||
print(f"DEBUG: Skipped flat color texture: {img.name}")
|
||||
continue
|
||||
|
||||
materials = image_to_materials.get(img.name, [])
|
||||
|
||||
if len(materials) == 0:
|
||||
# Unused image - skip
|
||||
unused_count += 1
|
||||
print(f"DEBUG: Skipped unused image: {img.name}")
|
||||
continue
|
||||
elif len(materials) == 1:
|
||||
# Single material usage - check suffix recognition
|
||||
material_name = materials[0]
|
||||
suffix = self.extract_texture_suffix(img.name)
|
||||
original_name = img.name
|
||||
|
||||
# Skip images with unrecognized suffixes (only if they have a potential suffix pattern)
|
||||
if suffix is None and self.has_potential_suffix(img.name):
|
||||
unrecognized_suffix_count += 1
|
||||
unrecognized_list.append(img.name)
|
||||
print(f"DEBUG: Skipped image with unrecognized suffix: {img.name}")
|
||||
continue
|
||||
|
||||
if suffix:
|
||||
# Capitalize the suffix properly
|
||||
capitalized_suffix = self.capitalize_suffix(suffix)
|
||||
expected_name = f"{material_name}_{capitalized_suffix}"
|
||||
else:
|
||||
# No suffix detected, use material name only
|
||||
expected_name = material_name
|
||||
|
||||
# Check if the image is already correctly named
|
||||
if img.name == expected_name:
|
||||
already_correct_count += 1
|
||||
print(f"DEBUG: Skipped already correctly named: {img.name}")
|
||||
continue
|
||||
|
||||
# Avoid duplicate names
|
||||
new_name = self.ensure_unique_name(expected_name)
|
||||
|
||||
img.name = new_name
|
||||
renamed_count += 1
|
||||
renamed_list.append((original_name, new_name, material_name, capitalized_suffix if suffix else None))
|
||||
print(f"DEBUG: Renamed '{original_name}' → '{new_name}' (Material: {material_name}, Suffix: {capitalized_suffix if suffix else 'none'})")
|
||||
else:
|
||||
# Shared across multiple materials - skip
|
||||
shared_count += 1
|
||||
print(f"DEBUG: Skipped shared image: {img.name} (used by {len(materials)} materials: {', '.join(materials[:3])}{'...' if len(materials) > 3 else ''})")
|
||||
|
||||
# Console debug summary (keep for development)
|
||||
print(f"\n=== RENAME BY MATERIAL SUMMARY ===")
|
||||
print(f"Total selected: {len(selected_images)}")
|
||||
print(f"Renamed: {renamed_count}")
|
||||
print(f"Already correct (skipped): {already_correct_count}")
|
||||
print(f"Shared (skipped): {shared_count}")
|
||||
print(f"Unused (skipped): {unused_count}")
|
||||
print(f"CC3 ID textures (skipped): {cc3iid_count}")
|
||||
print(f"Flat colors (skipped): {flatcolor_count}")
|
||||
print(f"Unrecognized suffixes (skipped): {unrecognized_suffix_count}")
|
||||
|
||||
if renamed_list:
|
||||
print(f"\nDetailed rename log:")
|
||||
for original, new, material, suffix in renamed_list:
|
||||
suffix_info = f" (suffix: {suffix})" if suffix else " (no suffix)"
|
||||
print(f" '{original}' → '{new}' for material '{material}'{suffix_info}")
|
||||
|
||||
if unrecognized_list:
|
||||
print(f"\nImages with unrecognized suffixes:")
|
||||
for img_name in unrecognized_list:
|
||||
print(f" '{img_name}'")
|
||||
|
||||
print(f"===================================\n")
|
||||
|
||||
# Show popup summary dialog
|
||||
self.show_summary_dialog(context, len(selected_images), renamed_count, shared_count, unused_count, cc3iid_count, flatcolor_count, already_correct_count, unrecognized_suffix_count, renamed_list)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def show_summary_dialog(self, context, total_selected, renamed_count, shared_count, unused_count, cc3iid_count, flatcolor_count, already_correct_count, unrecognized_suffix_count, renamed_list):
|
||||
"""Show a popup dialog with the rename summary"""
|
||||
# Prepare detailed rename information for display
|
||||
details_text = ""
|
||||
if renamed_list:
|
||||
for original, new, material, suffix in renamed_list:
|
||||
suffix_info = f" ({suffix})" if suffix else ""
|
||||
details_text += f"'{original}' → '{new}'{suffix_info}\n"
|
||||
|
||||
# Invoke the summary dialog
|
||||
dialog = bpy.ops.bst.rename_summary_dialog('INVOKE_DEFAULT',
|
||||
total_selected=total_selected,
|
||||
renamed_count=renamed_count,
|
||||
shared_count=shared_count,
|
||||
unused_count=unused_count,
|
||||
cc3iid_count=cc3iid_count,
|
||||
flatcolor_count=flatcolor_count,
|
||||
already_correct_count=already_correct_count,
|
||||
unrecognized_suffix_count=unrecognized_suffix_count,
|
||||
rename_details=details_text.strip())
|
||||
|
||||
def get_image_material_mapping(self, images):
|
||||
"""Create mapping of image names to materials that use them"""
|
||||
image_to_materials = {}
|
||||
|
||||
# Initialize mapping
|
||||
for img in images:
|
||||
image_to_materials[img.name] = []
|
||||
|
||||
# Check all materials for image usage
|
||||
for material in bpy.data.materials:
|
||||
if not material.use_nodes:
|
||||
continue
|
||||
|
||||
material_images = set()
|
||||
|
||||
# Find all image texture nodes in this material
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == 'TEX_IMAGE' and node.image:
|
||||
material_images.add(node.image.name)
|
||||
|
||||
# Add this material to each image's usage list
|
||||
for img_name in material_images:
|
||||
if img_name in image_to_materials:
|
||||
image_to_materials[img_name].append(material.name)
|
||||
|
||||
return image_to_materials
|
||||
|
||||
def extract_texture_suffix(self, name):
|
||||
"""Extract texture type suffix from image name (case-insensitive)"""
|
||||
# Comprehensive list of texture suffixes
|
||||
suffixes = [
|
||||
# Standard PBR suffixes
|
||||
'diffuse', 'basecolor', 'base_color', 'albedo', 'color', 'col',
|
||||
'normal', 'norm', 'nrm', 'bump',
|
||||
'roughness', 'rough', 'rgh',
|
||||
'metallic', 'metal', 'mtl',
|
||||
'specular', 'spec', 'spc',
|
||||
'ao', 'ambient_occlusion', 'ambientocclusion', 'occlusion',
|
||||
'gradao',
|
||||
'height', 'displacement', 'disp', 'displace',
|
||||
'opacity', 'alpha', 'mask',
|
||||
'emission', 'emissive', 'emit',
|
||||
'subsurface', 'sss', 'transmission',
|
||||
|
||||
# Character Creator / iClone suffixes
|
||||
'base', 'diffusemap', 'normalmap', 'roughnessmap', 'metallicmap',
|
||||
'aomap', 'opacitymap', 'emissionmap', 'heightmap', 'displacementmap',
|
||||
'detail_normal', 'detail_diffuse', 'detail_mask',
|
||||
'blend', 'id', 'cavity', 'curvature', 'transmap', 'rgbamask', 'sssmap', 'micronmask',
|
||||
'bcbmap', 'mnaomask', 'specmask', 'micron', 'cfulcmask', 'nmuilmask', 'nbmap', 'enmask', 'blend_multiply',
|
||||
|
||||
# Hair-related compound suffixes (no spaces)
|
||||
'hairflowmap', 'hairidmap', 'hairrootmap', 'hairdepthmap',
|
||||
'flowmap', 'idmap', 'rootmap', 'depthmap',
|
||||
|
||||
# Wrinkle map suffixes (Character Creator)
|
||||
'wrinkle_normal1', 'wrinkle_normal2', 'wrinkle_normal3',
|
||||
'wrinkle_roughness1', 'wrinkle_roughness2', 'wrinkle_roughness3',
|
||||
'wrinkle_diffuse1', 'wrinkle_diffuse2', 'wrinkle_diffuse3',
|
||||
'wrinkle_mask1', 'wrinkle_mask2', 'wrinkle_mask3',
|
||||
'wrinkle_flow1', 'wrinkle_flow2', 'wrinkle_flow3',
|
||||
|
||||
# Character Creator pack suffixes (with spaces)
|
||||
'flow pack', 'msmnao pack', 'roughness pack', 'sstm pack',
|
||||
'flow_pack', 'msmnao_pack', 'roughness_pack', 'sstm_pack',
|
||||
|
||||
# Hair-related multi-word suffixes (spaces)
|
||||
'hair flow map', 'hair id map', 'hair root map', 'hair depth map',
|
||||
'flow map', 'id map', 'root map', 'depth map',
|
||||
|
||||
# Additional common variations
|
||||
'tex', 'map', 'img', 'texture',
|
||||
'd', 'n', 'r', 'm', 's', 'a', 'h', 'o', 'e' # Single letter abbreviations
|
||||
]
|
||||
|
||||
# Remove file extension first
|
||||
base_name = re.sub(r'\.[^.]+$', '', name)
|
||||
|
||||
# Sort suffixes by length (longest first) to prioritize more specific matches
|
||||
sorted_suffixes = sorted(suffixes, key=len, reverse=True)
|
||||
|
||||
# First, try to find multi-word suffixes with spaces (case-insensitive)
|
||||
for suffix in sorted_suffixes:
|
||||
if ' ' in suffix: # Multi-word suffix
|
||||
# Pattern: ends with space + suffix
|
||||
pattern = rf'\s+({re.escape(suffix)})$'
|
||||
match = re.search(pattern, base_name, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
|
||||
# Pattern: ends with suffix (no space separator, but exact match)
|
||||
if base_name.lower().endswith(suffix.lower()) and len(base_name) > len(suffix):
|
||||
# Check if there's a word boundary before the suffix
|
||||
prefix_end = len(base_name) - len(suffix)
|
||||
if prefix_end > 0 and base_name[prefix_end - 1] in ' _-':
|
||||
return suffix.lower()
|
||||
|
||||
# Then try single-word suffixes with traditional separators
|
||||
for suffix in sorted_suffixes:
|
||||
if ' ' not in suffix: # Single word suffix
|
||||
# Pattern: ends with _suffix or -suffix or .suffix
|
||||
pattern = rf'[._-]({re.escape(suffix)})$'
|
||||
match = re.search(pattern, base_name, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
|
||||
# Check for numeric suffixes (like _01, _02, etc.)
|
||||
numeric_match = re.search(r'[._-](\d+)$', base_name)
|
||||
if numeric_match:
|
||||
return numeric_match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def ensure_unique_name(self, proposed_name):
|
||||
"""Ensure the proposed name is unique among all images"""
|
||||
if proposed_name not in bpy.data.images:
|
||||
return proposed_name
|
||||
|
||||
# If name exists, add numerical suffix
|
||||
counter = 1
|
||||
while f"{proposed_name}.{counter:03d}" in bpy.data.images:
|
||||
counter += 1
|
||||
|
||||
return f"{proposed_name}.{counter:03d}"
|
||||
|
||||
def capitalize_suffix(self, suffix):
|
||||
"""Properly capitalize texture type suffixes with correct formatting"""
|
||||
# Dictionary of common texture suffixes with proper capitalization
|
||||
suffix_mapping = {
|
||||
# Standard PBR suffixes
|
||||
'diffuse': 'Diffuse',
|
||||
'basecolor': 'BaseColor',
|
||||
'base_color': 'BaseColor',
|
||||
'albedo': 'Albedo',
|
||||
'color': 'Color',
|
||||
'col': 'Color',
|
||||
|
||||
'normal': 'Normal',
|
||||
'norm': 'Normal',
|
||||
'nrm': 'Normal',
|
||||
'bump': 'Bump',
|
||||
|
||||
'roughness': 'Roughness',
|
||||
'rough': 'Roughness',
|
||||
'rgh': 'Roughness',
|
||||
|
||||
'metallic': 'Metallic',
|
||||
'metal': 'Metallic',
|
||||
'mtl': 'Metallic',
|
||||
|
||||
'specular': 'Specular',
|
||||
'spec': 'Specular',
|
||||
'spc': 'Specular',
|
||||
|
||||
'ao': 'AO',
|
||||
'ambient_occlusion': 'AmbientOcclusion',
|
||||
'ambientocclusion': 'AmbientOcclusion',
|
||||
'occlusion': 'Occlusion',
|
||||
'gradao': 'GradAO',
|
||||
|
||||
'height': 'Height',
|
||||
'displacement': 'Displacement',
|
||||
'disp': 'Displacement',
|
||||
'displace': 'Displacement',
|
||||
|
||||
'opacity': 'Opacity',
|
||||
'alpha': 'Alpha',
|
||||
'mask': 'Mask',
|
||||
'transmap': 'TransMap',
|
||||
|
||||
'emission': 'Emission',
|
||||
'emissive': 'Emission',
|
||||
'emit': 'Emission',
|
||||
|
||||
'subsurface': 'Subsurface',
|
||||
'sss': 'SSS',
|
||||
'transmission': 'Transmission',
|
||||
|
||||
# Character Creator / iClone suffixes
|
||||
'base': 'Base',
|
||||
'diffusemap': 'DiffuseMap',
|
||||
'normalmap': 'NormalMap',
|
||||
'roughnessmap': 'RoughnessMap',
|
||||
'metallicmap': 'MetallicMap',
|
||||
'aomap': 'AOMap',
|
||||
'opacitymap': 'OpacityMap',
|
||||
'emissionmap': 'EmissionMap',
|
||||
'heightmap': 'HeightMap',
|
||||
'displacementmap': 'DisplacementMap',
|
||||
'detail_normal': 'DetailNormal',
|
||||
'detail_diffuse': 'DetailDiffuse',
|
||||
'detail_mask': 'DetailMask',
|
||||
'blend': 'Blend',
|
||||
'id': 'ID',
|
||||
'cavity': 'Cavity',
|
||||
'curvature': 'Curvature',
|
||||
'transmap': 'TransMap',
|
||||
'rgbamask': 'RGBAMask',
|
||||
'sssmap': 'SSSMap',
|
||||
'micronmask': 'MicroNMask',
|
||||
'bcbmap': 'BCBMap',
|
||||
'mnaomask': 'MNAOMask',
|
||||
'specmask': 'SpecMask',
|
||||
'micron': 'MicroN',
|
||||
'cfulcmask': 'CFULCMask',
|
||||
'nmuilmask': 'NMUILMask',
|
||||
'nbmap': 'NBMap',
|
||||
'enmask': 'ENMask',
|
||||
'blend_multiply': 'Blend_Multiply',
|
||||
|
||||
# Hair-related compound suffixes (no spaces)
|
||||
'hairflowmap': 'HairFlowMap',
|
||||
'hairidmap': 'HairIDMap',
|
||||
'hairrootmap': 'HairRootMap',
|
||||
'hairdepthmap': 'HairDepthMap',
|
||||
'flowmap': 'FlowMap',
|
||||
'idmap': 'IDMap',
|
||||
'rootmap': 'RootMap',
|
||||
'depthmap': 'DepthMap',
|
||||
|
||||
# Wrinkle map suffixes (Character Creator)
|
||||
'wrinkle_normal1': 'Wrinkle_Normal1',
|
||||
'wrinkle_normal2': 'Wrinkle_Normal2',
|
||||
'wrinkle_normal3': 'Wrinkle_Normal3',
|
||||
'wrinkle_roughness1': 'Wrinkle_Roughness1',
|
||||
'wrinkle_roughness2': 'Wrinkle_Roughness2',
|
||||
'wrinkle_roughness3': 'Wrinkle_Roughness3',
|
||||
'wrinkle_diffuse1': 'Wrinkle_Diffuse1',
|
||||
'wrinkle_diffuse2': 'Wrinkle_Diffuse2',
|
||||
'wrinkle_diffuse3': 'Wrinkle_Diffuse3',
|
||||
'wrinkle_mask1': 'Wrinkle_Mask1',
|
||||
'wrinkle_mask2': 'Wrinkle_Mask2',
|
||||
'wrinkle_mask3': 'Wrinkle_Mask3',
|
||||
'wrinkle_flow1': 'Wrinkle_Flow1',
|
||||
'wrinkle_flow2': 'Wrinkle_Flow2',
|
||||
'wrinkle_flow3': 'Wrinkle_Flow3',
|
||||
|
||||
# Character Creator pack suffixes (with spaces)
|
||||
'flow pack': 'Flow Pack',
|
||||
'msmnao pack': 'MSMNAO Pack',
|
||||
'roughness pack': 'Roughness Pack',
|
||||
'sstm pack': 'SSTM Pack',
|
||||
'flow_pack': 'Flow_Pack',
|
||||
'msmnao_pack': 'MSMNAO_Pack',
|
||||
'roughness_pack': 'Roughness_Pack',
|
||||
'sstm_pack': 'SSTM_Pack',
|
||||
|
||||
# Hair-related multi-word suffixes
|
||||
'hair flow map': 'HairFlowMap',
|
||||
'hair id map': 'HairIDMap',
|
||||
'hair root map': 'HairRootMap',
|
||||
'hair depth map': 'HairDepthMap',
|
||||
'flow map': 'FlowMap',
|
||||
'id map': 'IDMap',
|
||||
'root map': 'RootMap',
|
||||
'depth map': 'DepthMap',
|
||||
|
||||
# Additional common variations
|
||||
'tex': 'Texture',
|
||||
'map': 'Map',
|
||||
'img': 'Image',
|
||||
'texture': 'Texture',
|
||||
|
||||
# Single letter abbreviations
|
||||
'd': 'Diffuse',
|
||||
'n': 'Normal',
|
||||
'r': 'Roughness',
|
||||
'm': 'Metallic',
|
||||
's': 'Specular',
|
||||
'a': 'Alpha',
|
||||
'h': 'Height',
|
||||
'o': 'Occlusion',
|
||||
'e': 'Emission'
|
||||
}
|
||||
|
||||
# Get the proper capitalization from mapping, or capitalize first letter as fallback
|
||||
return suffix_mapping.get(suffix.lower(), suffix.capitalize())
|
||||
|
||||
def has_potential_suffix(self, name):
|
||||
"""Check if the image name has a potential suffix pattern that we should try to recognize"""
|
||||
# Remove file extension first
|
||||
base_name = re.sub(r'\.[^.]+$', '', name)
|
||||
|
||||
# Check for common suffix patterns: _something, -something, .something, or space something
|
||||
suffix_patterns = [
|
||||
r'[._-][a-zA-Z0-9]+$', # Underscore, dot, or dash followed by alphanumeric
|
||||
r'\s+[a-zA-Z0-9\s]+$', # Space followed by alphanumeric (for multi-word suffixes)
|
||||
]
|
||||
|
||||
for pattern in suffix_patterns:
|
||||
if re.search(pattern, base_name):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# Registration classes - need to register both operators
|
||||
classes = (
|
||||
RENAME_OT_summary_dialog,
|
||||
Rename_images_by_mat,
|
||||
)
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
|
||||
class CreateOrthoCamera(Operator):
|
||||
"""Create an orthographic camera with predefined settings"""
|
||||
bl_idname = "bst.create_ortho_camera"
|
||||
bl_label = "Create Ortho Camera"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
# Create a new camera
|
||||
bpy.ops.object.camera_add()
|
||||
camera = context.active_object
|
||||
|
||||
# Set camera to orthographic
|
||||
camera.data.type = 'ORTHO'
|
||||
camera.data.ortho_scale = 1.8 # Set orthographic scale
|
||||
|
||||
# Set camera position
|
||||
camera.location = (0, -2, 1) # x=0, y=-2m, z=1m
|
||||
|
||||
# Set camera rotation (90 degrees around X axis)
|
||||
camera.rotation_euler = (1.5708, 0, 0) # 90 degrees in radians
|
||||
|
||||
# Get or create camera collection
|
||||
camera_collection = bpy.data.collections.get("Camera")
|
||||
if not camera_collection:
|
||||
camera_collection = bpy.data.collections.new("Camera")
|
||||
context.scene.collection.children.link(camera_collection)
|
||||
|
||||
# Move camera to camera collection
|
||||
# First unlink from current collection
|
||||
for collection in camera.users_collection:
|
||||
collection.objects.unlink(camera)
|
||||
# Then link to camera collection
|
||||
camera_collection.objects.link(camera)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def register():
|
||||
bpy.utils.register_class(CreateOrthoCamera)
|
||||
|
||||
def unregister():
|
||||
bpy.utils.unregister_class(CreateOrthoCamera)
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
@@ -0,0 +1,253 @@
|
||||
import bpy
|
||||
import bmesh
|
||||
from mathutils import Color
|
||||
|
||||
def rgb_to_hex(r, g, b, a=1.0):
|
||||
"""Convert RGBA values (0-1 range) to hex color code."""
|
||||
# Convert to 0-255 range and format as hex
|
||||
r_int = int(round(r * 255))
|
||||
g_int = int(round(g * 255))
|
||||
b_int = int(round(b * 255))
|
||||
a_int = int(round(a * 255))
|
||||
|
||||
# If alpha is full (255), use RGB format, otherwise use RGBA
|
||||
if a_int == 255:
|
||||
return f"#{r_int:02X}{g_int:02X}{b_int:02X}"
|
||||
else:
|
||||
return f"#{r_int:02X}{g_int:02X}{b_int:02X}{a_int:02X}"
|
||||
|
||||
def is_flat_color_image_efficient(image, max_pixels_to_check=10000):
|
||||
"""
|
||||
Efficiently check if an image has all pixels of the same color.
|
||||
|
||||
Args:
|
||||
image: The image to check
|
||||
max_pixels_to_check: Maximum number of pixels to check (for performance)
|
||||
|
||||
Returns:
|
||||
tuple: (is_flat, color) where is_flat is bool and color is RGBA tuple
|
||||
"""
|
||||
if not image or not image.pixels:
|
||||
print(f" DEBUG: No image or no pixels")
|
||||
return False, None
|
||||
|
||||
# Get pixel data
|
||||
pixels = image.pixels[:]
|
||||
|
||||
if len(pixels) == 0:
|
||||
print(f" DEBUG: Empty pixel array")
|
||||
return False, None
|
||||
|
||||
# Images in Blender are typically RGBA, so 4 values per pixel
|
||||
channels = image.channels
|
||||
if channels not in [3, 4]: # RGB or RGBA
|
||||
print(f" DEBUG: Unsupported channels: {channels}")
|
||||
return False, None
|
||||
|
||||
# Get the first pixel color as reference
|
||||
first_pixel = pixels[:channels]
|
||||
print(f" DEBUG: Reference color: {first_pixel}")
|
||||
|
||||
# Calculate total pixels
|
||||
total_pixels = len(pixels) // channels
|
||||
print(f" DEBUG: Total pixels: {total_pixels}")
|
||||
|
||||
# Determine how many pixels to check
|
||||
pixels_to_check = min(total_pixels, max_pixels_to_check)
|
||||
|
||||
# For small images, check every pixel
|
||||
if total_pixels <= max_pixels_to_check:
|
||||
step = 1
|
||||
print(f" DEBUG: Checking all {total_pixels} pixels")
|
||||
else:
|
||||
# For large images, sample evenly across the image
|
||||
step = total_pixels // pixels_to_check
|
||||
print(f" DEBUG: Sampling {pixels_to_check} pixels with step {step}")
|
||||
|
||||
# Check pixels
|
||||
checked_count = 0
|
||||
for i in range(0, total_pixels, step):
|
||||
pixel_start = i * channels
|
||||
current_pixel = pixels[pixel_start:pixel_start + channels]
|
||||
checked_count += 1
|
||||
|
||||
# Compare with reference pixel (exact match)
|
||||
for j in range(channels):
|
||||
if current_pixel[j] != first_pixel[j]:
|
||||
print(f" DEBUG: Pixel {i} differs at channel {j}: {current_pixel[j]} vs {first_pixel[j]}")
|
||||
print(f" DEBUG: Checked {checked_count} pixels before finding difference")
|
||||
return False, None
|
||||
|
||||
print(f" DEBUG: All {checked_count} checked pixels are identical")
|
||||
|
||||
# If we get here, all checked pixels are the same color
|
||||
if channels == 3:
|
||||
return True, (first_pixel[0], first_pixel[1], first_pixel[2], 1.0)
|
||||
else:
|
||||
return True, tuple(first_pixel)
|
||||
|
||||
def is_flat_color_image(image):
|
||||
"""Check if an image has all pixels of the same color."""
|
||||
# Use the efficient version by default
|
||||
return is_flat_color_image_efficient(image, max_pixels_to_check=10000)
|
||||
|
||||
def safe_rename_image(image, new_name):
|
||||
"""Safely rename an image datablock using context override."""
|
||||
try:
|
||||
# Method 1: Try direct assignment first (works in some contexts)
|
||||
image.name = new_name
|
||||
return True
|
||||
except:
|
||||
try:
|
||||
# Method 2: Use context override with outliner
|
||||
for area in bpy.context.screen.areas:
|
||||
if area.type == 'OUTLINER':
|
||||
with bpy.context.temp_override(area=area):
|
||||
image.name = new_name
|
||||
return True
|
||||
except:
|
||||
try:
|
||||
# Method 3: Use bpy.ops with context override
|
||||
# Set the image as active and use the rename operator
|
||||
bpy.context.view_layer.objects.active = None
|
||||
|
||||
# Create a temporary override context
|
||||
override_context = bpy.context.copy()
|
||||
override_context['edit_image'] = image
|
||||
|
||||
with bpy.context.temp_override(**override_context):
|
||||
image.name = new_name
|
||||
return True
|
||||
except:
|
||||
# Method 4: Try using the data API directly with update
|
||||
try:
|
||||
old_name = image.name
|
||||
# Force an update cycle
|
||||
bpy.context.view_layer.update()
|
||||
image.name = new_name
|
||||
bpy.context.view_layer.update()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def rename_flat_color_textures():
|
||||
"""Main function to find and rename flat color textures."""
|
||||
renamed_count = 0
|
||||
failed_count = 0
|
||||
processed_count = 0
|
||||
|
||||
print("Scanning for flat color textures...")
|
||||
|
||||
# Store rename operations to perform them in batch
|
||||
rename_operations = []
|
||||
|
||||
for image in bpy.data.images:
|
||||
processed_count += 1
|
||||
|
||||
# Skip if image has no pixel data
|
||||
if not hasattr(image, 'pixels') or len(image.pixels) == 0:
|
||||
print(f"Skipping '{image.name}': No pixel data available")
|
||||
continue
|
||||
|
||||
# Check if image has flat color
|
||||
is_flat, color = is_flat_color_image(image)
|
||||
|
||||
if is_flat and color:
|
||||
# Convert color to hex
|
||||
hex_color = rgb_to_hex(*color)
|
||||
|
||||
# Store original name for logging
|
||||
original_name = image.name
|
||||
|
||||
# Check if name is already a hex color (to avoid renaming again)
|
||||
if not original_name.startswith('#'):
|
||||
rename_operations.append((image, original_name, hex_color, color))
|
||||
else:
|
||||
print(f"Skipping '{original_name}': Already appears to be hex-named")
|
||||
else:
|
||||
print(f"'{image.name}': Not a flat color texture")
|
||||
|
||||
# Perform rename operations
|
||||
print(f"\nPerforming {len(rename_operations)} rename operation(s)...")
|
||||
|
||||
for image, original_name, hex_color, color in rename_operations:
|
||||
success = safe_rename_image(image, hex_color)
|
||||
if success:
|
||||
print(f"Renamed '{original_name}' to '{hex_color}' (Color: RGBA{color})")
|
||||
renamed_count += 1
|
||||
else:
|
||||
print(f"Failed to rename '{original_name}' to '{hex_color}' - Context restriction")
|
||||
failed_count += 1
|
||||
|
||||
print(f"\nSummary:")
|
||||
print(f"Processed: {processed_count} images")
|
||||
print(f"Successfully renamed: {renamed_count} flat color textures")
|
||||
if failed_count > 0:
|
||||
print(f"Failed to rename: {failed_count} textures (try running from Python Console instead)")
|
||||
|
||||
return renamed_count
|
||||
|
||||
def reload_image_pixels():
|
||||
"""Reload pixel data for all images (useful if images aren't loaded)."""
|
||||
print("Reloading pixel data for all images...")
|
||||
|
||||
for image in bpy.data.images:
|
||||
if image.source == 'FILE' and image.filepath:
|
||||
try:
|
||||
image.reload()
|
||||
print(f"Reloaded: {image.name}")
|
||||
except:
|
||||
print(f"Failed to reload: {image.name}")
|
||||
|
||||
# Alternative function for running in restricted contexts
|
||||
def print_rename_suggestions():
|
||||
"""Print suggested renames without actually renaming (for restricted contexts)."""
|
||||
suggestions = []
|
||||
|
||||
print("Scanning for flat color textures (suggestion mode)...")
|
||||
|
||||
for image in bpy.data.images:
|
||||
if not hasattr(image, 'pixels') or len(image.pixels) == 0:
|
||||
continue
|
||||
|
||||
is_flat, color = is_flat_color_image(image)
|
||||
|
||||
if is_flat and color and not image.name.startswith('#'):
|
||||
hex_color = rgb_to_hex(*color)
|
||||
suggestions.append((image.name, hex_color, color))
|
||||
|
||||
if suggestions:
|
||||
print(f"\nFound {len(suggestions)} flat color texture(s) that could be renamed:")
|
||||
print("-" * 60)
|
||||
for original_name, hex_color, color in suggestions:
|
||||
print(f"'{original_name}' -> '{hex_color}' (RGBA{color})")
|
||||
|
||||
print("\nTo actually rename them, run this script from:")
|
||||
print("1. Blender's Python Console, or")
|
||||
print("2. Command line with: blender file.blend --python script.py")
|
||||
else:
|
||||
print("\nNo flat color textures found that need renaming.")
|
||||
|
||||
# Main execution
|
||||
if __name__ == "__main__":
|
||||
print("=" * 50)
|
||||
print("Flat Color Texture Renamer")
|
||||
print("=" * 50)
|
||||
|
||||
# Optional: Reload images to ensure pixel data is available
|
||||
# Uncomment the line below if you want to force reload all images
|
||||
# reload_image_pixels()
|
||||
|
||||
# Try to run the renaming process
|
||||
try:
|
||||
renamed_count = rename_flat_color_textures()
|
||||
|
||||
if renamed_count > 0:
|
||||
print(f"\nSuccessfully renamed {renamed_count} flat color texture(s)!")
|
||||
else:
|
||||
print("\nNo flat color textures found to rename.")
|
||||
except Exception as e:
|
||||
print(f"\nContext restriction detected. Running in suggestion mode...")
|
||||
print_rename_suggestions()
|
||||
|
||||
print("Script completed.")
|
||||
@@ -0,0 +1,612 @@
|
||||
import bpy
|
||||
|
||||
def safe_wgt_removal():
|
||||
"""Safely remove only WGT widget objects that are clearly ghosts"""
|
||||
|
||||
print("="*80)
|
||||
print("CONSERVATIVE WGT GHOST REMOVAL")
|
||||
print("="*80)
|
||||
|
||||
# Find all WGT objects
|
||||
wgt_objects = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.name.startswith('WGT-'):
|
||||
wgt_objects.append(obj)
|
||||
|
||||
print(f"Found {len(wgt_objects)} WGT objects")
|
||||
|
||||
# Check which ones are actually being used by armatures
|
||||
used_wgts = set()
|
||||
for armature in bpy.data.armatures:
|
||||
for bone in armature.bones:
|
||||
if bone.use_deform and hasattr(bone, 'custom_shape') and bone.custom_shape:
|
||||
used_wgts.add(bone.custom_shape.name)
|
||||
|
||||
print(f"Found {len(used_wgts)} WGT objects actually used by armatures")
|
||||
|
||||
# Remove unused WGT objects
|
||||
removed_wgts = 0
|
||||
for obj in wgt_objects:
|
||||
if obj.name not in used_wgts:
|
||||
try:
|
||||
# Skip linked objects (they're legitimate library content)
|
||||
if hasattr(obj, 'library') and obj.library is not None:
|
||||
print(f" Skipping linked WGT: {obj.name} (from {obj.library.name})")
|
||||
continue
|
||||
|
||||
# Check if it's in the WGTS collection (typical ghost pattern)
|
||||
in_wgts_collection = False
|
||||
for collection in bpy.data.collections:
|
||||
if 'WGTS' in collection.name and obj in collection.objects.values():
|
||||
in_wgts_collection = True
|
||||
break
|
||||
|
||||
if in_wgts_collection:
|
||||
print(f" Removing unused WGT: {obj.name}")
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
removed_wgts += 1
|
||||
except Exception as e:
|
||||
print(f" Failed to remove {obj.name}: {e}")
|
||||
|
||||
print(f"Removed {removed_wgts} unused WGT objects")
|
||||
return removed_wgts
|
||||
|
||||
def is_collection_in_scene_hierarchy(collection, scene_collection):
|
||||
"""Recursively check if a collection exists anywhere in the scene collection hierarchy"""
|
||||
if collection == scene_collection:
|
||||
return True
|
||||
|
||||
for child_collection in scene_collection.children:
|
||||
if child_collection == collection:
|
||||
return True
|
||||
if is_collection_in_scene_hierarchy(collection, child_collection):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clean_empty_collections():
|
||||
"""Remove empty collections that are not linked to scenes"""
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("CLEANING EMPTY COLLECTIONS")
|
||||
print("="*80)
|
||||
|
||||
removed_collections = 0
|
||||
collections_to_remove = []
|
||||
|
||||
for collection in bpy.data.collections:
|
||||
# Check if collection is empty
|
||||
if len(collection.objects) == 0 and len(collection.children) == 0:
|
||||
# Skip linked collections (they're legitimate library content)
|
||||
if hasattr(collection, 'library') and collection.library is not None:
|
||||
print(f" Skipping linked empty collection: {collection.name}")
|
||||
continue
|
||||
|
||||
# Check if it's anywhere in any scene's collection hierarchy
|
||||
linked_to_scene = False
|
||||
for scene in bpy.data.scenes:
|
||||
if is_collection_in_scene_hierarchy(collection, scene.collection):
|
||||
linked_to_scene = True
|
||||
print(f" Preserving empty collection: {collection.name} (in scene '{scene.name}')")
|
||||
break
|
||||
|
||||
if not linked_to_scene:
|
||||
collections_to_remove.append(collection)
|
||||
|
||||
for collection in collections_to_remove:
|
||||
try:
|
||||
print(f" Removing empty collection: {collection.name}")
|
||||
bpy.data.collections.remove(collection)
|
||||
removed_collections += 1
|
||||
except Exception as e:
|
||||
print(f" Failed to remove collection {collection.name}: {e}")
|
||||
|
||||
print(f"Removed {removed_collections} empty collections")
|
||||
return removed_collections
|
||||
|
||||
def is_object_legitimate_outside_scene(obj):
|
||||
"""Check if an object has legitimate reasons to exist outside scenes"""
|
||||
|
||||
# WGT objects (rig widgets) are legitimate outside scenes
|
||||
if obj.name.startswith('WGT-'):
|
||||
return True
|
||||
|
||||
# Collection instance objects (linked collection references) are legitimate
|
||||
if obj.instance_type == 'COLLECTION' and obj.instance_collection is not None:
|
||||
return True
|
||||
|
||||
# Objects used as curve modifiers, constraints targets, etc.
|
||||
# Check if object is used by modifiers on other objects
|
||||
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:
|
||||
return True
|
||||
if hasattr(settings, 'instance_object') and settings.instance_object == obj:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clean_object_ghosts():
|
||||
"""Remove objects that are not in any scene and have no legitimate purpose (potential ghosts)"""
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("OBJECT GHOST CLEANUP")
|
||||
print("="*80)
|
||||
|
||||
# Get all objects, excluding cameras and lights by default (they're often not in scenes for good reasons)
|
||||
candidate_objects = [obj for obj in bpy.data.objects if obj.type not in ['CAMERA', 'LIGHT']]
|
||||
|
||||
if not candidate_objects:
|
||||
print("No candidate objects found")
|
||||
return 0
|
||||
|
||||
print(f"Found {len(candidate_objects)} candidate objects")
|
||||
|
||||
removed_objects = 0
|
||||
ghosts_to_remove = []
|
||||
|
||||
for obj in candidate_objects:
|
||||
# Skip linked objects (they're legitimate library content)
|
||||
if hasattr(obj, 'library') and obj.library is not None:
|
||||
continue
|
||||
|
||||
# Check which scenes contain it
|
||||
in_scenes = []
|
||||
for scene in bpy.data.scenes:
|
||||
if obj in scene.objects.values():
|
||||
in_scenes.append(scene.name)
|
||||
|
||||
# If not in any scene, check if it has legitimate reasons to exist
|
||||
if len(in_scenes) == 0:
|
||||
if is_object_legitimate_outside_scene(obj):
|
||||
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 obj.users >= 2:
|
||||
ghosts_to_remove.append(obj)
|
||||
print(f" Marking ghost for removal: {obj.name} (type: {obj.type})")
|
||||
|
||||
# Remove the ghost objects
|
||||
for obj in ghosts_to_remove:
|
||||
try:
|
||||
print(f" Removing object ghost: {obj.name}")
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
removed_objects += 1
|
||||
except Exception as e:
|
||||
print(f" Failed to remove object {obj.name}: {e}")
|
||||
|
||||
print(f"Removed {removed_objects} ghost objects")
|
||||
return removed_objects
|
||||
|
||||
def manual_object_analysis():
|
||||
"""Manual analysis of objects - show info but don't auto-remove"""
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("OBJECT GHOST ANALYSIS (MANUAL REVIEW)")
|
||||
print("="*80)
|
||||
|
||||
# Get all objects, excluding cameras and lights (they're often legitimately not in scenes)
|
||||
candidate_objects = [obj for obj in bpy.data.objects if obj.type not in ['CAMERA', 'LIGHT']]
|
||||
|
||||
# Filter to only objects not in scenes for analysis
|
||||
objects_not_in_scenes = []
|
||||
for obj in candidate_objects:
|
||||
# Skip linked objects for analysis
|
||||
if hasattr(obj, 'library') and obj.library is not None:
|
||||
continue
|
||||
|
||||
# Check which scenes contain it
|
||||
in_scenes = []
|
||||
for scene in bpy.data.scenes:
|
||||
if obj in scene.objects.values():
|
||||
in_scenes.append(scene.name)
|
||||
|
||||
if len(in_scenes) == 0:
|
||||
objects_not_in_scenes.append(obj)
|
||||
|
||||
if not objects_not_in_scenes:
|
||||
print("No local objects found outside scenes")
|
||||
return
|
||||
|
||||
print(f"Found {len(objects_not_in_scenes)} local objects not in any scene:")
|
||||
|
||||
for obj in objects_not_in_scenes:
|
||||
print(f"\n Object: {obj.name} (type: {obj.type})")
|
||||
print(f" Users: {obj.users}")
|
||||
print(f" Parent: {obj.parent.name if obj.parent else 'None'}")
|
||||
|
||||
# Check collections
|
||||
in_collections = []
|
||||
for collection in bpy.data.collections:
|
||||
if obj in collection.objects.values():
|
||||
in_collections.append(collection.name)
|
||||
print(f" In collections: {in_collections}")
|
||||
|
||||
# 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)")
|
||||
else:
|
||||
print(f" -> UNCLEAR: Manual review needed")
|
||||
|
||||
def main():
|
||||
"""Main conservative cleanup function"""
|
||||
|
||||
print("CONSERVATIVE GHOST DATA CLEANUP")
|
||||
print("="*80)
|
||||
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("="*80)
|
||||
|
||||
initial_objects = len(list(bpy.data.objects))
|
||||
initial_collections = len(list(bpy.data.collections))
|
||||
|
||||
# Safe operations only
|
||||
wgts_removed = safe_wgt_removal()
|
||||
collections_removed = clean_empty_collections()
|
||||
object_ghosts_removed = clean_object_ghosts()
|
||||
|
||||
# Show remaining object analysis
|
||||
manual_object_analysis()
|
||||
|
||||
# Final purge
|
||||
print("\n" + "="*80)
|
||||
print("FINAL SAFE PURGE")
|
||||
print("="*80)
|
||||
|
||||
try:
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
print("Safe purge completed")
|
||||
except:
|
||||
print("Purge had issues")
|
||||
|
||||
final_objects = len(list(bpy.data.objects))
|
||||
final_collections = len(list(bpy.data.collections))
|
||||
|
||||
print(f"\n" + "="*80)
|
||||
print("CONSERVATIVE CLEANUP SUMMARY")
|
||||
print("="*80)
|
||||
print(f"Objects: {initial_objects} -> {final_objects} (removed {initial_objects - final_objects})")
|
||||
print(f"Collections: {initial_collections} -> {final_collections} (removed {collections_removed})")
|
||||
print(f"WGT objects removed: {wgts_removed}")
|
||||
print(f"Object ghosts removed: {object_ghosts_removed}")
|
||||
print("="*80)
|
||||
|
||||
class GhostBuster(bpy.types.Operator):
|
||||
"""Conservative cleanup of ghost data (unused WGT objects, empty collections)"""
|
||||
bl_idname = "bst.ghost_buster"
|
||||
bl_label = "Ghost Buster"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
# Call the main ghost buster function
|
||||
main()
|
||||
self.report({'INFO'}, "Ghost data cleanup completed")
|
||||
return {'FINISHED'}
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"Ghost buster failed: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
class GhostDetector(bpy.types.Operator):
|
||||
"""Detect and analyze ghost data without removing it"""
|
||||
bl_idname = "bst.ghost_detector"
|
||||
bl_label = "Ghost Detector"
|
||||
bl_options = {'REGISTER', 'INTERNAL'}
|
||||
|
||||
# Properties to store analysis data
|
||||
total_wgt_objects: bpy.props.IntProperty(default=0)
|
||||
unused_wgt_objects: bpy.props.IntProperty(default=0)
|
||||
used_wgt_objects: bpy.props.IntProperty(default=0)
|
||||
empty_collections: bpy.props.IntProperty(default=0)
|
||||
ghost_objects: bpy.props.IntProperty(default=0)
|
||||
ghost_potential: bpy.props.IntProperty(default=0)
|
||||
ghost_legitimate: bpy.props.IntProperty(default=0)
|
||||
ghost_low_priority: bpy.props.IntProperty(default=0)
|
||||
wgt_details: bpy.props.StringProperty(default="")
|
||||
collection_details: bpy.props.StringProperty(default="")
|
||||
ghost_details: bpy.props.StringProperty(default="")
|
||||
|
||||
def analyze_ghost_data(self):
|
||||
"""Analyze ghost data similar to ghost_buster functions"""
|
||||
|
||||
# Analyze WGT objects
|
||||
wgt_objects = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.name.startswith('WGT-'):
|
||||
wgt_objects.append(obj)
|
||||
|
||||
self.total_wgt_objects = len(wgt_objects)
|
||||
|
||||
# Check which WGT objects are used by armatures
|
||||
used_wgts = set()
|
||||
for armature in bpy.data.armatures:
|
||||
for bone in armature.bones:
|
||||
if bone.use_deform and hasattr(bone, 'custom_shape') and bone.custom_shape:
|
||||
used_wgts.add(bone.custom_shape.name)
|
||||
|
||||
self.used_wgt_objects = len(used_wgts)
|
||||
|
||||
# Count unused WGT objects
|
||||
unused_wgts = []
|
||||
wgt_details_list = []
|
||||
for obj in wgt_objects:
|
||||
if obj.name not in used_wgts:
|
||||
# Skip linked objects (they're legitimate library content)
|
||||
if hasattr(obj, 'library') and obj.library is not None:
|
||||
continue
|
||||
|
||||
# Check if it's in the WGTS collection (typical ghost pattern)
|
||||
in_wgts_collection = False
|
||||
for collection in bpy.data.collections:
|
||||
if 'WGTS' in collection.name and obj in collection.objects.values():
|
||||
in_wgts_collection = True
|
||||
break
|
||||
|
||||
if in_wgts_collection:
|
||||
unused_wgts.append(obj)
|
||||
wgt_details_list.append(f"• {obj.name} (in WGTS collection)")
|
||||
|
||||
self.unused_wgt_objects = len(unused_wgts)
|
||||
self.wgt_details = "\n".join(wgt_details_list[:10]) # Limit to first 10
|
||||
if len(unused_wgts) > 10:
|
||||
self.wgt_details += f"\n... and {len(unused_wgts) - 10} more"
|
||||
|
||||
# Analyze empty collections
|
||||
empty_collections = []
|
||||
collection_details_list = []
|
||||
for collection in bpy.data.collections:
|
||||
if len(collection.objects) == 0 and len(collection.children) == 0:
|
||||
# Skip linked collections (they're legitimate library content)
|
||||
if hasattr(collection, 'library') and collection.library is not None:
|
||||
continue
|
||||
|
||||
# Check if it's anywhere in any scene's collection hierarchy
|
||||
linked_to_scene = False
|
||||
for scene in bpy.data.scenes:
|
||||
if is_collection_in_scene_hierarchy(collection, scene.collection):
|
||||
linked_to_scene = True
|
||||
break
|
||||
|
||||
if not linked_to_scene:
|
||||
empty_collections.append(collection)
|
||||
collection_details_list.append(f"• {collection.name}")
|
||||
|
||||
self.empty_collections = len(empty_collections)
|
||||
self.collection_details = "\n".join(collection_details_list[:10]) # Limit to first 10
|
||||
if len(empty_collections) > 10:
|
||||
self.collection_details += f"\n... and {len(empty_collections) - 10} more"
|
||||
|
||||
# Analyze ghost objects (objects not in scenes)
|
||||
candidate_objects = [obj for obj in bpy.data.objects if obj.type not in ['CAMERA', 'LIGHT']]
|
||||
|
||||
potential_ghosts = 0
|
||||
legitimate = 0
|
||||
low_priority = 0
|
||||
ghost_details_list = []
|
||||
|
||||
for obj in candidate_objects:
|
||||
# Skip linked objects (they're legitimate library content)
|
||||
if hasattr(obj, 'library') and obj.library is not None:
|
||||
continue
|
||||
|
||||
# Check which scenes contain it
|
||||
in_scenes = []
|
||||
for scene in bpy.data.scenes:
|
||||
if obj in scene.objects.values():
|
||||
in_scenes.append(scene.name)
|
||||
|
||||
# Only analyze objects not in scenes
|
||||
if len(in_scenes) == 0:
|
||||
# Classify object
|
||||
status = ""
|
||||
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)"
|
||||
else:
|
||||
status = "UNCLEAR"
|
||||
|
||||
ghost_details_list.append(f"• {obj.name} ({obj.type}): {status}")
|
||||
|
||||
self.ghost_objects = len([obj for obj in candidate_objects if len([s for s in bpy.data.scenes if obj in s.objects.values()]) == 0 and not (hasattr(obj, 'library') and obj.library is not None)])
|
||||
self.ghost_potential = potential_ghosts
|
||||
self.ghost_legitimate = legitimate
|
||||
self.ghost_low_priority = low_priority
|
||||
self.ghost_details = "\n".join(ghost_details_list[:10]) # Limit to first 10
|
||||
if len(ghost_details_list) > 10:
|
||||
self.ghost_details += f"\n... and {len(ghost_details_list) - 10} more"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Title
|
||||
layout.label(text="Ghost Data Analysis", icon='GHOST_ENABLED')
|
||||
layout.separator()
|
||||
|
||||
# WGT Objects section
|
||||
box = layout.box()
|
||||
box.label(text="WGT Widget Objects", icon='ARMATURE_DATA')
|
||||
col = box.column(align=True)
|
||||
col.label(text=f"Total WGT objects: {self.total_wgt_objects}")
|
||||
col.label(text=f"Used by armatures: {self.used_wgt_objects}", icon='CHECKMARK')
|
||||
if self.unused_wgt_objects > 0:
|
||||
col.label(text=f"Unused (potential ghosts): {self.unused_wgt_objects}", icon='ERROR')
|
||||
if self.wgt_details:
|
||||
box.separator()
|
||||
details_col = box.column(align=True)
|
||||
for line in self.wgt_details.split('\n'):
|
||||
if line.strip():
|
||||
details_col.label(text=line)
|
||||
else:
|
||||
col.label(text="No unused WGT objects found", icon='CHECKMARK')
|
||||
|
||||
# Empty Collections section
|
||||
box = layout.box()
|
||||
box.label(text="Empty Collections", icon='OUTLINER_COLLECTION')
|
||||
col = box.column(align=True)
|
||||
if self.empty_collections > 0:
|
||||
col.label(text=f"Empty unlinked collections: {self.empty_collections}", icon='ERROR')
|
||||
if self.collection_details:
|
||||
box.separator()
|
||||
details_col = box.column(align=True)
|
||||
for line in self.collection_details.split('\n'):
|
||||
if line.strip():
|
||||
details_col.label(text=line)
|
||||
else:
|
||||
col.label(text="No empty unlinked collections found", icon='CHECKMARK')
|
||||
|
||||
# Ghost Objects section
|
||||
box = layout.box()
|
||||
box.label(text="Ghost Objects Analysis", icon='OBJECT_DATA')
|
||||
col = box.column(align=True)
|
||||
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')
|
||||
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')
|
||||
|
||||
if self.ghost_details:
|
||||
box.separator()
|
||||
details_col = box.column(align=True)
|
||||
for line in self.ghost_details.split('\n'):
|
||||
if line.strip():
|
||||
details_col.label(text=line)
|
||||
else:
|
||||
col.label(text="No ghost objects found", icon='CHECKMARK')
|
||||
|
||||
# Summary
|
||||
layout.separator()
|
||||
summary_box = layout.box()
|
||||
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="Use Ghost Buster to clean up safely")
|
||||
else:
|
||||
summary_box.label(text="No ghost data issues detected!", icon='CHECKMARK')
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
# Analyze the ghost data before showing the dialog
|
||||
self.analyze_ghost_data()
|
||||
return context.window_manager.invoke_popup(self, width=500)
|
||||
|
||||
class ResyncEnforce(bpy.types.Operator):
|
||||
"""Resync Enforce: Fix broken library override hierarchies by rebuilding from linked references"""
|
||||
bl_idname = "bst.resync_enforce"
|
||||
bl_label = "Resync Enforce"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
# Only available if there are selected objects
|
||||
return context.selected_objects
|
||||
|
||||
def execute(self, context):
|
||||
# Get selected objects
|
||||
selected_objects = context.selected_objects.copy()
|
||||
|
||||
if not selected_objects:
|
||||
self.report({'WARNING'}, "No objects selected for resync enforce")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Count library override objects
|
||||
override_objects = []
|
||||
for obj in selected_objects:
|
||||
if obj.override_library:
|
||||
override_objects.append(obj)
|
||||
|
||||
if not override_objects:
|
||||
self.report({'WARNING'}, "No library override objects found in selection")
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
# Store the current selection
|
||||
original_selection = set(context.selected_objects)
|
||||
|
||||
# Select only the override objects
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in override_objects:
|
||||
obj.select_set(True)
|
||||
|
||||
# Call Blender's resync enforce operation
|
||||
result = bpy.ops.object.library_override_operation(
|
||||
'INVOKE_DEFAULT',
|
||||
type='OVERRIDE_LIBRARY_RESYNC_HIERARCHY_ENFORCE',
|
||||
selection_set='SELECTED'
|
||||
)
|
||||
|
||||
if result == {'FINISHED'}:
|
||||
self.report({'INFO'}, f"Resync enforce completed on {len(override_objects)} override objects")
|
||||
return_code = {'FINISHED'}
|
||||
else:
|
||||
self.report({'WARNING'}, "Resync enforce operation was cancelled or failed")
|
||||
return_code = {'CANCELLED'}
|
||||
|
||||
# Restore original selection
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in original_selection:
|
||||
if obj.name in bpy.data.objects: # Check if object still exists
|
||||
obj.select_set(True)
|
||||
|
||||
return return_code
|
||||
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"Resync enforce failed: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Note: main() is called by the operator, not automatically
|
||||
|
||||
# List of classes to register
|
||||
classes = (
|
||||
GhostBuster,
|
||||
GhostDetector,
|
||||
ResyncEnforce,
|
||||
)
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -0,0 +1,63 @@
|
||||
import bpy
|
||||
|
||||
class RemoveCustomSplitNormals(bpy.types.Operator):
|
||||
"""Remove custom split normals and apply smooth shading to all accessible mesh objects"""
|
||||
bl_idname = "bst.remove_custom_split_normals"
|
||||
bl_label = "Remove Custom Split Normals"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
only_selected: bpy.props.BoolProperty(
|
||||
name="Only Selected Objects",
|
||||
description="Apply only to selected objects",
|
||||
default=True
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
# Store the current context
|
||||
original_active = context.active_object
|
||||
original_selected = context.selected_objects.copy()
|
||||
original_mode = context.mode
|
||||
|
||||
# Get object names that are in the current view layer
|
||||
view_layer_object_names = set(context.view_layer.objects.keys())
|
||||
|
||||
# Choose objects based on the property
|
||||
if self.only_selected:
|
||||
objects = [obj for obj in context.selected_objects if obj.type == 'MESH' and obj.name in view_layer_object_names]
|
||||
else:
|
||||
objects = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.name in view_layer_object_names]
|
||||
|
||||
processed_count = 0
|
||||
for obj in objects:
|
||||
mesh = obj.data
|
||||
if mesh.has_custom_normals:
|
||||
# Select and make active
|
||||
obj.select_set(True)
|
||||
context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.customdata_custom_splitnormals_clear()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.shade_smooth()
|
||||
obj.select_set(False)
|
||||
processed_count += 1
|
||||
self.report({'INFO'}, f"Removed custom split normals and applied smooth shading to: {obj.name}")
|
||||
|
||||
# Restore original selection and active object
|
||||
context.view_layer.objects.active = original_active
|
||||
for obj in original_selected:
|
||||
if obj.name in view_layer_object_names:
|
||||
obj.select_set(True)
|
||||
|
||||
self.report({'INFO'}, f"Done: custom split normals removed and smooth shading applied to {'selected' if self.only_selected else 'all'} mesh objects. ({processed_count} processed)")
|
||||
return {'FINISHED'}
|
||||
|
||||
# Registration
|
||||
def register():
|
||||
bpy.utils.register_class(MESH_OT_RemoveCustomSplitNormals)
|
||||
|
||||
def unregister():
|
||||
bpy.utils.unregister_class(MESH_OT_RemoveCustomSplitNormals)
|
||||
|
||||
# Only run if this script is run directly
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
@@ -0,0 +1,100 @@
|
||||
import bpy
|
||||
|
||||
def find_node_distance_to_basecolor(node, visited=None):
|
||||
"""Find the shortest path distance from a node to any Base Color input"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if node in visited:
|
||||
return float('inf')
|
||||
|
||||
visited.add(node)
|
||||
|
||||
# If this is a Principled BSDF node, check if it has a Base Color input
|
||||
if node.type == 'BSDF_PRINCIPLED':
|
||||
for input in node.inputs:
|
||||
if input.name == 'Base Color':
|
||||
# If this input is connected, return 0 (we found our target)
|
||||
if input.links:
|
||||
return 0
|
||||
return float('inf')
|
||||
|
||||
# Check all outputs of this node
|
||||
min_distance = float('inf')
|
||||
for output in node.outputs:
|
||||
for link in output.links:
|
||||
# Recursively check connected nodes
|
||||
distance = find_node_distance_to_basecolor(link.to_node, visited.copy())
|
||||
if distance is not None and distance < min_distance:
|
||||
min_distance = distance + 1
|
||||
|
||||
return min_distance if min_distance != float('inf') else None
|
||||
|
||||
def find_connected_basecolor_texture(node_tree):
|
||||
"""Find any image texture directly connected to a Base Color input"""
|
||||
for node in node_tree.nodes:
|
||||
if node.type == 'BSDF_PRINCIPLED':
|
||||
base_color_input = node.inputs.get('Base Color')
|
||||
if base_color_input and base_color_input.links:
|
||||
# Get the node connected to Base Color
|
||||
connected_node = base_color_input.links[0].from_node
|
||||
# If it's an image texture, return it
|
||||
if connected_node.type == 'TEX_IMAGE' and connected_node.image:
|
||||
return connected_node
|
||||
return None
|
||||
|
||||
def select_diffuse_nodes():
|
||||
# Get all materials in the blend file
|
||||
materials = bpy.data.materials
|
||||
|
||||
# Counter for found nodes
|
||||
found_nodes = 0
|
||||
|
||||
# Keywords to look for in image names (case insensitive)
|
||||
keywords = ['diffuse', 'basecolor', 'base_color', 'albedo', 'color']
|
||||
|
||||
# Iterate through all materials
|
||||
for material in materials:
|
||||
# Skip materials without node trees
|
||||
if not material.use_nodes:
|
||||
continue
|
||||
|
||||
node_tree = material.node_tree
|
||||
|
||||
# First, try to find any image texture connected to Base Color
|
||||
base_color_texture = find_connected_basecolor_texture(node_tree)
|
||||
if base_color_texture:
|
||||
node_tree.nodes.active = base_color_texture
|
||||
base_color_texture.select = True
|
||||
found_nodes += 1
|
||||
print(f"Selected Base Color connected texture '{base_color_texture.image.name}' in material: {material.name}")
|
||||
continue
|
||||
|
||||
# If no direct connection found, fall back to name-based search
|
||||
matching_nodes = []
|
||||
for node in node_tree.nodes:
|
||||
if node.type == 'TEX_IMAGE' and node.image:
|
||||
# Check if the image name contains any of our keywords
|
||||
image_name = node.image.name.lower()
|
||||
if any(keyword in image_name for keyword in keywords):
|
||||
# Calculate distance to Base Color input
|
||||
distance = find_node_distance_to_basecolor(node)
|
||||
if distance is not None:
|
||||
matching_nodes.append((node, distance))
|
||||
|
||||
# If we found any matching nodes, select the one with the shortest distance
|
||||
if matching_nodes:
|
||||
# Sort by distance (closest to Base Color first)
|
||||
matching_nodes.sort(key=lambda x: x[1])
|
||||
selected_node = matching_nodes[0][0]
|
||||
|
||||
node_tree.nodes.active = selected_node
|
||||
selected_node.select = True
|
||||
found_nodes += 1
|
||||
print(f"Selected named texture '{selected_node.image.name}' in material: {material.name} (distance to Base Color: {matching_nodes[0][1]})")
|
||||
|
||||
print(f"\nTotal texture nodes selected: {found_nodes}")
|
||||
|
||||
# Only run if this script is run directly
|
||||
if __name__ == "__main__":
|
||||
select_diffuse_nodes()
|
||||
@@ -0,0 +1,100 @@
|
||||
import bpy
|
||||
|
||||
class SpawnSceneStructure(bpy.types.Operator):
|
||||
"""Create a standard scene collection structure: Env, Animation, Lgt with subcollections"""
|
||||
bl_idname = "bst.spawn_scene_structure"
|
||||
bl_label = "Spawn Scene Structure"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def find_layer_collection(self, layer_collection, collection_name):
|
||||
"""Recursively find a layer collection by name"""
|
||||
if layer_collection.collection.name == collection_name:
|
||||
return layer_collection
|
||||
|
||||
for child in layer_collection.children:
|
||||
result = self.find_layer_collection(child, collection_name)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
def execute(self, context):
|
||||
scene = context.scene
|
||||
scene_collection = scene.collection
|
||||
|
||||
# Define the structure to create
|
||||
structure = {
|
||||
"Env": ["ROOTS", "Dressing"],
|
||||
"Animation": ["Cam", "Char"],
|
||||
"Lgt": []
|
||||
}
|
||||
|
||||
created_collections = []
|
||||
skipped_collections = []
|
||||
|
||||
try:
|
||||
for main_collection_name, subcollections in structure.items():
|
||||
# Check if main collection already exists
|
||||
main_collection = None
|
||||
for existing_collection in scene_collection.children:
|
||||
if existing_collection.name == main_collection_name:
|
||||
main_collection = existing_collection
|
||||
skipped_collections.append(main_collection_name)
|
||||
break
|
||||
|
||||
# Create main collection if it doesn't exist
|
||||
if main_collection is None:
|
||||
main_collection = bpy.data.collections.new(main_collection_name)
|
||||
scene_collection.children.link(main_collection)
|
||||
created_collections.append(main_collection_name)
|
||||
|
||||
# Create subcollections
|
||||
for subcollection_name in subcollections:
|
||||
# Check if subcollection already exists
|
||||
subcollection_exists = False
|
||||
existing_subcollection = None
|
||||
for sub in main_collection.children:
|
||||
if sub.name == subcollection_name:
|
||||
subcollection_exists = True
|
||||
existing_subcollection = sub
|
||||
skipped_collections.append(f"{main_collection_name}/{subcollection_name}")
|
||||
break
|
||||
|
||||
# Create subcollection if it doesn't exist
|
||||
if not subcollection_exists:
|
||||
subcollection = bpy.data.collections.new(subcollection_name)
|
||||
main_collection.children.link(subcollection)
|
||||
created_collections.append(f"{main_collection_name}/{subcollection_name}")
|
||||
|
||||
# Apply special settings to ROOTS collection
|
||||
if subcollection_name == "ROOTS":
|
||||
subcollection.hide_viewport = True # Hide in all viewports
|
||||
# Exclude from view layer
|
||||
view_layer = context.view_layer
|
||||
layer_collection = self.find_layer_collection(view_layer.layer_collection, subcollection_name)
|
||||
if layer_collection:
|
||||
layer_collection.exclude = True
|
||||
else:
|
||||
# Apply settings to existing ROOTS collection if it wasn't properly configured
|
||||
if subcollection_name == "ROOTS" and existing_subcollection:
|
||||
existing_subcollection.hide_viewport = True
|
||||
view_layer = context.view_layer
|
||||
layer_collection = self.find_layer_collection(view_layer.layer_collection, subcollection_name)
|
||||
if layer_collection:
|
||||
layer_collection.exclude = True
|
||||
|
||||
# Report results
|
||||
if created_collections:
|
||||
created_list = ", ".join(created_collections)
|
||||
if skipped_collections:
|
||||
skipped_list = ", ".join(skipped_collections)
|
||||
self.report({'INFO'}, f"Created: {created_list}. Skipped existing: {skipped_list}")
|
||||
else:
|
||||
self.report({'INFO'}, f"Created scene structure: {created_list}")
|
||||
else:
|
||||
self.report({'INFO'}, "Scene structure already exists - no collections created")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"Failed to create scene structure: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
Reference in New Issue
Block a user