2026-02-16
This commit is contained in:
@@ -1,3 +1,102 @@
|
||||
## [v2.5.0] - 2026-01-28
|
||||
|
||||
### Features
|
||||
- Missing file tools: add “Relink All” and improve replacement workflow.
|
||||
|
||||
### Fixes
|
||||
- Missing file UI: fix text field paste + layout/truncation issues; center the detect-missing popup; refine replacement path handling (better dir vs file behavior).
|
||||
- RNA analysis: expand datablock coverage and refine dependency tracking to reduce false “unused” results.
|
||||
|
||||
### Internal
|
||||
- Maintenance: remove deprecated recovery option; improve ignore rules for hidden dot-directories.
|
||||
|
||||
## [v2.4.1] - 2026-01-14
|
||||
|
||||
### Fixes
|
||||
- Fixed RNA analysis crashes when opening new blend files by rebuilding data-block type references dynamically
|
||||
- Fixed indentation errors that prevented RNA dump from processing most data-blocks
|
||||
- Fixed compositing nodetree detection by adding scenes as root nodes in dependency graph
|
||||
- RNA dump JSON file now always generated regardless of debug print settings
|
||||
- Refactored repetitive snapshotting code into `_safe_snapshot()` helper function
|
||||
|
||||
## [v2.4.0] - 2026-01-13
|
||||
|
||||
### Features
|
||||
- **Major Architecture Change: RNA-Based Analysis System**
|
||||
- Replaced multi-process worker system with faster, more robust RNA-based dependency analysis
|
||||
- All data types now use unified RNA introspection for dependency tracking
|
||||
- Eliminated worker processes, job indexing, and subprocess overhead
|
||||
- RNA data can be optionally dumped to JSON for debugging
|
||||
- Improved Clean dialog layout
|
||||
- Increased dialog width to 1000px for better visibility
|
||||
- Items now display in 4-column grid layout to reduce vertical scrolling
|
||||
|
||||
### Fixes
|
||||
- Fixed node groups used by objects via Geometry Nodes modifiers not being detected as used
|
||||
- Fixed RigidBodyWorld and other scene-linked data-blocks incorrectly flagged as cleanable
|
||||
- Fixed area lights and other object data-blocks in scene collections not being marked as used
|
||||
- Added safety checks to prevent crashes during RNA extraction (recursion limits, data-block validation)
|
||||
- Fixed RNA extraction handling for objects' modifier node groups
|
||||
|
||||
### Performance
|
||||
- Significantly faster scanning across all categories using RNA analysis
|
||||
- Single-pass dependency graph building shared across all category scans
|
||||
|
||||
## [v2.3.1] - 2026-01-13
|
||||
|
||||
### Fixes
|
||||
- Integrate proper UDIM detection
|
||||
|
||||
## [v2.3.0] - 2026-01-06
|
||||
|
||||
### Features
|
||||
- Added "Enable Debug Prints" preference to control debug console output
|
||||
- Debug messages now only print when this preference is enabled (default: off)
|
||||
- All debug print statements use centralized `config.debug_print()` helper
|
||||
|
||||
### Fixes
|
||||
- Fixed preferences not displaying in Blender 5.0 extensions
|
||||
- Preferences now correctly match the full module path (`bl_ext.vscode_development.atomic_data_manager`)
|
||||
- Added safe property setter to handle read-only context errors during file loading
|
||||
- Fixed node groups used only by unused materials/objects not being detected as unused (#5)
|
||||
- Node groups now recursively check if parent node groups are unused
|
||||
- Fixed compositor node tree detection to use reference comparison instead of name
|
||||
- Fixed missing import error in node_group_compositors()
|
||||
- Made Clean execute deletion synchronous for faster performance
|
||||
- Fixed callback-initiated scan state not being preserved, causing scans to fail
|
||||
- Fixed instanced collection usage detection
|
||||
|
||||
## [v2.2.0] - 2026-01-05
|
||||
|
||||
### Features
|
||||
- Add loading bars; non-blocking timer-based UI (#10)
|
||||
- Operations no longer freeze the UI during scanning
|
||||
- Real-time progress updates with cancel support at any time
|
||||
- Descriptive status messages showing current operation details
|
||||
- Unified Smart Select and Clean scanning logic
|
||||
- Eliminated code duplication between operations
|
||||
- Clean now only scans selected categories (more efficient)
|
||||
- Both operations use consistent incremental scanning for images and worlds
|
||||
- Added manual cache clear operator for testing and debugging
|
||||
|
||||
### Performance
|
||||
- Optimized deep scan functions with caching and fast-path checks
|
||||
- Image scanning now uses cached results to avoid redundant scene scans
|
||||
- Early exit for clearly unused images using Blender's built-in user count
|
||||
- Incremental processing for large datasets
|
||||
- Images processed in batches (5 per callback) to maintain UI responsiveness
|
||||
- Worlds processed one at a time incrementally
|
||||
|
||||
### Fixes
|
||||
- Fixed images used only by unused objects being incorrectly flagged as unused (#5)
|
||||
- Fixed material detection in brushes and node groups (#6, #7)
|
||||
- Fixed Clean operator not showing dialog when invoked programmatically (#8)
|
||||
- Improved material detection in inspection tools (brushes, node groups)
|
||||
|
||||
### Internal
|
||||
- Refactored scanning architecture for maintainability
|
||||
- Added comprehensive debug output for troubleshooting
|
||||
|
||||
## [v2.1.0] - 2025-12-18
|
||||
|
||||
### Features
|
||||
|
||||
@@ -224,6 +224,23 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
|
||||
# search field for the inspect replace operator
|
||||
replace_field: bpy.props.StringProperty()
|
||||
|
||||
# progress tracking properties for timer-based operations
|
||||
is_operation_running: bpy.props.BoolProperty(default=False)
|
||||
operation_progress: bpy.props.FloatProperty(
|
||||
default=0.0,
|
||||
min=0.0,
|
||||
max=100.0,
|
||||
subtype='PERCENTAGE' # This makes it display as percentage
|
||||
)
|
||||
operation_status: bpy.props.StringProperty(default="")
|
||||
cancel_operation: bpy.props.BoolProperty(default=False)
|
||||
|
||||
|
||||
def _on_undo_pre(scene):
|
||||
"""Handler called before undo - invalidate cache."""
|
||||
from .ops import main_ops
|
||||
main_ops._invalidate_cache()
|
||||
|
||||
|
||||
def register():
|
||||
register_class(ATOMIC_PG_main)
|
||||
@@ -233,6 +250,9 @@ def register():
|
||||
ui.register()
|
||||
ops.register()
|
||||
|
||||
# Register undo handler to invalidate cache
|
||||
bpy.app.handlers.undo_pre.append(_on_undo_pre)
|
||||
|
||||
# bootstrap Rainy's Extensions repository
|
||||
rainys_repo_bootstrap.register()
|
||||
|
||||
@@ -241,6 +261,10 @@ def unregister():
|
||||
# bootstrap unregistration
|
||||
rainys_repo_bootstrap.unregister()
|
||||
|
||||
# Remove undo handler
|
||||
if _on_undo_pre in bpy.app.handlers.undo_pre:
|
||||
bpy.app.handlers.undo_pre.remove(_on_undo_pre)
|
||||
|
||||
# atomic package unregistration
|
||||
ui.unregister()
|
||||
ops.unregister()
|
||||
|
||||
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
|
||||
|
||||
id = "atomic_data_manager"
|
||||
name = "Atomic Data Manager"
|
||||
version = "2.1.0"
|
||||
version = "2.5.0"
|
||||
type = "add-on"
|
||||
author = "RaincloudTheDragon"
|
||||
maintainer = "RaincloudTheDragon"
|
||||
|
||||
@@ -32,6 +32,7 @@ Blender, not in here.
|
||||
enable_missing_file_warning = True
|
||||
include_fake_users = False
|
||||
enable_pie_menu_ui = True
|
||||
enable_debug_prints = False
|
||||
|
||||
# hidden atomic preferences
|
||||
pie_menu_type = "D"
|
||||
@@ -39,4 +40,13 @@ pie_menu_alt = False
|
||||
pie_menu_any = False
|
||||
pie_menu_ctrl = False
|
||||
pie_menu_oskey = False
|
||||
pie_menu_shift = False
|
||||
pie_menu_shift = False
|
||||
|
||||
|
||||
def debug_print(*args, **kwargs):
|
||||
"""
|
||||
Print debug messages only if enable_debug_prints is True.
|
||||
Usage: debug_print("message") or debug_print(f"formatted {value}")
|
||||
"""
|
||||
if enable_debug_prints:
|
||||
print(*args, **kwargs)
|
||||
@@ -30,6 +30,9 @@ from ..utils import compat
|
||||
from .utils import delete
|
||||
from .utils import duplicate
|
||||
|
||||
# Module-level state for inspection delete
|
||||
_inspect_delete_state = None
|
||||
|
||||
|
||||
def _check_library_or_override(datablock):
|
||||
"""Check if datablock is library-linked or override, return error message if so."""
|
||||
@@ -488,115 +491,181 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
|
||||
bl_label = "Delete Data-Block"
|
||||
|
||||
def execute(self, context):
|
||||
atom = bpy.context.scene.atomic
|
||||
atom = context.scene.atomic
|
||||
inspection = atom.active_inspection
|
||||
|
||||
if inspection == 'COLLECTIONS':
|
||||
key = atom.collections_field
|
||||
collections = bpy.data.collections
|
||||
|
||||
if key in collections.keys():
|
||||
collection = collections[key]
|
||||
error = _check_library_or_override(collection)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.collection(key)
|
||||
atom.collections_field = ""
|
||||
|
||||
elif inspection == 'IMAGES':
|
||||
key = atom.images_field
|
||||
images = bpy.data.images
|
||||
|
||||
if key in images.keys():
|
||||
image = images[key]
|
||||
error = _check_library_or_override(image)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.image(key)
|
||||
atom.images_field = ""
|
||||
|
||||
elif inspection == 'LIGHTS':
|
||||
key = atom.lights_field
|
||||
lights = bpy.data.lights
|
||||
|
||||
if key in lights.keys():
|
||||
light = lights[key]
|
||||
error = _check_library_or_override(light)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.light(key)
|
||||
atom.lights_field = ""
|
||||
|
||||
elif inspection == 'MATERIALS':
|
||||
key = atom.materials_field
|
||||
materials = bpy.data.materials
|
||||
|
||||
if key in materials.keys():
|
||||
material = materials[key]
|
||||
error = _check_library_or_override(material)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.material(key)
|
||||
atom.materials_field = ""
|
||||
|
||||
elif inspection == 'NODE_GROUPS':
|
||||
key = atom.node_groups_field
|
||||
node_groups = bpy.data.node_groups
|
||||
|
||||
if key in node_groups.keys():
|
||||
node_group = node_groups[key]
|
||||
error = _check_library_or_override(node_group)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.node_group(key)
|
||||
atom.node_groups_field = ""
|
||||
|
||||
elif inspection == 'PARTICLES':
|
||||
key = atom.particles_field
|
||||
particles = bpy.data.particles
|
||||
if key in particles.keys():
|
||||
particle = particles[key]
|
||||
error = _check_library_or_override(particle)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.particle(key)
|
||||
atom.particles_field = ""
|
||||
|
||||
elif inspection == 'TEXTURES':
|
||||
key = atom.textures_field
|
||||
textures = bpy.data.textures
|
||||
|
||||
if key in textures.keys():
|
||||
texture = textures[key]
|
||||
error = _check_library_or_override(texture)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.texture(key)
|
||||
atom.textures_field = ""
|
||||
|
||||
elif inspection == 'WORLDS':
|
||||
key = atom.worlds_field
|
||||
worlds = bpy.data.worlds
|
||||
|
||||
if key in worlds.keys():
|
||||
world = worlds[key]
|
||||
error = _check_library_or_override(world)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.world(key)
|
||||
atom.worlds_field = ""
|
||||
|
||||
# Initialize progress tracking
|
||||
atom.is_operation_running = True
|
||||
atom.operation_progress = 0.0
|
||||
atom.operation_status = f"Deleting {inspection.lower()}..."
|
||||
atom.cancel_operation = False
|
||||
|
||||
# Store state in module-level variable for timer processing
|
||||
global _inspect_delete_state
|
||||
_inspect_delete_state = {
|
||||
'inspection': inspection
|
||||
}
|
||||
|
||||
# Start timer for processing (even though it's quick, keep UI responsive)
|
||||
bpy.app.timers.register(_process_inspect_delete_step)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def _process_inspect_delete_step():
|
||||
"""Process inspection delete in steps to avoid blocking the UI"""
|
||||
atom = bpy.context.scene.atomic
|
||||
global _inspect_delete_state
|
||||
|
||||
if _inspect_delete_state is None:
|
||||
return None
|
||||
|
||||
inspection = _inspect_delete_state['inspection']
|
||||
|
||||
# Check for cancellation
|
||||
if atom.cancel_operation:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_progress = 0.0
|
||||
atom.operation_status = "Operation cancelled"
|
||||
atom.cancel_operation = False
|
||||
_inspect_delete_state = None
|
||||
# Force UI update
|
||||
for area in bpy.context.screen.areas:
|
||||
area.tag_redraw()
|
||||
return None
|
||||
|
||||
atom.operation_progress = 50.0
|
||||
|
||||
# Perform deletion
|
||||
try:
|
||||
if inspection == 'COLLECTIONS':
|
||||
key = atom.collections_field
|
||||
collections = bpy.data.collections
|
||||
|
||||
if key in collections.keys():
|
||||
collection = collections[key]
|
||||
error = _check_library_or_override(collection)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.collection(key)
|
||||
atom.collections_field = ""
|
||||
|
||||
elif inspection == 'IMAGES':
|
||||
key = atom.images_field
|
||||
images = bpy.data.images
|
||||
|
||||
if key in images.keys():
|
||||
image = images[key]
|
||||
error = _check_library_or_override(image)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.image(key)
|
||||
atom.images_field = ""
|
||||
|
||||
elif inspection == 'LIGHTS':
|
||||
key = atom.lights_field
|
||||
lights = bpy.data.lights
|
||||
|
||||
if key in lights.keys():
|
||||
light = lights[key]
|
||||
error = _check_library_or_override(light)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.light(key)
|
||||
atom.lights_field = ""
|
||||
|
||||
elif inspection == 'MATERIALS':
|
||||
key = atom.materials_field
|
||||
materials = bpy.data.materials
|
||||
|
||||
if key in materials.keys():
|
||||
material = materials[key]
|
||||
error = _check_library_or_override(material)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.material(key)
|
||||
atom.materials_field = ""
|
||||
|
||||
elif inspection == 'NODE_GROUPS':
|
||||
key = atom.node_groups_field
|
||||
node_groups = bpy.data.node_groups
|
||||
|
||||
if key in node_groups.keys():
|
||||
node_group = node_groups[key]
|
||||
error = _check_library_or_override(node_group)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.node_group(key)
|
||||
atom.node_groups_field = ""
|
||||
|
||||
elif inspection == 'PARTICLES':
|
||||
key = atom.particles_field
|
||||
particles = bpy.data.particles
|
||||
if key in particles.keys():
|
||||
particle = particles[key]
|
||||
error = _check_library_or_override(particle)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.particle(key)
|
||||
atom.particles_field = ""
|
||||
|
||||
elif inspection == 'TEXTURES':
|
||||
key = atom.textures_field
|
||||
textures = bpy.data.textures
|
||||
|
||||
if key in textures.keys():
|
||||
texture = textures[key]
|
||||
error = _check_library_or_override(texture)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.texture(key)
|
||||
atom.textures_field = ""
|
||||
|
||||
elif inspection == 'WORLDS':
|
||||
key = atom.worlds_field
|
||||
worlds = bpy.data.worlds
|
||||
|
||||
if key in worlds.keys():
|
||||
world = worlds[key]
|
||||
error = _check_library_or_override(world)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.world(key)
|
||||
atom.worlds_field = ""
|
||||
except:
|
||||
pass # Handle any errors gracefully
|
||||
|
||||
# Operation complete
|
||||
atom.is_operation_running = False
|
||||
atom.operation_progress = 100.0
|
||||
atom.operation_status = ""
|
||||
|
||||
# Clear state
|
||||
_inspect_delete_state = None
|
||||
|
||||
# Force UI update
|
||||
for area in bpy.context.screen.areas:
|
||||
area.tag_redraw()
|
||||
|
||||
return None # Stop timer
|
||||
|
||||
|
||||
reg_list = [
|
||||
ATOMIC_OT_inspection_rename,
|
||||
ATOMIC_OT_inspection_replace,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -26,61 +26,131 @@ import bpy
|
||||
from ...stats import unused
|
||||
|
||||
|
||||
def collections():
|
||||
def collections(cached_list=None):
|
||||
# removes all unused collections from the project
|
||||
for collection_key in unused.collections_deep():
|
||||
bpy.data.collections.remove(bpy.data.collections[collection_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
collection_keys = cached_list
|
||||
else:
|
||||
collection_keys = unused.collections_deep()
|
||||
|
||||
for collection_key in collection_keys:
|
||||
if collection_key in bpy.data.collections:
|
||||
bpy.data.collections.remove(bpy.data.collections[collection_key])
|
||||
|
||||
|
||||
def images():
|
||||
def images(cached_list=None):
|
||||
# removes all unused images from the project
|
||||
for image_key in unused.images_deep():
|
||||
bpy.data.images.remove(bpy.data.images[image_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
image_keys = cached_list
|
||||
else:
|
||||
image_keys = unused.images_deep()
|
||||
|
||||
for image_key in image_keys:
|
||||
if image_key in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[image_key])
|
||||
|
||||
|
||||
def lights():
|
||||
def lights(cached_list=None):
|
||||
# removes all unused lights from the project
|
||||
for light_key in unused.lights_deep():
|
||||
bpy.data.lights.remove(bpy.data.lights[light_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
light_keys = cached_list
|
||||
else:
|
||||
light_keys = unused.lights_deep()
|
||||
|
||||
for light_key in light_keys:
|
||||
if light_key in bpy.data.lights:
|
||||
bpy.data.lights.remove(bpy.data.lights[light_key])
|
||||
|
||||
|
||||
def materials():
|
||||
def materials(cached_list=None):
|
||||
# removes all unused materials from the project
|
||||
for light_key in unused.materials_deep():
|
||||
bpy.data.materials.remove(bpy.data.materials[light_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
material_keys = cached_list
|
||||
else:
|
||||
material_keys = unused.materials_deep()
|
||||
|
||||
for material_key in material_keys:
|
||||
if material_key in bpy.data.materials:
|
||||
bpy.data.materials.remove(bpy.data.materials[material_key])
|
||||
|
||||
|
||||
def node_groups():
|
||||
def node_groups(cached_list=None):
|
||||
# removes all unused node groups from the project
|
||||
for node_group_key in unused.node_groups_deep():
|
||||
bpy.data.node_groups.remove(bpy.data.node_groups[node_group_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
node_group_keys = cached_list
|
||||
else:
|
||||
node_group_keys = unused.node_groups_deep()
|
||||
|
||||
for node_group_key in node_group_keys:
|
||||
if node_group_key in bpy.data.node_groups:
|
||||
bpy.data.node_groups.remove(bpy.data.node_groups[node_group_key])
|
||||
|
||||
|
||||
def particles():
|
||||
def particles(cached_list=None):
|
||||
# removes all unused particle systems from the project
|
||||
for particle_key in unused.particles_deep():
|
||||
bpy.data.particles.remove(bpy.data.particles[particle_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
particle_keys = cached_list
|
||||
else:
|
||||
particle_keys = unused.particles_deep()
|
||||
|
||||
for particle_key in particle_keys:
|
||||
if particle_key in bpy.data.particles:
|
||||
bpy.data.particles.remove(bpy.data.particles[particle_key])
|
||||
|
||||
|
||||
def textures():
|
||||
def textures(cached_list=None):
|
||||
# removes all unused textures from the project
|
||||
for texture_key in unused.textures_deep():
|
||||
bpy.data.textures.remove(bpy.data.textures[texture_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
texture_keys = cached_list
|
||||
else:
|
||||
texture_keys = unused.textures_deep()
|
||||
|
||||
for texture_key in texture_keys:
|
||||
if texture_key in bpy.data.textures:
|
||||
bpy.data.textures.remove(bpy.data.textures[texture_key])
|
||||
|
||||
|
||||
def worlds():
|
||||
def worlds(cached_list=None):
|
||||
# removes all unused worlds from the project
|
||||
for world_key in unused.worlds():
|
||||
bpy.data.worlds.remove(bpy.data.worlds[world_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
world_keys = cached_list
|
||||
else:
|
||||
world_keys = unused.worlds()
|
||||
|
||||
for world_key in world_keys:
|
||||
if world_key in bpy.data.worlds:
|
||||
bpy.data.worlds.remove(bpy.data.worlds[world_key])
|
||||
|
||||
|
||||
def objects():
|
||||
def objects(cached_list=None):
|
||||
# removes all unused objects from the project
|
||||
for object_key in unused.objects_deep():
|
||||
bpy.data.objects.remove(bpy.data.objects[object_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
object_keys = cached_list
|
||||
else:
|
||||
object_keys = unused.objects_deep()
|
||||
|
||||
for object_key in object_keys:
|
||||
if object_key in bpy.data.objects:
|
||||
bpy.data.objects.remove(bpy.data.objects[object_key])
|
||||
|
||||
|
||||
def armatures():
|
||||
def armatures(cached_list=None):
|
||||
# removes all unused armatures from the project
|
||||
for armature_key in unused.armatures_deep():
|
||||
bpy.data.armatures.remove(bpy.data.armatures[armature_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
armature_keys = cached_list
|
||||
else:
|
||||
armature_keys = unused.armatures_deep()
|
||||
|
||||
for armature_key in armature_keys:
|
||||
if armature_key in bpy.data.armatures:
|
||||
bpy.data.armatures.remove(bpy.data.armatures[armature_key])
|
||||
|
||||
@@ -59,8 +59,23 @@ def get_missing(data):
|
||||
# Blender 4.2/4.5: Both use 'packed_file' (singular)
|
||||
is_packed = bool(datablock.packed_file) if hasattr(datablock, 'packed_file') else False
|
||||
|
||||
# Check if file exists (with special handling for UDIM images)
|
||||
file_exists = False
|
||||
if abspath and isinstance(datablock, bpy.types.Image) and '<UDIM>' in abspath:
|
||||
# UDIM image: check if any UDIM tile files exist
|
||||
# UDIM tiles are numbered 1001, 1002, etc. (standard range is 1001-1099)
|
||||
# Check a reasonable range of UDIM tiles
|
||||
for udim_tile in range(1001, 1100): # Check tiles 1001-1099
|
||||
udim_path = abspath.replace('<UDIM>', str(udim_tile))
|
||||
if os.path.isfile(udim_path):
|
||||
file_exists = True
|
||||
break
|
||||
elif abspath:
|
||||
# Regular file: check if it exists
|
||||
file_exists = os.path.isfile(abspath)
|
||||
|
||||
# if data-block is not packed and has an invalid filepath
|
||||
if not is_packed and not os.path.isfile(abspath):
|
||||
if not is_packed and not file_exists:
|
||||
|
||||
# if data-block is not in our do not flag list
|
||||
# append it to the missing data list
|
||||
@@ -86,3 +101,54 @@ def images():
|
||||
def libraries():
|
||||
# returns a list of keys of libraries with a non-existent filepath
|
||||
return get_missing(bpy.data.libraries)
|
||||
|
||||
|
||||
def get_missing_library_info(library_key):
|
||||
"""
|
||||
Get information about a missing library for matching and validation.
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
- 'filepath': original filepath
|
||||
- 'filename': basename for matching
|
||||
- 'linked_data_blocks': list of data-block names linked from this library
|
||||
"""
|
||||
if library_key not in bpy.data.libraries:
|
||||
return None
|
||||
|
||||
library = bpy.data.libraries[library_key]
|
||||
filepath = library.filepath
|
||||
filename = os.path.basename(bpy.path.abspath(filepath)) if filepath else ""
|
||||
|
||||
# Get linked data-block names (collections, objects, materials, etc.)
|
||||
linked_data_blocks = []
|
||||
try:
|
||||
# Collections
|
||||
for collection in bpy.data.collections:
|
||||
if collection.library == library:
|
||||
linked_data_blocks.append(('COLLECTION', collection.name))
|
||||
# Objects
|
||||
for obj in bpy.data.objects:
|
||||
if obj.library == library:
|
||||
linked_data_blocks.append(('OBJECT', obj.name))
|
||||
# Materials
|
||||
for material in bpy.data.materials:
|
||||
if material.library == library:
|
||||
linked_data_blocks.append(('MATERIAL', material.name))
|
||||
# Meshes
|
||||
for mesh in bpy.data.meshes:
|
||||
if mesh.library == library:
|
||||
linked_data_blocks.append(('MESH', mesh.name))
|
||||
# Armatures
|
||||
for armature in bpy.data.armatures:
|
||||
if armature.library == library:
|
||||
linked_data_blocks.append(('ARMATURE', armature.name))
|
||||
except Exception:
|
||||
# If we can't access library data, return what we have
|
||||
pass
|
||||
|
||||
return {
|
||||
'filepath': filepath,
|
||||
'filename': filename,
|
||||
'linked_data_blocks': linked_data_blocks
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ as determined by stats.users.py
|
||||
import bpy
|
||||
from .. import config
|
||||
from ..utils import compat
|
||||
from ..utils import version
|
||||
from . import users
|
||||
|
||||
|
||||
@@ -90,20 +91,72 @@ def images_deep():
|
||||
# this list also exists in images_shallow()
|
||||
do_not_flag = ["Render Result", "Viewer Node", "D-NOISE Export"]
|
||||
|
||||
total_images = len(bpy.data.images)
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Starting, total images: {total_images}")
|
||||
checked = 0
|
||||
|
||||
for image in bpy.data.images:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(image):
|
||||
continue
|
||||
|
||||
checked += 1
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Checking image {checked}/{total_images}: '{image.name}'")
|
||||
|
||||
# First check: standard unused detection
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Calling users.image_all('{image.name}')...")
|
||||
if not users.image_all(image.name):
|
||||
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Image '{image.name}' is unused (first check)")
|
||||
# check if image has a fake user or if ignore fake users
|
||||
# is enabled
|
||||
if not image.use_fake_user or config.include_fake_users:
|
||||
|
||||
# if image is not in our do not flag list
|
||||
if image.name not in do_not_flag:
|
||||
unused.append(image.name)
|
||||
else:
|
||||
# Second check: image is used, but check if it's ONLY used by unused objects
|
||||
# This fixes issue #5: images used by unused objects should be marked as unused
|
||||
# Get all objects that use this image (directly or indirectly)
|
||||
objects_using_image = []
|
||||
|
||||
# Check materials that use the image
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Getting materials for '{image.name}'...")
|
||||
mat_names = users.image_materials(image.name)
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Found {len(mat_names)} materials using '{image.name}'")
|
||||
for mat_name in mat_names:
|
||||
# Get objects using this material
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Getting objects for material '{mat_name}'...")
|
||||
objects_using_image.extend(users.material_objects(mat_name))
|
||||
# Also check Geometry Nodes usage
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Getting Geometry Nodes objects for material '{mat_name}'...")
|
||||
objects_using_image.extend(users.material_geometry_nodes(mat_name))
|
||||
|
||||
# Check Geometry Nodes directly
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Getting Geometry Nodes objects for '{image.name}'...")
|
||||
objects_using_image.extend(users.image_geometry_nodes(image.name))
|
||||
|
||||
# Remove duplicates
|
||||
objects_using_image = list(set(objects_using_image))
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Found {len(objects_using_image)} objects using '{image.name}'")
|
||||
|
||||
# If image is only used by objects, and ALL those objects are unused, mark image as unused
|
||||
# Check each object individually to avoid recursion issues
|
||||
if objects_using_image:
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Checking if all {len(objects_using_image)} objects are unused...")
|
||||
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_image)
|
||||
if all_objects_unused:
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): All objects are unused, marking '{image.name}' as unused")
|
||||
# Check if image has a fake user or if ignore fake users is enabled
|
||||
if not image.use_fake_user or config.include_fake_users:
|
||||
# if image is not in our do not flag list
|
||||
if image.name not in do_not_flag:
|
||||
unused.append(image.name)
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Added '{image.name}' to unused list")
|
||||
else:
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Some objects are used, '{image.name}' is not unused")
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Finished checking '{image.name}'")
|
||||
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Complete, checked {checked} images, found {len(unused)} unused")
|
||||
return unused
|
||||
|
||||
|
||||
@@ -160,12 +213,36 @@ def materials_deep():
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(material):
|
||||
continue
|
||||
|
||||
# Check if material is used by brushes - these should always be ignored
|
||||
if users.material_brushes(material.name):
|
||||
continue
|
||||
|
||||
# First check: standard unused detection
|
||||
if not users.material_all(material.name):
|
||||
|
||||
# check if material has a fake user or if ignore fake users
|
||||
# is enabled
|
||||
if not material.use_fake_user or config.include_fake_users:
|
||||
unused.append(material.name)
|
||||
else:
|
||||
# Second check: material is used, but check if it's ONLY used by unused objects
|
||||
# This fixes issue #5: materials used by unused objects should be marked as unused
|
||||
# Get all objects that use this material
|
||||
objects_using_material = []
|
||||
objects_using_material.extend(users.material_objects(material.name))
|
||||
objects_using_material.extend(users.material_geometry_nodes(material.name))
|
||||
|
||||
# Remove duplicates
|
||||
objects_using_material = list(set(objects_using_material))
|
||||
|
||||
# If material is only used by objects, and ALL those objects are unused, mark material as unused
|
||||
# Check each object individually to avoid recursion issues
|
||||
if objects_using_material:
|
||||
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_material)
|
||||
if all_objects_unused:
|
||||
# Check if material has a fake user or if ignore fake users is enabled
|
||||
if not material.use_fake_user or config.include_fake_users:
|
||||
unused.append(material.name)
|
||||
|
||||
return unused
|
||||
|
||||
@@ -174,24 +251,147 @@ def materials_shallow():
|
||||
# returns a list of keys of unused material that may be
|
||||
# incomplete, but is significantly faster than doing a deep search
|
||||
|
||||
return shallow(bpy.data.materials)
|
||||
unused_materials = shallow(bpy.data.materials)
|
||||
|
||||
# Filter out materials used by brushes - these should always be ignored
|
||||
filtered = []
|
||||
for key in unused_materials:
|
||||
material = bpy.data.materials.get(key)
|
||||
if material and not users.material_brushes(key):
|
||||
filtered.append(key)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def _is_compositor_node_tree(node_group):
|
||||
"""
|
||||
Check if a node group is a compositor node tree.
|
||||
In Blender 5.0+, each scene has a compositing_node_tree that should be ignored.
|
||||
|
||||
Args:
|
||||
node_group: The node group to check
|
||||
|
||||
Returns:
|
||||
bool: True if the node group is a compositor node tree
|
||||
"""
|
||||
# Check if this node group is any scene's compositor node tree
|
||||
# Use compat function to handle version differences properly
|
||||
for scene in bpy.data.scenes:
|
||||
if scene.use_nodes:
|
||||
node_tree = compat.get_scene_compositor_node_tree(scene)
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: scene='{scene.name}', use_nodes={scene.use_nodes}, node_tree={node_tree}")
|
||||
if node_tree:
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: node_tree.name='{node_tree.name}', checking against '{node_group.name}'")
|
||||
# Check by reference, not just name (in case user renamed it)
|
||||
if node_tree == node_group:
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: '{node_group.name}' is scene '{scene.name}' compositor node tree")
|
||||
return True
|
||||
else:
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: node_tree != node_group (reference comparison failed)")
|
||||
|
||||
# Also check if it's used as a node within any compositor (via node_group_compositors)
|
||||
# This handles the case where the node group is used within a compositor, not just as the tree itself
|
||||
comp_users = users.node_group_compositors(node_group.name)
|
||||
if comp_users:
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: '{node_group.name}' is used in compositor: {comp_users}")
|
||||
return True
|
||||
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: '{node_group.name}' is NOT a compositor node tree")
|
||||
return False
|
||||
|
||||
|
||||
def node_groups_deep():
|
||||
# returns a list of keys of unused node_groups
|
||||
|
||||
unused = []
|
||||
# Track which node groups we've already determined are unused (to avoid infinite recursion)
|
||||
_unused_node_groups_cache = set()
|
||||
|
||||
def _is_node_group_unused(ng_name, visited=None):
|
||||
"""Recursively check if a node group is unused.
|
||||
Returns True if the node group is only used by unused materials/objects/node_groups."""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
# Avoid infinite recursion
|
||||
if ng_name in visited:
|
||||
return False
|
||||
visited.add(ng_name)
|
||||
|
||||
# Check cache first
|
||||
if ng_name in _unused_node_groups_cache:
|
||||
return True
|
||||
|
||||
node_group = bpy.data.node_groups.get(ng_name)
|
||||
if not node_group:
|
||||
return False
|
||||
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(node_group):
|
||||
return False
|
||||
# Skip compositor node trees
|
||||
if _is_compositor_node_tree(node_group):
|
||||
return False
|
||||
|
||||
# First check: node group has no users at all
|
||||
all_users = users.node_group_all(ng_name)
|
||||
config.debug_print(f"[Atomic Debug] _is_node_group_unused: '{ng_name}' - all_users = {all_users}")
|
||||
if not all_users:
|
||||
if not node_group.use_fake_user or config.include_fake_users:
|
||||
config.debug_print(f"[Atomic Debug] _is_node_group_unused: '{ng_name}' has no users, marking as unused")
|
||||
_unused_node_groups_cache.add(ng_name)
|
||||
return True
|
||||
|
||||
# Second check: node group is used, but check if it's ONLY used by unused materials/objects/node_groups
|
||||
# Get all materials and objects that use this node group
|
||||
materials_using_ng = users.node_group_materials(ng_name)
|
||||
objects_using_ng = users.node_group_objects(ng_name)
|
||||
parent_node_groups = users.node_group_node_groups(ng_name)
|
||||
|
||||
# Collect all objects that use this node group (directly or via materials)
|
||||
all_objects_using_ng = list(objects_using_ng) # Direct object usage via geometry nodes
|
||||
|
||||
# For each material using this node group, get objects using that material
|
||||
for mat_name in materials_using_ng:
|
||||
# Get objects using this material
|
||||
objects_using_mat = users.material_objects(mat_name)
|
||||
objects_using_mat.extend(users.material_geometry_nodes(mat_name))
|
||||
all_objects_using_ng.extend(objects_using_mat)
|
||||
|
||||
# Remove duplicates
|
||||
all_objects_using_ng = list(set(all_objects_using_ng))
|
||||
|
||||
# Check if all objects are unused
|
||||
all_objects_unused = True
|
||||
if all_objects_using_ng:
|
||||
all_objects_unused = all(not users.object_all(obj_name) for obj_name in all_objects_using_ng)
|
||||
|
||||
# Check if all parent node groups are unused (recursive)
|
||||
all_parent_ngs_unused = True
|
||||
if parent_node_groups:
|
||||
for parent_ng_name in parent_node_groups:
|
||||
if not _is_node_group_unused(parent_ng_name, visited.copy()):
|
||||
all_parent_ngs_unused = False
|
||||
break
|
||||
|
||||
# If node group is only used by unused objects and unused parent node groups, mark it as unused
|
||||
if all_objects_unused and all_parent_ngs_unused:
|
||||
if not node_group.use_fake_user or config.include_fake_users:
|
||||
_unused_node_groups_cache.add(ng_name)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
if not users.node_group_all(node_group.name):
|
||||
|
||||
# check if node group has a fake user or if ignore fake users
|
||||
# is enabled
|
||||
if not node_group.use_fake_user or config.include_fake_users:
|
||||
unused.append(node_group.name)
|
||||
# Skip compositor node trees (Blender 5.0+ creates one per file)
|
||||
if _is_compositor_node_tree(node_group):
|
||||
continue
|
||||
|
||||
if _is_node_group_unused(node_group.name):
|
||||
unused.append(node_group.name)
|
||||
|
||||
return unused
|
||||
|
||||
@@ -200,7 +400,16 @@ def node_groups_shallow():
|
||||
# returns a list of keys of unused node groups that may be
|
||||
# incomplete, but is significantly faster than doing a deep search
|
||||
|
||||
return shallow(bpy.data.node_groups)
|
||||
unused = shallow(bpy.data.node_groups)
|
||||
|
||||
# Filter out compositor node trees (Blender 5.0+ creates one per file)
|
||||
filtered = []
|
||||
for node_group_name in unused:
|
||||
node_group = bpy.data.node_groups.get(node_group_name)
|
||||
if node_group and not _is_compositor_node_tree(node_group):
|
||||
filtered.append(node_group_name)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def particles_deep():
|
||||
@@ -263,21 +472,27 @@ def textures_shallow():
|
||||
|
||||
def worlds():
|
||||
# returns a full list of keys of unused worlds
|
||||
|
||||
config.debug_print(f"[Atomic Debug] unused.worlds(): Starting, total worlds: {len(bpy.data.worlds)}")
|
||||
unused = []
|
||||
checked = 0
|
||||
|
||||
for world in bpy.data.worlds:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(world):
|
||||
continue
|
||||
|
||||
checked += 1
|
||||
config.debug_print(f"[Atomic Debug] unused.worlds(): Checking '{world.name}' (users={world.users}, fake_user={world.use_fake_user})")
|
||||
|
||||
# if data-block has no users or if it has a fake user and
|
||||
# ignore fake users is enabled
|
||||
if world.users == 0 or (world.users == 1 and
|
||||
world.use_fake_user and
|
||||
config.include_fake_users):
|
||||
config.debug_print(f"[Atomic Debug] unused.worlds(): '{world.name}' is unused, adding to list")
|
||||
unused.append(world.name)
|
||||
|
||||
config.debug_print(f"[Atomic Debug] unused.worlds(): Complete, checked {checked} worlds, found {len(unused)} unused")
|
||||
return unused
|
||||
|
||||
|
||||
|
||||
@@ -23,18 +23,35 @@ def get_all_unused_parallel():
|
||||
"""
|
||||
# Execute all checks sequentially but in a clean batch
|
||||
# This avoids threading overhead while keeping code organized
|
||||
return {
|
||||
'collections': unused.collections_deep(),
|
||||
'images': unused.images_deep(),
|
||||
'lights': unused.lights_deep(),
|
||||
'materials': unused.materials_deep(),
|
||||
'node_groups': unused.node_groups_deep(),
|
||||
'objects': unused.objects_deep(),
|
||||
'particles': unused.particles_deep(),
|
||||
'textures': unused.textures_deep(),
|
||||
'armatures': unused.armatures_deep(),
|
||||
'worlds': unused.worlds(),
|
||||
}
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Starting, will scan {len(CATEGORIES)} categories")
|
||||
result = {}
|
||||
for i, category in enumerate(CATEGORIES):
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Scanning {category} ({i+1}/{len(CATEGORIES)})...")
|
||||
if category == 'collections':
|
||||
result[category] = unused.collections_deep()
|
||||
elif category == 'images':
|
||||
result[category] = unused.images_deep()
|
||||
elif category == 'lights':
|
||||
result[category] = unused.lights_deep()
|
||||
elif category == 'materials':
|
||||
result[category] = unused.materials_deep()
|
||||
elif category == 'node_groups':
|
||||
result[category] = unused.node_groups_deep()
|
||||
elif category == 'objects':
|
||||
result[category] = unused.objects_deep()
|
||||
elif category == 'particles':
|
||||
result[category] = unused.particles_deep()
|
||||
elif category == 'textures':
|
||||
result[category] = unused.textures_deep()
|
||||
elif category == 'armatures':
|
||||
result[category] = unused.armatures_deep()
|
||||
elif category == 'worlds':
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Calling unused.worlds()...")
|
||||
result[category] = unused.worlds()
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: unused.worlds() returned {len(result[category])} unused worlds")
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Finished {category}")
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Complete, returning results")
|
||||
return result
|
||||
|
||||
|
||||
def _has_any_unused_collections():
|
||||
@@ -54,10 +71,35 @@ def _has_any_unused_images():
|
||||
for image in bpy.data.images:
|
||||
if compat.is_library_or_override(image):
|
||||
continue
|
||||
|
||||
# First check: standard unused detection
|
||||
if not users.image_all(image.name):
|
||||
if not image.use_fake_user or config.include_fake_users:
|
||||
if image.name not in do_not_flag:
|
||||
return True
|
||||
else:
|
||||
# Second check: image is used, but check if it's ONLY used by unused objects
|
||||
# This fixes issue #5: images used by unused objects should be marked as unused
|
||||
objects_using_image = []
|
||||
|
||||
# Check materials that use the image
|
||||
for mat_name in users.image_materials(image.name):
|
||||
objects_using_image.extend(users.material_objects(mat_name))
|
||||
objects_using_image.extend(users.material_geometry_nodes(mat_name))
|
||||
|
||||
# Check Geometry Nodes directly
|
||||
objects_using_image.extend(users.image_geometry_nodes(image.name))
|
||||
|
||||
# Remove duplicates
|
||||
objects_using_image = list(set(objects_using_image))
|
||||
|
||||
# If image is only used by objects, and ALL those objects are unused, mark image as unused
|
||||
if objects_using_image:
|
||||
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_image)
|
||||
if all_objects_unused:
|
||||
if not image.use_fake_user or config.include_fake_users:
|
||||
if image.name not in do_not_flag:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -77,9 +119,31 @@ def _has_any_unused_materials():
|
||||
for material in bpy.data.materials:
|
||||
if compat.is_library_or_override(material):
|
||||
continue
|
||||
|
||||
# Skip materials used by brushes - these should always be ignored
|
||||
if users.material_brushes(material.name):
|
||||
continue
|
||||
|
||||
# First check: standard unused detection
|
||||
if not users.material_all(material.name):
|
||||
if not material.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
else:
|
||||
# Second check: material is used, but check if it's ONLY used by unused objects
|
||||
# This fixes issue #5: materials used by unused objects should be marked as unused
|
||||
objects_using_material = []
|
||||
objects_using_material.extend(users.material_objects(material.name))
|
||||
objects_using_material.extend(users.material_geometry_nodes(material.name))
|
||||
|
||||
# Remove duplicates
|
||||
objects_using_material = list(set(objects_using_material))
|
||||
|
||||
# If material is only used by objects, and ALL those objects are unused, mark material as unused
|
||||
if objects_using_material:
|
||||
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_material)
|
||||
if all_objects_unused:
|
||||
if not material.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -88,6 +152,10 @@ def _has_any_unused_node_groups():
|
||||
for node_group in bpy.data.node_groups:
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
# Skip compositor node trees (Blender 5.0+ creates one per file)
|
||||
# Import the helper function from unused module
|
||||
if unused._is_compositor_node_tree(node_group):
|
||||
continue
|
||||
if not users.node_group_all(node_group.name):
|
||||
if not node_group.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
@@ -124,13 +192,19 @@ def _has_any_unused_textures():
|
||||
|
||||
def _has_any_unused_worlds():
|
||||
"""Check if there are any unused worlds (short-circuits early)."""
|
||||
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Starting, total worlds: {len(bpy.data.worlds)}")
|
||||
checked = 0
|
||||
for world in bpy.data.worlds:
|
||||
if compat.is_library_or_override(world):
|
||||
continue
|
||||
checked += 1
|
||||
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Checking world '{world.name}' (users={world.users}, fake_user={world.use_fake_user}, include_fake_users={config.include_fake_users})")
|
||||
if world.users == 0 or (world.users == 1 and
|
||||
world.use_fake_user and
|
||||
config.include_fake_users):
|
||||
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Found unused world '{world.name}', returning True")
|
||||
return True
|
||||
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Checked {checked} worlds, none unused, returning False")
|
||||
return False
|
||||
|
||||
|
||||
@@ -156,6 +230,10 @@ def _has_any_unused_armatures():
|
||||
return False
|
||||
|
||||
|
||||
# Category order for progress tracking
|
||||
CATEGORIES = ['collections', 'images', 'lights', 'materials', 'node_groups',
|
||||
'objects', 'particles', 'textures', 'armatures', 'worlds']
|
||||
|
||||
def get_unused_for_smart_select():
|
||||
"""
|
||||
Get unused data for smart select operation (returns booleans).
|
||||
@@ -168,17 +246,29 @@ def get_unused_for_smart_select():
|
||||
"""
|
||||
# Use optimized short-circuit versions that stop as soon as
|
||||
# they find ONE unused item, rather than computing the full list
|
||||
return {
|
||||
'collections': _has_any_unused_collections(),
|
||||
'images': _has_any_unused_images(),
|
||||
'lights': _has_any_unused_lights(),
|
||||
'materials': _has_any_unused_materials(),
|
||||
'node_groups': _has_any_unused_node_groups(),
|
||||
'objects': _has_any_unused_objects(),
|
||||
'particles': _has_any_unused_particles(),
|
||||
'textures': _has_any_unused_textures(),
|
||||
'armatures': _has_any_unused_armatures(),
|
||||
'worlds': _has_any_unused_worlds(),
|
||||
}
|
||||
result = {}
|
||||
for category in CATEGORIES:
|
||||
if category == 'collections':
|
||||
result[category] = _has_any_unused_collections()
|
||||
elif category == 'images':
|
||||
result[category] = _has_any_unused_images()
|
||||
elif category == 'lights':
|
||||
result[category] = _has_any_unused_lights()
|
||||
elif category == 'materials':
|
||||
result[category] = _has_any_unused_materials()
|
||||
elif category == 'node_groups':
|
||||
result[category] = _has_any_unused_node_groups()
|
||||
elif category == 'objects':
|
||||
result[category] = _has_any_unused_objects()
|
||||
elif category == 'particles':
|
||||
result[category] = _has_any_unused_particles()
|
||||
elif category == 'textures':
|
||||
result[category] = _has_any_unused_textures()
|
||||
elif category == 'armatures':
|
||||
result[category] = _has_any_unused_armatures()
|
||||
elif category == 'worlds':
|
||||
result[category] = _has_any_unused_worlds()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ a material would be searching for the image_materials() function.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
from ..utils import compat
|
||||
|
||||
|
||||
def collection_all(collection_key):
|
||||
@@ -42,7 +43,8 @@ def collection_all(collection_key):
|
||||
collection_meshes(collection_key) + \
|
||||
collection_others(collection_key) + \
|
||||
collection_rigidbody_world(collection_key) + \
|
||||
collection_scenes(collection_key)
|
||||
collection_scenes(collection_key) + \
|
||||
collection_instances(collection_key)
|
||||
|
||||
|
||||
def collection_cameras(collection_key):
|
||||
@@ -189,6 +191,32 @@ def collection_scenes(collection_key):
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def collection_instances(collection_key):
|
||||
# returns a list of object keys that instance this collection
|
||||
# Collection instances are objects with instance_type='COLLECTION' and
|
||||
# instance_collection pointing to this collection
|
||||
# Only counts objects that are actually in scene collections
|
||||
|
||||
users = []
|
||||
collection = bpy.data.collections[collection_key]
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
# Skip library-linked and override objects
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
|
||||
# Check if object is a collection instance
|
||||
if hasattr(obj, 'instance_type') and obj.instance_type == 'COLLECTION':
|
||||
if hasattr(obj, 'instance_collection') and obj.instance_collection:
|
||||
if obj.instance_collection.name == collection.name:
|
||||
# Only count if the instance object is in a scene
|
||||
# (otherwise the collection isn't really being used)
|
||||
if object_all(obj.name):
|
||||
users.append(obj.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def _scene_collection_contains(parent_collection, target_collection):
|
||||
# helper that checks whether target_collection exists inside the
|
||||
# parent_collection hierarchy
|
||||
@@ -255,6 +283,9 @@ def image_compositors(image_key):
|
||||
|
||||
def image_materials(image_key):
|
||||
# returns a list of material keys that use the image
|
||||
# Only returns materials that are actually used (in scenes)
|
||||
# This ensures images are correctly detected as unused when their
|
||||
# materials are unused (fixes issue #5)
|
||||
|
||||
users = []
|
||||
image = bpy.data.images[image_key]
|
||||
@@ -263,6 +294,13 @@ def image_materials(image_key):
|
||||
node_group_users = image_node_groups(image_key)
|
||||
|
||||
for mat in bpy.data.materials:
|
||||
# Skip library-linked and override materials
|
||||
from ..utils import compat
|
||||
if compat.is_library_or_override(mat):
|
||||
continue
|
||||
|
||||
# Check if this material uses the image
|
||||
material_uses_image = False
|
||||
|
||||
# if material uses a valid node tree, check each node
|
||||
if mat.use_nodes and mat.node_tree:
|
||||
@@ -273,7 +311,7 @@ def image_materials(image_key):
|
||||
|
||||
# if the nodes image is our image
|
||||
if node.image.name == image.name:
|
||||
users.append(mat.name)
|
||||
material_uses_image = True
|
||||
|
||||
# if image in node in node group in node tree
|
||||
elif node.type == 'GROUP':
|
||||
@@ -282,23 +320,38 @@ def image_materials(image_key):
|
||||
# list of node groups that use this image
|
||||
if node.node_tree and \
|
||||
node.node_tree.name in node_group_users:
|
||||
users.append(mat.name)
|
||||
material_uses_image = True
|
||||
|
||||
# Only add material if it uses the image AND is actually used
|
||||
if material_uses_image:
|
||||
# Check if material is actually used (in scenes)
|
||||
if material_all(mat.name):
|
||||
users.append(mat.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def image_node_groups(image_key):
|
||||
# returns a list of keys of node groups that use this image
|
||||
# Only returns node groups that are actually used (in scenes)
|
||||
# This ensures images are correctly detected as unused when their
|
||||
# node groups are unused (fixes issue #5)
|
||||
|
||||
users = []
|
||||
image = bpy.data.images[image_key]
|
||||
|
||||
# for each node group
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override node groups
|
||||
from ..utils import compat
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
|
||||
# if node group contains our image
|
||||
if node_group_has_image(node_group.name, image.name):
|
||||
users.append(node_group.name)
|
||||
# Only add node group if it is actually used (in scenes)
|
||||
if node_group_all(node_group.name):
|
||||
users.append(node_group.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
@@ -349,6 +402,9 @@ def image_textures(image_key):
|
||||
|
||||
def image_geometry_nodes(image_key):
|
||||
# returns a list of object keys that use the image through Geometry Nodes
|
||||
# Only returns objects that are actually used (in scenes)
|
||||
# This ensures images are correctly detected as unused when their
|
||||
# objects are unused (fixes issue #5)
|
||||
|
||||
users = []
|
||||
image = bpy.data.images[image_key]
|
||||
@@ -360,6 +416,19 @@ def image_geometry_nodes(image_key):
|
||||
from ..utils import compat
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
# Skip library-linked and override objects
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
|
||||
# Check if object is in any scene collection (reuse object_all logic)
|
||||
# This ensures recursive checking: if the object using the image isn't in a scene,
|
||||
# the image isn't considered used
|
||||
obj_scenes = object_all(obj.name)
|
||||
is_in_scene = bool(obj_scenes)
|
||||
|
||||
if not is_in_scene:
|
||||
continue # Skip objects not in scene collections
|
||||
|
||||
# check Geometry Nodes modifiers
|
||||
if hasattr(obj, 'modifiers'):
|
||||
for modifier in obj.modifiers:
|
||||
@@ -438,6 +507,65 @@ def material_all(material_key):
|
||||
# Check node group usage (materials in node groups used elsewhere)
|
||||
users.extend(material_node_groups(material_key))
|
||||
|
||||
# Check brush usage (materials used by brushes for stroke)
|
||||
users.extend(material_brushes(material_key))
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def material_brushes(material_key):
|
||||
# returns a list of brush keys that use this material
|
||||
# Brushes use materials for stroke rendering (Grease Pencil brushes)
|
||||
|
||||
users = []
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
if not hasattr(bpy.data, 'brushes'):
|
||||
return []
|
||||
|
||||
for brush in bpy.data.brushes:
|
||||
# Grease Pencil brushes use materials via gpencil_settings
|
||||
if hasattr(brush, 'gpencil_settings'):
|
||||
gp_settings = brush.gpencil_settings
|
||||
if gp_settings:
|
||||
# Check material property in gpencil_settings
|
||||
if hasattr(gp_settings, 'material'):
|
||||
gp_mat = gp_settings.material
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if gp_mat and gp_mat == material:
|
||||
users.append(brush.name)
|
||||
|
||||
# Check material_index - need to get material from Grease Pencil object
|
||||
if hasattr(gp_settings, 'material_index'):
|
||||
mat_idx = gp_settings.material_index
|
||||
# Check all Grease Pencil objects for this material
|
||||
for gp_obj in bpy.data.objects:
|
||||
if gp_obj.type == 'GPENCIL' and gp_obj.data:
|
||||
gp_data = gp_obj.data
|
||||
if hasattr(gp_data, 'materials') and gp_data.materials:
|
||||
if 0 <= mat_idx < len(gp_data.materials):
|
||||
gp_mat = gp_data.materials[mat_idx]
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if gp_mat and gp_mat == material:
|
||||
users.append(brush.name)
|
||||
|
||||
# Also check for stroke_material (some brush types)
|
||||
if hasattr(brush, 'stroke_material'):
|
||||
stroke_mat = brush.stroke_material
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if stroke_mat and stroke_mat == material:
|
||||
users.append(brush.name)
|
||||
|
||||
# Check for material property (some brush types)
|
||||
if hasattr(brush, 'material'):
|
||||
mat = brush.material
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if mat and mat == material:
|
||||
users.append(brush.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
@@ -446,7 +574,10 @@ def material_geometry_nodes(material_key):
|
||||
# Only counts objects that are in scene collections (recursive check)
|
||||
|
||||
users = []
|
||||
material = bpy.data.materials[material_key]
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
# Import compat module for version-safe geometry nodes access
|
||||
from ..utils import compat
|
||||
@@ -471,7 +602,8 @@ def material_geometry_nodes(material_key):
|
||||
ng = compat.get_geometry_nodes_modifier_node_group(modifier)
|
||||
if ng:
|
||||
# Check if this node group or any nested node groups contain the material
|
||||
if node_group_has_material(ng.name, material.name):
|
||||
# Pass material datablock reference to ensure we match the correct material
|
||||
if node_group_has_material_by_ref(ng.name, material):
|
||||
users.append(obj.name)
|
||||
|
||||
return distinct(users)
|
||||
@@ -486,14 +618,18 @@ def material_node_groups(material_key):
|
||||
# Optimized to return early when usage is found
|
||||
|
||||
from ..utils import compat
|
||||
material = bpy.data.materials[material_key]
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
# Check all node groups to see if they contain this material
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override node groups
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
if node_group_has_material(node_group.name, material.name):
|
||||
# Use the by_ref version to avoid name collision issues with linked materials
|
||||
if node_group_has_material_by_ref(node_group.name, material):
|
||||
# This node group contains the material, check if the node group is used
|
||||
# Check usage contexts in order of likelihood, return early when found
|
||||
|
||||
@@ -550,11 +686,40 @@ def material_node_groups(material_key):
|
||||
return [] # Material not used in any node groups
|
||||
|
||||
|
||||
def material_node_groups_list(material_key):
|
||||
# returns a list of node group names that contain this material
|
||||
# This is used for inspection UI to show which node groups use the material
|
||||
# Unlike material_node_groups(), this returns all node groups that contain
|
||||
# the material, regardless of whether they're used
|
||||
|
||||
from ..utils import compat
|
||||
users = []
|
||||
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
# Check all node groups to see if they contain this material
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override node groups
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
# Use the by_ref version to avoid name collision issues with linked materials
|
||||
if node_group_has_material_by_ref(node_group.name, material):
|
||||
users.append(node_group.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def material_objects(material_key):
|
||||
# returns a list of object keys that use this material
|
||||
|
||||
users = []
|
||||
material = bpy.data.materials[material_key]
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
|
||||
@@ -564,10 +729,12 @@ def material_objects(material_key):
|
||||
# for each material slot
|
||||
for slot in obj.material_slots:
|
||||
|
||||
# if material slot has a valid material and it is our
|
||||
# material
|
||||
if slot.material and slot.material.name == material.name:
|
||||
users.append(obj.name)
|
||||
# if material slot has a valid material, check if it's our material
|
||||
# Compare by datablock reference first to avoid matching linked materials with same name
|
||||
if slot.material:
|
||||
# Compare by reference (handles name collisions between local and linked materials)
|
||||
if slot.material == material:
|
||||
users.append(obj.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
@@ -585,7 +752,7 @@ def node_group_all(node_group_key):
|
||||
|
||||
def node_group_compositors(node_group_key):
|
||||
# returns a list containing "Compositor" if the node group is used in
|
||||
# the scene's compositor
|
||||
# any scene's compositor (either as the compositor's node tree itself, or as a node within it)
|
||||
|
||||
users = []
|
||||
node_group = bpy.data.node_groups[node_group_key]
|
||||
@@ -593,27 +760,40 @@ def node_group_compositors(node_group_key):
|
||||
# a list of node groups that use our node group
|
||||
node_group_users = node_group_node_groups(node_group_key)
|
||||
|
||||
# Import compat module for version-safe compositor access
|
||||
# Import compat and version modules for version-safe compositor access
|
||||
from ..utils import compat
|
||||
from ..utils import version
|
||||
|
||||
# if our compositor uses nodes and has a valid node tree
|
||||
scene = bpy.context.scene
|
||||
if scene.use_nodes:
|
||||
node_tree = compat.get_scene_compositor_node_tree(scene)
|
||||
if node_tree:
|
||||
# check each node in the compositor
|
||||
for node in node_tree.nodes:
|
||||
|
||||
# if the node is a group and has a valid node tree
|
||||
if hasattr(node, 'node_tree') and node.node_tree:
|
||||
|
||||
# if the node group is our node group
|
||||
if node.node_tree.name == node_group.name:
|
||||
users.append("Compositor")
|
||||
|
||||
# if the node group is in our list of node group users
|
||||
if node.node_tree.name in node_group_users:
|
||||
users.append("Compositor")
|
||||
# Check ALL scenes, not just the current one
|
||||
for scene in bpy.data.scenes:
|
||||
# First check: is this node group the compositor's node tree itself?
|
||||
if version.is_version_at_least(5, 0, 0):
|
||||
if hasattr(scene, 'compositing_node_tree') and scene.compositing_node_tree:
|
||||
# Check by reference, not just name (in case user renamed it)
|
||||
if scene.compositing_node_tree == node_group:
|
||||
users.append("Compositor")
|
||||
continue # Already found, skip checking nodes within it
|
||||
else:
|
||||
if hasattr(scene, 'node_tree') and scene.node_tree:
|
||||
# Check by reference, not just name (in case user renamed it)
|
||||
if scene.node_tree == node_group:
|
||||
users.append("Compositor")
|
||||
continue # Already found, skip checking nodes within it
|
||||
|
||||
# Second check: is this node group used as a node within the compositor?
|
||||
if scene.use_nodes:
|
||||
node_tree = compat.get_scene_compositor_node_tree(scene)
|
||||
if node_tree:
|
||||
# check each node in the compositor
|
||||
for node in node_tree.nodes:
|
||||
# if the node is a group and has a valid node tree
|
||||
if hasattr(node, 'node_tree') and node.node_tree:
|
||||
# if the node group is our node group (check by reference)
|
||||
if node.node_tree == node_group:
|
||||
users.append("Compositor")
|
||||
# if the node group is in our list of node group users
|
||||
elif node.node_tree.name in node_group_users:
|
||||
users.append("Compositor")
|
||||
|
||||
return distinct(users)
|
||||
|
||||
@@ -621,6 +801,8 @@ def node_group_compositors(node_group_key):
|
||||
def node_group_materials(node_group_key):
|
||||
# returns a list of material keys that use the node group in their
|
||||
# node trees
|
||||
# Note: Unlike image_materials(), this returns ALL materials using the node group,
|
||||
# not just used ones. This allows node_groups_deep() to check if materials are unused.
|
||||
|
||||
users = []
|
||||
node_group = bpy.data.node_groups[node_group_key]
|
||||
@@ -629,6 +811,10 @@ def node_group_materials(node_group_key):
|
||||
node_group_users = node_group_node_groups(node_group_key)
|
||||
|
||||
for material in bpy.data.materials:
|
||||
# Skip library-linked and override materials
|
||||
from ..utils import compat
|
||||
if compat.is_library_or_override(material):
|
||||
continue
|
||||
|
||||
# if material uses nodes and has a valid node tree, check each node
|
||||
if material.use_nodes and material.node_tree:
|
||||
@@ -754,6 +940,31 @@ def node_group_objects(node_group_key):
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def _check_node_input_sockets_for_image(node, image_key):
|
||||
"""Helper function to check if any input socket of a node contains the image.
|
||||
This handles nodes like Menu Switch that have materials/images in input sockets."""
|
||||
try:
|
||||
image = bpy.data.images[image_key]
|
||||
if not hasattr(node, 'inputs'):
|
||||
return False
|
||||
|
||||
for input_socket in node.inputs:
|
||||
try:
|
||||
# Check if socket has a default_value that is an image
|
||||
if hasattr(input_socket, 'default_value') and input_socket.default_value:
|
||||
socket_value = input_socket.default_value
|
||||
# Check if it's an image datablock
|
||||
if hasattr(socket_value, 'name') and hasattr(socket_value, 'filepath'):
|
||||
if socket_value.name == image.name or socket_value == image:
|
||||
return True
|
||||
except (AttributeError, ReferenceError, RuntimeError, TypeError, KeyError):
|
||||
continue # Skip this socket if we can't access it
|
||||
except (KeyError, AttributeError):
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def node_group_has_image(node_group_key, image_key):
|
||||
# recursively returns true if the node group contains this image
|
||||
# directly or if it contains a node group a node group that contains
|
||||
@@ -774,9 +985,14 @@ def node_group_has_image(node_group_key, image_key):
|
||||
if node.image.name == image.name:
|
||||
has_image = True
|
||||
|
||||
# Check input sockets for images (e.g., Menu Switch nodes)
|
||||
# This handles nodes that have images connected via input sockets
|
||||
if not has_image:
|
||||
has_image = _check_node_input_sockets_for_image(node, image_key)
|
||||
|
||||
# recurse case
|
||||
# if node is a node group and has a valid node tree
|
||||
elif hasattr(node, 'node_tree') and node.node_tree:
|
||||
if not has_image and hasattr(node, 'node_tree') and node.node_tree:
|
||||
has_image = node_group_has_image(
|
||||
node.node_tree.name, image.name)
|
||||
|
||||
@@ -855,15 +1071,51 @@ def node_group_has_texture(node_group_key, texture_key):
|
||||
return has_texture
|
||||
|
||||
|
||||
def node_group_has_material(node_group_key, material_key):
|
||||
def _check_node_input_sockets_for_material(node, material_key):
|
||||
"""Helper function to check if any input socket of a node contains the material.
|
||||
This handles nodes like Menu Switch that have materials in input sockets."""
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
return _check_node_input_sockets_for_material_by_ref(node, material)
|
||||
except (KeyError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def _check_node_input_sockets_for_material_by_ref(node, material):
|
||||
"""Helper function to check if any input socket of a node contains the material.
|
||||
Takes material datablock directly to avoid name collision issues with linked materials."""
|
||||
if not material or not hasattr(node, 'inputs'):
|
||||
return False
|
||||
|
||||
for input_socket in node.inputs:
|
||||
try:
|
||||
# Check socket type - material sockets are typically 'MATERIAL' type
|
||||
socket_type = getattr(input_socket, 'type', '')
|
||||
if socket_type == 'MATERIAL' or 'material' in str(socket_type).lower():
|
||||
# Check if this socket has a default_value that is a material
|
||||
if hasattr(input_socket, 'default_value') and input_socket.default_value:
|
||||
socket_material = input_socket.default_value
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if socket_material and socket_material == material:
|
||||
return True
|
||||
except (AttributeError, ReferenceError, RuntimeError, TypeError, KeyError):
|
||||
continue # Skip this socket if we can't access it
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def node_group_has_material_by_ref(node_group_key, material):
|
||||
# returns true if a node group contains this material (directly or nested)
|
||||
# Takes material datablock directly to avoid name collision issues with linked materials
|
||||
|
||||
has_material = False
|
||||
try:
|
||||
node_group = bpy.data.node_groups[node_group_key]
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return False
|
||||
|
||||
if not material:
|
||||
return False
|
||||
|
||||
try:
|
||||
for node in node_group.nodes:
|
||||
@@ -883,10 +1135,9 @@ def node_group_has_material(node_group_key, material_key):
|
||||
# Check the default_value (for unlinked materials)
|
||||
if hasattr(material_socket, 'default_value'):
|
||||
socket_material = material_socket.default_value
|
||||
if socket_material and hasattr(socket_material, 'name'):
|
||||
if (socket_material.name == material.name or
|
||||
socket_material == material):
|
||||
has_material = True
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if socket_material and socket_material == material:
|
||||
has_material = True
|
||||
except (KeyError, AttributeError, ReferenceError, RuntimeError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -900,11 +1151,10 @@ def node_group_has_material(node_group_key, material_key):
|
||||
# Check if this socket has a default_value that is a material
|
||||
if hasattr(input_socket, 'default_value') and input_socket.default_value:
|
||||
socket_material = input_socket.default_value
|
||||
if socket_material and hasattr(socket_material, 'name'):
|
||||
if (socket_material.name == material.name or
|
||||
socket_material == material):
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if socket_material and socket_material == material:
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
except (AttributeError, ReferenceError, RuntimeError, TypeError):
|
||||
continue # Skip this socket if we can't access it
|
||||
|
||||
@@ -912,8 +1162,8 @@ def node_group_has_material(node_group_key, material_key):
|
||||
if not has_material and hasattr(node, 'material'):
|
||||
try:
|
||||
if node.material:
|
||||
if (node.material.name == material.name or
|
||||
node.material == material):
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if node.material == material:
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
@@ -926,14 +1176,33 @@ def node_group_has_material(node_group_key, material_key):
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
pass # Skip if bl_idname access fails
|
||||
|
||||
# Check for Menu Switch nodes and other nodes with material inputs
|
||||
# Menu Switch nodes can have materials in any of their input sockets
|
||||
if not has_material and hasattr(node, 'bl_idname'):
|
||||
try:
|
||||
node_type = node.bl_idname
|
||||
# Check for Menu Switch node (GeometryNodeMenuSwitch)
|
||||
if node_type == 'GeometryNodeMenuSwitch' or 'MenuSwitch' in node_type:
|
||||
has_material = _check_node_input_sockets_for_material_by_ref(node, material)
|
||||
if has_material:
|
||||
break
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
pass # Skip if bl_idname access fails
|
||||
|
||||
# General check: Check all input sockets for materials (catches other node types)
|
||||
# This is a fallback for any node that might have material inputs
|
||||
if not has_material:
|
||||
has_material = _check_node_input_sockets_for_material_by_ref(node, material)
|
||||
if has_material:
|
||||
break
|
||||
|
||||
# Fallback: Check for any node with a material property (e.g., Set Material)
|
||||
# This catches other node types that might have materials
|
||||
if not has_material and hasattr(node, 'material'):
|
||||
try:
|
||||
if node.material:
|
||||
# Check both by name and by direct reference for robustness
|
||||
if (node.material.name == material.name or
|
||||
node.material == material):
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if node.material == material:
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
@@ -948,8 +1217,8 @@ def node_group_has_material(node_group_key, material_key):
|
||||
if hasattr(node, 'material'):
|
||||
try:
|
||||
if node.material:
|
||||
if (node.material.name == material.name or
|
||||
node.material == material):
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if node.material == material:
|
||||
has_material = True
|
||||
break
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
@@ -962,8 +1231,8 @@ def node_group_has_material(node_group_key, material_key):
|
||||
if not has_material and hasattr(node, 'node_tree'):
|
||||
try:
|
||||
if node.node_tree:
|
||||
has_material = node_group_has_material(
|
||||
node.node_tree.name, material.name)
|
||||
has_material = node_group_has_material_by_ref(
|
||||
node.node_tree.name, material)
|
||||
except (KeyError, AttributeError, ReferenceError, RuntimeError):
|
||||
continue # Skip invalid node groups
|
||||
|
||||
@@ -979,6 +1248,18 @@ def node_group_has_material(node_group_key, material_key):
|
||||
return has_material
|
||||
|
||||
|
||||
def node_group_has_material(node_group_key, material_key):
|
||||
# returns true if a node group contains this material (directly or nested)
|
||||
# Wrapper that converts material_key to material datablock for reference-based comparison
|
||||
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return False
|
||||
|
||||
return node_group_has_material_by_ref(node_group_key, material)
|
||||
|
||||
|
||||
def particle_all(particle_key):
|
||||
# returns a list of keys of every data-block that uses this particle
|
||||
# system
|
||||
@@ -1168,22 +1449,68 @@ def texture_particles(texture_key):
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def object_all(object_key):
|
||||
def object_all(object_key, _visited_objects=None):
|
||||
# returns a list of scene names where the object is used
|
||||
# An object is "used" if it's in any collection that's part of any scene's collection hierarchy
|
||||
# OR if it's in a collection that is instanced (and that instance is in a scene)
|
||||
# _visited_objects is used to prevent infinite recursion when checking instanced collections
|
||||
|
||||
users = []
|
||||
obj = bpy.data.objects[object_key]
|
||||
if _visited_objects is None:
|
||||
_visited_objects = set()
|
||||
|
||||
# Prevent infinite recursion
|
||||
if object_key in _visited_objects:
|
||||
return []
|
||||
_visited_objects.add(object_key)
|
||||
|
||||
try:
|
||||
users = []
|
||||
obj = bpy.data.objects[object_key]
|
||||
|
||||
# Get all collections that contain this object
|
||||
for collection in obj.users_collection:
|
||||
# Check if this collection is in any scene's hierarchy
|
||||
for scene in bpy.data.scenes:
|
||||
if _scene_collection_contains(scene.collection, collection):
|
||||
if scene.name not in users:
|
||||
users.append(scene.name)
|
||||
|
||||
return distinct(users)
|
||||
# Get all collections that contain this object
|
||||
for collection in obj.users_collection:
|
||||
# Check if this collection is in any scene's hierarchy
|
||||
for scene in bpy.data.scenes:
|
||||
if _scene_collection_contains(scene.collection, collection):
|
||||
if scene.name not in users:
|
||||
users.append(scene.name)
|
||||
|
||||
# Also check if this collection is instanced (and the instance is in a scene)
|
||||
# Get all objects that instance this collection
|
||||
for instance_obj in bpy.data.objects:
|
||||
# Skip library-linked and override objects
|
||||
if compat.is_library_or_override(instance_obj):
|
||||
continue
|
||||
|
||||
# Check if object is a collection instance
|
||||
if hasattr(instance_obj, 'instance_type') and instance_obj.instance_type == 'COLLECTION':
|
||||
if hasattr(instance_obj, 'instance_collection') and instance_obj.instance_collection:
|
||||
if instance_obj.instance_collection.name == collection.name:
|
||||
# Check if the instance object is in a scene (using visited set to prevent recursion)
|
||||
# First check if instance object is directly in a scene collection
|
||||
instance_direct_scenes = []
|
||||
for instance_collection in instance_obj.users_collection:
|
||||
for scene in bpy.data.scenes:
|
||||
if _scene_collection_contains(scene.collection, instance_collection):
|
||||
if scene.name not in instance_direct_scenes:
|
||||
instance_direct_scenes.append(scene.name)
|
||||
|
||||
# If instance object is directly in a scene, the instanced collection's objects are used
|
||||
if instance_direct_scenes:
|
||||
for scene_name in instance_direct_scenes:
|
||||
if scene_name not in users:
|
||||
users.append(scene_name)
|
||||
else:
|
||||
# Instance object is not directly in a scene, but might be in an instanced collection
|
||||
# Recursively check (with visited set to prevent infinite loops)
|
||||
instance_scenes = object_all(instance_obj.name, _visited_objects)
|
||||
for scene_name in instance_scenes:
|
||||
if scene_name not in users:
|
||||
users.append(scene_name)
|
||||
|
||||
return distinct(users)
|
||||
finally:
|
||||
_visited_objects.remove(object_key)
|
||||
|
||||
|
||||
def armature_all(armature_key):
|
||||
|
||||
@@ -328,6 +328,8 @@ class ATOMIC_OT_inspect_materials(bpy.types.Operator):
|
||||
|
||||
# user lists
|
||||
users_objects = []
|
||||
users_brushes = []
|
||||
users_node_groups = []
|
||||
|
||||
def draw(self, context):
|
||||
global inspection_update_trigger
|
||||
@@ -349,13 +351,35 @@ class ATOMIC_OT_inspect_materials(bpy.types.Operator):
|
||||
if atom.materials_field in bpy.data.materials.keys():
|
||||
self.users_objects = \
|
||||
users.material_objects(atom.materials_field)
|
||||
self.users_brushes = \
|
||||
users.material_brushes(atom.materials_field)
|
||||
self.users_node_groups = \
|
||||
users.material_node_groups_list(atom.materials_field)
|
||||
|
||||
# if key is invalid, empty the user lists
|
||||
else:
|
||||
self.users_objects = []
|
||||
self.users_brushes = []
|
||||
self.users_node_groups = []
|
||||
|
||||
inspection_update_trigger = False
|
||||
|
||||
# brushes box list
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Brushes",
|
||||
items=self.users_brushes,
|
||||
icon="BRUSH_DATA"
|
||||
)
|
||||
|
||||
# node groups box list
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Node Groups",
|
||||
items=self.users_node_groups,
|
||||
icon="NODETREE"
|
||||
)
|
||||
|
||||
# objects box list
|
||||
ui_layouts.box_list_diverse(
|
||||
layout=layout,
|
||||
|
||||
@@ -57,6 +57,27 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
atom.worlds
|
||||
]
|
||||
|
||||
# Progress display section (only visible when operation is running)
|
||||
if atom.is_operation_running:
|
||||
box = layout.box()
|
||||
col = box.column(align=True)
|
||||
|
||||
# Progress bar with percentage (Blender shows percentage in the bar with PERCENTAGE subtype)
|
||||
progress_row = col.row(align=True)
|
||||
progress_row.scale_y = 1.5
|
||||
progress_row.prop(atom, "operation_progress", text="", slider=True)
|
||||
|
||||
# Status text
|
||||
if atom.operation_status:
|
||||
col.label(text=atom.operation_status, icon='TIME')
|
||||
|
||||
# Cancel button
|
||||
row = col.row()
|
||||
row.scale_y = 1.5
|
||||
row.operator("atomic.cancel_operation", text="Cancel", icon='X')
|
||||
|
||||
layout.separator()
|
||||
|
||||
# nuke and clean buttons
|
||||
row = layout.row(align=True)
|
||||
row.scale_y = 2.0
|
||||
@@ -160,10 +181,12 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
# right column
|
||||
col = split.column(align=True)
|
||||
|
||||
# images buttons
|
||||
splitcol = col.split(factor=0.8, align=True)
|
||||
|
||||
splitcol.prop(
|
||||
# images buttons (deep scan checkbox, images checkbox, inspect button)
|
||||
# Standard split layout for images (matches other categories)
|
||||
images_split = col.split(factor=0.8, align=True)
|
||||
|
||||
# Images checkbox (will be slightly offset due to deep scan, but inspect aligns)
|
||||
images_split.prop(
|
||||
atom,
|
||||
"images",
|
||||
text="Images",
|
||||
@@ -171,7 +194,8 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
icon='IMAGE_DATA'
|
||||
)
|
||||
|
||||
splitcol.operator(
|
||||
# Inspect button (right, aligns with other inspect buttons)
|
||||
images_split.operator(
|
||||
"atomic.inspect_images",
|
||||
icon='VIEWZOOM',
|
||||
text=""
|
||||
@@ -267,7 +291,11 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
icon='RESTRICT_SELECT_OFF'
|
||||
)
|
||||
|
||||
|
||||
# Cache and missing file management
|
||||
row = layout.row(align=True)
|
||||
row.operator("atomic.clear_cache", text="Clear Cache", icon="FILE_REFRESH")
|
||||
row.operator("atomic.detect_missing", text="Detect Missing", icon="LIBRARY_DATA_DIRECT")
|
||||
|
||||
reg_list = [ATOMIC_PT_main_panel]
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,41 @@ from .. import config
|
||||
from ..stats import missing
|
||||
from .utils import ui_layouts
|
||||
|
||||
# Module-level state for detect missing operator instance
|
||||
_detect_missing_operator_instance = None
|
||||
|
||||
|
||||
def _warp_cursor_to_area_center(context, prefer_area_type="VIEW_3D") -> None:
|
||||
"""Best-effort: move cursor to area center so popups appear centered.
|
||||
|
||||
Blender's popup placement is often tied to the last event mouse position.
|
||||
Warping is a hack, but it's the only reliable way to 'center' popups in some contexts.
|
||||
"""
|
||||
win = getattr(context, "window", None)
|
||||
screen = getattr(context, "screen", None)
|
||||
if win is None or screen is None:
|
||||
return
|
||||
|
||||
area = None
|
||||
for a in screen.areas:
|
||||
if a.type == prefer_area_type:
|
||||
area = a
|
||||
break
|
||||
if area is None and screen.areas:
|
||||
area = screen.areas[0]
|
||||
if area is None:
|
||||
return
|
||||
|
||||
try:
|
||||
# cursor_warp expects WINDOW-relative coordinates (0,0 at window bottom-left),
|
||||
# not OS desktop coordinates. Warping to the window center is the most
|
||||
# reliable option across layouts.
|
||||
x = int(win.width / 2)
|
||||
y = int(win.height / 2)
|
||||
win.cursor_warp(x, y)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Atomic Data Manager Detect Missing Files Popup
|
||||
class ATOMIC_OT_detect_missing(bpy.types.Operator):
|
||||
@@ -42,38 +77,6 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
|
||||
missing_images = []
|
||||
missing_libraries = []
|
||||
|
||||
# missing file recovery option enum property
|
||||
recovery_option: bpy.props.EnumProperty(
|
||||
items=[
|
||||
(
|
||||
'IGNORE',
|
||||
'Ignore Missing Files',
|
||||
'Ignore the missing files and leave them offline'
|
||||
),
|
||||
(
|
||||
'RELOAD',
|
||||
'Reload Missing Files',
|
||||
'Reload the missing files from their existing file paths'
|
||||
),
|
||||
(
|
||||
'REMOVE',
|
||||
'Remove Missing Files',
|
||||
'Remove the missing files from the project'
|
||||
),
|
||||
(
|
||||
'SEARCH',
|
||||
'Search for Missing Files (under development)',
|
||||
'Search for the missing files in a directory'
|
||||
),
|
||||
(
|
||||
'REPLACE',
|
||||
'Specify Replacement Files (under development)',
|
||||
'Replace missing files with new files'
|
||||
),
|
||||
],
|
||||
default='IGNORE'
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
@@ -109,12 +112,20 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
|
||||
|
||||
row = layout.separator() # extra space
|
||||
|
||||
# recovery option selection
|
||||
# recovery option buttons
|
||||
row = layout.row()
|
||||
row.label(text="What would you like to do?")
|
||||
|
||||
row = layout.row()
|
||||
row.prop(self, 'recovery_option', text="")
|
||||
row.scale_y = 1.5
|
||||
op_reload = row.operator("atomic.reload_missing", text="Reload", icon="FILE_REFRESH")
|
||||
op_remove = row.operator("atomic.remove_missing", text="Remove", icon="TRASH")
|
||||
op_search = row.operator("atomic.search_missing", text="Search", icon="VIEWZOOM")
|
||||
op_replace = row.operator("atomic.replace_missing", text="Replace", icon="FILEBROWSER")
|
||||
|
||||
# Refresh button
|
||||
row = layout.row()
|
||||
refresh_op = row.operator("atomic.detect_missing_refresh", text="Refresh", icon="FILE_REFRESH")
|
||||
|
||||
# missing files interface if no missing files are found
|
||||
else:
|
||||
@@ -129,54 +140,89 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
|
||||
row = layout.separator() # extra space
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
# ignore missing files will take no action
|
||||
|
||||
# reload missing files
|
||||
if self.recovery_option == 'RELOAD':
|
||||
bpy.ops.atomic.reload_missing('INVOKE_DEFAULT')
|
||||
|
||||
# remove missing files
|
||||
elif self.recovery_option == 'REMOVE':
|
||||
bpy.ops.atomic.remove_missing('INVOKE_DEFAULT')
|
||||
|
||||
# search for missing files
|
||||
elif self.recovery_option == 'SEARCH':
|
||||
bpy.ops.atomic.search_missing('INVOKE_DEFAULT')
|
||||
|
||||
# replace missing files
|
||||
elif self.recovery_option == 'REPLACE':
|
||||
bpy.ops.atomic.replace_missing('INVOKE_DEFAULT')
|
||||
|
||||
# Buttons now directly invoke operators, so execute just closes the dialog
|
||||
# IGNORE is the default behavior (no action taken)
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
|
||||
# update missing file lists
|
||||
global _detect_missing_operator_instance
|
||||
|
||||
# Store operator instance for refresh functionality
|
||||
_detect_missing_operator_instance = self
|
||||
|
||||
# Always refresh missing file lists when invoked
|
||||
self.missing_images = missing.images()
|
||||
self.missing_libraries = missing.libraries()
|
||||
|
||||
wm = context.window_manager
|
||||
|
||||
# Force popup placement to screen center (see BlenderArtists reference).
|
||||
_warp_cursor_to_area_center(context)
|
||||
|
||||
# invoke large dialog if there are missing files
|
||||
if self.missing_images or self.missing_libraries:
|
||||
return wm.invoke_props_dialog(self, width=500)
|
||||
|
||||
# invoke small dialog if there are no missing files
|
||||
else:
|
||||
return wm.invoke_popup(self, width=300)
|
||||
return wm.invoke_props_dialog(self, width=300)
|
||||
|
||||
|
||||
@persistent
|
||||
def autodetect_missing_files(dummy=None):
|
||||
# invokes the detect missing popup when missing files are detected upon
|
||||
# loading a new Blender project
|
||||
# Use a timer to defer the operator call since load_post handlers
|
||||
# cannot directly invoke operators that modify data
|
||||
if config.enable_missing_file_warning and \
|
||||
(missing.images() or missing.libraries()):
|
||||
def invoke_detect_missing():
|
||||
try:
|
||||
bpy.ops.atomic.detect_missing('INVOKE_DEFAULT')
|
||||
except RuntimeError:
|
||||
# If still in invalid context, ignore (will be handled on next user action)
|
||||
pass
|
||||
return None # Run once
|
||||
|
||||
bpy.app.timers.register(invoke_detect_missing, first_interval=0.1)
|
||||
|
||||
|
||||
# Refresh operator for missing file detection
|
||||
class ATOMIC_OT_detect_missing_refresh(bpy.types.Operator):
|
||||
"""Refresh missing file detection"""
|
||||
bl_idname = "atomic.detect_missing_refresh"
|
||||
bl_label = "Refresh Missing Files"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
def execute(self, context):
|
||||
global _detect_missing_operator_instance
|
||||
|
||||
# Update the stored operator instance if it exists and is valid
|
||||
if _detect_missing_operator_instance is not None:
|
||||
try:
|
||||
# Check if operator instance is still valid
|
||||
_ = _detect_missing_operator_instance.bl_idname
|
||||
|
||||
# Update the missing file lists
|
||||
_detect_missing_operator_instance.missing_images = missing.images()
|
||||
_detect_missing_operator_instance.missing_libraries = missing.libraries()
|
||||
|
||||
# Redraw all areas to refresh the dialog
|
||||
for area in context.screen.areas:
|
||||
area.tag_redraw()
|
||||
|
||||
self.report({'INFO'}, "Missing files list refreshed")
|
||||
return {'FINISHED'}
|
||||
except (ReferenceError, AttributeError, TypeError):
|
||||
# Operator instance invalidated, clear it
|
||||
_detect_missing_operator_instance = None
|
||||
|
||||
# If no valid instance, invoke a new dialog
|
||||
bpy.ops.atomic.detect_missing('INVOKE_DEFAULT')
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
reg_list = [ATOMIC_OT_detect_missing]
|
||||
reg_list = [ATOMIC_OT_detect_missing, ATOMIC_OT_detect_missing_refresh]
|
||||
|
||||
|
||||
def register():
|
||||
|
||||
@@ -24,11 +24,41 @@ some functions for syncing the preference properties with external factors.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import os
|
||||
from bpy.utils import register_class
|
||||
from ..utils import compat
|
||||
from .. import config
|
||||
import sys
|
||||
# updater removed in Blender 4.5 extension format
|
||||
|
||||
# Get the root module name dynamically
|
||||
def _get_addon_module_name():
|
||||
"""Get the root addon module name for bl_idname."""
|
||||
# In Blender 5.0 extensions loaded via VSCode, the module name is the full path
|
||||
# e.g., "bl_ext.vscode_development.atomic_data_manager"
|
||||
# We need to get it from the parent package (atomic_data_manager)
|
||||
try:
|
||||
# Get parent package name from __package__ (remove .ui suffix)
|
||||
if __package__:
|
||||
parent_pkg = __package__.rsplit('.', 1)[0] if '.' in __package__ else __package__
|
||||
# Get the actual module from sys.modules to get its __name__
|
||||
parent_module = sys.modules.get(parent_pkg)
|
||||
if parent_module and hasattr(parent_module, '__name__'):
|
||||
module_name = parent_module.__name__
|
||||
config.debug_print(f"[Atomic Debug] Using parent module __name__ as bl_idname: {module_name}")
|
||||
return module_name
|
||||
else:
|
||||
# Use the package name directly
|
||||
config.debug_print(f"[Atomic Debug] Using parent package name as bl_idname: {parent_pkg}")
|
||||
return parent_pkg
|
||||
except Exception as e:
|
||||
config.debug_print(f"[Atomic Debug] Could not get parent module name: {e}")
|
||||
|
||||
# Last fallback
|
||||
module_name = "atomic_data_manager"
|
||||
config.debug_print(f"[Atomic Debug] Using fallback bl_idname: {module_name}")
|
||||
return module_name
|
||||
|
||||
|
||||
def _get_addon_prefs():
|
||||
# robustly find our AddonPreferences instance regardless of module name
|
||||
@@ -106,6 +136,9 @@ def copy_prefs_to_config(self, context):
|
||||
config.include_fake_users = \
|
||||
atomic_preferences.include_fake_users
|
||||
|
||||
config.enable_debug_prints = \
|
||||
atomic_preferences.enable_debug_prints
|
||||
|
||||
# hidden atomic preferences
|
||||
config.pie_menu_type = \
|
||||
atomic_preferences.pie_menu_type
|
||||
@@ -192,7 +225,9 @@ def remove_pie_menu_hotkeys():
|
||||
|
||||
# Atomic Data Manager Preference Panel UI
|
||||
class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
|
||||
bl_idname = "atomic_data_manager"
|
||||
# bl_idname must match the add-on's module name exactly
|
||||
# Get it dynamically to ensure it matches what Blender registered
|
||||
bl_idname = _get_addon_module_name()
|
||||
|
||||
# visible atomic preferences
|
||||
enable_missing_file_warning: bpy.props.BoolProperty(
|
||||
@@ -214,6 +249,11 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
|
||||
update=update_pie_menu_hotkeys
|
||||
)
|
||||
|
||||
enable_debug_prints: bpy.props.BoolProperty(
|
||||
description="Enable debug print statements in the console",
|
||||
default=False
|
||||
)
|
||||
|
||||
# hidden atomic preferences
|
||||
pie_menu_type: bpy.props.StringProperty(
|
||||
default="D"
|
||||
@@ -243,6 +283,9 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Debug: verify draw is being called
|
||||
config.debug_print("[Atomic Debug] Preferences draw() method called")
|
||||
|
||||
split = layout.split()
|
||||
|
||||
@@ -266,6 +309,13 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
|
||||
text="Include Fake Users"
|
||||
)
|
||||
|
||||
# enable debug prints toggle
|
||||
col.prop(
|
||||
self,
|
||||
"enable_debug_prints",
|
||||
text="Enable Debug Prints"
|
||||
)
|
||||
|
||||
# pie menu settings
|
||||
pie_split = col.split(factor=0.55) # nice
|
||||
|
||||
@@ -317,7 +367,13 @@ keymaps = []
|
||||
|
||||
def register():
|
||||
for cls in reg_list:
|
||||
register_class(cls)
|
||||
try:
|
||||
register_class(cls)
|
||||
config.debug_print(f"[Atomic Debug] Registered preferences class: {cls.__name__} with bl_idname: {cls.bl_idname}")
|
||||
except Exception as e:
|
||||
print(f"[Atomic Error] Failed to register preferences class {cls.__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# make sure global preferences are updated on registration
|
||||
copy_prefs_to_config(None, None)
|
||||
|
||||
@@ -362,7 +362,6 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
|
||||
text="Unnamed: {0}".format(count.worlds_unnamed())
|
||||
)
|
||||
|
||||
|
||||
reg_list = [ATOMIC_PT_stats_panel]
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ between Blender 4.2 LTS, 4.5 LTS, and 5.0.
|
||||
import bpy
|
||||
from bpy.utils import register_class, unregister_class
|
||||
from . import version
|
||||
from .. import config
|
||||
|
||||
|
||||
def safe_register_class(cls):
|
||||
@@ -116,7 +117,7 @@ def get_scene_compositor_node_tree(scene):
|
||||
Get the compositor node tree from a scene, handling version differences.
|
||||
|
||||
In Blender 4.2/4.5: scene.node_tree
|
||||
In Blender 5.0+: scene.compositing_node_tree
|
||||
In Blender 5.0+: scene.compositing_node_group
|
||||
|
||||
Args:
|
||||
scene: The scene object
|
||||
@@ -124,14 +125,35 @@ def get_scene_compositor_node_tree(scene):
|
||||
Returns:
|
||||
NodeTree or None: The compositor node tree if available
|
||||
"""
|
||||
# Blender 5.0+ uses compositing_node_tree
|
||||
# Blender 5.0+ uses compositing_node_group (not compositing_node_tree!)
|
||||
if version.is_version_at_least(5, 0, 0):
|
||||
if hasattr(scene, 'compositing_node_tree') and scene.compositing_node_tree:
|
||||
return scene.compositing_node_tree
|
||||
# Try compositing_node_group first (Blender 5.0+)
|
||||
try:
|
||||
node_tree = getattr(scene, 'compositing_node_group', None)
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: scene='{scene.name}', use_nodes={scene.use_nodes}, compositing_node_group={node_tree}")
|
||||
if node_tree:
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: Found compositor node tree: {node_tree.name}")
|
||||
return node_tree
|
||||
except (AttributeError, TypeError) as e:
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: compositing_node_group access failed: {e}")
|
||||
|
||||
# Fallback: try compositing_node_tree (in case it exists in some versions)
|
||||
try:
|
||||
node_tree = getattr(scene, 'compositing_node_tree', None)
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: compositing_node_tree={node_tree}")
|
||||
if node_tree:
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: Found via compositing_node_tree: {node_tree.name}")
|
||||
return node_tree
|
||||
except (AttributeError, TypeError) as e:
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: compositing_node_tree access failed: {e}")
|
||||
else:
|
||||
# Blender 4.2/4.5 uses node_tree
|
||||
if hasattr(scene, 'node_tree') and scene.node_tree:
|
||||
return scene.node_tree
|
||||
try:
|
||||
node_tree = getattr(scene, 'node_tree', None)
|
||||
if node_tree:
|
||||
return node_tree
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user