disable (currently unused) addons, update
This commit is contained in:
@@ -54,13 +54,13 @@
|
||||
"id": "rainclouds_bulk_scene_tools",
|
||||
"name": "Raincloud's Bulk Scene Tools",
|
||||
"tagline": "Bulk utilities for optimizing scene data",
|
||||
"version": "0.17.0",
|
||||
"version": "0.18.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"blender_version_min": "4.5.0",
|
||||
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
|
||||
"permissions": {
|
||||
"files": "Read and write external resources referenced by scenes"
|
||||
@@ -70,9 +70,9 @@
|
||||
"Workflow",
|
||||
"Materials"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.17.0/Rainys_Bulk_Scene_Tools.v0.17.0.zip",
|
||||
"archive_size": 80981,
|
||||
"archive_hash": "sha256:419433069465b45ea903bd7bb46d89aa28b9c96c541d587d5f3be651a762811f"
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/0.18.0/Rainys_Bulk_Scene_Tools.v0.18.0.zip",
|
||||
"archive_size": 75599,
|
||||
"archive_hash": "sha256:6653a01b9ac5822e51113609b5a2970102715ec5f7816a9dccd80ecb4c755ea5"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
@@ -92,7 +92,7 @@
|
||||
"submission",
|
||||
"utility"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.8/SheepIt_Project_Submitter.v0.0.8.zip",
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/based-blendfile-packer/releases/download/v0.0.8/SheepIt_Project_Submitter.v0.0.8.zip",
|
||||
"archive_size": 47667,
|
||||
"archive_hash": "sha256:93cd8f18456079130c48c66cfd40235f7fe6414f929f59f90670e7a864821110"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ from .panels import bulk_scene_general
|
||||
from .ops.AutoMatExtractor import RBST_AutoMat_OT_AutoMatExtractor, RBST_AutoMat_OT_summary_dialog
|
||||
from .ops.Rename_images_by_mat import RBST_RenameImg_OT_Rename_images_by_mat, RBST_RenameImg_OT_summary_dialog
|
||||
from .ops.FreeGPU import RBST_FreeGPU
|
||||
from .ops import ghost_buster
|
||||
from . import rainys_repo_bootstrap
|
||||
from .utils import compat
|
||||
|
||||
@@ -77,7 +76,6 @@ def 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
|
||||
@@ -106,10 +104,6 @@ def unregister():
|
||||
delattr(bpy.types.Scene, '_bst_keymaps')
|
||||
|
||||
# Unregister modules
|
||||
try:
|
||||
ghost_buster.unregister()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bulk_path_management.unregister()
|
||||
except Exception:
|
||||
|
||||
@@ -3,12 +3,12 @@ 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.17.0"
|
||||
version = "0.18.0"
|
||||
type = "add-on"
|
||||
|
||||
maintainer = "RaincloudTheDragon <raincloudthedragon@gmail.com>"
|
||||
license = ["GPL-3.0-or-later"]
|
||||
blender_version_min = "4.2.0"
|
||||
blender_version_min = "4.5.0"
|
||||
|
||||
website = "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools"
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# v0.18.0 2026-07-14
|
||||
- Official support: Blender 4.5 and 5.2 LTS only; minimum version raised to 4.5 (#9)
|
||||
- Bulk Data Remap: native node group rename/merge for numbered duplicates (#16)
|
||||
- Ghost Buster removed; use Atomic instead (#17)
|
||||
- Remapper UI: larger Purge Unused button, fixed post-Ghost-Buster spacing
|
||||
|
||||
# v0.17.0 2026-03-13
|
||||
- Remove Action FU: new operator in Animation Data section—clears fake users from all actions so only used animations are kept
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from ..utils import version
|
||||
|
||||
|
||||
def _collect_keyframe_stats(action):
|
||||
"""Return (total_keyframes, keyframe_frames_set). Compatible with 4.2/4.5 (action.fcurves) and 5.0 (layers/strips/channelbags)."""
|
||||
"""Return (total_keyframes, keyframe_frames_set). Compatible with 4.5 (action.fcurves) and 5.0+ (layers/strips/channelbags)."""
|
||||
keyframe_frames = set()
|
||||
total_keyframes = 0
|
||||
if version.is_version_less_than(5, 0, 0):
|
||||
|
||||
@@ -1,688 +0,0 @@
|
||||
import bpy
|
||||
from ..utils import compat
|
||||
|
||||
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_used_by_scene_instance_collections(obj):
|
||||
"""Check if object is in a collection that's being instanced by objects in scenes"""
|
||||
|
||||
# Find all collections that contain this object
|
||||
obj_collections = []
|
||||
for collection in bpy.data.collections:
|
||||
if obj in collection.objects.values():
|
||||
obj_collections.append(collection)
|
||||
|
||||
if not obj_collections:
|
||||
return False
|
||||
|
||||
# Check if any of these collections are being instanced by objects in scenes
|
||||
for collection in obj_collections:
|
||||
# Find objects that instance this collection
|
||||
for other_obj in bpy.data.objects:
|
||||
if (other_obj.instance_type == 'COLLECTION' and
|
||||
other_obj.instance_collection == collection):
|
||||
|
||||
# Check if the instancing object is in any scene
|
||||
for scene in bpy.data.scenes:
|
||||
if other_obj in scene.objects.values():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_object_legitimate_outside_scene(obj):
|
||||
"""Check if an object has legitimate reasons to exist outside scenes"""
|
||||
|
||||
# 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 that are being used by instance collections in scenes are legitimate
|
||||
if is_object_used_by_scene_instance_collections(obj):
|
||||
return True
|
||||
|
||||
# Objects used as curve modifiers, constraints targets, etc.
|
||||
# Check if object is used by modifiers on other objects that are in scenes
|
||||
for other_obj in bpy.data.objects:
|
||||
# Check if the other object is in any scene
|
||||
in_scene = False
|
||||
for scene in bpy.data.scenes:
|
||||
if other_obj in scene.objects.values():
|
||||
in_scene = True
|
||||
break
|
||||
|
||||
if in_scene:
|
||||
for modifier in other_obj.modifiers:
|
||||
if 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 that are in scenes
|
||||
for other_obj in bpy.data.objects:
|
||||
# Check if the other object is in any scene
|
||||
in_scene = False
|
||||
for scene in bpy.data.scenes:
|
||||
if other_obj in scene.objects.values():
|
||||
in_scene = True
|
||||
break
|
||||
|
||||
if in_scene:
|
||||
for constraint in other_obj.constraints:
|
||||
if hasattr(constraint, 'target') and constraint.target == obj:
|
||||
return True
|
||||
if hasattr(constraint, 'subtarget') and constraint.subtarget == obj.name:
|
||||
return True
|
||||
|
||||
# Check if object is used in particle systems on objects that are in scenes
|
||||
for other_obj in bpy.data.objects:
|
||||
# Check if the other object is in any scene
|
||||
in_scene = False
|
||||
for scene in bpy.data.scenes:
|
||||
if other_obj in scene.objects.values():
|
||||
in_scene = True
|
||||
break
|
||||
|
||||
if in_scene:
|
||||
for modifier in other_obj.modifiers:
|
||||
if modifier.type == 'PARTICLE_SYSTEM':
|
||||
settings = modifier.particle_system.settings
|
||||
if hasattr(settings, 'object') and settings.object == obj:
|
||||
return True
|
||||
if hasattr(settings, 'instance_object') and settings.instance_object == obj:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clean_object_ghosts(delete_low_priority=False):
|
||||
"""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
|
||||
|
||||
# If not legitimate, it's a ghost - but be conservative with low user count objects
|
||||
should_remove = False
|
||||
removal_reason = ""
|
||||
|
||||
if obj.users >= 2:
|
||||
# Higher user count ghosts are definitely safe to remove
|
||||
should_remove = True
|
||||
removal_reason = "ghost (users >= 2, no legitimate use found)"
|
||||
elif obj.users < 2 and delete_low_priority:
|
||||
# Low user count ghosts only if user enables the option
|
||||
should_remove = True
|
||||
removal_reason = "low priority ghost (users < 2, no legitimate use found)"
|
||||
elif obj.users < 2:
|
||||
print(f" Skipping low priority object: {obj.name} (users < 2, enable 'Delete Low Priority' to remove)")
|
||||
|
||||
if should_remove:
|
||||
ghosts_to_remove.append(obj)
|
||||
print(f" Marking ghost for removal: {obj.name} (type: {obj.type}) - {removal_reason}")
|
||||
|
||||
# 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" -> GHOST: No legitimate use found, users >= 2 (will be removed)")
|
||||
elif obj.users < 2:
|
||||
print(f" -> LOW PRIORITY: No legitimate use found, users < 2 (needs option enabled)")
|
||||
else:
|
||||
print(f" -> UNCLEAR: Manual review needed")
|
||||
|
||||
def main(delete_low_priority=False):
|
||||
"""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 with no legitimate use")
|
||||
if delete_low_priority:
|
||||
print(" - Including low priority ghosts (no legitimate use, users < 2)")
|
||||
else:
|
||||
print(" - Excluding low priority ghosts (no legitimate use, users < 2)")
|
||||
print("="*80)
|
||||
|
||||
initial_objects = len(list(bpy.data.objects))
|
||||
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(delete_low_priority)
|
||||
|
||||
# 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 RBST_Bustin_OT_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:
|
||||
# Get the delete low priority setting from scene properties
|
||||
delete_low_priority = getattr(context.scene, "ghost_buster_delete_low_priority", False)
|
||||
|
||||
# Call the main ghost buster function
|
||||
main(delete_low_priority)
|
||||
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 RBST_Bustin_OT_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:
|
||||
potential_ghosts += 1
|
||||
status = "GHOST (no legitimate use found, users >= 2)"
|
||||
elif obj.users < 2:
|
||||
low_priority += 1
|
||||
status = "LOW PRIORITY (no legitimate use found, users < 2)"
|
||||
else:
|
||||
status = "UNCLEAR"
|
||||
|
||||
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"Ghosts (users >= 2): {self.ghost_potential}", icon='ERROR')
|
||||
if self.ghost_legitimate > 0:
|
||||
col.label(text=f"Legitimate objects: {self.ghost_legitimate}", icon='CHECKMARK')
|
||||
if self.ghost_low_priority > 0:
|
||||
col.label(text=f"Low priority (users < 2): {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} ghost data issues that will be removed", icon='ERROR')
|
||||
if self.ghost_low_priority > 0:
|
||||
summary_box.label(text=f"+ {self.ghost_low_priority} low priority issues (optional)", icon='QUESTION')
|
||||
summary_box.label(text="Use Ghost Buster to clean up safely")
|
||||
else:
|
||||
summary_box.label(text="No ghost data issues detected!", icon='CHECKMARK')
|
||||
if self.ghost_low_priority > 0:
|
||||
summary_box.label(text=f"({self.ghost_low_priority} low priority issues available)", icon='INFO')
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
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 RBST_Bustin_OT_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 = (
|
||||
RBST_Bustin_OT_GhostBuster,
|
||||
RBST_Bustin_OT_GhostDetector,
|
||||
RBST_Bustin_OT_ResyncEnforce,
|
||||
)
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
compat.safe_register_class(cls)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
compat.safe_unregister_class(cls)
|
||||
+183
-70
@@ -4,8 +4,6 @@ import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
# Import ghost buster functionality
|
||||
from ..ops.ghost_buster import RBST_Bustin_OT_GhostBuster, RBST_Bustin_OT_GhostDetector, RBST_Bustin_OT_ResyncEnforce
|
||||
from ..utils import compat
|
||||
|
||||
# Regular expression to match numbered suffixes like .001, .002, _001, _0001, etc.
|
||||
@@ -45,6 +43,12 @@ def RBST_DatRem_register_properties():
|
||||
default=True
|
||||
)
|
||||
|
||||
bpy.types.Scene.dataremap_node_groups = bpy.props.BoolProperty( # type: ignore
|
||||
name="Node Groups",
|
||||
description="Find and remap duplicate node groups",
|
||||
default=True
|
||||
)
|
||||
|
||||
# Add properties for showing duplicate lists
|
||||
bpy.types.Scene.show_image_duplicates = bpy.props.BoolProperty( # type: ignore
|
||||
name="Show Image Duplicates",
|
||||
@@ -70,6 +74,12 @@ def RBST_DatRem_register_properties():
|
||||
default=False
|
||||
)
|
||||
|
||||
bpy.types.Scene.show_node_group_duplicates = bpy.props.BoolProperty( # type: ignore
|
||||
name="Show Node Group Duplicates",
|
||||
description="Show list of duplicate node groups",
|
||||
default=False
|
||||
)
|
||||
|
||||
# Sort by selected properties for each data type
|
||||
bpy.types.Scene.dataremap_sort_images = bpy.props.BoolProperty( # type: ignore
|
||||
name="Sort Images by Selected",
|
||||
@@ -91,6 +101,11 @@ def RBST_DatRem_register_properties():
|
||||
description="Show selected world groups at the top of the list",
|
||||
default=False
|
||||
)
|
||||
bpy.types.Scene.dataremap_sort_node_groups = bpy.props.BoolProperty( # type: ignore
|
||||
name="Sort Node Groups by Selected",
|
||||
description="Show selected node group groups at the top of the list",
|
||||
default=False
|
||||
)
|
||||
|
||||
# Search filter properties for each data type
|
||||
bpy.types.Scene.dataremap_search_images = bpy.props.StringProperty( # type: ignore
|
||||
@@ -117,6 +132,12 @@ def RBST_DatRem_register_properties():
|
||||
default=""
|
||||
)
|
||||
|
||||
bpy.types.Scene.dataremap_search_node_groups = bpy.props.StringProperty( # type: ignore
|
||||
name="Search Node Groups",
|
||||
description="Filter node groups 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 = {}
|
||||
@@ -128,23 +149,18 @@ def RBST_DatRem_register_properties():
|
||||
# Store the last clicked group for shift-click range selection
|
||||
if not hasattr(bpy.types.Scene, "last_clicked_group"):
|
||||
bpy.types.Scene.last_clicked_group = {}
|
||||
|
||||
# Ghost Buster properties
|
||||
bpy.types.Scene.ghost_buster_delete_low_priority = bpy.props.BoolProperty( # type: ignore
|
||||
name="Delete Low Priority Ghosts",
|
||||
description="Delete objects not in scenes with no legitimate use and users < 2",
|
||||
default=False
|
||||
)
|
||||
|
||||
def RBST_DatRem_unregister_properties():
|
||||
del bpy.types.Scene.dataremap_images
|
||||
del bpy.types.Scene.dataremap_materials
|
||||
del bpy.types.Scene.dataremap_fonts
|
||||
del bpy.types.Scene.dataremap_worlds
|
||||
del bpy.types.Scene.dataremap_node_groups
|
||||
del bpy.types.Scene.show_image_duplicates
|
||||
del bpy.types.Scene.show_material_duplicates
|
||||
del bpy.types.Scene.show_font_duplicates
|
||||
del bpy.types.Scene.show_world_duplicates
|
||||
del bpy.types.Scene.show_node_group_duplicates
|
||||
if hasattr(bpy.types.Scene, "excluded_remap_groups"):
|
||||
del bpy.types.Scene.excluded_remap_groups
|
||||
if hasattr(bpy.types.Scene, "expanded_remap_groups"):
|
||||
@@ -157,10 +173,7 @@ def RBST_DatRem_unregister_properties():
|
||||
del bpy.types.Scene.dataremap_sort_materials
|
||||
del bpy.types.Scene.dataremap_sort_fonts
|
||||
del bpy.types.Scene.dataremap_sort_worlds
|
||||
|
||||
# Delete ghost buster properties
|
||||
if hasattr(bpy.types.Scene, "ghost_buster_delete_low_priority"):
|
||||
del bpy.types.Scene.ghost_buster_delete_low_priority
|
||||
del bpy.types.Scene.dataremap_sort_node_groups
|
||||
|
||||
def RBST_DatRem_get_base_name(name):
|
||||
"""Extract the base name without numbered suffix"""
|
||||
@@ -194,6 +207,56 @@ def RBST_DatRem_find_data_groups(data_collection):
|
||||
return {name: items for name, items in groups.items()
|
||||
if len(items) > 1 and any(not (hasattr(item, 'library') and item.library is not None) for item in items)}
|
||||
|
||||
def RBST_DatRem_get_node_group_tree_label(node_group):
|
||||
"""Return a short label for a node group's tree type."""
|
||||
tree_id = getattr(node_group, 'bl_idname', '') or ''
|
||||
if tree_id.endswith('NodeTree'):
|
||||
return tree_id[:-8]
|
||||
return tree_id or 'Unknown'
|
||||
|
||||
def RBST_DatRem_format_node_group_group_key(group_key):
|
||||
"""Format a node group group key for display."""
|
||||
if ':' not in group_key:
|
||||
return group_key
|
||||
tree_id, base_name = group_key.split(':', 1)
|
||||
tree_label = tree_id.replace('NodeTree', '') if tree_id.endswith('NodeTree') else tree_id
|
||||
return f"{base_name} ({tree_label})"
|
||||
|
||||
def RBST_DatRem_find_node_group_data_groups():
|
||||
"""Group node groups by tree type and base name."""
|
||||
groups = {}
|
||||
|
||||
for node_group in bpy.data.node_groups:
|
||||
if node_group.users == 0:
|
||||
continue
|
||||
if hasattr(node_group, 'library') and node_group.library is not None:
|
||||
continue
|
||||
|
||||
base_name = RBST_DatRem_get_base_name(node_group.name)
|
||||
tree_id = getattr(node_group, 'bl_idname', 'UnknownNodeTree')
|
||||
group_key = f"{tree_id}:{base_name}"
|
||||
|
||||
if group_key not in groups:
|
||||
groups[group_key] = []
|
||||
groups[group_key].append(node_group)
|
||||
|
||||
return {name: items for name, items in groups.items()
|
||||
if len(items) > 1 and any(not (hasattr(item, 'library') and item.library is not None) for item in items)}
|
||||
|
||||
def RBST_DatRem_get_duplicate_groups(data_type):
|
||||
"""Return duplicate groups for a remapper data type."""
|
||||
if data_type == "images":
|
||||
return RBST_DatRem_find_data_groups(bpy.data.images)
|
||||
if data_type == "materials":
|
||||
return RBST_DatRem_find_data_groups(bpy.data.materials)
|
||||
if data_type == "fonts":
|
||||
return RBST_DatRem_find_data_groups(bpy.data.fonts)
|
||||
if data_type == "worlds":
|
||||
return RBST_DatRem_find_data_groups(bpy.data.worlds)
|
||||
if data_type == "node_groups":
|
||||
return RBST_DatRem_find_node_group_data_groups()
|
||||
return {}
|
||||
|
||||
def RBST_DatRem_find_target_data(data_group):
|
||||
"""Find the target data block to remap to"""
|
||||
# Filter out linked datablocks
|
||||
@@ -242,7 +305,7 @@ def RBST_DatRem_clean_data_names(data_collection):
|
||||
|
||||
return cleaned_count
|
||||
|
||||
def RBST_DatRem_remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap_worlds):
|
||||
def RBST_DatRem_remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap_worlds, remap_node_groups):
|
||||
"""Remap redundant data blocks to their base versions like Blender's Remap Users function, and clean up names."""
|
||||
remapped_count = 0
|
||||
cleaned_count = 0
|
||||
@@ -587,6 +650,35 @@ def RBST_DatRem_remap_data_blocks(context, remap_images, remap_materials, remap_
|
||||
# Then clean up any remaining numbered suffixes
|
||||
cleaned_count += RBST_DatRem_clean_data_names(bpy.data.worlds)
|
||||
|
||||
# Process node groups
|
||||
if remap_node_groups:
|
||||
node_group_groups = RBST_DatRem_find_node_group_data_groups()
|
||||
for group_key, node_groups in node_group_groups.items():
|
||||
if f"node_groups:{group_key}" in context.scene.excluded_remap_groups:
|
||||
continue
|
||||
|
||||
target_group = RBST_DatRem_find_target_data(node_groups)
|
||||
|
||||
if RBST_DatRem_get_base_name(target_group.name) != target_group.name:
|
||||
try:
|
||||
target_group.name = RBST_DatRem_get_base_name(target_group.name)
|
||||
except AttributeError:
|
||||
print(f"Warning: Cannot rename linked node group {target_group.name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
for node_group in node_groups:
|
||||
if node_group != target_group:
|
||||
try:
|
||||
node_group.user_remap(target_group)
|
||||
remapped_count += 1
|
||||
except AttributeError:
|
||||
print(f"Warning: Cannot remap linked node group {node_group.name}")
|
||||
except Exception as e:
|
||||
print(f"Error using built-in remap for node groups: {e}")
|
||||
|
||||
cleaned_count += RBST_DatRem_clean_data_names(bpy.data.node_groups)
|
||||
|
||||
# Force an update of the dependency graph to ensure all users are properly updated
|
||||
if context.view_layer:
|
||||
context.view_layer.update()
|
||||
@@ -605,14 +697,20 @@ class RBST_DatRem_OT_RemapData(bpy.types.Operator):
|
||||
remap_materials = context.scene.dataremap_materials
|
||||
remap_fonts = context.scene.dataremap_fonts
|
||||
remap_worlds = context.scene.dataremap_worlds
|
||||
remap_node_groups = context.scene.dataremap_node_groups
|
||||
|
||||
# Count duplicates before remapping (only for local datablocks)
|
||||
image_groups = RBST_DatRem_find_data_groups(bpy.data.images) if remap_images else {}
|
||||
material_groups = RBST_DatRem_find_data_groups(bpy.data.materials) if remap_materials else {}
|
||||
font_groups = RBST_DatRem_find_data_groups(bpy.data.fonts) if remap_fonts else {}
|
||||
world_groups = RBST_DatRem_find_data_groups(bpy.data.worlds) if remap_worlds else {}
|
||||
node_group_groups = RBST_DatRem_find_node_group_data_groups() if remap_node_groups else {}
|
||||
|
||||
total_duplicates = sum(len(group) - 1 for groups in [image_groups, material_groups, font_groups, world_groups] for group in groups.values())
|
||||
total_duplicates = sum(
|
||||
len(group) - 1
|
||||
for groups in [image_groups, material_groups, font_groups, world_groups, node_group_groups]
|
||||
for group in groups.values()
|
||||
)
|
||||
|
||||
# Count data blocks with numbered suffixes (only for local datablocks)
|
||||
total_numbered = 0
|
||||
@@ -636,6 +734,11 @@ class RBST_DatRem_OT_RemapData(bpy.types.Operator):
|
||||
if world.users > 0
|
||||
and not (hasattr(world, 'library') and world.library is not None)
|
||||
and RBST_DatRem_get_base_name(world.name) != world.name)
|
||||
if remap_node_groups:
|
||||
total_numbered += sum(1 for node_group in bpy.data.node_groups
|
||||
if node_group.users > 0
|
||||
and not (hasattr(node_group, 'library') and node_group.library is not None)
|
||||
and RBST_DatRem_get_base_name(node_group.name) != node_group.name)
|
||||
|
||||
if total_duplicates == 0 and total_numbered == 0:
|
||||
self.report({'INFO'}, "No local data blocks to process")
|
||||
@@ -647,7 +750,8 @@ class RBST_DatRem_OT_RemapData(bpy.types.Operator):
|
||||
remap_images,
|
||||
remap_materials,
|
||||
remap_fonts,
|
||||
remap_worlds
|
||||
remap_worlds,
|
||||
remap_node_groups,
|
||||
)
|
||||
|
||||
# Report results
|
||||
@@ -808,15 +912,7 @@ class RBST_DatRem_OT_SelectAllGroups(bpy.types.Operator):
|
||||
context.scene.excluded_remap_groups = {}
|
||||
|
||||
# Get the appropriate data groups based on data_type
|
||||
data_groups = {}
|
||||
if self.data_type == "images":
|
||||
data_groups = RBST_DatRem_find_data_groups(bpy.data.images)
|
||||
elif self.data_type == "materials":
|
||||
data_groups = RBST_DatRem_find_data_groups(bpy.data.materials)
|
||||
elif self.data_type == "fonts":
|
||||
data_groups = RBST_DatRem_find_data_groups(bpy.data.fonts)
|
||||
elif self.data_type == "worlds":
|
||||
data_groups = RBST_DatRem_find_data_groups(bpy.data.worlds)
|
||||
data_groups = RBST_DatRem_get_duplicate_groups(self.data_type)
|
||||
|
||||
# Process only groups with more than one item
|
||||
for base_name, items in data_groups.items():
|
||||
@@ -873,15 +969,7 @@ class RBST_DatRem_OT_ToggleGroupSelection(bpy.types.Operator):
|
||||
last_group = context.scene.last_clicked_group[self.data_type]
|
||||
|
||||
# Get all data groups for this data type
|
||||
data_groups = []
|
||||
if self.data_type == "images":
|
||||
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.images).keys())
|
||||
elif self.data_type == "materials":
|
||||
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.materials).keys())
|
||||
elif self.data_type == "fonts":
|
||||
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.fonts).keys())
|
||||
elif self.data_type == "worlds":
|
||||
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.worlds).keys())
|
||||
data_groups = list(RBST_DatRem_get_duplicate_groups(self.data_type).keys())
|
||||
|
||||
# Find the indices of the last clicked group and the current group
|
||||
try:
|
||||
@@ -966,6 +1054,10 @@ def RBST_DatRem_search_matches_group(group, search_string):
|
||||
# Check base name
|
||||
if search_lower in base_name.lower():
|
||||
return True
|
||||
if ':' in base_name:
|
||||
formatted_name = RBST_DatRem_format_node_group_group_key(base_name)
|
||||
if search_lower in formatted_name.lower():
|
||||
return True
|
||||
# Check all item names in group
|
||||
for item in items:
|
||||
if search_lower in item.name.lower():
|
||||
@@ -1075,9 +1167,13 @@ def RBST_DatRem_draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
row.template_icon(icon_value=target_item.preview.icon_id, scale=1.0)
|
||||
else:
|
||||
row.label(text="", icon='WORLD')
|
||||
elif data_type == "node_groups":
|
||||
row.label(text="", icon='NODETREE')
|
||||
|
||||
display_name = RBST_DatRem_format_node_group_group_key(base_name) if data_type == "node_groups" else base_name
|
||||
|
||||
# Add rename operator for the group name
|
||||
rename_op = row.operator("bst.rename_datablock_remap", text=f"{base_name}: {len(items)} versions", emboss=False)
|
||||
rename_op = row.operator("bst.rename_datablock_remap", text=f"{display_name}: {len(items)} versions", emboss=False)
|
||||
rename_op.data_type = data_type
|
||||
rename_op.old_name = target_item.name
|
||||
|
||||
@@ -1112,9 +1208,15 @@ def RBST_DatRem_draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
sub_row.template_icon(icon_value=item.preview.icon_id, scale=1.0)
|
||||
else:
|
||||
sub_row.label(text="", icon='WORLD')
|
||||
elif data_type == "node_groups":
|
||||
sub_row.label(text="", icon='NODETREE')
|
||||
|
||||
item_label = item.name
|
||||
if data_type == "node_groups":
|
||||
item_label = f"{item.name} ({RBST_DatRem_get_node_group_tree_label(item)})"
|
||||
|
||||
# Add rename operator for each item
|
||||
rename_op = sub_row.operator("bst.rename_datablock_remap", text=f"{item.name}", emboss=False)
|
||||
rename_op = sub_row.operator("bst.rename_datablock_remap", text=item_label, emboss=False)
|
||||
rename_op.data_type = data_type
|
||||
rename_op.old_name = item.name
|
||||
|
||||
@@ -1160,9 +1262,14 @@ class RBST_DatRem_PT_BulkDataRemap(bpy.types.Panel):
|
||||
linked_types.append("worlds")
|
||||
linked_paths.update(RBST_DatRem_get_linked_file_paths(bpy.data.worlds))
|
||||
|
||||
# Display warning about linked datablocks in a separate section if found
|
||||
if context.scene.dataremap_node_groups and RBST_DatRem_has_linked_datablocks(bpy.data.node_groups):
|
||||
linked_datablocks_found = True
|
||||
linked_types.append("node groups")
|
||||
linked_paths.update(RBST_DatRem_get_linked_file_paths(bpy.data.node_groups))
|
||||
|
||||
# Display warning about linked datablocks inside the remapper box
|
||||
if linked_datablocks_found:
|
||||
warning_box = layout.box()
|
||||
warning_box = box.box()
|
||||
warning_box.alert = True
|
||||
warning_box.label(text="Warning: Linked datablocks detected", icon='ERROR')
|
||||
warning_box.label(text=f"Types: {', '.join(linked_types)}")
|
||||
@@ -1192,16 +1299,19 @@ class RBST_DatRem_PT_BulkDataRemap(bpy.types.Panel):
|
||||
material_groups = RBST_DatRem_find_data_groups(bpy.data.materials)
|
||||
font_groups = RBST_DatRem_find_data_groups(bpy.data.fonts)
|
||||
world_groups = RBST_DatRem_find_data_groups(bpy.data.worlds)
|
||||
node_group_groups = RBST_DatRem_find_node_group_data_groups()
|
||||
|
||||
image_duplicates = sum(len(group) - 1 for group in image_groups.values())
|
||||
material_duplicates = sum(len(group) - 1 for group in material_groups.values())
|
||||
font_duplicates = sum(len(group) - 1 for group in font_groups.values())
|
||||
world_duplicates = sum(len(group) - 1 for group in world_groups.values())
|
||||
node_group_duplicates = sum(len(group) - 1 for group in node_group_groups.values())
|
||||
|
||||
image_numbered = sum(1 for img in bpy.data.images if img.users > 0 and RBST_DatRem_get_base_name(img.name) != img.name)
|
||||
material_numbered = sum(1 for mat in bpy.data.materials if mat.users > 0 and RBST_DatRem_get_base_name(mat.name) != mat.name)
|
||||
font_numbered = sum(1 for font in bpy.data.fonts if font.users > 0 and RBST_DatRem_get_base_name(font.name) != font.name)
|
||||
world_numbered = sum(1 for world in bpy.data.worlds if world.users > 0 and RBST_DatRem_get_base_name(world.name) != world.name)
|
||||
node_group_numbered = sum(1 for node_group in bpy.data.node_groups if node_group.users > 0 and RBST_DatRem_get_base_name(node_group.name) != node_group.name)
|
||||
|
||||
# Initialize excluded_remap_groups if it doesn't exist
|
||||
if not hasattr(context.scene, "excluded_remap_groups"):
|
||||
@@ -1332,6 +1442,33 @@ class RBST_DatRem_PT_BulkDataRemap(bpy.types.Panel):
|
||||
if context.scene.show_world_duplicates and world_duplicates > 0 and context.scene.dataremap_worlds:
|
||||
RBST_DatRem_draw_data_duplicates(col, context, "worlds", world_groups)
|
||||
|
||||
# Node Groups
|
||||
row = col.row()
|
||||
split = row.split(factor=0.6)
|
||||
sub_row = split.row()
|
||||
|
||||
op = sub_row.operator("bst.toggle_data_type", text="", icon='NODETREE', depress=context.scene.dataremap_node_groups)
|
||||
op.data_type = "node_groups"
|
||||
|
||||
if context.scene.dataremap_node_groups:
|
||||
sub_row.label(text="Node Groups")
|
||||
else:
|
||||
sub_row.label(text="Node Groups", icon='RADIOBUT_OFF')
|
||||
|
||||
sub_row = split.row()
|
||||
if node_group_duplicates > 0:
|
||||
sub_row.prop(context.scene, "show_node_group_duplicates",
|
||||
text=f"{node_group_duplicates} duplicates",
|
||||
icon='DISCLOSURE_TRI_DOWN' if context.scene.show_node_group_duplicates else 'DISCLOSURE_TRI_RIGHT',
|
||||
emboss=False)
|
||||
elif node_group_numbered > 0:
|
||||
sub_row.label(text=f"{node_group_numbered} numbered")
|
||||
else:
|
||||
sub_row.label(text="0 duplicates")
|
||||
|
||||
if context.scene.show_node_group_duplicates and node_group_duplicates > 0 and context.scene.dataremap_node_groups:
|
||||
RBST_DatRem_draw_data_duplicates(col, context, "node_groups", node_group_groups)
|
||||
|
||||
# Add the operator button
|
||||
row = box.row()
|
||||
row.scale_y = 1.5
|
||||
@@ -1355,8 +1492,8 @@ class RBST_DatRem_PT_BulkDataRemap(bpy.types.Panel):
|
||||
dbu_box.label(text="Data-block utilities addon not installed", icon='ERROR')
|
||||
|
||||
# Show total counts
|
||||
total_duplicates = image_duplicates + material_duplicates + font_duplicates + world_duplicates
|
||||
total_numbered = image_numbered + material_numbered + font_numbered + world_numbered
|
||||
total_duplicates = image_duplicates + material_duplicates + font_duplicates + world_duplicates + node_group_duplicates
|
||||
total_numbered = image_numbered + material_numbered + font_numbered + world_numbered + node_group_numbered
|
||||
|
||||
if total_duplicates > 0 or total_numbered > 0:
|
||||
box.separator()
|
||||
@@ -1367,37 +1504,9 @@ class RBST_DatRem_PT_BulkDataRemap(bpy.types.Panel):
|
||||
else:
|
||||
box.label(text="No data blocks to process")
|
||||
|
||||
# Add a separator and purge button
|
||||
box.separator()
|
||||
row = box.row()
|
||||
row.operator("bst.purge_unused_data", icon='TRASH')
|
||||
|
||||
# Ghost Buster section
|
||||
layout.separator()
|
||||
ghost_box = layout.box()
|
||||
ghost_box.label(text="Ghost Buster - Experimental")
|
||||
|
||||
col = ghost_box.column()
|
||||
col.label(text="Ghost data cleanup & library override fixes:")
|
||||
col.label(text="• Unused local WGT widget objects")
|
||||
col.label(text="• Empty unlinked collections")
|
||||
col.label(text="• Objects not in scenes with no legitimate use")
|
||||
col.label(text="• Fix broken library override hierarchies")
|
||||
|
||||
# Two button layout
|
||||
row = ghost_box.row(align=True)
|
||||
row = layout.row()
|
||||
row.scale_y = 1.5
|
||||
row.operator("bst.ghost_detector", text="Ghost Detector", icon='ZOOM_IN')
|
||||
row.operator("bst.ghost_buster", text="Ghost Buster", icon='GHOST_ENABLED')
|
||||
|
||||
# Ghost Buster option
|
||||
ghost_box.prop(context.scene, "ghost_buster_delete_low_priority", text="Delete Low Priority Ghosts")
|
||||
|
||||
# Resync Enforce button
|
||||
ghost_box.separator()
|
||||
row = ghost_box.row()
|
||||
row.scale_y = 1.2
|
||||
row.operator("bst.resync_enforce", text="Resync Enforce", icon='FILE_REFRESH')
|
||||
row.operator("bst.purge_unused_data", icon='TRASH')
|
||||
|
||||
# Add a new operator for toggling data types
|
||||
class RBST_DatRem_OT_ToggleDataType(bpy.types.Operator):
|
||||
@@ -1421,6 +1530,8 @@ class RBST_DatRem_OT_ToggleDataType(bpy.types.Operator):
|
||||
context.scene.dataremap_fonts = not context.scene.dataremap_fonts
|
||||
elif self.data_type == "worlds":
|
||||
context.scene.dataremap_worlds = not context.scene.dataremap_worlds
|
||||
elif self.data_type == "node_groups":
|
||||
context.scene.dataremap_node_groups = not context.scene.dataremap_node_groups
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -1543,6 +1654,8 @@ class RBST_DatRem_OT_RenameDatablock(bpy.types.Operator):
|
||||
data_collection = bpy.data.fonts
|
||||
elif self.data_type == "worlds":
|
||||
data_collection = bpy.data.worlds
|
||||
elif self.data_type == "node_groups":
|
||||
data_collection = bpy.data.node_groups
|
||||
|
||||
if not data_collection:
|
||||
self.report({'ERROR'}, "Invalid data type")
|
||||
|
||||
+1
-1
@@ -395,7 +395,7 @@ class RBST_ViewDisp_OT_RefreshMaterialPreviews(bpy.types.Operator):
|
||||
if hasattr(material, 'preview_ensure'):
|
||||
try:
|
||||
preview = material.preview_ensure()
|
||||
# In Blender 4.2/4.5, accessing icon_id is safe and helps ensure generation
|
||||
# In Blender 4.5, accessing icon_id is safe and helps ensure generation
|
||||
# In Blender 5.0+, we skip this to avoid crashes
|
||||
if not version.is_version_5_0() and preview and hasattr(preview, 'icon_id'):
|
||||
try:
|
||||
|
||||
@@ -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.2, 4.4, 4.5, and 5.0.
|
||||
Officially supports Blender 4.5 and 5.2.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
This module provides API compatibility functions for handling differences
|
||||
between Blender 4.2, 4.4, 4.5, and 5.0.
|
||||
between Blender 4.5 and 5.2.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"""
|
||||
This module provides version detection and comparison utilities for
|
||||
multi-version Blender support (4.2, 4.4, 4.5, and 5.0).
|
||||
|
||||
multi-version Blender support (4.5 and 5.2).
|
||||
"""
|
||||
|
||||
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)
|
||||
VERSION_5_2 = (5, 2, 0)
|
||||
|
||||
|
||||
def get_blender_version():
|
||||
@@ -25,7 +23,7 @@ def get_blender_version():
|
||||
|
||||
def get_version_string():
|
||||
"""
|
||||
Returns the current Blender version as a string (e.g., "4.2.0").
|
||||
Returns the current Blender version as a string (e.g., "4.5.0").
|
||||
|
||||
Returns:
|
||||
str: Version string in format "major.minor.patch"
|
||||
@@ -76,35 +74,16 @@ 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
|
||||
str: '4.5' or '5.2' for officially supported versions; otherwise 'major.minor'
|
||||
"""
|
||||
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
|
||||
if major == 4 and minor >= 5:
|
||||
return '4.5'
|
||||
if major >= 5 and is_version_at_least(5, 2, 0):
|
||||
return '5.2'
|
||||
return f"{major}.{minor}"
|
||||
|
||||
|
||||
def is_version_4_5():
|
||||
@@ -116,3 +95,7 @@ def is_version_5_0():
|
||||
"""Check if running Blender 5.0 or later."""
|
||||
return is_version_at_least(5, 0, 0)
|
||||
|
||||
|
||||
def is_version_5_2():
|
||||
"""Check if running Blender 5.2 LTS or later."""
|
||||
return is_version_at_least(5, 2, 0)
|
||||
|
||||
Reference in New Issue
Block a user