2025-07-01
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
bl_info = {
|
||||
"name": "Raincloud's Bulk Scene Tools",
|
||||
"author": "RaincloudTheDragon",
|
||||
"version": (0, 7, 0),
|
||||
"blender": (4, 4, 1),
|
||||
"location": "View3D > Sidebar > Edit Tab",
|
||||
"description": "Tools for bulk operations on scene data",
|
||||
"warning": "",
|
||||
"doc_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
|
||||
"category": "Scene",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"support": "COMMUNITY",
|
||||
}
|
||||
|
||||
import bpy # type: ignore
|
||||
from bpy.types import AddonPreferences, Operator, Panel # type: ignore
|
||||
from bpy.props import BoolProperty, IntProperty # type: ignore
|
||||
from .panels import bulk_viewport_display
|
||||
from .panels import bulk_data_remap
|
||||
from .panels import bulk_path_management
|
||||
from .panels import bulk_scene_general
|
||||
from .ops.AutoMatExtractor import AutoMatExtractor
|
||||
from .ops.Rename_images_by_mat import Rename_images_by_mat, RENAME_OT_summary_dialog
|
||||
from .ops.FreeGPU import BST_FreeGPU
|
||||
from .ops import ghost_buster
|
||||
from . import updater
|
||||
|
||||
# Addon preferences class for update settings
|
||||
class BST_AddonPreferences(AddonPreferences):
|
||||
bl_idname = __package__
|
||||
|
||||
# Auto Updater settings
|
||||
check_for_updates: BoolProperty(
|
||||
name="Check for Updates on Startup",
|
||||
description="Automatically check for new versions of the addon when Blender starts",
|
||||
default=True,
|
||||
)
|
||||
|
||||
update_check_interval: IntProperty( # type: ignore
|
||||
name="Update check interval (hours)",
|
||||
description="How often to check for updates (in hours)",
|
||||
default=24,
|
||||
min=1,
|
||||
max=168 # 1 week max
|
||||
)
|
||||
|
||||
# AutoMat Extractor settings
|
||||
automat_common_outside_blend: BoolProperty(
|
||||
name="Place 'common' folder outside 'blend' folder",
|
||||
description="If enabled, the 'common' folder for shared textures will be placed directly in 'textures/'. If disabled, it will be placed inside 'textures/<blend_name>/'",
|
||||
default=False,
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Custom updater UI
|
||||
box = layout.box()
|
||||
box.label(text="Update Settings")
|
||||
row = box.row()
|
||||
row.prop(self, "check_for_updates")
|
||||
row = box.row()
|
||||
row.prop(self, "update_check_interval")
|
||||
|
||||
# Check for updates button
|
||||
row = box.row()
|
||||
row.operator("bst.check_for_updates", icon='FILE_REFRESH')
|
||||
|
||||
# Show update status if available
|
||||
if updater.UpdaterState.update_available:
|
||||
box.label(text=f"Update available: v{updater.UpdaterState.update_version}")
|
||||
row = box.row()
|
||||
row.operator("bst.install_update", icon='IMPORT')
|
||||
row = box.row()
|
||||
row.operator("wm.url_open", text="Download Update").url = updater.UpdaterState.update_download_url
|
||||
elif updater.UpdaterState.checking_for_updates:
|
||||
box.label(text="Checking for updates...")
|
||||
elif updater.UpdaterState.error_message:
|
||||
box.label(text=f"Error checking for updates: {updater.UpdaterState.error_message}")
|
||||
|
||||
# AutoMat Extractor settings
|
||||
box = layout.box()
|
||||
box.label(text="AutoMat Extractor Settings")
|
||||
row = box.row()
|
||||
row.prop(self, "automat_common_outside_blend")
|
||||
|
||||
# Main panel for Bulk Scene Tools
|
||||
class VIEW3D_PT_BulkSceneTools(Panel):
|
||||
"""Bulk Scene Tools Panel"""
|
||||
bl_label = "Bulk Scene Tools"
|
||||
bl_idname = "VIEW3D_PT_bulk_scene_tools"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Edit'
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Tools for bulk operations on scene data")
|
||||
|
||||
# List of all classes in this module
|
||||
classes = (
|
||||
VIEW3D_PT_BulkSceneTools,
|
||||
BST_AddonPreferences,
|
||||
AutoMatExtractor,
|
||||
Rename_images_by_mat,
|
||||
RENAME_OT_summary_dialog,
|
||||
BST_FreeGPU,
|
||||
)
|
||||
|
||||
def register():
|
||||
# Register classes from this module (do this first to ensure preferences are available)
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
# Print debug info about preferences
|
||||
try:
|
||||
prefs = bpy.context.preferences.addons.get(__package__)
|
||||
if prefs:
|
||||
print(f"Addon preferences registered successfully: {prefs}")
|
||||
else:
|
||||
print("WARNING: Addon preferences not found after registration!")
|
||||
print(f"Available addons: {', '.join(bpy.context.preferences.addons.keys())}")
|
||||
except Exception as e:
|
||||
print(f"Error accessing preferences: {str(e)}")
|
||||
|
||||
# Register the updater module
|
||||
updater.register()
|
||||
|
||||
# Check for updates on startup
|
||||
if hasattr(updater, "check_for_updates"):
|
||||
updater.check_for_updates()
|
||||
|
||||
# Register modules
|
||||
bulk_scene_general.register()
|
||||
bulk_viewport_display.register()
|
||||
bulk_data_remap.register()
|
||||
bulk_path_management.register()
|
||||
ghost_buster.register()
|
||||
|
||||
# Add keybind for Free GPU (global context)
|
||||
wm = bpy.context.window_manager
|
||||
kc = wm.keyconfigs.addon
|
||||
if kc:
|
||||
# Use Screen keymap for global shortcuts that work everywhere
|
||||
km = kc.keymaps.new(name='Screen', space_type='EMPTY')
|
||||
kmi = km.keymap_items.new('bst.free_gpu', 'M', 'PRESS', ctrl=True, alt=True, shift=True)
|
||||
# Store keymap for cleanup
|
||||
addon_keymaps = getattr(bpy.types.Scene, '_bst_keymaps', [])
|
||||
addon_keymaps.append((km, kmi))
|
||||
bpy.types.Scene._bst_keymaps = addon_keymaps
|
||||
|
||||
def unregister():
|
||||
# Remove keybinds
|
||||
addon_keymaps = getattr(bpy.types.Scene, '_bst_keymaps', [])
|
||||
for km, kmi in addon_keymaps:
|
||||
try:
|
||||
km.keymap_items.remove(kmi)
|
||||
except:
|
||||
pass
|
||||
addon_keymaps.clear()
|
||||
if hasattr(bpy.types.Scene, '_bst_keymaps'):
|
||||
delattr(bpy.types.Scene, '_bst_keymaps')
|
||||
|
||||
# Unregister modules
|
||||
try:
|
||||
ghost_buster.unregister()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bulk_path_management.unregister()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bulk_data_remap.unregister()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bulk_viewport_display.unregister()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bulk_scene_general.unregister()
|
||||
except Exception:
|
||||
pass
|
||||
# Unregister the updater module
|
||||
try:
|
||||
updater.unregister()
|
||||
except Exception:
|
||||
pass
|
||||
# Unregister classes from this module
|
||||
for cls in reversed(classes):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Raincloud's Bulk Scene Tools",
|
||||
"author": "RaincloudTheDragon",
|
||||
"version": [0, 7, 0],
|
||||
"blender": [4, 4, 1],
|
||||
"location": "View3D > Sidebar > Edit Tab",
|
||||
"description": "Tools for bulk operations on scene data",
|
||||
"category": "Scene",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"support": "COMMUNITY",
|
||||
"doc_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
|
||||
"tracker_url": ""
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# v 0.7.0
|
||||
|
||||
## New: Ghost Detection System
|
||||
- **Universal Object Analysis**: Expanded ghost detection from CC-objects only to all object types (meshes, empties, curves, etc.)
|
||||
- **Enhanced Safety Framework**: Added comprehensive protection for legitimate objects outside scenes:
|
||||
- WGT rig widgets (`WGT-*` objects)
|
||||
- Modifier targets (curve modifiers, constraints)
|
||||
- Constraint targets and references
|
||||
- Particle system objects
|
||||
- Collection instance objects (linked collection references)
|
||||
- **Smart Classification**: Objects not in scenes now categorized as:
|
||||
- `LEGITIMATE`: Has valid use outside scenes (protected)
|
||||
- `LOW PRIORITY`: Only collection reference (preserved)
|
||||
- `GHOST`: Multiple users but not in scenes (removed)
|
||||
- **Conservative Cleanup Logic**: Only removes objects with 2+ users that have no legitimate purpose
|
||||
- **Updated UI**: Ghost Detector popup now shows "Ghost Objects Analysis" with enhanced categorization and object type details
|
||||
- **Improved Safety**: All linked/library content automatically protected from ghost detection
|
||||
|
||||
# v 0.6.1
|
||||
|
||||
## Bug Fixes
|
||||
- **Fixed flat color detection**: Redesigned algorithm with exact pixel matching and smart sampling
|
||||
- **Fixed AutoMat Extractor**: Now properly organizes images by material instead of dumping everything to common folder
|
||||
- **Fixed viewport color setting**: Resolved context restriction errors with deferred color application
|
||||
- **Fixed timer performance**: Reduced timer frequency and improved cancellation reliability
|
||||
- **Enhanced debugging**: Added comprehensive console reporting for all bulk operations
|
||||
|
||||
## Improvements
|
||||
- Better performance with optimized sampling
|
||||
- More reliable cancellation system
|
||||
- Context-safe operations that don't interfere with Blender's drawing state
|
||||
|
||||
# v 0.6.0
|
||||
|
||||
- **Enhancement: Progress Reporting & Cancellation**
|
||||
- Some of the PathMan's operators are pretty resource-intense. Due to Python's GIL, I haven't been able to figure out how to run some of these more efficiently. Without the console window, you're flying blind, so I've integrated a loading bar with progress reporting for the following operators:
|
||||
- Flat Color Texture Renamer
|
||||
- Remove Extensions
|
||||
- Save All to image Paths
|
||||
- Remap Selected
|
||||
- Rename by Material
|
||||
- AutoMat Extractor
|
||||
|
||||
# v 0.5.1
|
||||
|
||||
- **Enhanced AutoMat Extractor:**
|
||||
- Added a crucial safety check to prevent textures from overwriting each other if they resolve to the same filename (e.g., `Image.001.png` and `Image.002.png` both becoming `Image.png`).
|
||||
- The operator now correctly sanitizes names with numerical suffixes before saving.
|
||||
- A new summary dialog now appears after the operation, reporting how many files were extracted successfully and listing any files that were skipped due to naming conflicts.
|
||||
- Added a user preference to control the location of the `common` folder, allowing it to be placed either inside or outside the blend file's specific texture folder. A checkbox for this setting was added to the UI.
|
||||
- **Improved Suffix Handling:**
|
||||
- The "Rename by Material" tool now correctly preserves spaces in packed texture names (e.g., `Flow Pack` instead of `FlowPack`).
|
||||
- Added support for underscore-separated packed texture names (e.g., `flow_pack`).
|
||||
- **Bug Fixes:**
|
||||
- Resolved multiple `AttributeError` and `TypeError` exceptions that occurred due to incorrect addon name lookups and invalid icon names, making the UI and addon registration more robust.
|
||||
|
||||
# v 0.5.0
|
||||
|
||||
- **Integrated Scene General: Free GPU VRAM**
|
||||
- **Integrated PathMan: Automatic Material Extractor**
|
||||
- **Integrated PathMan: Rename Image Textures by Material**: Added comprehensive texture suffix recognition
|
||||
- Recognizes many Character Creator suffixes
|
||||
- Recognizes most standard material suffixes
|
||||
- Images with unrecognized suffixes are skipped instead of renamed, preventing unintended modifications
|
||||
- Enhanced logging: Unrecognized suffix images are listed separately for easy identification
|
||||
- **UI Improvements**:
|
||||
- Rearranged workflow layout: Make Paths Relative/Absolute moved to main workflow section
|
||||
- Remap Selected moved under path preview for better workflow progression
|
||||
- Rename by Material and AutoMat Extractor repositioned after Remap Selected
|
||||
- Added Autopack toggle at beginning of workflow sections (both Node Editor and 3D Viewport)
|
||||
- Consolidated draw functions: Node Editor panel now serves as master template for both panels
|
||||
|
||||
# v 0.4.1
|
||||
|
||||
- Fixed traceback error causing remap to fail to draw buttons
|
||||
|
||||
# v 0.4.0
|
||||
|
||||
Overhaul! Added new Scene General panel, major enhancements to all panels and functions.
|
||||
|
||||
# v0.3.0
|
||||
|
||||
- Added image path remapping for unpacked images, keeping them organized.
|
||||
@@ -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'}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
import bpy
|
||||
from ..ops.NoSubdiv import NoSubdiv
|
||||
from ..ops.remove_custom_split_normals import RemoveCustomSplitNormals
|
||||
from ..ops.create_ortho_camera import CreateOrthoCamera
|
||||
from ..ops.spawn_scene_structure import SpawnSceneStructure
|
||||
|
||||
class BulkSceneGeneral(bpy.types.Panel):
|
||||
"""Bulk Scene General Panel"""
|
||||
bl_label = "Scene General"
|
||||
bl_idname = "VIEW3D_PT_bulk_scene_general"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Edit'
|
||||
bl_parent_id = "VIEW3D_PT_bulk_scene_tools"
|
||||
bl_order = 0 # This will make it appear at the very top of the main panel
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Scene Structure section
|
||||
box = layout.box()
|
||||
box.label(text="Scene Structure")
|
||||
row = box.row()
|
||||
row.scale_y = 1.2
|
||||
row.operator("bst.spawn_scene_structure", text="Spawn Scene Structure", icon='OUTLINER_COLLECTION')
|
||||
|
||||
# Mesh section
|
||||
box = layout.box()
|
||||
box.label(text="Mesh")
|
||||
# Add checkbox for only_selected property
|
||||
row = box.row()
|
||||
row.prop(context.window_manager, "bst_no_subdiv_only_selected", text="Selected Only")
|
||||
row = box.row(align=True)
|
||||
row.operator("bst.no_subdiv", text="No Subdiv", icon='MOD_SUBSURF').only_selected = context.window_manager.bst_no_subdiv_only_selected
|
||||
row.operator("bst.remove_custom_split_normals", text="Remove Custom Split Normals", icon='X').only_selected = context.window_manager.bst_no_subdiv_only_selected
|
||||
|
||||
row = box.row(align=True)
|
||||
row.operator("bst.create_ortho_camera", text="Create Ortho Camera", icon='OUTLINER_DATA_CAMERA')
|
||||
row = box.row(align=True)
|
||||
row.operator("bst.free_gpu", text="Free GPU", icon='MEMORY')
|
||||
|
||||
# List of all classes in this module
|
||||
classes = (
|
||||
BulkSceneGeneral,
|
||||
NoSubdiv, # Add NoSubdiv operator class
|
||||
RemoveCustomSplitNormals,
|
||||
CreateOrthoCamera,
|
||||
SpawnSceneStructure,
|
||||
)
|
||||
|
||||
# Registration
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
# Register the window manager property for the checkbox
|
||||
bpy.types.WindowManager.bst_no_subdiv_only_selected = bpy.props.BoolProperty(
|
||||
name="Selected Only",
|
||||
description="Apply only to selected objects",
|
||||
default=True
|
||||
)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
# Unregister the window manager property
|
||||
if hasattr(bpy.types.WindowManager, "bst_no_subdiv_only_selected"):
|
||||
del bpy.types.WindowManager.bst_no_subdiv_only_selected
|
||||
@@ -0,0 +1,963 @@
|
||||
import bpy # type: ignore
|
||||
import numpy as np
|
||||
from time import time
|
||||
import os
|
||||
from enum import Enum
|
||||
import colorsys # Add colorsys for RGB to HSV conversion
|
||||
from ..ops.select_diffuse_nodes import select_diffuse_nodes # Import the specific function
|
||||
|
||||
# Material processing status enum
|
||||
class MaterialStatus(Enum):
|
||||
PENDING = 0
|
||||
PROCESSING = 1
|
||||
COMPLETED = 2
|
||||
FAILED = 3
|
||||
PREVIEW_BASED = 4
|
||||
|
||||
# Global variables to store results and track progress
|
||||
material_results = {} # {material_name: (color, status)}
|
||||
current_material = ""
|
||||
processed_count = 0
|
||||
total_materials = 0
|
||||
start_time = 0
|
||||
is_processing = False
|
||||
material_queue = []
|
||||
current_index = 0
|
||||
|
||||
# Scene properties for viewport display settings
|
||||
def register_viewport_properties():
|
||||
bpy.types.Scene.viewport_colors_selected_only = bpy.props.BoolProperty( # type: ignore
|
||||
name="Selected Objects Only",
|
||||
description="Apply viewport colors only to materials in selected objects",
|
||||
default=False
|
||||
)
|
||||
|
||||
bpy.types.Scene.viewport_colors_batch_size = bpy.props.IntProperty( # type: ignore
|
||||
name="Batch Size",
|
||||
description="Number of materials to process in each batch",
|
||||
default=50,
|
||||
min=1,
|
||||
max=50
|
||||
)
|
||||
|
||||
bpy.types.Scene.viewport_colors_use_vectorized = bpy.props.BoolProperty( # type: ignore
|
||||
name="Use Vectorized Processing",
|
||||
description="Use vectorized operations for image processing (faster but uses more memory)",
|
||||
default=True
|
||||
)
|
||||
|
||||
bpy.types.Scene.viewport_colors_darken_amount = bpy.props.FloatProperty( # type: ignore
|
||||
name="Color Adjustment",
|
||||
description="Adjust viewport colors by ±10% (+1 = +10% lighter, 0 = no change, -1 = -10% darker)",
|
||||
default=0.0,
|
||||
min=-1.0,
|
||||
max=1.0,
|
||||
subtype='FACTOR'
|
||||
)
|
||||
|
||||
bpy.types.Scene.viewport_colors_value_amount = bpy.props.FloatProperty( # type: ignore
|
||||
name="Saturation Adjustment",
|
||||
description="Adjust color saturation by ±10% (+1 = +10% more saturated, 0 = no change, -1 = -10% less saturated)",
|
||||
default=1.0,
|
||||
min=-1.0,
|
||||
max=1.0,
|
||||
subtype='FACTOR'
|
||||
)
|
||||
|
||||
bpy.types.Scene.viewport_colors_progress = bpy.props.FloatProperty( # type: ignore
|
||||
name="Progress",
|
||||
description="Progress of the viewport color setting operation",
|
||||
default=0.0,
|
||||
min=0.0,
|
||||
max=100.0,
|
||||
subtype='PERCENTAGE'
|
||||
)
|
||||
|
||||
bpy.types.Scene.viewport_colors_show_advanced = bpy.props.BoolProperty( # type: ignore
|
||||
name="Show Advanced Options",
|
||||
description="Show advanced options for viewport color extraction",
|
||||
default=False
|
||||
)
|
||||
|
||||
# New properties for thumbnail-based color extraction
|
||||
bpy.types.Scene.viewport_colors_use_preview = bpy.props.BoolProperty( # type: ignore
|
||||
name="Use Material Thumbnails",
|
||||
description="Use Blender's material thumbnails for color extraction (faster and more reliable)",
|
||||
default=True
|
||||
)
|
||||
|
||||
bpy.types.Scene.show_material_results = bpy.props.BoolProperty(
|
||||
name="",
|
||||
description="Show material results in the viewport display panel",
|
||||
default=True
|
||||
)
|
||||
|
||||
def unregister_viewport_properties():
|
||||
del bpy.types.Scene.viewport_colors_use_preview
|
||||
del bpy.types.Scene.viewport_colors_batch_size
|
||||
del bpy.types.Scene.viewport_colors_use_vectorized
|
||||
del bpy.types.Scene.viewport_colors_darken_amount
|
||||
del bpy.types.Scene.viewport_colors_value_amount
|
||||
del bpy.types.Scene.viewport_colors_progress
|
||||
del bpy.types.Scene.viewport_colors_selected_only
|
||||
del bpy.types.Scene.viewport_colors_show_advanced
|
||||
del bpy.types.Scene.show_material_results
|
||||
|
||||
class VIEWPORT_OT_SetViewportColors(bpy.types.Operator):
|
||||
"""Set Viewport Display colors from BSDF base color or texture"""
|
||||
bl_idname = "bst.set_viewport_colors"
|
||||
bl_label = "Set Viewport Colors"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
global material_results, current_material, processed_count, total_materials, start_time, is_processing, material_queue, current_index
|
||||
|
||||
# Reset global variables
|
||||
material_results = {}
|
||||
current_material = ""
|
||||
processed_count = 0
|
||||
is_processing = True
|
||||
start_time = time()
|
||||
current_index = 0
|
||||
|
||||
# Get materials based on selection mode
|
||||
if context.scene.viewport_colors_selected_only:
|
||||
# Get materials from selected objects only
|
||||
materials = []
|
||||
for obj in context.selected_objects:
|
||||
if obj.type == 'MESH' and obj.data.materials:
|
||||
for mat in obj.data.materials:
|
||||
if mat and not mat.is_grease_pencil and mat not in materials:
|
||||
materials.append(mat)
|
||||
else:
|
||||
# Get all materials in the scene
|
||||
materials = [mat for mat in bpy.data.materials if not mat.is_grease_pencil]
|
||||
|
||||
total_materials = len(materials)
|
||||
material_queue = materials.copy()
|
||||
|
||||
if total_materials == 0:
|
||||
self.report({'WARNING'}, "No materials found to process")
|
||||
is_processing = False
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Reset progress
|
||||
context.scene.viewport_colors_progress = 0.0
|
||||
|
||||
# Start a timer to process materials in batches
|
||||
bpy.app.timers.register(self._process_batch)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def _process_batch(self):
|
||||
global material_results, current_material, processed_count, total_materials, is_processing, material_queue, current_index
|
||||
|
||||
if not is_processing or len(material_queue) == 0:
|
||||
is_processing = False
|
||||
self.report_info()
|
||||
return None
|
||||
|
||||
# Get the batch size from scene properties
|
||||
batch_size = bpy.context.scene.viewport_colors_batch_size
|
||||
use_vectorized = bpy.context.scene.viewport_colors_use_vectorized
|
||||
|
||||
# Process a batch of materials
|
||||
batch_end = min(current_index + batch_size, len(material_queue))
|
||||
batch = material_queue[current_index:batch_end]
|
||||
|
||||
for material in batch:
|
||||
# Skip if material is invalid or has been deleted
|
||||
if material is None or material.name not in bpy.data.materials:
|
||||
processed_count += 1
|
||||
continue
|
||||
|
||||
current_material = material.name
|
||||
|
||||
# Process the material
|
||||
color, status = process_material(material, use_vectorized)
|
||||
|
||||
# Apply the color to the material
|
||||
if color:
|
||||
# Store the color change to apply later in main thread
|
||||
material_results[material.name] = (color, status)
|
||||
# Mark this material for color application
|
||||
if not hasattr(self, 'pending_color_changes'):
|
||||
self.pending_color_changes = []
|
||||
self.pending_color_changes.append((material, color))
|
||||
else:
|
||||
# Store the result without color change
|
||||
material_results[material.name] = (None, status)
|
||||
|
||||
# Update processed count
|
||||
processed_count += 1
|
||||
|
||||
# Update progress
|
||||
if total_materials > 0:
|
||||
bpy.context.scene.viewport_colors_progress = (processed_count / total_materials) * 100
|
||||
|
||||
# Update the current index
|
||||
current_index = batch_end
|
||||
|
||||
# Force a redraw of the UI
|
||||
for area in bpy.context.screen.areas:
|
||||
area.tag_redraw()
|
||||
|
||||
# Check if we're done
|
||||
if current_index >= len(material_queue):
|
||||
is_processing = False
|
||||
# Apply pending color changes in main thread
|
||||
if hasattr(self, 'pending_color_changes') and self.pending_color_changes:
|
||||
bpy.app.timers.register(self._apply_color_changes)
|
||||
self.report_info()
|
||||
return None
|
||||
|
||||
# Continue processing
|
||||
return 0.1 # Check again in 0.1 seconds
|
||||
|
||||
def _apply_color_changes(self):
|
||||
"""Apply pending color changes in the main thread"""
|
||||
if not hasattr(self, 'pending_color_changes') or not self.pending_color_changes:
|
||||
return None
|
||||
|
||||
# Apply a batch of color changes
|
||||
batch_size = 10 # Process 10 materials at a time
|
||||
batch = self.pending_color_changes[:batch_size]
|
||||
|
||||
for material, color in batch:
|
||||
try:
|
||||
if material and material.name in bpy.data.materials:
|
||||
material.diffuse_color = (*color, 1.0)
|
||||
except Exception as e:
|
||||
print(f"Could not set diffuse_color for {material.name if material else 'Unknown'}: {e}")
|
||||
|
||||
# Remove processed items
|
||||
self.pending_color_changes = self.pending_color_changes[batch_size:]
|
||||
|
||||
# Continue if there are more to process
|
||||
if self.pending_color_changes:
|
||||
return 0.01 # Process next batch in 0.01 seconds
|
||||
|
||||
# All done
|
||||
print(f"Applied viewport colors to {len(batch)} materials")
|
||||
return None
|
||||
|
||||
def report_info(self):
|
||||
global processed_count, start_time
|
||||
elapsed_time = time() - start_time
|
||||
|
||||
# Count materials by status
|
||||
preview_count = 0
|
||||
node_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for _, status in material_results.values():
|
||||
if status == MaterialStatus.PREVIEW_BASED:
|
||||
preview_count += 1
|
||||
elif status == MaterialStatus.COMPLETED:
|
||||
node_count += 1
|
||||
elif status == MaterialStatus.FAILED:
|
||||
failed_count += 1
|
||||
|
||||
# Use a popup menu instead of self.report since this might be called from a timer
|
||||
def draw_popup(self, context):
|
||||
self.layout.label(text=f"Processed {processed_count} materials in {elapsed_time:.2f} seconds")
|
||||
self.layout.label(text=f"Thumbnail-based: {preview_count}, Node-based: {node_count}")
|
||||
self.layout.label(text=f"Failed: {failed_count}")
|
||||
|
||||
bpy.context.window_manager.popup_menu(draw_popup, title="Processing Complete", icon='INFO')
|
||||
|
||||
def correct_viewport_color(color):
|
||||
"""Adjust viewport colors by color intensity and saturation"""
|
||||
r, g, b = color
|
||||
|
||||
# Get the color adjustment amount (-1 to +1) and scale it to ±10%
|
||||
color_adjustment = bpy.context.scene.viewport_colors_darken_amount * 0.1
|
||||
|
||||
# Get the saturation adjustment amount (-1 to +1) and scale it to ±10%
|
||||
saturation_adjustment = bpy.context.scene.viewport_colors_value_amount * 0.1
|
||||
|
||||
# First apply the color adjustment (RGB)
|
||||
r = r + color_adjustment
|
||||
g = g + color_adjustment
|
||||
b = b + color_adjustment
|
||||
|
||||
# Clamp RGB values after color adjustment
|
||||
r = max(0.0, min(1.0, r))
|
||||
g = max(0.0, min(1.0, g))
|
||||
b = max(0.0, min(1.0, b))
|
||||
|
||||
# Then apply the saturation adjustment using HSV
|
||||
if saturation_adjustment != 0:
|
||||
# Convert to HSV
|
||||
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
||||
|
||||
# Adjust saturation while preserving hue and value
|
||||
s = s + saturation_adjustment
|
||||
s = max(0.0, min(1.0, s))
|
||||
|
||||
# Convert back to RGB
|
||||
r, g, b = colorsys.hsv_to_rgb(h, s, v)
|
||||
|
||||
return (r, g, b)
|
||||
|
||||
def process_material(material, use_vectorized=True):
|
||||
"""Process a material to determine its viewport color"""
|
||||
if not material:
|
||||
print(f"Material is None, using fallback color")
|
||||
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
|
||||
|
||||
if material.is_grease_pencil:
|
||||
print(f"Material {material.name}: is a grease pencil material, using fallback color")
|
||||
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
|
||||
|
||||
try:
|
||||
# Get color from material thumbnail
|
||||
print(f"Material {material.name}: Attempting to extract color from thumbnail")
|
||||
|
||||
# Get color from the material thumbnail
|
||||
color = get_color_from_preview(material, use_vectorized)
|
||||
|
||||
if color:
|
||||
print(f"Material {material.name}: Thumbnail color = {color}")
|
||||
|
||||
# Correct color for viewport display
|
||||
corrected_color = correct_viewport_color(color)
|
||||
print(f"Material {material.name}: Corrected thumbnail color = {corrected_color}")
|
||||
|
||||
return corrected_color, MaterialStatus.PREVIEW_BASED
|
||||
else:
|
||||
print(f"Material {material.name}: Could not extract color from thumbnail, using fallback color")
|
||||
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing material {material.name}: {e}")
|
||||
return (1, 1, 1), MaterialStatus.FAILED
|
||||
|
||||
def get_average_color(image, use_vectorized=True):
|
||||
"""Calculate the average color of an image"""
|
||||
if not image or not image.has_data:
|
||||
return None
|
||||
|
||||
# Get image pixels
|
||||
pixels = list(image.pixels)
|
||||
|
||||
if use_vectorized and np is not None:
|
||||
# Use NumPy for faster processing
|
||||
pixels_np = np.array(pixels)
|
||||
|
||||
# Reshape to RGBA format
|
||||
pixels_np = pixels_np.reshape(-1, 4)
|
||||
|
||||
# Calculate average color (ignoring alpha)
|
||||
avg_color = pixels_np[:, :3].mean(axis=0)
|
||||
|
||||
return avg_color.tolist()
|
||||
else:
|
||||
# Fallback to pure Python
|
||||
total_r, total_g, total_b = 0, 0, 0
|
||||
pixel_count = len(pixels) // 4
|
||||
|
||||
for i in range(0, len(pixels), 4):
|
||||
total_r += pixels[i]
|
||||
total_g += pixels[i+1]
|
||||
total_b += pixels[i+2]
|
||||
|
||||
if pixel_count > 0:
|
||||
return [total_r / pixel_count, total_g / pixel_count, total_b / pixel_count]
|
||||
else:
|
||||
return None
|
||||
|
||||
def find_image_node(node, visited=None):
|
||||
"""Find the first image node connected to the given node"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if node in visited:
|
||||
return None
|
||||
|
||||
visited.add(node)
|
||||
|
||||
# Check if this is an image node
|
||||
if node.type == 'TEX_IMAGE' and node.image:
|
||||
return node
|
||||
|
||||
# Check input connections
|
||||
for input_socket in node.inputs:
|
||||
for link in input_socket.links:
|
||||
from_node = link.from_node
|
||||
result = find_image_node(from_node, visited)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
def find_color_source(node, socket_name=None, visited=None):
|
||||
"""
|
||||
Recursively trace color data through nodes to find the source
|
||||
This is an enhanced version that handles mix nodes and node groups
|
||||
"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
# Avoid infinite recursion
|
||||
node_id = (node, socket_name)
|
||||
if node_id in visited:
|
||||
return None, None
|
||||
|
||||
visited.add(node_id)
|
||||
|
||||
# Handle different node types
|
||||
if node.type == 'TEX_IMAGE' and node.image:
|
||||
# Direct image texture
|
||||
return node, 'Color'
|
||||
|
||||
elif node.type == 'RGB':
|
||||
# Direct RGB color
|
||||
return node, 'Color'
|
||||
|
||||
elif node.type == 'VALTORGB': # Color Ramp
|
||||
return node, 'Color'
|
||||
|
||||
elif node.type == 'MIX_RGB' or node.type == 'MIX':
|
||||
# For mix nodes, check the factor to determine which input to prioritize
|
||||
factor = 0.5 # Default to equal mix
|
||||
|
||||
# Try to get the factor value
|
||||
if len(node.inputs) >= 1:
|
||||
if hasattr(node.inputs[0], 'default_value'):
|
||||
factor = node.inputs[0].default_value
|
||||
|
||||
# If factor is close to 0, prioritize the first color input
|
||||
# If factor is close to 1, prioritize the second color input
|
||||
# Otherwise, check both with second having slightly higher priority
|
||||
|
||||
if factor < 0.1: # Strongly favor first input
|
||||
if len(node.inputs) >= 2 and node.inputs[1].links:
|
||||
color1_node = node.inputs[1].links[0].from_node
|
||||
result_node, result_socket = find_color_source(color1_node, None, visited)
|
||||
if result_node:
|
||||
return result_node, result_socket
|
||||
|
||||
# Fallback to second input
|
||||
if len(node.inputs) >= 3 and node.inputs[2].links:
|
||||
color2_node = node.inputs[2].links[0].from_node
|
||||
result_node, result_socket = find_color_source(color2_node, None, visited)
|
||||
if result_node:
|
||||
return result_node, result_socket
|
||||
|
||||
elif factor > 0.9: # Strongly favor second input
|
||||
if len(node.inputs) >= 3 and node.inputs[2].links:
|
||||
color2_node = node.inputs[2].links[0].from_node
|
||||
result_node, result_socket = find_color_source(color2_node, None, visited)
|
||||
if result_node:
|
||||
return result_node, result_socket
|
||||
|
||||
# Fallback to first input
|
||||
if len(node.inputs) >= 2 and node.inputs[1].links:
|
||||
color1_node = node.inputs[1].links[0].from_node
|
||||
result_node, result_socket = find_color_source(color1_node, None, visited)
|
||||
if result_node:
|
||||
return result_node, result_socket
|
||||
else:
|
||||
# Check both inputs with slight preference for the second input (usually the main color)
|
||||
# First try Color2 (second input)
|
||||
if len(node.inputs) >= 3 and node.inputs[2].links:
|
||||
color2_node = node.inputs[2].links[0].from_node
|
||||
result_node, result_socket = find_color_source(color2_node, None, visited)
|
||||
if result_node:
|
||||
return result_node, result_socket
|
||||
|
||||
# Then try Color1 (first input)
|
||||
if len(node.inputs) >= 2 and node.inputs[1].links:
|
||||
color1_node = node.inputs[1].links[0].from_node
|
||||
result_node, result_socket = find_color_source(color1_node, None, visited)
|
||||
if result_node:
|
||||
return result_node, result_socket
|
||||
|
||||
elif node.type == 'GROUP':
|
||||
# Handle node groups by finding the group output node and tracing back
|
||||
if node.node_tree:
|
||||
# Find output node in the group
|
||||
for group_node in node.node_tree.nodes:
|
||||
if group_node.type == 'GROUP_OUTPUT':
|
||||
# Find which input socket corresponds to the color output
|
||||
for i, output in enumerate(node.outputs):
|
||||
if output.links and (socket_name is None or output.name == socket_name):
|
||||
# Find the corresponding input in the group output node
|
||||
if i < len(group_node.inputs) and group_node.inputs[i].links:
|
||||
input_link = group_node.inputs[i].links[0]
|
||||
source_node = input_link.from_node
|
||||
source_socket = input_link.from_socket.name
|
||||
return find_color_source(source_node, source_socket, visited)
|
||||
|
||||
elif node.type == 'BSDF_PRINCIPLED':
|
||||
# If we somehow got to a principled BSDF node, check its base color input
|
||||
base_color_input = node.inputs.get('Base Color')
|
||||
if base_color_input and base_color_input.links:
|
||||
connected_node = base_color_input.links[0].from_node
|
||||
return find_color_source(connected_node, None, visited)
|
||||
|
||||
# For shader nodes, try to find color inputs
|
||||
elif 'BSDF' in node.type or 'SHADER' in node.type:
|
||||
# Look for color inputs in shader nodes
|
||||
color_input_names = ['Color', 'Base Color', 'Diffuse Color', 'Tint']
|
||||
for name in color_input_names:
|
||||
input_socket = node.inputs.get(name)
|
||||
if input_socket and input_socket.links:
|
||||
connected_node = input_socket.links[0].from_node
|
||||
result_node, result_socket = find_color_source(connected_node, None, visited)
|
||||
if result_node:
|
||||
return result_node, result_socket
|
||||
|
||||
# For other node types, check all inputs
|
||||
for input_socket in node.inputs:
|
||||
if input_socket.links:
|
||||
from_node = input_socket.links[0].from_node
|
||||
result_node, result_socket = find_color_source(from_node, None, visited)
|
||||
if result_node:
|
||||
return result_node, result_socket
|
||||
|
||||
# If we get here, no color source was found
|
||||
return None, None
|
||||
|
||||
def get_final_color(material):
|
||||
"""Get the final color for a material"""
|
||||
if not material or not material.use_nodes:
|
||||
print(f"Material {material.name if material else 'None'} has no nodes")
|
||||
return None
|
||||
|
||||
# Find the Principled BSDF node
|
||||
principled_node = None
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == 'BSDF_PRINCIPLED':
|
||||
principled_node = node
|
||||
break
|
||||
|
||||
if not principled_node:
|
||||
print(f"Material {material.name}: No Principled BSDF node found")
|
||||
return None
|
||||
|
||||
# Get the Base Color input
|
||||
base_color_input = principled_node.inputs.get('Base Color')
|
||||
if not base_color_input:
|
||||
print(f"Material {material.name}: No Base Color input found")
|
||||
return None
|
||||
|
||||
# Check if there's a texture connected to the Base Color input
|
||||
if base_color_input.links:
|
||||
connected_node = base_color_input.links[0].from_node
|
||||
print(f"Material {material.name}: Base Color connected to {connected_node.name} of type {connected_node.type}")
|
||||
|
||||
# Use the enhanced color source finding function
|
||||
source_node, source_socket = find_color_source(connected_node)
|
||||
|
||||
if source_node:
|
||||
print(f"Material {material.name}: Found color source node {source_node.name} of type {source_node.type}")
|
||||
|
||||
# Handle different source node types
|
||||
if source_node.type == 'TEX_IMAGE' and source_node.image:
|
||||
print(f"Material {material.name}: Using image texture {source_node.image.name}")
|
||||
color = get_average_color(source_node.image)
|
||||
if color:
|
||||
print(f"Material {material.name}: Image average color = {color}")
|
||||
return color
|
||||
else:
|
||||
print(f"Material {material.name}: Could not calculate image average color")
|
||||
|
||||
# If it's a color ramp, get the average color from the ramp
|
||||
elif source_node.type == 'VALTORGB': # Color Ramp node
|
||||
print(f"Material {material.name}: Using color ramp")
|
||||
# Get the average of the color stops
|
||||
elements = source_node.color_ramp.elements
|
||||
if elements:
|
||||
avg_color = [0, 0, 0]
|
||||
for element in elements:
|
||||
color = element.color[:3] # Ignore alpha
|
||||
avg_color[0] += color[0]
|
||||
avg_color[1] += color[1]
|
||||
avg_color[2] += color[2]
|
||||
|
||||
avg_color[0] /= len(elements)
|
||||
avg_color[1] /= len(elements)
|
||||
avg_color[2] /= len(elements)
|
||||
|
||||
print(f"Material {material.name}: Color ramp average = {avg_color}")
|
||||
return avg_color
|
||||
|
||||
# If it's an RGB node, use its color
|
||||
elif source_node.type == 'RGB':
|
||||
color = list(source_node.outputs[0].default_value)[:3]
|
||||
print(f"Material {material.name}: RGB node color = {color}")
|
||||
return color
|
||||
|
||||
# For other node types, try to get color from the output socket
|
||||
elif source_socket and hasattr(source_node.outputs, '__getitem__'):
|
||||
for output in source_node.outputs:
|
||||
if output.name == source_socket:
|
||||
if hasattr(output, 'default_value') and len(output.default_value) >= 3:
|
||||
color = list(output.default_value)[:3]
|
||||
print(f"Material {material.name}: Node output socket color = {color}")
|
||||
return color
|
||||
|
||||
print(f"Material {material.name}: Could not extract color from source node {source_node.name} of type {source_node.type}")
|
||||
else:
|
||||
print(f"Material {material.name}: Could not find color source node in the node tree")
|
||||
|
||||
# Debug: Print the node tree structure to help diagnose the issue
|
||||
print(f"Material {material.name}: Node tree structure:")
|
||||
for node in material.node_tree.nodes:
|
||||
print(f" - Node: {node.name}, Type: {node.type}")
|
||||
for input_socket in node.inputs:
|
||||
if input_socket.links:
|
||||
print(f" - Input: {input_socket.name} connected to {input_socket.links[0].from_node.name}")
|
||||
|
||||
# If no texture or couldn't get texture color, use the base color value
|
||||
color = list(base_color_input.default_value)[:3]
|
||||
print(f"Material {material.name}: Using base color value = {color}")
|
||||
return color
|
||||
|
||||
def find_diffuse_texture(material):
|
||||
"""Find the diffuse texture in a material"""
|
||||
if not material or not material.use_nodes:
|
||||
return None
|
||||
|
||||
# Find the principled BSDF node
|
||||
principled_node = None
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == 'BSDF_PRINCIPLED':
|
||||
principled_node = node
|
||||
break
|
||||
|
||||
if not principled_node:
|
||||
return None
|
||||
|
||||
# Find the base color input
|
||||
base_color_input = principled_node.inputs.get('Base Color')
|
||||
if not base_color_input or not base_color_input.links:
|
||||
return None
|
||||
|
||||
# Get the connected node
|
||||
connected_node = base_color_input.links[0].from_node
|
||||
|
||||
# Use the enhanced color source finding function
|
||||
source_node, _ = find_color_source(connected_node)
|
||||
|
||||
# Check if we found an image texture
|
||||
if source_node and source_node.type == 'TEX_IMAGE' and source_node.image:
|
||||
return source_node.image
|
||||
|
||||
return None
|
||||
|
||||
def get_status_icon(status):
|
||||
"""Get the icon for a material status"""
|
||||
if status == MaterialStatus.PENDING:
|
||||
return 'TRIA_RIGHT'
|
||||
elif status == MaterialStatus.PROCESSING:
|
||||
return 'SORTTIME'
|
||||
elif status == MaterialStatus.COMPLETED:
|
||||
return 'CHECKMARK'
|
||||
elif status == MaterialStatus.PREVIEW_BASED:
|
||||
return 'IMAGE_DATA'
|
||||
elif status == MaterialStatus.FAILED:
|
||||
return 'ERROR'
|
||||
else:
|
||||
return 'QUESTION'
|
||||
|
||||
def get_status_text(status):
|
||||
"""Get the text for a material status"""
|
||||
if status == MaterialStatus.PENDING:
|
||||
return "Pending"
|
||||
elif status == MaterialStatus.PROCESSING:
|
||||
return "Processing"
|
||||
elif status == MaterialStatus.COMPLETED:
|
||||
return "Node-based"
|
||||
elif status == MaterialStatus.PREVIEW_BASED:
|
||||
return "Thumbnail-based"
|
||||
elif status == MaterialStatus.FAILED:
|
||||
return "Failed"
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
class VIEW3D_PT_BulkViewportDisplay(bpy.types.Panel):
|
||||
"""Bulk Viewport Display Panel"""
|
||||
bl_label = "Bulk Viewport Display"
|
||||
bl_idname = "VIEW3D_PT_bulk_viewport_display"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Edit'
|
||||
bl_parent_id = "VIEW3D_PT_bulk_scene_tools"
|
||||
bl_order = 3
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Viewport Colors section
|
||||
box = layout.box()
|
||||
box.label(text="Viewport Colors")
|
||||
|
||||
# Add description
|
||||
col = box.column()
|
||||
col.label(text="Set viewport colors from material thumbnails")
|
||||
|
||||
# Add primary settings
|
||||
col = box.column(align=True)
|
||||
col.prop(context.scene, "viewport_colors_selected_only")
|
||||
|
||||
# Add advanced options in a collapsible section
|
||||
row = box.row()
|
||||
row.prop(context.scene, "viewport_colors_show_advanced",
|
||||
icon='DISCLOSURE_TRI_DOWN' if context.scene.viewport_colors_show_advanced else 'DISCLOSURE_TRI_RIGHT',
|
||||
emboss=False)
|
||||
row.label(text="Advanced Options")
|
||||
|
||||
if context.scene.viewport_colors_show_advanced:
|
||||
adv_col = box.column(align=True)
|
||||
adv_col.prop(context.scene, "viewport_colors_batch_size")
|
||||
adv_col.prop(context.scene, "viewport_colors_use_vectorized")
|
||||
adv_col.prop(context.scene, "viewport_colors_darken_amount")
|
||||
adv_col.prop(context.scene, "viewport_colors_value_amount")
|
||||
|
||||
# Add the operator button
|
||||
row = box.row()
|
||||
row.scale_y = 1.5
|
||||
row.operator("bst.set_viewport_colors")
|
||||
|
||||
# Show progress if processing
|
||||
if is_processing:
|
||||
row = box.row()
|
||||
row.label(text=f"Processing: {processed_count}/{total_materials}")
|
||||
|
||||
# Add a progress bar
|
||||
row = box.row()
|
||||
row.prop(context.scene, "viewport_colors_progress", text="")
|
||||
|
||||
# Show material results if available
|
||||
if material_results:
|
||||
box.separator()
|
||||
row = box.row()
|
||||
row.prop(context.scene, "show_material_results",
|
||||
icon='DISCLOSURE_TRI_DOWN' if context.scene.show_material_results else 'DISCLOSURE_TRI_RIGHT',
|
||||
emboss=False)
|
||||
row.label(text="Material Results:")
|
||||
if context.scene.show_material_results:
|
||||
# Create a scrollable list
|
||||
material_box = box.box()
|
||||
row = material_box.row()
|
||||
col = row.column()
|
||||
|
||||
# Collect materials to remove
|
||||
materials_to_remove = []
|
||||
|
||||
# Count materials by status
|
||||
preview_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# Display material results - use a copy of the keys to avoid modification during iteration
|
||||
for material_name in list(material_results.keys()):
|
||||
color, status = material_results[material_name]
|
||||
|
||||
# Update counts
|
||||
if status == MaterialStatus.PREVIEW_BASED:
|
||||
preview_count += 1
|
||||
elif status == MaterialStatus.FAILED:
|
||||
failed_count += 1
|
||||
|
||||
row = col.row(align=True)
|
||||
|
||||
# Add status icon
|
||||
row.label(text="", icon=get_status_icon(status))
|
||||
|
||||
# Add material name with operator to select it
|
||||
op = row.operator("bst.select_in_editor", text=material_name)
|
||||
op.material_name = material_name
|
||||
|
||||
# Add color preview
|
||||
if color:
|
||||
material = bpy.data.materials.get(material_name)
|
||||
if material: # Check if material still exists
|
||||
row.prop(material, "diffuse_color", text="")
|
||||
else:
|
||||
# Material no longer exists, show a placeholder color
|
||||
row.label(text="", icon='ERROR')
|
||||
# Mark for removal
|
||||
materials_to_remove.append(material_name)
|
||||
|
||||
# Remove materials that no longer exist
|
||||
for material_name in materials_to_remove:
|
||||
material_results.pop(material_name, None)
|
||||
|
||||
# Show statistics
|
||||
if len(material_results) > 0:
|
||||
material_box.separator()
|
||||
stats_col = material_box.column(align=True)
|
||||
stats_col.label(text=f"Total: {len(material_results)} materials")
|
||||
stats_col.label(text=f"Thumbnail-based: {preview_count}")
|
||||
stats_col.label(text=f"Failed: {failed_count}")
|
||||
|
||||
# Add the select diffuse nodes button at the bottom
|
||||
layout.separator()
|
||||
layout.operator("bst.select_diffuse_nodes", icon='NODE_TEXTURE')
|
||||
|
||||
class MATERIAL_OT_SelectInEditor(bpy.types.Operator):
|
||||
"""Select this material in the editor"""
|
||||
bl_idname = "bst.select_in_editor"
|
||||
bl_label = "Select Material"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
material_name: bpy.props.StringProperty( # type: ignore
|
||||
name="Material Name",
|
||||
description="Name of the material to select",
|
||||
default=""
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
# Find the material
|
||||
material = bpy.data.materials.get(self.material_name)
|
||||
if not material:
|
||||
# Remove this entry from material_results to avoid future errors
|
||||
if self.material_name in material_results:
|
||||
material_results.pop(self.material_name, None)
|
||||
# Force a redraw of the UI
|
||||
for area in context.screen.areas:
|
||||
area.tag_redraw()
|
||||
self.report({'ERROR'}, f"Material '{self.material_name}' not found")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Find an object using this material
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH' and obj.data.materials:
|
||||
for i, mat in enumerate(obj.data.materials):
|
||||
if mat == material:
|
||||
# Select the object
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
context.view_layer.objects.active = obj
|
||||
|
||||
# Set the active material index
|
||||
obj.active_material_index = i
|
||||
|
||||
# Switch to material properties
|
||||
for area in context.screen.areas:
|
||||
if area.type == 'PROPERTIES':
|
||||
for space in area.spaces:
|
||||
if space.type == 'PROPERTIES':
|
||||
space.context = 'MATERIAL'
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
self.report({'WARNING'}, f"No object using material '{self.material_name}' found")
|
||||
return {'CANCELLED'}
|
||||
|
||||
def get_color_from_preview(material, use_vectorized=True):
|
||||
"""Extract the average color from a material thumbnail"""
|
||||
if not material:
|
||||
return None
|
||||
|
||||
# Force Blender to generate the material preview if it doesn't exist
|
||||
# This uses Blender's internal preview generation system
|
||||
preview = material.preview
|
||||
if not preview:
|
||||
return None
|
||||
|
||||
# Ensure the preview is generated (this should be very fast as Blender maintains these)
|
||||
if preview.icon_id == 0:
|
||||
# Use Blender's standard preview size
|
||||
preview.icon_size = (128, 128)
|
||||
# This triggers Blender's internal preview generation
|
||||
icon_id = preview.icon_id # Store in variable instead of just accessing
|
||||
|
||||
# Access the preview image data - these are the same thumbnails shown in the material panel
|
||||
preview_image = preview.icon_pixels_float
|
||||
|
||||
if not preview_image or len(preview_image) == 0:
|
||||
return None
|
||||
|
||||
if use_vectorized and np is not None:
|
||||
# Use NumPy for faster processing
|
||||
pixels_np = np.array(preview_image)
|
||||
|
||||
# Reshape to RGBA format (preview is stored as a flat RGBA array)
|
||||
pixels_np = pixels_np.reshape(-1, 4)
|
||||
|
||||
# Calculate average color (ignoring alpha and any pure black pixels which are often the background)
|
||||
# Filter out black pixels (background) by checking if R+G+B is very small
|
||||
non_black_mask = np.sum(pixels_np[:, :3], axis=1) > 0.05
|
||||
|
||||
if np.any(non_black_mask):
|
||||
# Only use non-black pixels for the average
|
||||
avg_color = pixels_np[non_black_mask][:, :3].mean(axis=0)
|
||||
return avg_color.tolist()
|
||||
else:
|
||||
# If all pixels are black, return the average of all pixels
|
||||
avg_color = pixels_np[:, :3].mean(axis=0)
|
||||
return avg_color.tolist()
|
||||
else:
|
||||
# Fallback to pure Python
|
||||
total_r, total_g, total_b = 0, 0, 0
|
||||
pixel_count = 0
|
||||
non_black_count = 0
|
||||
|
||||
# Process pixels in groups of 4 (RGBA)
|
||||
for i in range(0, len(preview_image), 4):
|
||||
r, g, b, a = preview_image[i:i+4]
|
||||
|
||||
# Skip black pixels (background)
|
||||
if r + g + b > 0.05:
|
||||
total_r += r
|
||||
total_g += g
|
||||
total_b += b
|
||||
non_black_count += 1
|
||||
|
||||
pixel_count += 1
|
||||
|
||||
# If we found non-black pixels, use their average
|
||||
if non_black_count > 0:
|
||||
return [total_r / non_black_count, total_g / non_black_count, total_b / non_black_count]
|
||||
# Otherwise, use the average of all pixels
|
||||
elif pixel_count > 0:
|
||||
return [total_r / pixel_count, total_g / pixel_count, total_b / pixel_count]
|
||||
else:
|
||||
return None
|
||||
|
||||
class VIEWPORT_OT_SelectDiffuseNodes(bpy.types.Operator):
|
||||
bl_idname = "bst.select_diffuse_nodes"
|
||||
bl_label = "Set Texture Display"
|
||||
bl_description = "Select the most relevant diffuse/base color image texture node in each material"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
if select_diffuse_nodes:
|
||||
select_diffuse_nodes()
|
||||
self.report({'INFO'}, "Diffuse/BaseColor image nodes selected.")
|
||||
else:
|
||||
self.report({'ERROR'}, "select_diffuse_nodes function not found.")
|
||||
return {'FINISHED'}
|
||||
|
||||
# List of all classes in this module
|
||||
classes = (
|
||||
VIEWPORT_OT_SetViewportColors,
|
||||
VIEW3D_PT_BulkViewportDisplay,
|
||||
MATERIAL_OT_SelectInEditor,
|
||||
VIEWPORT_OT_SelectDiffuseNodes,
|
||||
)
|
||||
|
||||
# Registration
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
# Register properties
|
||||
register_viewport_properties()
|
||||
|
||||
def unregister():
|
||||
# Unregister properties
|
||||
try:
|
||||
unregister_viewport_properties()
|
||||
except Exception:
|
||||
pass
|
||||
# Unregister classes
|
||||
for cls in reversed(classes):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -0,0 +1,51 @@
|
||||
# Raincloud's Bulk Scene Tools
|
||||
|
||||
A couple Blender tools to help me automate some tedious tasks in scene optimization.
|
||||
|
||||
## Features
|
||||
|
||||
- Bulk Data Remap
|
||||
- Bulk Viewport Display
|
||||
- Automatic update checking and one-click updates from GitHub releases
|
||||
|
||||
Officially supports Blender 4.4.1, but may still work on older versions.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the addon (zip file)
|
||||
2. In Blender, go to Edit > Preferences > Add-ons
|
||||
3. Click "Install..." and select the downloaded zip file, or click and drag if it allows.
|
||||
4. Ensure addon is enabled.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open blender file/scene to optimize
|
||||
2. Open side panel > Edit tab > Bulk Scene Tools
|
||||
3. Data remapper: Select data types to remap. Currently supports Images, Materials, and Fonts. Select to exclude data type from remapping.
|
||||
4. View amount of duplicates and use the dropdown menus to select which duplicate groups to exclude from remapping.
|
||||
5. Remap. This action is undo-able!
|
||||
6. If remapping has successfully remapped to your liking, Purge Unused Data so that the Viewport Display function has less materials to calculate, unless you are applying it only to selected objects.
|
||||
7. Recommend activating Solid viewport shading mode so you can see what the Material Viewport function is doing. Change color from Material to Texture if you prefer; the function should find the diffuse texture if one exists.
|
||||
8. Apply material calculation to selected objects if preferred.
|
||||
9. Manually set display color for objects that couldn't be calculated, or weren't calculated to your preference.
|
||||
|
||||
## Workflow for unpacking and organizing all textures
|
||||
|
||||
1. Pack all images (File > external data > pack resources, or BST > Bulk Path Management > Workflow > Pack)
|
||||
2. Rename all image (datablocks) as preferred (can be easily done within the Bulk Operations dropdown, but I also recommend the Simple Renaming extension available from the Blender community)
|
||||
3. Remap all image paths as preferred (Bulk Operations)
|
||||
4. Bulk Path Management > Save All (If selected, will save selected, if none are selected, will save all images in file)
|
||||
5. Remove pack
|
||||
|
||||
### Updating the addon
|
||||
|
||||
The addon will automatically check for updates when Blender starts. You can also:
|
||||
|
||||
1. Go to Edit > Preferences > Add-ons
|
||||
2. Find "Raincloud's Bulk Scene Tools" in the list
|
||||
3. In the addon preferences, click "Check Now" to check for updates
|
||||
4. If an update is available, click "Install Update" to download and install it
|
||||
|
||||
## Author
|
||||
|
||||
- **RaincloudTheDragon**
|
||||
@@ -0,0 +1 @@
|
||||
requests>=2.25.0
|
||||
@@ -0,0 +1,236 @@
|
||||
import bpy # type: ignore
|
||||
import requests # type: ignore
|
||||
import zipfile
|
||||
import tempfile
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
from bpy.app.handlers import persistent # type: ignore
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Updater configuration
|
||||
GITHUB_REPO = "RaincloudTheDragon/Rainys-Bulk-Scene-Tools"
|
||||
GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
||||
UPDATE_CHECK_INTERVAL = 86400 # 24 hours in seconds
|
||||
|
||||
# Updater state tracking
|
||||
class UpdaterState:
|
||||
checking_for_updates = False
|
||||
update_available = False
|
||||
update_version = ""
|
||||
update_download_url = ""
|
||||
error_message = ""
|
||||
last_check_time = 0
|
||||
|
||||
def get_current_version():
|
||||
"""Get the current addon version as a string"""
|
||||
from .. import bl_info
|
||||
version = bl_info["version"]
|
||||
return ".".join(str(v) for v in version)
|
||||
|
||||
def version_tuple_from_string(version_str):
|
||||
"""Convert a version string to a tuple for comparison"""
|
||||
try:
|
||||
return tuple(int(n) for n in version_str.split('.'))
|
||||
except:
|
||||
return (0, 0, 0)
|
||||
|
||||
def check_for_updates(async_check=True):
|
||||
"""Check for updates on GitHub"""
|
||||
if async_check:
|
||||
thread = threading.Thread(target=_check_for_updates_async)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
else:
|
||||
return _check_for_updates_async()
|
||||
|
||||
def _check_for_updates_async():
|
||||
"""Check for updates asynchronously"""
|
||||
UpdaterState.checking_for_updates = True
|
||||
UpdaterState.error_message = ""
|
||||
|
||||
try:
|
||||
current_version = get_current_version()
|
||||
current_version_tuple = version_tuple_from_string(current_version)
|
||||
|
||||
# Request the latest release info from GitHub
|
||||
headers = {}
|
||||
response = requests.get(GITHUB_API_URL, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
release_data = response.json()
|
||||
latest_version = release_data["tag_name"].lstrip('v')
|
||||
latest_version_tuple = version_tuple_from_string(latest_version)
|
||||
|
||||
# Check if update is available
|
||||
if latest_version_tuple > current_version_tuple:
|
||||
UpdaterState.update_available = True
|
||||
UpdaterState.update_version = latest_version
|
||||
|
||||
# Get the zip file URL
|
||||
for asset in release_data["assets"]:
|
||||
if asset["name"].endswith(".zip"):
|
||||
UpdaterState.update_download_url = asset["browser_download_url"]
|
||||
break
|
||||
|
||||
if not UpdaterState.update_download_url:
|
||||
UpdaterState.update_download_url = release_data["zipball_url"]
|
||||
else:
|
||||
UpdaterState.update_available = False
|
||||
|
||||
UpdaterState.last_check_time = time.time()
|
||||
result = True
|
||||
|
||||
except Exception as e:
|
||||
UpdaterState.error_message = str(e)
|
||||
result = False
|
||||
|
||||
UpdaterState.checking_for_updates = False
|
||||
return result
|
||||
|
||||
def download_and_install_update():
|
||||
"""Download and install the addon update"""
|
||||
if not UpdaterState.update_available or not UpdaterState.update_download_url:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create a temporary directory
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_zip_path = os.path.join(temp_dir, "addon_update.zip")
|
||||
|
||||
# Download the zip file
|
||||
response = requests.get(UpdaterState.update_download_url, stream=True, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
with open(temp_zip_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
# Get the addon directory
|
||||
addon_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
# Extract to temporary location
|
||||
extract_dir = os.path.join(temp_dir, "extracted")
|
||||
with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
|
||||
# Find the addon root in the extracted files
|
||||
addon_root = None
|
||||
for root, dirs, files in os.walk(extract_dir):
|
||||
if "__init__.py" in files:
|
||||
# Found potential addon root
|
||||
with open(os.path.join(root, "__init__.py"), 'r') as f:
|
||||
content = f.read()
|
||||
if "bl_info" in content:
|
||||
addon_root = root
|
||||
break
|
||||
|
||||
if not addon_root:
|
||||
# Try with the first directory if no clear addon root was found
|
||||
for item in os.listdir(extract_dir):
|
||||
if os.path.isdir(os.path.join(extract_dir, item)):
|
||||
addon_root = os.path.join(extract_dir, item)
|
||||
break
|
||||
|
||||
if not addon_root:
|
||||
raise Exception("Could not find addon root in the downloaded files")
|
||||
|
||||
# Copy files to addon directory
|
||||
# First, remove all old files except user settings
|
||||
for item in os.listdir(addon_dir):
|
||||
if item == "__pycache__":
|
||||
continue # Skip pycache
|
||||
item_path = os.path.join(addon_dir, item)
|
||||
if os.path.isfile(item_path):
|
||||
os.remove(item_path)
|
||||
elif os.path.isdir(item_path) and item != "user_settings":
|
||||
shutil.rmtree(item_path)
|
||||
|
||||
# Copy new files
|
||||
for item in os.listdir(addon_root):
|
||||
s = os.path.join(addon_root, item)
|
||||
d = os.path.join(addon_dir, item)
|
||||
if os.path.isfile(s):
|
||||
shutil.copy2(s, d)
|
||||
elif os.path.isdir(s):
|
||||
shutil.copytree(s, d)
|
||||
|
||||
# Clean up
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
# Mark for reload
|
||||
bpy.ops.script.reload()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
UpdaterState.error_message = str(e)
|
||||
if 'temp_dir' in locals() and os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir)
|
||||
return False
|
||||
|
||||
@persistent
|
||||
def check_for_updates_handler(dummy):
|
||||
"""Handler to check for updates when Blender starts"""
|
||||
# Wait a bit to let Blender start up properly
|
||||
def delayed_check():
|
||||
time.sleep(2) # Wait 2 seconds after startup
|
||||
if time.time() - UpdaterState.last_check_time > UPDATE_CHECK_INTERVAL:
|
||||
check_for_updates()
|
||||
|
||||
thread = threading.Thread(target=delayed_check)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Add handler to check for updates on Blender startup
|
||||
if check_for_updates_handler not in bpy.app.handlers.load_post:
|
||||
bpy.app.handlers.load_post.append(check_for_updates_handler)
|
||||
|
||||
# Updater operators
|
||||
class BST_OT_CheckForUpdates(bpy.types.Operator):
|
||||
"""Check for updates for Raincloud's Bulk Scene Tools"""
|
||||
bl_idname = "bst.check_for_updates"
|
||||
bl_label = "Check for Updates"
|
||||
bl_description = "Check for new versions of the addon"
|
||||
|
||||
def execute(self, context):
|
||||
# Run synchronously for direct feedback
|
||||
if check_for_updates(async_check=False):
|
||||
if UpdaterState.update_available:
|
||||
self.report({'INFO'}, f"Update available: v{UpdaterState.update_version}")
|
||||
else:
|
||||
self.report({'INFO'}, "No updates available")
|
||||
else:
|
||||
self.report({'ERROR'}, f"Error checking for updates: {UpdaterState.error_message}")
|
||||
return {'FINISHED'}
|
||||
|
||||
class BST_OT_InstallUpdate(bpy.types.Operator):
|
||||
"""Install available update for Raincloud's Bulk Scene Tools"""
|
||||
bl_idname = "bst.install_update"
|
||||
bl_label = "Install Update"
|
||||
bl_description = "Download and install the latest version"
|
||||
|
||||
def execute(self, context):
|
||||
if download_and_install_update():
|
||||
self.report({'INFO'}, "Update installed successfully. Restart Blender to complete update.")
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
self.report({'ERROR'}, f"Error installing update: {UpdaterState.error_message}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# List of classes in this module
|
||||
classes = (
|
||||
BST_OT_CheckForUpdates,
|
||||
BST_OT_InstallUpdate,
|
||||
)
|
||||
|
||||
def register():
|
||||
"""Register all classes in this module"""
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
def unregister():
|
||||
"""Unregister all classes in this module"""
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
Reference in New Issue
Block a user