2026-02-16

This commit is contained in:
2026-03-17 15:25:32 -06:00
parent d5dd373de0
commit 60100fbab2
560 changed files with 33397 additions and 20776 deletions
@@ -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):