2026-01-01

This commit is contained in:
2026-03-17 15:16:34 -06:00
parent ec4cf523fb
commit b80274187b
263 changed files with 95164 additions and 3848 deletions
@@ -10,6 +10,7 @@ from .ops.Rename_images_by_mat import Rename_images_by_mat, RENAME_OT_summary_di
from .ops.FreeGPU import BST_FreeGPU
from .ops import ghost_buster
from . import rainys_repo_bootstrap
from .utils import compat
# Addon preferences class for update settings
class BST_AddonPreferences(AddonPreferences):
@@ -58,7 +59,7 @@ classes = (
def register():
# Register classes from this module (do this first to ensure preferences are available)
for cls in classes:
bpy.utils.register_class(cls)
compat.safe_register_class(cls)
# Print debug info about preferences
try:
@@ -128,10 +129,7 @@ def unregister():
rainys_repo_bootstrap.unregister()
# Unregister classes from this module
for cls in reversed(classes):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
pass
compat.safe_unregister_class(cls)
if __name__ == "__main__":
register()
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
id = "rainclouds_bulk_scene_tools"
name = "Raincloud's Bulk Scene Tools"
tagline = "Bulk utilities for optimizing scene data"
version = "0.12.0"
version = "0.14.0"
type = "add-on"
maintainer = "RaincloudTheDragon <raincloudthedragon@gmail.com>"
@@ -1,3 +1,14 @@
# v0.14.0
- Added operator to select all images with absolute paths (#3)
- Added search functionality to filter datablocks in PathMan and Data Remapper panels (#4)
# v0.13.1
- Fix github workflow to include new utils folder
# v0.13.0
- Set up compat for #9, still needs bugchecking, but the main setup is complete.
- Fixed #10
# v0.12.0
- Integrate Rainy's Extension Repo bootstrapper
- Set minimum Blender version to 4.2 for #9
@@ -7,6 +7,7 @@ from ..panels.bulk_path_management import (
set_image_paths,
ensure_directory_for_path,
)
from ..utils import compat
class AUTOMAT_OT_summary_dialog(bpy.types.Operator):
"""Show AutoMat Extractor operation summary"""
@@ -70,8 +71,9 @@ class AutoMatExtractor(bpy.types.Operator):
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
addon_entry = context.preferences.addons.get(addon_name)
prefs = addon_entry.preferences if addon_entry else None
common_outside = prefs.automat_common_outside_blend if prefs else False
# Get selected images
selected_images = [img for img in bpy.data.images if hasattr(img, "bst_selected") and img.bst_selected]
@@ -190,12 +192,23 @@ class AutoMatExtractor(bpy.types.Operator):
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]
# Get blend file name - respect user preference if set
if props.use_blend_subfolder:
blend_name = props.blend_subfolder
if not blend_name:
# Fall back to filename if not specified
blend_path = bpy.data.filepath
if blend_path:
blend_name = os.path.splitext(os.path.basename(blend_path))[0]
else:
blend_name = "untitled"
else:
blend_name = "untitled"
# Derive from filename
blend_path = bpy.data.filepath
if blend_path:
blend_name = os.path.splitext(os.path.basename(blend_path))[0]
else:
blend_name = "untitled"
blend_name = self.sanitize_filename(blend_name)
# Determine common path
@@ -532,9 +545,9 @@ classes = (
def register():
for cls in classes:
bpy.utils.register_class(cls)
compat.safe_register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
compat.safe_unregister_class(cls)
@@ -1,5 +1,6 @@
import bpy
import re
from ..utils import compat
class RENAME_OT_summary_dialog(bpy.types.Operator):
"""Show rename operation summary"""
@@ -505,9 +506,9 @@ classes = (
def register():
for cls in classes:
bpy.utils.register_class(cls)
compat.safe_register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
compat.safe_unregister_class(cls)
@@ -1,5 +1,6 @@
import bpy
from bpy.types import Operator
from ..utils import compat
class CreateOrthoCamera(Operator):
"""Create an orthographic camera with predefined settings"""
@@ -38,10 +39,10 @@ class CreateOrthoCamera(Operator):
return {'FINISHED'}
def register():
bpy.utils.register_class(CreateOrthoCamera)
compat.safe_register_class(CreateOrthoCamera)
def unregister():
bpy.utils.unregister_class(CreateOrthoCamera)
compat.safe_unregister_class(CreateOrthoCamera)
if __name__ == "__main__":
register()
@@ -1,4 +1,5 @@
import bpy
from ..utils import compat
def safe_wgt_removal():
"""Safely remove only WGT widget objects that are clearly ghosts"""
@@ -680,11 +681,8 @@ classes = (
def register():
for cls in classes:
bpy.utils.register_class(cls)
compat.safe_register_class(cls)
def unregister():
for cls in reversed(classes):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
pass
compat.safe_unregister_class(cls)
@@ -1,4 +1,5 @@
import bpy
from ..utils import compat
class RemoveCustomSplitNormals(bpy.types.Operator):
"""Remove custom split normals and apply smooth shading to all accessible mesh objects"""
@@ -53,10 +54,10 @@ class RemoveCustomSplitNormals(bpy.types.Operator):
# Registration
def register():
bpy.utils.register_class(MESH_OT_RemoveCustomSplitNormals)
compat.safe_register_class(RemoveCustomSplitNormals)
def unregister():
bpy.utils.unregister_class(MESH_OT_RemoveCustomSplitNormals)
compat.safe_unregister_class(RemoveCustomSplitNormals)
# Only run if this script is run directly
if __name__ == "__main__":
@@ -6,6 +6,7 @@ import subprocess
# Import ghost buster functionality
from ..ops.ghost_buster import GhostBuster, GhostDetector, ResyncEnforce
from ..utils import compat
# Regular expression to match numbered suffixes like .001, .002, _001, _0001, etc.
NUMBERED_SUFFIX_PATTERN = re.compile(r'(.*?)[._](\d{3,})$')
@@ -91,6 +92,31 @@ def register_dataremap_properties():
default=False
)
# Search filter properties for each data type
bpy.types.Scene.dataremap_search_images = bpy.props.StringProperty( # type: ignore
name="Search Images",
description="Filter images by name (case-insensitive)",
default=""
)
bpy.types.Scene.dataremap_search_materials = bpy.props.StringProperty( # type: ignore
name="Search Materials",
description="Filter materials by name (case-insensitive)",
default=""
)
bpy.types.Scene.dataremap_search_fonts = bpy.props.StringProperty( # type: ignore
name="Search Fonts",
description="Filter fonts by name (case-insensitive)",
default=""
)
bpy.types.Scene.dataremap_search_worlds = bpy.props.StringProperty( # type: ignore
name="Search Worlds",
description="Filter worlds by name (case-insensitive)",
default=""
)
# Dictionary to store excluded groups
if not hasattr(bpy.types.Scene, "excluded_remap_groups"):
bpy.types.Scene.excluded_remap_groups = {}
@@ -859,6 +885,21 @@ def draw_drag_selectable_checkbox(layout, context, data_type, group_key):
op.group_key = group_key
op.data_type = data_type
def search_matches_group(group, search_string):
"""Check if search string matches group base name or any item in group"""
if not search_string:
return True
search_lower = search_string.lower()
base_name, items = group
# Check base name
if search_lower in base_name.lower():
return True
# Check all item names in group
for item in items:
if search_lower in item.name.lower():
return True
return False
# Update the UI code to use the custom draw function
def draw_data_duplicates(layout, context, data_type, data_groups):
"""Draw the list of duplicate data items with drag-selectable checkboxes and click to rename"""
@@ -881,6 +922,13 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
if hasattr(context.scene, sort_prop_name):
select_row.prop(context.scene, sort_prop_name, text="Sort by Selected")
# Add search filter
search_row = box_dup.row()
search_row.label(text="", icon='VIEWZOOM')
search_prop_name = f"dataremap_search_{data_type}"
if hasattr(context.scene, search_prop_name):
search_row.prop(context.scene, search_prop_name, text="")
box_dup.separator(factor=0.5)
# Initialize the expanded groups dictionary if it doesn't exist
@@ -890,6 +938,15 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
# Get the groups and possibly sort them
group_items = list(data_groups.items())
# Filter by search string if provided
search_prop_name = f"dataremap_search_{data_type}"
search_string = ""
if hasattr(context.scene, search_prop_name):
search_string = getattr(context.scene, search_prop_name)
if search_string:
group_items = [group for group in group_items if search_matches_group(group, search_string)]
# Sort by selection if enabled
sort_prop_name = f"dataremap_sort_{data_type}"
if hasattr(context.scene, sort_prop_name) and getattr(context.scene, sort_prop_name):
@@ -1443,14 +1500,11 @@ def register():
register_dataremap_properties()
for cls in classes:
bpy.utils.register_class(cls)
compat.safe_register_class(cls)
def unregister():
for cls in reversed(classes):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
pass
compat.safe_unregister_class(cls)
# Unregister properties
try:
unregister_dataremap_properties()
@@ -3,6 +3,7 @@ from bpy.types import Panel, Operator, PropertyGroup # type: ignore
from bpy.props import StringProperty, BoolProperty, EnumProperty, PointerProperty, CollectionProperty # type: ignore
import os
import re
from ..utils import compat
class REMOVE_EXT_OT_summary_dialog(bpy.types.Operator):
"""Show remove extensions operation summary"""
@@ -257,6 +258,13 @@ class BST_PathProperties(PropertyGroup):
default=True
) # type: ignore
# Search filter for images
search_filter: StringProperty(
name="Search Filter",
description="Filter images by name (case-insensitive)",
default=""
) # type: ignore
# Smart pathing properties
smart_base_path: StringProperty(
name="Base Path",
@@ -594,6 +602,61 @@ class BST_OT_select_active_images(Operator):
return {'FINISHED'}
# Operator to select all images with absolute paths
class BST_OT_select_absolute_images(Operator):
bl_idname = "bst.select_absolute_images"
bl_label = "Select Absolute Images"
bl_description = "Select all images with absolute file paths"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
selected_count = 0
# Iterate through all images
for img in bpy.data.images:
# Skip images that shouldn't be checked
if (img.source == 'GENERATED' or # Procedurally generated
img.source == 'VIEWER' or # Render Result, Viewer Node, etc.
img.name in ['Render Result', 'Viewer Node']): # Special Blender images
continue
# Check if image has a file path
if not img.filepath and not img.filepath_raw:
continue
# Check both filepath and filepath_raw for absolute paths
is_absolute = False
# Check filepath
if img.filepath:
# Skip Blender relative paths (starting with //)
if not img.filepath.startswith('//'):
# Convert to absolute path and check
abs_path = bpy.path.abspath(img.filepath)
if abs_path and os.path.isabs(abs_path):
is_absolute = True
# Check filepath_raw if filepath wasn't absolute
if not is_absolute and img.filepath_raw:
# Skip Blender relative paths (starting with //)
if not img.filepath_raw.startswith('//'):
# Convert to absolute path and check
abs_path = bpy.path.abspath(img.filepath_raw)
if abs_path and os.path.isabs(abs_path):
is_absolute = True
# Select image if it has an absolute path
if is_absolute:
img.bst_selected = True
selected_count += 1
if selected_count > 0:
self.report({'INFO'}, f"Selected {selected_count} images with absolute paths")
else:
self.report({'INFO'}, "No images with absolute paths found")
return {'FINISHED'}
# Add a class for renaming datablocks
class BST_OT_rename_datablock(Operator):
"""Click to rename datablock"""
@@ -1482,7 +1545,8 @@ class NODE_PT_bulk_path_tools(Panel):
# Get addon preferences
addon_name = __package__.split('.')[0]
prefs = context.preferences.addons.get(addon_name).preferences
addon_entry = context.preferences.addons.get(addon_name)
prefs = addon_entry.preferences if addon_entry else None
row = box.row(align=True)
row.enabled = any_selected
@@ -1495,7 +1559,8 @@ class NODE_PT_bulk_path_tools(Panel):
# Right side: checkbox
col = split.column()
col.prop(prefs, "automat_common_outside_blend", text="", icon='FOLDER_REDIRECT')
if prefs:
col.prop(prefs, "automat_common_outside_blend", text="", icon='FOLDER_REDIRECT')
# Bulk operations section
box = layout.box()
@@ -1521,10 +1586,16 @@ class NODE_PT_bulk_path_tools(Panel):
row = box.row(align=True)
row.operator("bst.select_material_images", text="Material Images")
row.operator("bst.select_active_images", text="Active Images")
row.operator("bst.select_absolute_images", text="Absolute Images", icon='FOLDER_REDIRECT')
# Sorting option
row = box.row()
row.prop(path_props, "sort_by_selected", text="Sort by Selected")
# Search filter
row = box.row()
row.label(text="", icon='VIEWZOOM')
row.prop(path_props, "search_filter", text="")
box.separator()
@@ -1539,6 +1610,12 @@ class NODE_PT_bulk_path_tools(Panel):
# Use original order
sorted_images = bpy.data.images
# Filter by search string if provided
search_filter = path_props.search_filter
if search_filter:
search_lower = search_filter.lower()
sorted_images = [img for img in sorted_images if search_lower in img.name.lower()]
for img in sorted_images:
# Add bst_selected attribute if it doesn't exist
if not hasattr(img, "bst_selected"):
@@ -1590,6 +1667,7 @@ classes = (
BST_OT_toggle_path_edit,
BST_OT_select_material_images,
BST_OT_select_active_images,
BST_OT_select_absolute_images,
BST_OT_rename_datablock,
BST_OT_toggle_image_selection,
BST_OT_reuse_material_path,
@@ -1609,7 +1687,7 @@ classes = (
def register():
for cls in classes:
bpy.utils.register_class(cls)
compat.safe_register_class(cls)
# Register properties
bpy.types.Scene.bst_path_props = PointerProperty(type=BST_PathProperties)
@@ -1633,7 +1711,7 @@ def unregister():
# Unregister classes
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
compat.safe_unregister_class(cls)
if __name__ == "__main__":
register()
@@ -7,6 +7,7 @@ from ..ops.delete_single_keyframe_actions import DeleteSingleKeyframeActions
from ..ops.find_material_users import FindMaterialUsers, MATERIAL_USERS_OT_summary_dialog
from ..ops.remove_unused_material_slots import RemoveUnusedMaterialSlots
from ..ops.convert_relations_to_constraint import ConvertRelationsToConstraint
from ..utils import compat
class BulkSceneGeneral(bpy.types.Panel):
"""Bulk Scene General Panel"""
@@ -76,7 +77,7 @@ classes = (
# Registration
def register():
for cls in classes:
bpy.utils.register_class(cls)
compat.safe_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",
@@ -92,10 +93,7 @@ def register():
def unregister():
for cls in reversed(classes):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
pass
compat.safe_unregister_class(cls)
# Unregister the window manager property
if hasattr(bpy.types.WindowManager, "bst_no_subdiv_only_selected"):
del bpy.types.WindowManager.bst_no_subdiv_only_selected
@@ -5,6 +5,7 @@ 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
from ..utils import compat
# Material processing status enum
class MaterialStatus(Enum):
@@ -1014,7 +1015,7 @@ classes = (
# Registration
def register():
for cls in classes:
bpy.utils.register_class(cls)
compat.safe_register_class(cls)
# Register properties
register_viewport_properties()
@@ -1027,7 +1028,4 @@ def unregister():
pass
# Unregister classes
for cls in reversed(classes):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
pass
compat.safe_unregister_class(cls)
@@ -7,7 +7,7 @@ A couple Blender tools to help me automate some tedious tasks in scene optimizat
- Bulk Data Remap
- Bulk Viewport Display
Officially supports Blender 4.4.1, but may still work on older versions.
Officially supports Blender 4.2, 4.4, 4.5, and 5.0.
## Installation
@@ -0,0 +1,10 @@
"""
This package contains utility modules for version detection and API compatibility.
"""
from . import version
from . import compat
__all__ = ['version', 'compat']
@@ -0,0 +1,46 @@
"""
This module provides API compatibility functions for handling differences
between Blender 4.2, 4.4, 4.5, and 5.0.
"""
import bpy
from bpy.utils import register_class, unregister_class
from . import version
def safe_register_class(cls):
"""
Safely register a class, handling any version-specific registration issues.
Args:
cls: The class to register
Returns:
bool: True if registration succeeded, False otherwise
"""
try:
register_class(cls)
return True
except Exception as e:
print(f"Warning: Failed to register {cls.__name__}: {e}")
return False
def safe_unregister_class(cls):
"""
Safely unregister a class, handling any version-specific unregistration issues.
Args:
cls: The class to unregister
Returns:
bool: True if unregistration succeeded, False otherwise
"""
try:
unregister_class(cls)
return True
except Exception as e:
print(f"Warning: Failed to unregister {cls.__name__}: {e}")
return False
@@ -0,0 +1,118 @@
"""
This module provides version detection and comparison utilities for
multi-version Blender support (4.2, 4.4, 4.5, and 5.0).
"""
import bpy
# Version constants
VERSION_4_2 = (4, 2, 0)
VERSION_4_4 = (4, 4, 0)
VERSION_4_5 = (4, 5, 0)
VERSION_5_0 = (5, 0, 0)
def get_blender_version():
"""
Returns the current Blender version as a tuple (major, minor, patch).
Returns:
tuple: (major, minor, patch) version numbers
"""
return bpy.app.version
def get_version_string():
"""
Returns the current Blender version as a string (e.g., "4.2.0").
Returns:
str: Version string in format "major.minor.patch"
"""
version = get_blender_version()
return f"{version[0]}.{version[1]}.{version[2]}"
def is_version_at_least(major, minor=0, patch=0):
"""
Check if the current Blender version is at least the specified version.
Args:
major (int): Major version number
minor (int): Minor version number (default: 0)
patch (int): Patch version number (default: 0)
Returns:
bool: True if current version >= specified version
"""
current = get_blender_version()
target = (major, minor, patch)
if current[0] != target[0]:
return current[0] > target[0]
if current[1] != target[1]:
return current[1] > target[1]
return current[2] >= target[2]
def is_version_less_than(major, minor=0, patch=0):
"""
Check if the current Blender version is less than the specified version.
Args:
major (int): Major version number
minor (int): Minor version number (default: 0)
patch (int): Patch version number (default: 0)
Returns:
bool: True if current version < specified version
"""
return not is_version_at_least(major, minor, patch)
def get_version_category():
"""
Returns the version category string for the current Blender version.
Returns:
str: '4.2', '4.4', '4.5', or '5.0' based on the current version
"""
version = get_blender_version()
major, minor = version[0], version[1]
if major == 4:
if minor < 4:
return '4.2'
elif minor < 5:
return '4.4'
else:
return '4.5'
elif major >= 5:
return '5.0'
else:
# Fallback for older versions
return f"{major}.{minor}"
def is_version_4_2():
"""Check if running Blender 4.2 (4.2.x only, not 4.3 or 4.4)."""
version = get_blender_version()
return version[0] == 4 and version[1] == 2
def is_version_4_4():
"""Check if running Blender 4.4 (4.4.x only, not 4.5)."""
version = get_blender_version()
return version[0] == 4 and version[1] == 4
def is_version_4_5():
"""Check if running Blender 4.5 LTS."""
return is_version_at_least(4, 5, 0) and is_version_less_than(5, 0, 0)
def is_version_5_0():
"""Check if running Blender 5.0 or later."""
return is_version_at_least(5, 0, 0)