2026-01-01

This commit is contained in:
2026-03-17 15:16:34 -06:00
parent ec4cf523fb
commit b80274187b
263 changed files with 95164 additions and 3848 deletions
@@ -1,6 +1,41 @@
# Changelog
## [v2.1.0] - 2025-12-18
All notable changes to this project will be documented in this file.
### Features
- Added support for detecting unused objects and armatures (#1)
- Objects not present in any scene collections are now detected as unused
- Armatures not used by any objects in scenes (including direct use, modifiers, and constraints like "Child Of") are detected as unused
- Smart Select and Clean operations now support objects and armatures
### Fixes
- Fixed material detection in Geometry Nodes Set Material nodes
- Materials used in Geometry Nodes' "Set Material" nodes are now correctly detected as used
- Fixed legacy issue where materials in node groups (e.g., "outline-highlight" in "box-highlight" node group) were incorrectly flagged as unused
- This was a hangover from Atomic never being developed past Blender 2.93, where Geometry Nodes Set Material nodes use input sockets rather than direct material properties
- Performance optimizations for Smart Select and Clean operations (#3)
- Removed inefficient threading implementation that was causing poor performance
- Implemented short-circuiting logic in Smart Select to exit early when unused items are found
- Fixed UI operators to use cached values instead of recalculating on every draw call
- Note: Further performance improvements are limited by Blender's Python API being single-threaded and requiring sequential access to `bpy.data` collections, making true parallelization impossible without risking data corruption
### Internal
- Removed incorrect "Remington Creative" copyright notices from newly created files
- Updated repository configuration in manifest
## [v2.0.3] - 2025-12-17
### Fixes
- Fixed missing import error in missing file detection
## [v2.0.2] - 2025-12-17
### Fixes
- Atomic now completely ignores all library-linked and override datablocks across all operations, as originally intended.
## [v2.0.1] - 2025-12-16
### Fixes
- Blender 5.0 compatibility: Fixed `AttributeError` when detecting missing library files (Library objects use `packed_file` singular, Image objects use `packed_files` plural in 5.0)
- Fixed unregistration errors in Blender 4.5 by using safe unregister functions throughout the codebase
## [v2.0.0] - Raincloud's first re-release
@@ -25,7 +25,7 @@ registration for all packages within the add-on.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from .utils import compat
from . import ops
from . import ui
from .ui import inspect_ui
@@ -40,8 +40,10 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
lights: bpy.props.BoolProperty(default=False)
materials: bpy.props.BoolProperty(default=False)
node_groups: bpy.props.BoolProperty(default=False)
objects: bpy.props.BoolProperty(default=False)
particles: bpy.props.BoolProperty(default=False)
textures: bpy.props.BoolProperty(default=False)
armatures: bpy.props.BoolProperty(default=False)
worlds: bpy.props.BoolProperty(default=False)
# inspect data-block search fields
@@ -66,6 +68,12 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
textures_field: bpy.props.StringProperty(
update=inspect_ui.update_inspection)
objects_field: bpy.props.StringProperty(
update=inspect_ui.update_inspection)
armatures_field: bpy.props.StringProperty(
update=inspect_ui.update_inspection)
worlds_field: bpy.props.StringProperty(
update=inspect_ui.update_inspection)
@@ -111,6 +119,16 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
'WORLDS',
'Worlds',
'Worlds'
),
(
'OBJECTS',
'Objects',
'Objects'
),
(
'ARMATURES',
'Armatures',
'Armatures'
)
],
default='COLLECTIONS'
@@ -188,7 +206,14 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
'Worlds',
'WORLD',
9
)
),
(
'ARMATURES',
'Armatures',
'Armatures',
'ARMATURE_DATA',
10
)
],
default='OVERVIEW'
)
@@ -220,5 +245,5 @@ def unregister():
ui.unregister()
ops.unregister()
unregister_class(ATOMIC_PG_main)
compat.safe_unregister_class(ATOMIC_PG_main)
del bpy.types.Scene.atomic
@@ -2,14 +2,14 @@ schema_version = "1.0.0"
id = "atomic_data_manager"
name = "Atomic Data Manager"
version = "2.0.0"
version = "2.1.0"
type = "add-on"
author = "RaincloudTheDragon"
maintainer = "RaincloudTheDragon"
blender_version_min = "4.2.0"
license = ["GPL-3.0-or-later"]
description = "An Intelligent Data Manager for Blender."
homepage = "https://github.com/grantwilk/atomic-data-manager"
homepage = "https://github.com/RaincloudTheDragon/atomic-data-manager/"
tagline = "Smart cleanup and inspection of Blender data-blocks"
tags = ["utility", "management", "cleanup"]
@@ -30,9 +30,10 @@ intefaces in Blender.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
from .. import config
from ..stats import unused
from ..stats import unused_parallel
from .utils import nuke
from .utils import clean
from ..ui.utils import ui_layouts
@@ -157,8 +158,10 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
unused_lights = []
unused_materials = []
unused_node_groups = []
unused_objects = []
unused_particles = []
unused_textures = []
unused_armatures = []
unused_worlds = []
def draw(self, context):
@@ -167,67 +170,74 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
col = layout.column()
col.label(text="Remove the following data-blocks?")
collections = sorted(unused.collections_deep())
# Use cached values from invoke() instead of recalculating
ui_layouts.box_list(
layout=layout,
title="Collections",
items=collections,
items=sorted(self.unused_collections),
icon="OUTLINER_OB_GROUP_INSTANCE"
)
images = sorted(unused.images_deep())
ui_layouts.box_list(
layout=layout,
title="Images",
items=images,
items=sorted(self.unused_images),
icon="IMAGE_DATA"
)
lights = sorted(unused.lights_deep())
ui_layouts.box_list(
layout=layout,
title="Lights",
items=lights,
items=sorted(self.unused_lights),
icon="OUTLINER_OB_LIGHT"
)
materials = sorted(unused.materials_deep())
ui_layouts.box_list(
layout=layout,
title="Materials",
items=materials,
items=sorted(self.unused_materials),
icon="MATERIAL"
)
node_groups = sorted(unused.node_groups_deep())
ui_layouts.box_list(
layout=layout,
title="Node Groups",
items=node_groups,
items=sorted(self.unused_node_groups),
icon="NODETREE"
)
particles = sorted(unused.particles_deep())
ui_layouts.box_list(
layout=layout,
title="Objects",
items=sorted(self.unused_objects),
icon="OBJECT_DATA"
)
ui_layouts.box_list(
layout=layout,
title="Particle Systems",
items=particles,
items=sorted(self.unused_particles),
icon="PARTICLES"
)
textures = sorted(unused.textures_deep())
ui_layouts.box_list(
layout=layout,
title="Textures",
items=textures,
items=sorted(self.unused_textures),
icon="TEXTURE"
)
worlds = sorted(unused.worlds())
ui_layouts.box_list(
layout=layout,
title="Armatures",
items=sorted(self.unused_armatures),
icon="ARMATURE_DATA"
)
ui_layouts.box_list(
layout=layout,
title="Worlds",
items=worlds,
items=sorted(self.unused_worlds),
icon="WORLD"
)
@@ -240,8 +250,10 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
clean.lights()
clean.materials()
clean.node_groups()
clean.objects()
clean.particles()
clean.textures()
clean.armatures()
clean.worlds()
return {'FINISHED'}
@@ -249,14 +261,19 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
def invoke(self, context, event):
wm = context.window_manager
self.unused_collections = unused.collections_deep()
self.unused_images = unused.images_deep()
self.unused_lights = unused.lights_deep()
self.unused_materials = unused.materials_deep()
self.unused_node_groups = unused.node_groups_deep()
self.unused_particles = unused.particles_deep()
self.unused_textures = unused.textures_deep()
self.unused_worlds = unused.worlds()
# Use parallel execution for better performance
all_unused = unused_parallel.get_all_unused_parallel()
self.unused_collections = all_unused['collections']
self.unused_images = all_unused['images']
self.unused_lights = all_unused['lights']
self.unused_materials = all_unused['materials']
self.unused_node_groups = all_unused['node_groups']
self.unused_objects = all_unused['objects']
self.unused_particles = all_unused['particles']
self.unused_textures = all_unused['textures']
self.unused_armatures = all_unused['armatures']
self.unused_worlds = all_unused['worlds']
return wm.invoke_props_dialog(self)
@@ -790,4 +807,4 @@ def register():
def unregister():
for item in reg_list:
unregister_class(item)
compat.safe_unregister_class(item)
@@ -26,11 +26,18 @@ operators.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
from .utils import delete
from .utils import duplicate
def _check_library_or_override(datablock):
"""Check if datablock is library-linked or override, return error message if so."""
if compat.is_library_or_override(datablock):
return "Cannot modify library-linked or override datablocks"
return None
# Atomic Data Manager Inspection Rename Operator
class ATOMIC_OT_inspection_rename(bpy.types.Operator):
"""Give this data-block a new name"""
@@ -51,35 +58,75 @@ class ATOMIC_OT_inspection_rename(bpy.types.Operator):
name = atom.rename_field
if inspection == 'COLLECTIONS':
bpy.data.collections[atom.collections_field].name = name
collection = bpy.data.collections[atom.collections_field]
error = _check_library_or_override(collection)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
collection.name = name
atom.collections_field = name
if inspection == 'IMAGES':
bpy.data.images[atom.images_field].name = name
image = bpy.data.images[atom.images_field]
error = _check_library_or_override(image)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
image.name = name
atom.images_field = name
if inspection == 'LIGHTS':
bpy.data.lights[atom.lights_field].name = name
light = bpy.data.lights[atom.lights_field]
error = _check_library_or_override(light)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
light.name = name
atom.lights_field = name
if inspection == 'MATERIALS':
bpy.data.materials[atom.materials_field].name = name
material = bpy.data.materials[atom.materials_field]
error = _check_library_or_override(material)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
material.name = name
atom.materials_field = name
if inspection == 'NODE_GROUPS':
bpy.data.node_groups[atom.node_groups_field].name = name
node_group = bpy.data.node_groups[atom.node_groups_field]
error = _check_library_or_override(node_group)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
node_group.name = name
atom.node_groups_field = name
if inspection == 'PARTICLES':
bpy.data.particles[atom.particles_field].name = name
particle = bpy.data.particles[atom.particles_field]
error = _check_library_or_override(particle)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
particle.name = name
atom.particles_field = name
if inspection == 'TEXTURES':
bpy.data.textures[atom.textures_field].name = name
texture = bpy.data.textures[atom.textures_field]
error = _check_library_or_override(texture)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
texture.name = name
atom.textures_field = name
if inspection == 'WORLDS':
bpy.data.worlds[atom.worlds_field].name = name
world = bpy.data.worlds[atom.worlds_field]
error = _check_library_or_override(world)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
world.name = name
atom.worlds_field = name
atom.rename_field = ""
@@ -172,44 +219,72 @@ class ATOMIC_OT_inspection_replace(bpy.types.Operator):
if inspection == 'IMAGES' and \
atom.replace_field in bpy.data.images.keys():
bpy.data.images[atom.images_field].user_remap(
bpy.data.images[atom.replace_field])
image = bpy.data.images[atom.images_field]
error = _check_library_or_override(image)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
image.user_remap(bpy.data.images[atom.replace_field])
atom.images_field = atom.replace_field
if inspection == 'LIGHTS' and \
atom.replace_field in bpy.data.lights.keys():
bpy.data.lights[atom.lights_field].user_remap(
bpy.data.lights[atom.replace_field])
light = bpy.data.lights[atom.lights_field]
error = _check_library_or_override(light)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
light.user_remap(bpy.data.lights[atom.replace_field])
atom.lights_field = atom.replace_field
if inspection == 'MATERIALS' and \
atom.replace_field in bpy.data.materials.keys():
bpy.data.materials[atom.materials_field].user_remap(
bpy.data.materials[atom.replace_field])
material = bpy.data.materials[atom.materials_field]
error = _check_library_or_override(material)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
material.user_remap(bpy.data.materials[atom.replace_field])
atom.materials_field = atom.replace_field
if inspection == 'NODE_GROUPS' and \
atom.replace_field in bpy.data.node_groups.keys():
bpy.data.node_groups[atom.node_groups_field].user_remap(
bpy.data.node_groups[atom.replace_field])
node_group = bpy.data.node_groups[atom.node_groups_field]
error = _check_library_or_override(node_group)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
node_group.user_remap(bpy.data.node_groups[atom.replace_field])
atom.node_groups_field = atom.replace_field
if inspection == 'PARTICLES' and \
atom.replace_field in bpy.data.particles.keys():
bpy.data.particles[atom.particles_field].user_remap(
bpy.data.particles[atom.replace_field])
particle = bpy.data.particles[atom.particles_field]
error = _check_library_or_override(particle)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
particle.user_remap(bpy.data.particles[atom.replace_field])
atom.particles_field = atom.replace_field
if inspection == 'TEXTURES' and \
atom.replace_field in bpy.data.textures.keys():
bpy.data.textures[atom.textures_field].user_remap(
bpy.data.textures[atom.replace_field])
texture = bpy.data.textures[atom.textures_field]
error = _check_library_or_override(texture)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
texture.user_remap(bpy.data.textures[atom.replace_field])
atom.textures_field = atom.replace_field
if inspection == 'WORLDS' and \
atom.replace_field in bpy.data.worlds.keys():
bpy.data.worlds[atom.worlds_field].user_remap(
bpy.data.worlds[atom.replace_field])
world = bpy.data.worlds[atom.worlds_field]
error = _check_library_or_override(world)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
world.user_remap(bpy.data.worlds[atom.replace_field])
atom.worlds_field = atom.replace_field
atom.replace_field = ""
@@ -232,38 +307,59 @@ class ATOMIC_OT_inspection_toggle_fake_user(bpy.types.Operator):
if inspection == 'IMAGES':
image = bpy.data.images[atom.images_field]
bpy.data.images[atom.images_field].use_fake_user = \
not image.use_fake_user
error = _check_library_or_override(image)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
image.use_fake_user = not image.use_fake_user
if inspection == 'LIGHTS':
light = bpy.data.lights[atom.lights_field]
bpy.data.lights[atom.lights_field].use_fake_user = \
not light.use_fake_user
error = _check_library_or_override(light)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
light.use_fake_user = not light.use_fake_user
if inspection == 'MATERIALS':
material = bpy.data.materials[atom.materials_field]
bpy.data.materials[atom.materials_field].use_fake_user = \
not material.use_fake_user
error = _check_library_or_override(material)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
material.use_fake_user = not material.use_fake_user
if inspection == 'NODE_GROUPS':
node_group = bpy.data.node_groups[atom.node_groups_field]
bpy.data.node_groups[atom.node_groups_field].use_fake_user = \
not node_group.use_fake_user
error = _check_library_or_override(node_group)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
node_group.use_fake_user = not node_group.use_fake_user
if inspection == 'PARTICLES':
particle = bpy.data.particles[atom.particles_field]
bpy.data.particles[atom.particles_field].use_fake_user = \
not particle.use_fake_user
error = _check_library_or_override(particle)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
particle.use_fake_user = not particle.use_fake_user
if inspection == 'TEXTURES':
texture = bpy.data.textures[atom.textures_field]
bpy.data.textures[atom.textures_field].use_fake_user = \
not texture.use_fake_user
error = _check_library_or_override(texture)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
texture.use_fake_user = not texture.use_fake_user
if inspection == 'WORLDS':
world = bpy.data.worlds[atom.worlds_field]
bpy.data.worlds[atom.worlds_field].use_fake_user = \
not world.use_fake_user
error = _check_library_or_override(world)
if error:
self.report({'ERROR'}, error)
return {'CANCELLED'}
world.use_fake_user = not world.use_fake_user
return {'FINISHED'}
@@ -283,6 +379,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
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'}
copy_key = duplicate.collection(key)
atom.collections_field = copy_key
@@ -291,6 +392,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
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'}
copy_key = duplicate.image(key)
atom.images_field = copy_key
@@ -299,6 +405,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
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'}
copy_key = duplicate.light(key)
atom.lights_field = copy_key
@@ -307,6 +418,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
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'}
copy_key = duplicate.material(key)
atom.materials_field = copy_key
@@ -315,6 +431,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
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'}
copy_key = duplicate.node_group(key)
atom.node_groups_field = copy_key
@@ -323,6 +444,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
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'}
copy_key = duplicate.particle(key)
atom.particles_field = copy_key
@@ -331,6 +457,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
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'}
copy_key = duplicate.texture(key)
atom.textures_field = copy_key
@@ -339,6 +470,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
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'}
copy_key = duplicate.world(key)
atom.worlds_field = copy_key
@@ -360,6 +496,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
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 = ""
@@ -368,6 +509,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
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 = ""
@@ -376,6 +522,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
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 = ""
@@ -384,6 +535,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
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 = ""
@@ -392,6 +548,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
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 = ""
@@ -399,6 +560,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
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 = ""
@@ -407,6 +573,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
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 = ""
@@ -415,6 +586,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
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 = ""
@@ -437,4 +613,4 @@ def register():
def unregister():
for item in reg_list:
unregister_class(item)
compat.safe_unregister_class(item)
@@ -26,8 +26,9 @@ various selection operations.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
from ..stats import unused
from ..stats import unused_parallel
from .utils import clean
from .utils import nuke
from ..ui.utils import ui_layouts
@@ -57,7 +58,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
# display when the main panel collections property is toggled
if atom.collections:
collections = sorted(bpy.data.collections.keys())
from ..utils import compat
collections = sorted([c.name for c in bpy.data.collections
if not compat.is_library_or_override(c)])
ui_layouts.box_list(
layout=layout,
title="Collections",
@@ -67,7 +70,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
# display when the main panel images property is toggled
if atom.images:
images = sorted(bpy.data.images.keys())
from ..utils import compat
images = sorted([i.name for i in bpy.data.images
if not compat.is_library_or_override(i)])
ui_layouts.box_list(
layout=layout,
title="Images",
@@ -77,7 +82,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
# display when the main panel lights property is toggled
if atom.lights:
lights = sorted(bpy.data.lights.keys())
from ..utils import compat
lights = sorted([l.name for l in bpy.data.lights
if not compat.is_library_or_override(l)])
ui_layouts.box_list(
layout=layout,
title="Lights",
@@ -87,7 +94,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
# display when the main panel materials property is toggled
if atom.materials:
materials = sorted(bpy.data.materials.keys())
from ..utils import compat
materials = sorted([m.name for m in bpy.data.materials
if not compat.is_library_or_override(m)])
ui_layouts.box_list(
layout=layout,
title="Materials",
@@ -97,7 +106,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
# display when the main panel node groups property is toggled
if atom.node_groups:
node_groups = sorted(bpy.data.node_groups.keys())
from ..utils import compat
node_groups = sorted([ng.name for ng in bpy.data.node_groups
if not compat.is_library_or_override(ng)])
ui_layouts.box_list(
layout=layout,
title="Node Groups",
@@ -107,7 +118,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
# display when the main panel particle systems property is toggled
if atom.particles:
particles = sorted(bpy.data.particles.keys())
from ..utils import compat
particles = sorted([p.name for p in bpy.data.particles
if not compat.is_library_or_override(p)])
ui_layouts.box_list(
layout=layout,
title="Particle Systems",
@@ -117,7 +130,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
# display when the main panel textures property is toggled
if atom.textures:
textures = sorted(bpy.data.textures.keys())
from ..utils import compat
textures = sorted([t.name for t in bpy.data.textures
if not compat.is_library_or_override(t)])
ui_layouts.box_list(
layout=layout,
title="Textures",
@@ -127,7 +142,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
# display when the main panel worlds property is toggled
if atom.worlds:
worlds = sorted(bpy.data.worlds.keys())
from ..utils import compat
worlds = sorted([w.name for w in bpy.data.worlds
if not compat.is_library_or_override(w)])
ui_layouts.box_list(
layout=layout,
title="Worlds",
@@ -184,8 +201,10 @@ class ATOMIC_OT_clean(bpy.types.Operator):
unused_lights = []
unused_materials = []
unused_node_groups = []
unused_objects = []
unused_particles = []
unused_textures = []
unused_armatures = []
unused_worlds = []
def draw(self, context):
@@ -197,8 +216,9 @@ class ATOMIC_OT_clean(bpy.types.Operator):
# display if no main panel properties are toggled
if not (atom.collections or atom.images or atom.lights or
atom.materials or atom.node_groups or atom.particles
or atom.textures or atom.worlds):
atom.materials or atom.node_groups or atom.objects or
atom.particles or atom.textures or atom.armatures or
atom.worlds):
ui_layouts.box_list(
layout=layout,
@@ -249,6 +269,15 @@ class ATOMIC_OT_clean(bpy.types.Operator):
icon="NODETREE"
)
# display when the main panel objects property is toggled
if atom.objects:
ui_layouts.box_list(
layout=layout,
title="Objects",
items=self.unused_objects,
icon="OBJECT_DATA"
)
# display when the main panel particle systems property is toggled
if atom.particles:
ui_layouts.box_list(
@@ -260,14 +289,22 @@ class ATOMIC_OT_clean(bpy.types.Operator):
# display when the main panel textures property is toggled
if atom.textures:
textures = sorted(unused.textures_deep())
ui_layouts.box_list(
layout=layout,
title="Textures",
items=textures,
items=self.unused_textures,
icon="TEXTURE"
)
# display when the main panel armatures property is toggled
if atom.armatures:
ui_layouts.box_list(
layout=layout,
title="Armatures",
items=self.unused_armatures,
icon="ARMATURE_DATA"
)
# display when the main panel worlds property is toggled
if atom.worlds:
ui_layouts.box_list(
@@ -297,12 +334,18 @@ class ATOMIC_OT_clean(bpy.types.Operator):
if atom.node_groups:
clean.node_groups()
if atom.objects:
clean.objects()
if atom.particles:
clean.particles()
if atom.textures:
clean.textures()
if atom.armatures:
clean.armatures()
if atom.worlds:
clean.worlds()
@@ -314,29 +357,38 @@ class ATOMIC_OT_clean(bpy.types.Operator):
wm = context.window_manager
atom = bpy.context.scene.atomic
# Use parallel execution for better performance
all_unused = unused_parallel.get_all_unused_parallel()
if atom.collections:
self.unused_collections = unused.collections_deep()
self.unused_collections = all_unused['collections']
if atom.images:
self.unused_images = unused.images_deep()
self.unused_images = all_unused['images']
if atom.lights:
self.unused_lights = unused.lights_deep()
self.unused_lights = all_unused['lights']
if atom.materials:
self.unused_materials = unused.materials_deep()
self.unused_materials = all_unused['materials']
if atom.node_groups:
self.unused_node_groups = unused.node_groups_deep()
self.unused_node_groups = all_unused['node_groups']
if atom.objects:
self.unused_objects = all_unused['objects']
if atom.particles:
self.unused_particles = unused.particles_deep()
self.unused_particles = all_unused['particles']
if atom.textures:
self.unused_textures = unused.textures_deep()
self.unused_textures = all_unused['textures']
if atom.armatures:
self.unused_armatures = all_unused['armatures']
if atom.worlds:
self.unused_worlds = unused.worlds()
self.unused_worlds = all_unused['worlds']
return wm.invoke_props_dialog(self)
@@ -359,30 +411,20 @@ class ATOMIC_OT_smart_select(bpy.types.Operator):
bl_label = "Smart Select"
def execute(self, context):
bpy.context.scene.atomic.collections = \
any(unused.collections_deep())
bpy.context.scene.atomic.images = \
any(unused.images_deep())
bpy.context.scene.atomic.lights = \
any(unused.lights_deep())
bpy.context.scene.atomic.materials = \
any(unused.materials_deep())
bpy.context.scene.atomic.node_groups = \
any(unused.node_groups_deep())
bpy.context.scene.atomic.particles = \
any(unused.particles_deep())
bpy.context.scene.atomic.textures = \
any(unused.textures_deep())
bpy.context.scene.atomic.worlds = \
any(unused.worlds())
# Use parallel execution for better performance
unused_flags = unused_parallel.get_unused_for_smart_select()
atom = bpy.context.scene.atomic
atom.collections = unused_flags['collections']
atom.images = unused_flags['images']
atom.lights = unused_flags['lights']
atom.materials = unused_flags['materials']
atom.node_groups = unused_flags['node_groups']
atom.objects = unused_flags['objects']
atom.particles = unused_flags['particles']
atom.textures = unused_flags['textures']
atom.armatures = unused_flags['armatures']
atom.worlds = unused_flags['worlds']
return {'FINISHED'}
@@ -441,4 +483,4 @@ def register():
def unregister():
for item in reg_list:
unregister_class(item)
compat.safe_unregister_class(item)
@@ -31,7 +31,7 @@ attempting to reload missing project files.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
from ..stats import missing
from ..ui.utils import ui_layouts
@@ -192,4 +192,4 @@ def register():
def unregister():
for item in reg_list:
unregister_class(item)
compat.safe_unregister_class(item)
@@ -28,7 +28,7 @@ support page in the web browser.
import bpy
import webbrowser
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
# Atomic Data Manager Open Support Me Operator
@@ -52,4 +52,4 @@ def register():
def unregister():
for cls in reg_list:
unregister_class(cls)
compat.safe_unregister_class(cls)
@@ -72,3 +72,15 @@ def worlds():
# removes all unused worlds from the project
for world_key in unused.worlds():
bpy.data.worlds.remove(bpy.data.worlds[world_key])
def objects():
# removes all unused objects from the project
for object_key in unused.objects_deep():
bpy.data.objects.remove(bpy.data.objects[object_key])
def armatures():
# removes all unused armatures from the project
for armature_key in unused.armatures_deep():
bpy.data.armatures.remove(bpy.data.armatures[armature_key])
@@ -24,11 +24,18 @@ data categories.
"""
import bpy
from ...utils import compat
def nuke_data(data):
# removes all data-blocks from the indicated set of data
# Skip library-linked and override datablocks
keys_to_remove = []
for key in data.keys():
datablock = data[key]
if not compat.is_library_or_override(datablock):
keys_to_remove.append(key)
for key in keys_to_remove:
data.remove(data[key])
@@ -23,6 +23,7 @@ This file contains functions that count quantities of various sets of data.
"""
import bpy
from ..utils import compat
from . import unused
from . import unnamed
from . import missing
@@ -30,8 +31,11 @@ from . import missing
def collections():
# returns the number of collections in the project
return len(bpy.data.collections)
count = 0
for collection in bpy.data.collections:
if not compat.is_library_or_override(collection):
count += 1
return count
def collections_unused():
@@ -48,8 +52,11 @@ def collections_unnamed():
def images():
# returns the number of images in the project
return len(bpy.data.images)
count = 0
for image in bpy.data.images:
if not compat.is_library_or_override(image):
count += 1
return count
def images_unused():
@@ -72,8 +79,11 @@ def images_missing():
def lights():
# returns the number of lights in the project
return len(bpy.data.lights)
count = 0
for light in bpy.data.lights:
if not compat.is_library_or_override(light):
count += 1
return count
def lights_unused():
@@ -90,8 +100,11 @@ def lights_unnamed():
def materials():
# returns the number of materials in the project
return len(bpy.data.materials)
count = 0
for material in bpy.data.materials:
if not compat.is_library_or_override(material):
count += 1
return count
def materials_unused():
@@ -108,8 +121,11 @@ def materials_unnamed():
def node_groups():
# returns the number of node groups in the project
return len(bpy.data.node_groups)
count = 0
for node_group in bpy.data.node_groups:
if not compat.is_library_or_override(node_group):
count += 1
return count
def node_groups_unused():
@@ -126,8 +142,11 @@ def node_groups_unnamed():
def objects():
# returns the number of objects in the project
return len(bpy.data.objects)
count = 0
for obj in bpy.data.objects:
if not compat.is_library_or_override(obj):
count += 1
return count
def objects_unnamed():
@@ -138,8 +157,11 @@ def objects_unnamed():
def particles():
# returns the number of particles in the project
return len(bpy.data.particles)
count = 0
for particle in bpy.data.particles:
if not compat.is_library_or_override(particle):
count += 1
return count
def particles_unused():
@@ -156,8 +178,11 @@ def particles_unnamed():
def textures():
# returns the number of textures in the project
return len(bpy.data.textures)
count = 0
for texture in bpy.data.textures:
if not compat.is_library_or_override(texture):
count += 1
return count
def textures_unused():
@@ -174,8 +199,11 @@ def textures_unnamed():
def worlds():
# returns the number of worlds in the project
return len(bpy.data.worlds)
count = 0
for world in bpy.data.worlds:
if not compat.is_library_or_override(world):
count += 1
return count
def worlds_unused():
@@ -25,6 +25,7 @@ project.
import bpy
import os
from ..utils import version, compat
def get_missing(data):
@@ -37,12 +38,29 @@ def get_missing(data):
do_not_flag = ["Render Result", "Viewer Node", "D-NOISE Export"]
for datablock in data:
# Skip library-linked and override datablocks
if compat.is_library_or_override(datablock):
continue
# the absolute path to our data-block
abspath = bpy.path.abspath(datablock.filepath)
# Check if data-block is packed
# Blender 5.0+: Image objects use 'packed_files' (plural), Library objects use 'packed_file' (singular)
# Blender 4.2/4.5: Both Image and Library objects use 'packed_file' (singular)
is_packed = False
if version.is_version_at_least(5, 0, 0):
# Blender 5.0+: Check type-specific attributes
if isinstance(datablock, bpy.types.Image):
is_packed = bool(datablock.packed_files) if hasattr(datablock, 'packed_files') else False
elif isinstance(datablock, bpy.types.Library):
is_packed = bool(datablock.packed_file) if hasattr(datablock, 'packed_file') else False
else:
# Blender 4.2/4.5: Both use 'packed_file' (singular)
is_packed = bool(datablock.packed_file) if hasattr(datablock, 'packed_file') else False
# if data-block is not packed and has an invalid filepath
if not datablock.packed_files and not os.path.isfile(abspath):
if not is_packed and not os.path.isfile(abspath):
# if data-block is not in our do not flag list
# append it to the missing data list
@@ -50,7 +68,7 @@ def get_missing(data):
missing.append(datablock.name)
# if data-block is packed but it does not have a filepath
elif datablock.packed_files and not abspath:
elif is_packed and not abspath:
# if data-block is not in our do not flag list
# append it to the missing data list
@@ -25,6 +25,7 @@ Blender project.
import bpy
import re
from ..utils import compat
def collections():
@@ -32,6 +33,9 @@ def collections():
unnamed = []
for collection in bpy.data.collections:
# Skip library-linked and override datablocks
if compat.is_library_or_override(collection):
continue
if re.match(r'.*\.\d\d\d$', collection.name) or \
collection.name.startswith("Collection"):
unnamed.append(collection.name)
@@ -44,6 +48,9 @@ def images():
unnamed = []
for image in bpy.data.images:
# Skip library-linked and override datablocks
if compat.is_library_or_override(image):
continue
if re.match(r'.*\.\d\d\d$', image.name) or \
image.name.startswith("Untitled"):
unnamed.append(image.name)
@@ -56,6 +63,9 @@ def lights():
unnamed = []
for light in bpy.data.lights:
# Skip library-linked and override datablocks
if compat.is_library_or_override(light):
continue
if re.match(r'.*\.\d\d\d$', light.name) or \
light.name.startswith("Light"):
unnamed.append(light.name)
@@ -67,7 +77,10 @@ def materials():
# returns the keys of all unnamed materials in the project
unnamed = []
for material in bpy.data.lights:
for material in bpy.data.materials:
# Skip library-linked and override datablocks
if compat.is_library_or_override(material):
continue
if re.match(r'.*\.\d\d\d$', material.name) or \
material.name.startswith("Material"):
unnamed.append(material.name)
@@ -152,6 +165,9 @@ def objects():
unnamed = []
for obj in bpy.data.objects:
# Skip library-linked and override datablocks
if compat.is_library_or_override(obj):
continue
if re.match(r'.*\.\d\d\d$', obj.name) or \
obj.name.startswith(default_obj_names):
unnamed.append(obj.name)
@@ -164,6 +180,9 @@ def node_groups():
unnamed = []
for node_group in bpy.data.node_groups:
# Skip library-linked and override datablocks
if compat.is_library_or_override(node_group):
continue
if re.match(r'.*\.\d\d\d$', node_group.name) or \
node_group.name.startswith("NodeGroup"):
unnamed.append(node_group.name)
@@ -176,6 +195,9 @@ def particles():
unnamed = []
for particle in bpy.data.particles:
# Skip library-linked and override datablocks
if compat.is_library_or_override(particle):
continue
if re.match(r'.*\.\d\d\d$', particle.name) or \
particle.name.startswith("ParticleSettings"):
unnamed.append(particle.name)
@@ -188,6 +210,9 @@ def textures():
unnamed = []
for texture in bpy.data.textures:
# Skip library-linked and override datablocks
if compat.is_library_or_override(texture):
continue
if re.match(r'.*\.\d\d\d$', texture.name) or \
texture.name.startswith("Texture"):
unnamed.append(texture.name)
@@ -200,6 +225,9 @@ def worlds():
unnamed = []
for world in bpy.data.worlds:
# Skip library-linked and override datablocks
if compat.is_library_or_override(world):
continue
if re.match(r'.*\.\d\d\d$', world.name) or \
world.name.startswith("World"):
unnamed.append(world.name)
@@ -25,6 +25,7 @@ as determined by stats.users.py
import bpy
from .. import config
from ..utils import compat
from . import users
@@ -35,6 +36,9 @@ def shallow(data):
unused = []
for datablock in data:
# Skip library-linked and override datablocks
if compat.is_library_or_override(datablock):
continue
# if data-block has no users or if it has a fake user and
# ignore fake users is enabled
@@ -52,6 +56,9 @@ def collections_deep():
unused = []
for collection in bpy.data.collections:
# Skip library-linked and override datablocks
if compat.is_library_or_override(collection):
continue
if not users.collection_all(collection.name):
unused.append(collection.name)
@@ -65,6 +72,9 @@ def collections_shallow():
unused = []
for collection in bpy.data.collections:
# Skip library-linked and override datablocks
if compat.is_library_or_override(collection):
continue
if not (collection.objects or collection.children):
unused.append(collection.name)
@@ -81,6 +91,9 @@ def images_deep():
do_not_flag = ["Render Result", "Viewer Node", "D-NOISE Export"]
for image in bpy.data.images:
# Skip library-linked and override datablocks
if compat.is_library_or_override(image):
continue
if not users.image_all(image.name):
# check if image has a fake user or if ignore fake users
@@ -118,6 +131,9 @@ def lights_deep():
unused = []
for light in bpy.data.lights:
# Skip library-linked and override datablocks
if compat.is_library_or_override(light):
continue
if not users.light_all(light.name):
# check if light has a fake user or if ignore fake users
@@ -141,6 +157,9 @@ def materials_deep():
unused = []
for material in bpy.data.materials:
# Skip library-linked and override datablocks
if compat.is_library_or_override(material):
continue
if not users.material_all(material.name):
# check if material has a fake user or if ignore fake users
@@ -164,6 +183,9 @@ def node_groups_deep():
unused = []
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
@@ -190,6 +212,9 @@ def particles_deep():
unused = []
for particle in bpy.data.particles:
# Skip library-linked and override datablocks
if compat.is_library_or_override(particle):
continue
if not users.particle_all(particle.name):
# check if particle system has a fake user or if ignore fake
@@ -216,6 +241,9 @@ def textures_deep():
unused = []
for texture in bpy.data.textures:
# Skip library-linked and override datablocks
if compat.is_library_or_override(texture):
continue
if not users.texture_all(texture.name):
# check if texture has a fake user or if ignore fake users
@@ -239,6 +267,9 @@ def worlds():
unused = []
for world in bpy.data.worlds:
# Skip library-linked and override datablocks
if compat.is_library_or_override(world):
continue
# if data-block has no users or if it has a fake user and
# ignore fake users is enabled
@@ -248,3 +279,55 @@ def worlds():
unused.append(world.name)
return unused
def objects_deep():
# returns a list of keys of unused objects
unused = []
for obj in bpy.data.objects:
# Skip library-linked and override datablocks
if compat.is_library_or_override(obj):
continue
if not users.object_all(obj.name):
# check if object has a fake user or if ignore fake users
# is enabled
if not obj.use_fake_user or config.include_fake_users:
unused.append(obj.name)
return unused
def objects_shallow():
# returns a list of keys of unused objects that may be
# incomplete, but is significantly faster than doing a deep search
return shallow(bpy.data.objects)
def armatures_deep():
# returns a list of keys of unused armatures
unused = []
for armature in bpy.data.armatures:
# Skip library-linked and override datablocks
if compat.is_library_or_override(armature):
continue
if not users.armature_all(armature.name):
# check if armature has a fake user or if ignore fake users
# is enabled
if not armature.use_fake_user or config.include_fake_users:
unused.append(armature.name)
return unused
def armatures_shallow():
# returns a list of keys of unused armatures that may be
# incomplete, but is significantly faster than doing a deep search
return shallow(bpy.data.armatures)
@@ -0,0 +1,184 @@
import bpy
from ..stats import unused
from ..stats import users
from .. import config
from ..utils import compat
def get_all_unused_parallel():
"""
Get all unused data-blocks efficiently in a single batch.
Returns a dictionary with keys:
- collections: list of unused collection names
- images: list of unused image names
- lights: list of unused light names
- materials: list of unused material names
- node_groups: list of unused node group names
- objects: list of unused object names
- particles: list of unused particle names
- textures: list of unused texture names
- armatures: list of unused armature names
- worlds: list of unused world names
"""
# 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(),
}
def _has_any_unused_collections():
"""Check if there are any unused collections (short-circuits early)."""
for collection in bpy.data.collections:
if compat.is_library_or_override(collection):
continue
if not users.collection_all(collection.name):
return True
return False
def _has_any_unused_images():
"""Check if there are any unused images (short-circuits early)."""
do_not_flag = ["Render Result", "Viewer Node", "D-NOISE Export"]
for image in bpy.data.images:
if compat.is_library_or_override(image):
continue
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
return False
def _has_any_unused_lights():
"""Check if there are any unused lights (short-circuits early)."""
for light in bpy.data.lights:
if compat.is_library_or_override(light):
continue
if not users.light_all(light.name):
if not light.use_fake_user or config.include_fake_users:
return True
return False
def _has_any_unused_materials():
"""Check if there are any unused materials (short-circuits early)."""
for material in bpy.data.materials:
if compat.is_library_or_override(material):
continue
if not users.material_all(material.name):
if not material.use_fake_user or config.include_fake_users:
return True
return False
def _has_any_unused_node_groups():
"""Check if there are any unused node groups (short-circuits early)."""
for node_group in bpy.data.node_groups:
if compat.is_library_or_override(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
return False
def _has_any_unused_particles():
"""Check if there are any unused particles (short-circuits early)."""
if not hasattr(bpy.data, 'particles'):
return False
for particle in bpy.data.particles:
if compat.is_library_or_override(particle):
continue
if not users.particle_all(particle.name):
if not particle.use_fake_user or config.include_fake_users:
return True
return False
def _has_any_unused_textures():
"""Check if there are any unused textures (short-circuits early)."""
if not hasattr(bpy.data, 'textures'):
return False
for texture in bpy.data.textures:
if compat.is_library_or_override(texture):
continue
if not users.texture_all(texture.name):
if not texture.use_fake_user or config.include_fake_users:
return True
return False
def _has_any_unused_worlds():
"""Check if there are any unused worlds (short-circuits early)."""
for world in bpy.data.worlds:
if compat.is_library_or_override(world):
continue
if world.users == 0 or (world.users == 1 and
world.use_fake_user and
config.include_fake_users):
return True
return False
def _has_any_unused_objects():
"""Check if there are any unused objects (short-circuits early)."""
for obj in bpy.data.objects:
if compat.is_library_or_override(obj):
continue
if not users.object_all(obj.name):
if not obj.use_fake_user or config.include_fake_users:
return True
return False
def _has_any_unused_armatures():
"""Check if there are any unused armatures (short-circuits early)."""
for armature in bpy.data.armatures:
if compat.is_library_or_override(armature):
continue
if not users.armature_all(armature.name):
if not armature.use_fake_user or config.include_fake_users:
return True
return False
def get_unused_for_smart_select():
"""
Get unused data for smart select operation (returns booleans).
Optimized to short-circuit early - stops checking each category
as soon as unused data is found. This is much faster than computing
the full list of unused items.
Returns a dictionary with boolean values indicating if each category
has unused data-blocks.
"""
# 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(),
}
@@ -426,12 +426,24 @@ def light_objects(light_key):
def material_all(material_key):
# returns a list of keys of every data-block that uses this material
return material_objects(material_key) + \
material_geometry_nodes(material_key)
# Use comprehensive custom detection that covers all usage contexts
users = []
# Check direct object usage (material slots)
users.extend(material_objects(material_key))
# Check Geometry Nodes usage (materials in node groups used by objects)
users.extend(material_geometry_nodes(material_key))
# Check node group usage (materials in node groups used elsewhere)
users.extend(material_node_groups(material_key))
return distinct(users)
def material_geometry_nodes(material_key):
# returns a list of object keys that use the material via Geometry Nodes
# Only counts objects that are in scene collections (recursive check)
users = []
material = bpy.data.materials[material_key]
@@ -440,17 +452,104 @@ def material_geometry_nodes(material_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 material isn't in a scene,
# the material 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
if hasattr(obj, 'modifiers'):
for modifier in obj.modifiers:
if compat.is_geometry_nodes_modifier(modifier):
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):
users.append(obj.name)
return distinct(users)
def material_node_groups(material_key):
# returns a list of keys indicating where the material is used via node groups
# This checks if the material is used in any node group, and if that node group
# is itself used anywhere. This complements material_geometry_nodes() by checking
# additional usage contexts (materials, other node groups, compositor, etc.)
# Note: Geometry Nodes usage is already checked by material_geometry_nodes()
# Optimized to return early when usage is found
from ..utils import compat
material = bpy.data.materials[material_key]
# 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):
# This node group contains the material, check if the node group is used
# Check usage contexts in order of likelihood, return early when found
# First check: is it used in Geometry Nodes modifiers? (most common case)
# Note: material_geometry_nodes() already checks this, but we verify here too
obj_users = node_group_objects(node_group.name)
if obj_users:
return obj_users # Return immediately - material is used
# Second check: is it used in materials?
mat_users = node_group_materials(node_group.name)
if mat_users:
return mat_users # Return immediately - material is used
# Third check: is it used in compositor?
comp_users = node_group_compositors(node_group.name)
if comp_users:
return comp_users # Return immediately - material is used
# Fourth check: is it used in textures?
tex_users = node_group_textures(node_group.name)
if tex_users:
return tex_users # Return immediately - material is used
# Fifth check: is it used in worlds?
world_users = node_group_worlds(node_group.name)
if world_users:
return world_users # Return immediately - material is used
# Last check: is it used in other node groups? (recursive, but only if needed)
ng_users = node_group_node_groups(node_group.name)
if ng_users:
# Check if any parent node groups are used (quick check only)
for parent_ng_name in ng_users:
# Quick check: see if parent is used in objects (most common)
parent_obj_users = node_group_objects(parent_ng_name)
if parent_obj_users:
return parent_obj_users
# Quick check: see if parent is used in materials
parent_mat_users = node_group_materials(parent_ng_name)
if parent_mat_users:
return parent_mat_users
# Also check if parent is used in compositor, textures, worlds
parent_comp_users = node_group_compositors(parent_ng_name)
if parent_comp_users:
return parent_comp_users
parent_tex_users = node_group_textures(parent_ng_name)
if parent_tex_users:
return parent_tex_users
parent_world_users = node_group_worlds(parent_ng_name)
if parent_world_users:
return parent_world_users
return [] # Material not used in any node groups
def material_objects(material_key):
# returns a list of object keys that use this material
@@ -760,22 +859,122 @@ def node_group_has_material(node_group_key, material_key):
# returns true if a node group contains this material (directly or nested)
has_material = False
node_group = bpy.data.node_groups[node_group_key]
material = bpy.data.materials[material_key]
try:
node_group = bpy.data.node_groups[node_group_key]
material = bpy.data.materials[material_key]
except (KeyError, AttributeError):
return False
for node in node_group.nodes:
# base case: nodes with a material property (e.g., Set Material)
if hasattr(node, 'material') and node.material:
if node.material.name == material.name:
has_material = True
try:
for node in node_group.nodes:
try:
# Explicitly check for GeometryNodeSetMaterial nodes first
# This is the most reliable way to detect Set Material nodes in Geometry Nodes
if hasattr(node, 'bl_idname'):
try:
if node.bl_idname == 'GeometryNodeSetMaterial':
# Geometry Nodes Set Material nodes use input sockets, not a direct material property
# Check the material input socket
try:
# Try to access the Material input socket directly by name
if hasattr(node, 'inputs') and 'Material' in node.inputs:
try:
material_socket = node.inputs['Material']
# 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
except (KeyError, AttributeError, ReferenceError, RuntimeError, TypeError):
pass
# Also check all inputs as fallback (in case socket name differs)
if not has_material:
for input_socket in getattr(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(input_socket).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
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
except (AttributeError, ReferenceError, RuntimeError, TypeError):
continue # Skip this socket if we can't access it
# Also check if the node has a direct material property (fallback for some versions)
if not has_material and hasattr(node, 'material'):
try:
if node.material:
if (node.material.name == material.name or
node.material == material):
has_material = True
break # Found it, no need to continue
except (AttributeError, ReferenceError, RuntimeError):
pass # Skip if material access fails
if has_material:
break # Break outer loop if we found it
except (AttributeError, ReferenceError, RuntimeError):
pass # Skip if Set Material node input access fails
except (AttributeError, ReferenceError, RuntimeError):
pass # Skip if bl_idname access fails
# 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):
has_material = True
break # Found it, no need to continue
except (AttributeError, ReferenceError, RuntimeError):
pass # Skip if material access fails
# Also check node type by substring for Set Material nodes (backup check)
if not has_material and hasattr(node, 'bl_idname'):
try:
node_type = node.bl_idname
# Check for Geometry Nodes Set Material node type (substring match)
if 'SetMaterial' in node_type or 'SET_MATERIAL' in node_type.upper():
if hasattr(node, 'material'):
try:
if node.material:
if (node.material.name == material.name or
node.material == material):
has_material = True
break
except (AttributeError, ReferenceError, RuntimeError):
pass # Skip if material access fails
except (AttributeError, ReferenceError, RuntimeError):
pass # Skip if bl_idname access fails
# recurse case: nested node groups
elif hasattr(node, 'node_tree') and node.node_tree:
has_material = node_group_has_material(
node.node_tree.name, material.name)
# recurse case: nested node groups
# Check this separately (not elif) in case we need to recurse
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)
except (KeyError, AttributeError, ReferenceError, RuntimeError):
continue # Skip invalid node groups
if has_material:
break
if has_material:
break
except (AttributeError, ReferenceError, RuntimeError):
# Skip nodes that cause errors (e.g., invalid/corrupted nodes)
continue
except (AttributeError, ReferenceError, RuntimeError):
# If we can't even iterate nodes, return False
return False
return has_material
@@ -969,6 +1168,75 @@ def texture_particles(texture_key):
return distinct(users)
def object_all(object_key):
# 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
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)
def armature_all(armature_key):
# returns a list of object names that use the armature
# Checks direct usage, modifier usage, and constraint usage
# Only counts objects that are actually in scene collections (recursive check)
users = []
armature = bpy.data.armatures[armature_key]
# Check all objects - but only count those that are in scene collections
for obj in bpy.data.objects:
# Skip library-linked and override objects
from ..utils import compat
if compat.is_library_or_override(obj):
continue
# Check if object is in any scene collection (reuse object_all logic)
obj_scenes = object_all(obj.name)
is_in_scene = bool(obj_scenes)
# Check for usage regardless of scene status (we'll filter later)
found_usage = False
# 1. Direct usage: ARMATURE objects where object.data == armature
if obj.type == 'ARMATURE' and obj.data == armature:
found_usage = True
# 2. Modifier usage: Armature modifiers where modifier.object.data == armature
if not found_usage and hasattr(obj, 'modifiers'):
for modifier in obj.modifiers:
if modifier.type == 'ARMATURE':
if hasattr(modifier, 'object') and modifier.object:
if modifier.object.type == 'ARMATURE' and modifier.object.data == armature:
found_usage = True
break
# 3. Constraint usage: Constraints that target ARMATURE objects using this armature
if not found_usage and hasattr(obj, 'constraints'):
for constraint in obj.constraints:
if hasattr(constraint, 'target') and constraint.target:
if constraint.target.type == 'ARMATURE' and constraint.target.data == armature:
found_usage = True
break
# Only add to users if the object is actually in a scene
# This implements recursive checking: if the user object is unused, it doesn't count
if found_usage and is_in_scene:
users.append(obj.name)
return distinct(users)
def distinct(seq):
# returns a list of distinct elements
@@ -24,7 +24,7 @@ This file contains the inspection user interface.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
from ..stats import users
from .utils import ui_layouts
@@ -700,14 +700,138 @@ class ATOMIC_OT_inspect_worlds(bpy.types.Operator):
return wm.invoke_props_dialog(self)
# Atomic Data Manager Inspect Objects UI Operator
class ATOMIC_OT_inspect_objects(bpy.types.Operator):
"""Inspect Objects"""
bl_idname = "atomic.inspect_objects"
bl_label = "Inspect Objects"
# user lists
users_scenes = []
def draw(self, context):
global inspection_update_trigger
atom = bpy.context.scene.atomic
layout = self.layout
# inspect objects header
ui_layouts.inspect_header(
layout=layout,
atom_prop="objects_field",
data="objects"
)
# inspection update code
if inspection_update_trigger:
# if key is valid, update the user lists
if atom.objects_field in bpy.data.objects.keys():
self.users_scenes = users.object_all(atom.objects_field)
# if key is invalid, empty the user lists
else:
self.users_scenes = []
inspection_update_trigger = False
# scenes box list
ui_layouts.box_list(
layout=layout,
title="Scenes",
items=self.users_scenes,
icon="SCENE_DATA"
)
row = layout.row() # extra row for spacing
def execute(self, context):
return {'FINISHED'}
def invoke(self, context, event):
# update inspection context
atom = bpy.context.scene.atomic
atom.active_inspection = "OBJECTS"
# trigger update on invoke
global inspection_update_trigger
inspection_update_trigger = True
# invoke inspect dialog
wm = context.window_manager
return wm.invoke_props_dialog(self)
# Atomic Data Manager Inspect Armatures UI Operator
class ATOMIC_OT_inspect_armatures(bpy.types.Operator):
"""Inspect Armatures"""
bl_idname = "atomic.inspect_armatures"
bl_label = "Inspect Armatures"
# user lists
users_objects = []
def draw(self, context):
global inspection_update_trigger
atom = bpy.context.scene.atomic
layout = self.layout
# inspect armatures header
ui_layouts.inspect_header(
layout=layout,
atom_prop="armatures_field",
data="armatures"
)
# inspection update code
if inspection_update_trigger:
# if key is valid, update the user lists
if atom.armatures_field in bpy.data.armatures.keys():
self.users_objects = users.armature_all(atom.armatures_field)
# if key is invalid, empty the user lists
else:
self.users_objects = []
inspection_update_trigger = False
# objects box list
ui_layouts.box_list(
layout=layout,
title="Objects",
items=self.users_objects,
icon="OBJECT_DATA"
)
row = layout.row() # extra row for spacing
def execute(self, context):
return {'FINISHED'}
def invoke(self, context, event):
# update inspection context
atom = bpy.context.scene.atomic
atom.active_inspection = "ARMATURES"
# trigger update on invoke
global inspection_update_trigger
inspection_update_trigger = True
# invoke inspect dialog
wm = context.window_manager
return wm.invoke_props_dialog(self)
reg_list = [
ATOMIC_OT_inspect_collections,
ATOMIC_OT_inspect_images,
ATOMIC_OT_inspect_lights,
ATOMIC_OT_inspect_materials,
ATOMIC_OT_inspect_node_groups,
ATOMIC_OT_inspect_objects,
ATOMIC_OT_inspect_particles,
ATOMIC_OT_inspect_textures,
ATOMIC_OT_inspect_armatures,
ATOMIC_OT_inspect_worlds
]
@@ -719,4 +843,4 @@ def register():
def unregister():
for cls in reg_list:
unregister_class(cls)
compat.safe_unregister_class(cls)
@@ -28,7 +28,7 @@ category toggles and the category selection tools.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
from ..stats import count
from .utils import ui_layouts
@@ -50,8 +50,10 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
atom.lights,
atom.materials,
atom.node_groups,
atom.objects,
atom.particles,
atom.textures,
atom.armatures,
atom.worlds
]
@@ -87,6 +89,23 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
text=""
)
# objects buttons
splitcol = col.split(factor=0.8, align=True)
splitcol.prop(
atom,
"objects",
text="Objects",
icon='OBJECT_DATA',
toggle=True
)
splitcol.operator(
"atomic.inspect_objects",
icon='VIEWZOOM',
text=""
)
# lights buttons
splitcol = col.split(factor=0.8, align=True)
@@ -175,6 +194,23 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
text=""
)
# armatures buttons
splitcol = col.split(factor=0.8, align=True)
splitcol.prop(
atom,
"armatures",
text="Armatures",
icon='ARMATURE_DATA',
toggle=True
)
splitcol.operator(
"atomic.inspect_armatures",
icon='VIEWZOOM',
text=""
)
# particles buttons
splitcol = col.split(factor=0.8, align=True)
@@ -242,4 +278,4 @@ def register():
def unregister():
for cls in reg_list:
unregister_class(cls)
compat.safe_unregister_class(cls)
@@ -25,7 +25,7 @@ pops up when missing files are detected on file load.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
from bpy.app.handlers import persistent
from .. import config
from ..stats import missing
@@ -189,7 +189,7 @@ def register():
def unregister():
for item in reg_list:
unregister_class(item)
compat.safe_unregister_class(item)
# stop running missing file auto-detection after loading a Blender file
bpy.app.handlers.load_post.remove(autodetect_missing_files)
@@ -25,7 +25,7 @@ registration.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
# Atomic Data Manager Main Pie Menu
@@ -197,4 +197,4 @@ def register():
def unregister():
for cls in reg_list:
unregister_class(cls)
compat.safe_unregister_class(cls)
@@ -25,7 +25,7 @@ some functions for syncing the preference properties with external factors.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
from .. import config
# updater removed in Blender 4.5 extension format
@@ -328,6 +328,6 @@ def register():
def unregister():
for cls in reg_list:
unregister_class(cls)
compat.safe_unregister_class(cls)
remove_pie_menu_hotkeys()
@@ -28,7 +28,7 @@ it.
import bpy
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
from ..stats import count
from ..stats import misc
from .utils import ui_layouts
@@ -373,4 +373,4 @@ def register():
def unregister():
for cls in reg_list:
unregister_class(cls)
compat.safe_unregister_class(cls)
@@ -26,7 +26,7 @@ support Remington Creative popup.
import bpy
import time
from bpy.utils import register_class
from bpy.utils import unregister_class
from ..utils import compat
from bpy.app.handlers import persistent
from .. import config
from . import preferences_ui
@@ -123,6 +123,6 @@ def register():
def unregister():
for cls in reg_list:
unregister_class(cls)
compat.safe_unregister_class(cls)
bpy.app.handlers.load_post.remove(show_support_me_popup)
@@ -1,23 +1,4 @@
"""
Copyright (C) 2019 Remington Creative
This file is part of Atomic Data Manager.
Atomic Data Manager is free software: you can redistribute
it and/or modify it under the terms of the GNU General Public License
as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
Atomic Data Manager is distributed in the hope that it will
be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along
with Atomic Data Manager. If not, see <https://www.gnu.org/licenses/>.
---
This package contains utility modules for version detection and API compatibility.
"""
@@ -1,23 +1,4 @@
"""
Copyright (C) 2019 Remington Creative
This file is part of Atomic Data Manager.
Atomic Data Manager is free software: you can redistribute
it and/or modify it under the terms of the GNU General Public License
as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
Atomic Data Manager is distributed in the hope that it will
be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along
with Atomic Data Manager. If not, see <https://www.gnu.org/licenses/>.
---
This module provides API compatibility functions for handling differences
between Blender 4.2 LTS, 4.5 LTS, and 5.0.
@@ -152,3 +133,25 @@ def get_scene_compositor_node_tree(scene):
if hasattr(scene, 'node_tree') and scene.node_tree:
return scene.node_tree
return None
def is_library_or_override(datablock):
"""
Check if a datablock is library-linked or an override.
Atomic should completely ignore all datablocks within libraries.
Args:
datablock: The datablock to check
Returns:
bool: True if the datablock is library-linked or an override, False otherwise
"""
# Check if datablock is linked from a library
if hasattr(datablock, 'library') and datablock.library:
return True
# Check if datablock is an override (Blender 3.0+)
if hasattr(datablock, 'override_library') and datablock.override_library:
return True
return False
@@ -1,23 +1,4 @@
"""
Copyright (C) 2019 Remington Creative
This file is part of Atomic Data Manager.
Atomic Data Manager is free software: you can redistribute
it and/or modify it under the terms of the GNU General Public License
as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
Atomic Data Manager is distributed in the hope that it will
be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along
with Atomic Data Manager. If not, see <https://www.gnu.org/licenses/>.
---
This module provides version detection and comparison utilities for
multi-version Blender support (4.2 LTS, 4.5 LTS, and 5.0).