disable (currently unused) addons, update

This commit is contained in:
Nathan
2026-07-16 12:41:53 -06:00
parent e3310263f3
commit 0f26db3345
76 changed files with 19896 additions and 6197 deletions
@@ -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
@@ -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)
@@ -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")
@@ -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)