save startup blend for animation tab & whatnot
This commit is contained in:
2026-04-08 12:10:18 -06:00
parent 57a652524a
commit 692e200ffe
180 changed files with 12336 additions and 3431 deletions
@@ -1,18 +1,44 @@
## [v2.6.2] - 2026-04-06
### Fixes
- **Clean / unused materials**: if **`Material.users` > 0** (and not only a fake user) but **`material_all()`** finds no references, the material is **no longer** treated as unused—avoids false positives when a **local** material shares a name with a **linked** one or Blender holds other unmodeled refs. The same rule applies to **RNA / Smart Select** scans (the unified scanner uses `analyze_unused_from_graph`, not only `materials_deep`).
## [v2.6.1] - 2026-03-27
### Fixes
- **Stats → Storage**: action byte estimates include **layered/slotted actions** (Blender 4.4+): keyframes in **channel bags** on strips are counted, so values are no longer stuck at the **256 B** minimum when `Action.fcurves` is empty.
## [v2.6.0] - 2026-03-27
### Features
- **Stats for Nerds — Storage**: local blend storage estimates (bytes per datablock type, largest IDs with type + library-override icons and names), including **collections**; packed totals on Overview. Implementation lives in `utils/compat` for reliable `bl_ext` loading.
- **Smart Select / Clean (issue #15)**: materials and images used in Geometry Nodes are detected on **library override** objects (not only pure locals); pure linked objects are still skipped via `is_object_linked_without_override`.
- **Clean**: before removing rig objects, **unparent** scene objects that would be orphaned (keep transform) and **remove Armature modifiers** targeting deleted objects, with info reports.
## [v2.5.0] - 2026-01-28
### Features
- Missing file tools: add “Relink All” and improve replacement workflow.
- Missing file tools:
- Finalized Relinker and Linked Library Replacer features.
- Add “Relink All” button to Relinker
### Fixes
- Missing file UI: fix text field paste + layout/truncation issues; center the detect-missing popup; refine replacement path handling (better dir vs file behavior).
- RNA analysis: expand datablock coverage and refine dependency tracking to reduce false “unused” results.
### Internal
- Maintenance: remove deprecated recovery option; improve ignore rules for hidden dot-directories.
## [v2.4.1] - 2026-01-14
### Fixes
- Fixed RNA analysis crashes when opening new blend files by rebuilding data-block type references dynamically
- Fixed indentation errors that prevented RNA dump from processing most data-blocks
- Fixed compositing nodetree detection by adding scenes as root nodes in dependency graph
@@ -22,6 +48,7 @@
## [v2.4.0] - 2026-01-13
### Features
- **Major Architecture Change: RNA-Based Analysis System**
- Replaced multi-process worker system with faster, more robust RNA-based dependency analysis
- All data types now use unified RNA introspection for dependency tracking
@@ -32,6 +59,7 @@
- Items now display in 4-column grid layout to reduce vertical scrolling
### Fixes
- Fixed node groups used by objects via Geometry Nodes modifiers not being detected as used
- Fixed RigidBodyWorld and other scene-linked data-blocks incorrectly flagged as cleanable
- Fixed area lights and other object data-blocks in scene collections not being marked as used
@@ -39,22 +67,26 @@
- Fixed RNA extraction handling for objects' modifier node groups
### Performance
- Significantly faster scanning across all categories using RNA analysis
- Single-pass dependency graph building shared across all category scans
## [v2.3.1] - 2026-01-13
### Fixes
- Integrate proper UDIM detection
## [v2.3.0] - 2026-01-06
### Features
- Added "Enable Debug Prints" preference to control debug console output
- Debug messages now only print when this preference is enabled (default: off)
- All debug print statements use centralized `config.debug_print()` helper
### Fixes
- Fixed preferences not displaying in Blender 5.0 extensions
- Preferences now correctly match the full module path (`bl_ext.vscode_development.atomic_data_manager`)
- Added safe property setter to handle read-only context errors during file loading
@@ -69,6 +101,7 @@
## [v2.2.0] - 2026-01-05
### Features
- Add loading bars; non-blocking timer-based UI (#10)
- Operations no longer freeze the UI during scanning
- Real-time progress updates with cancel support at any time
@@ -80,6 +113,7 @@
- Added manual cache clear operator for testing and debugging
### Performance
- Optimized deep scan functions with caching and fast-path checks
- Image scanning now uses cached results to avoid redundant scene scans
- Early exit for clearly unused images using Blender's built-in user count
@@ -88,24 +122,28 @@
- Worlds processed one at a time incrementally
### Fixes
- Fixed images used only by unused objects being incorrectly flagged as unused (#5)
- Fixed material detection in brushes and node groups (#6, #7)
- Fixed Clean operator not showing dialog when invoked programmatically (#8)
- Improved material detection in inspection tools (brushes, node groups)
### Internal
- Refactored scanning architecture for maintainability
- Added comprehensive debug output for troubleshooting
## [v2.1.0] - 2025-12-18
### Features
- 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
@@ -117,38 +155,45 @@
- 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
### Feature
- Multi-version Blender support (4.2 LTS, 4.5 LTS, and 5.0)
- Version detection utilities in `utils/version.py`
- API compatibility layer in `utils/compat.py` for handling version differences
- Version detection utilities in `utils/version.py`
- API compatibility layer in `utils/compat.py` for handling version differences
### Fixes
- Blender 5.0 compatibility: Fixed `AttributeError` when accessing scene compositor node tree (changed from `scene.node_tree` to `scene.compositing_node_tree`)
- Collections assigned to `rigidbody_world.collection` are now correctly detected as used
### Internal
- GitHub Actions release workflow
- Integrated `rainys_repo_bootstrap` into `__init__.py` so the Rainy's Extensions repository is registered on add-on enable and the bootstrap guard resets on disable.
- Removed "Support Remington Creative" popup and all related functionality
- Removed Support popup preferences
- Removed Support popup preferences
@@ -144,6 +144,13 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
'FILE', # icon
0 # number / id
),
(
'STORAGE',
'Storage',
'Local datablocks vs linked libraries',
'DISK_DRIVE',
11
),
(
'COLLECTIONS',
'Collections',
@@ -239,7 +246,14 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
def _on_undo_pre(scene):
"""Handler called before undo - invalidate cache."""
from .ops import main_ops
from .utils.compat import invalidate_cache
main_ops._invalidate_cache()
invalidate_cache()
def _load_post_invalidate_storage(_dummy):
from .utils.compat import invalidate_cache
invalidate_cache()
def register():
@@ -252,6 +266,7 @@ def register():
# Register undo handler to invalidate cache
bpy.app.handlers.undo_pre.append(_on_undo_pre)
bpy.app.handlers.load_post.append(_load_post_invalidate_storage)
# bootstrap Rainy's Extensions repository
rainys_repo_bootstrap.register()
@@ -264,6 +279,8 @@ def unregister():
# Remove undo handler
if _on_undo_pre in bpy.app.handlers.undo_pre:
bpy.app.handlers.undo_pre.remove(_on_undo_pre)
if _load_post_invalidate_storage in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(_load_post_invalidate_storage)
# atomic package unregistration
ui.unregister()
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "atomic_data_manager"
name = "Atomic Data Manager"
version = "2.5.0"
version = "2.6.2"
type = "add-on"
author = "RaincloudTheDragon"
maintainer = "RaincloudTheDragon"
@@ -250,7 +250,9 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
clean.lights()
clean.materials()
clean.node_groups()
clean.objects()
for msg in clean.detach_scene_objects_from_removal_targets(set(self.unused_objects)):
self.report({'INFO'}, msg)
clean.objects(cached_list=self.unused_objects)
clean.particles()
clean.textures()
clean.armatures()
@@ -739,6 +739,14 @@ class ATOMIC_OT_clean(bpy.types.Operator):
bpy.ops.atomic.deselect_all()
return {'FINISHED'}
# Keep in-scene objects that are parented to / deformed by objects we delete
if atom.objects and self.unused_objects:
from .utils import clean as clean_utils
for msg in clean_utils.detach_scene_objects_from_removal_targets(
set(self.unused_objects)
):
self.report({'INFO'}, msg)
# Delete all items synchronously
deleted_count = 0
for category, unused_list in categories_to_clean:
@@ -24,6 +24,52 @@ This file contains functions for cleaning out specific data categories.
import bpy
from ...stats import unused
from ...utils import compat
def detach_scene_objects_from_removal_targets(object_names_to_remove):
"""
Unparent (world transform preserved) and remove Armature modifiers pointing at
objects that are about to be deleted, for objects that are NOT in the removal
set. Prevents in-scene props (e.g. parented to a rig) from being lost when the
rig object is removed.
Returns:
list[str]: Messages suitable for operator.report (one string per change).
"""
if not object_names_to_remove:
return []
remove = set(object_names_to_remove)
reports = []
for obj in bpy.data.objects:
if obj.name in remove:
continue
if compat.is_library_or_override(obj):
continue
parts = []
if obj.parent is not None and obj.parent.name in remove:
mw = obj.matrix_world.copy()
obj.parent = None
obj.matrix_world = mw
parts.append("unparented (kept world transform)")
for mod in list(obj.modifiers):
if mod.type != 'ARMATURE':
continue
target = getattr(mod, "object", None)
if target is not None and target.name in remove:
obj.modifiers.remove(mod)
parts.append("removed Armature modifier targeting deleted object")
if parts:
reports.append(
"Atomic: “%s”: %s" % (obj.name, "; ".join(parts))
)
return reports
def collections(cached_list=None):
@@ -137,7 +183,7 @@ def objects(cached_list=None):
object_keys = cached_list
else:
object_keys = unused.objects_deep()
for object_key in object_keys:
if object_key in bpy.data.objects:
bpy.data.objects.remove(bpy.data.objects[object_key])
@@ -0,0 +1,6 @@
"""
Statistics and analysis helpers for Atomic Data Manager.
Blend-file storage estimates live in ``utils.compat`` (merged there so
``bl_ext`` dev deploy cannot miss a separate ``utils/blend_storage`` file).
"""
@@ -915,6 +915,8 @@ def analyze_unused_from_graph(graph, category, include_fake_users=None):
Returns:
List of unused item names for the specified category
"""
from . import users
if include_fake_users is None:
include_fake_users = config.include_fake_users
@@ -1184,6 +1186,20 @@ def analyze_unused_from_graph(graph, category, include_fake_users=None):
item_name = datablock.name
if (category, item_name) not in used:
if item_name not in category_do_not_flag:
# Objects that appear in a scene collection must stay (traceable to a scene), even
# if the RNA graph missed them (e.g. mesh parented to an out-of-scene armature).
if category == 'objects':
try:
if users.object_all(item_name):
continue
except (AttributeError, KeyError, RuntimeError, ReferenceError):
pass
if category == 'materials':
try:
if datablock.users > 0 and not datablock.use_fake_user:
continue
except (AttributeError, RuntimeError, ReferenceError):
pass
unused.append(item_name)
except (AttributeError, RuntimeError, ReferenceError):
# Datablock may be invalid
@@ -223,6 +223,10 @@ def materials_deep():
# 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:
# If Blender still counts users but we found none, don't flag (name collisions
# with linked IDs, drivers, or refs we don't traverse). Fake-user purge unchanged.
if material.users > 0 and not material.use_fake_user:
continue
unused.append(material.name)
else:
# Second check: material is used, but check if it's ONLY used by unused objects
@@ -127,6 +127,8 @@ def _has_any_unused_materials():
# First check: standard unused detection
if not users.material_all(material.name):
if not material.use_fake_user or config.include_fake_users:
if material.users > 0 and not material.use_fake_user:
continue
return True
else:
# Second check: material is used, but check if it's ONLY used by unused objects
@@ -416,8 +416,8 @@ 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):
# Skip purely linked objects; library overrides can have local GN modifiers (#15)
if compat.is_object_linked_without_override(obj):
continue
# Check if object is in any scene collection (reuse object_all logic)
@@ -583,8 +583,8 @@ 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):
# Skip purely linked objects; library overrides can have local GN modifiers (#15)
if compat.is_object_linked_without_override(obj):
continue
# Check if object is in any scene collection (reuse object_all logic)
@@ -31,6 +31,13 @@ from bpy.utils import register_class
from ..utils import compat
from ..stats import count
from ..stats import misc
from ..utils.compat import (
format_bytes,
format_embedded_total,
get_report,
storage_override_icon,
storage_type_icon,
)
from .utils import ui_layouts
@@ -46,6 +53,8 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
def draw(self, context):
layout = self.layout
atom = bpy.context.scene.atomic
# Keep blend storage scan in sync whenever the stats panel is shown
storage_report = get_report()
# categories selector / header
row = layout.row()
@@ -66,6 +75,12 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
row = box.row()
row.label(text="Blend File Size: " + misc.blend_size())
row = box.row()
row.label(
text="Packed data (local IDs): "
+ format_embedded_total(storage_report["total_embedded_packed"])
)
# cateogry statistics
split = box.split()
@@ -115,6 +130,52 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
# world count
col.label(text=str(count.worlds()))
# local blend storage (excludes linked IDs)
elif atom.stats_mode == 'STORAGE':
row = box.row()
row.label(text="Local blend storage", icon='DISK_DRIVE')
rep = storage_report
col = box.column(align=True)
col.label(
text="IDs linked from another .blend (library) are excluded.",
icon='INFO',
)
col.label(text="Packed = exact bytes stored inside this .blend.")
col.label(text="Other sizes are estimates; disk file may compress.")
col.label(text="Second icon = library override (when applicable).")
row = col.row()
row.label(
text="Packed embedded total: "
+ format_embedded_total(rep["total_embedded_packed"])
)
col.separator()
col.label(text="By datablock type (estimated total)")
for t, nbytes in rep["by_type"][:12]:
col.label(text=" %s: %s" % (t, format_bytes(nbytes)))
col.separator()
col.label(text="Largest local datablocks (top 40)")
for r in rep["rows"][:40]:
split = col.split(factor=0.68)
left = split.row(align=True)
left.label(icon=storage_type_icon(r["type"]), text="")
left.label(
icon=storage_override_icon(r.get("is_lib_override", False)),
text="",
)
left.label(text=r["name"])
split.label(text=format_bytes(r["size_bytes"]))
if len(rep["rows"]) > 40:
col.label(text=" ...")
# collection statistics
elif atom.stats_mode == 'COLLECTIONS':
@@ -4,6 +4,7 @@ between Blender 4.2 LTS, 4.5 LTS, and 5.0.
"""
import os
import bpy
from bpy.utils import register_class, unregister_class
from . import version
@@ -177,3 +178,624 @@ def is_library_or_override(datablock):
return True
return False
def is_object_linked_without_override(obj):
"""
True if obj comes from another .blend file but is not a library override.
Override objects live in the current file and may have local modifiers
(e.g. Geometry Nodes) that reference local materials or images; those
must be scanned. Purely linked objects have no such local stack here.
"""
lib = getattr(obj, "library", None)
ovl = getattr(obj, "override_library", None)
return lib is not None and ovl is None
# --- Blend-file storage (lives here so bl_ext dev sync cannot miss a separate module) ---
_cache_report = None
_cache_light = None
_cache_vert_sum = None
def invalidate_cache():
global _cache_report, _cache_light, _cache_vert_sum
_cache_report = None
_cache_light = None
_cache_vert_sum = None
def _mesh_vertex_sum_sample():
"""Cheap geometry signature so cache invalidates on edit without save."""
s = 0
for m in bpy.data.meshes:
try:
s += len(m.vertices)
except (AttributeError, RuntimeError, ReferenceError):
pass
if s > 50_000_000:
break
return s
def _light_fingerprint():
fp = bpy.data.filepath
try:
mt = os.stat(fp).st_mtime if fp else 0
except OSError:
mt = 0
return (
fp,
mt,
len(bpy.data.meshes),
len(bpy.data.images),
len(bpy.data.materials),
len(bpy.data.node_groups),
len(bpy.data.objects),
len(bpy.data.armatures),
len(bpy.data.collections),
)
def _skip_linked(id_block):
return getattr(id_block, "library", None) is not None
# Set at start of build_report(); object.data IDs whose users are overridden objects
_storage_override_data_ids = frozenset()
def _object_data_override_ids():
"""IDs used as ob.data for at least one object with a library override."""
s = set()
for ob in bpy.data.objects:
if getattr(ob, "override_library", None) and ob.data is not None:
s.add(ob.data)
return frozenset(s)
def is_library_override_storage(id_block):
"""
True if this ID is a library override, including object-data reached only
via an overridden Object (obdata may lack override_library).
"""
if id_block is None:
return False
if getattr(id_block, "override_library", None):
return True
return id_block in _storage_override_data_ids
def _override_weight_factor(id_block):
return 0.08 if is_library_override_storage(id_block) else 1.0
def _mesh_size_bytes(m):
"""Rough serialized footprint estimate (verts/loops/faces), scaled for overrides."""
if _skip_linked(m):
return None
try:
v, l, p = len(m.vertices), len(m.loops), len(m.polygons)
except (AttributeError, RuntimeError, ReferenceError):
return None
ow = _override_weight_factor(m)
base = (v * 28 + l * 6 + p * 10 + 512) * ow
return max(64, int(base))
def _image_entry(img):
if _skip_linked(img):
return None
embedded = 0
pf = getattr(img, "packed_file", None)
if pf:
try:
data = pf.data
if data:
embedded = len(data)
except (AttributeError, TypeError, RuntimeError):
pass
if embedded == 0:
pfs = getattr(img, "packed_files", None)
if pfs:
for p in pfs:
try:
if hasattr(p, "data") and p.data:
embedded += len(p.data)
except (AttributeError, RuntimeError):
pass
ow = _override_weight_factor(img)
if embedded > 0:
size_b = max(1, int(embedded * ow))
return ("images", img.name, embedded, size_b, "packed")
size_b = max(1, int(256 * ow))
return ("images", img.name, 0, size_b, "external")
def _armature_size_bytes(a):
if _skip_linked(a):
return None
try:
n = len(a.bones)
except (AttributeError, RuntimeError, ReferenceError):
n = 0
ow = _override_weight_factor(a)
return int((2048 + n * 320) * ow)
def _curve_size_bytes(c):
if _skip_linked(c):
return None
try:
n = sum(len(s.points) for s in c.splines)
except (AttributeError, RuntimeError, ReferenceError):
n = 0
ow = _override_weight_factor(c)
return int((1024 + n * 24) * ow)
def _node_tree_size_bytes(nt):
if not nt or _skip_linked(nt):
return None
try:
n = len(nt.nodes) + len(nt.links)
except (AttributeError, RuntimeError, ReferenceError):
n = 0
ow = _override_weight_factor(nt)
return int((2048 + n * 96) * ow)
def _action_keyframe_counts(act):
"""
Keyframe point count and F-curve count for storage estimate.
Blender 4.4+ layered actions store curves in ActionChannelBag under
strips; act.fcurves is often empty, so we must walk layers/slots.
"""
kp, fc = 0, 0
layers = getattr(act, "layers", None)
if layers and len(layers) > 0:
for layer in layers:
strips = getattr(layer, "strips", None)
if not strips:
continue
for strip in strips:
if not (hasattr(strip, "channelbags") or hasattr(strip, "channelbag")):
continue
bags = getattr(strip, "channelbags", None)
if bags and len(bags) > 0:
for bag in bags:
for fcurve in bag.fcurves:
fc += 1
try:
kp += len(fcurve.keyframe_points)
except (TypeError, AttributeError, RuntimeError, ReferenceError):
pass
else:
slots = getattr(act, "slots", None)
if not slots:
continue
for slot in slots:
try:
bag = strip.channelbag(slot, ensure=False)
except (TypeError, AttributeError, RuntimeError, ReferenceError):
bag = None
if bag is None:
continue
for fcurve in bag.fcurves:
fc += 1
try:
kp += len(fcurve.keyframe_points)
except (TypeError, AttributeError, RuntimeError, ReferenceError):
pass
return kp, fc
fcurves = getattr(act, "fcurves", None)
if fcurves:
for fcurve in fcurves:
fc += 1
try:
kp += len(fcurve.keyframe_points)
except (TypeError, AttributeError, RuntimeError, ReferenceError):
pass
return kp, fc
def _action_size_bytes(act):
if _skip_linked(act):
return None
kp, fc = _action_keyframe_counts(act)
ow = _override_weight_factor(act)
return max(64, int((256 + kp * 20 + fc * 80) * ow))
def _object_size_bytes(ob):
if _skip_linked(ob):
return None
ow = _override_weight_factor(ob)
return int(192 * ow)
def _texture_size_bytes(tex):
if _skip_linked(tex):
return None
ow = _override_weight_factor(tex)
return int(512 * ow)
def _volume_size_bytes(vol):
if _skip_linked(vol):
return None
ow = _override_weight_factor(vol)
return int(4096 * ow)
def _pointcloud_size_bytes(pc):
if _skip_linked(pc):
return None
try:
n = len(pc.points)
except (AttributeError, RuntimeError, ReferenceError):
n = 0
ow = _override_weight_factor(pc)
return int((512 + n * 16) * ow)
def _sound_entry(snd):
if _skip_linked(snd):
return None
embedded = 0
pf = getattr(snd, "packed_file", None)
if pf:
try:
if hasattr(pf, "data") and pf.data:
embedded = len(pf.data)
except (AttributeError, TypeError, RuntimeError):
pass
ow = _override_weight_factor(snd)
if embedded > 0:
return ("sounds", snd.name, embedded, max(1, int(embedded * ow)), "packed")
return ("sounds", snd.name, 0, max(1, int(256 * ow)), "external")
def _font_entry(font):
if _skip_linked(font):
return None
embedded = 0
pf = getattr(font, "packed_file", None)
if pf and hasattr(pf, "data") and pf.data:
try:
embedded = len(pf.data)
except (TypeError, RuntimeError):
embedded = 0
ow = _override_weight_factor(font)
if embedded > 0:
return ("fonts", font.name, embedded, max(1, int(embedded * ow)), "packed")
return None
def _collection_size_bytes(coll):
if _skip_linked(coll):
return None
try:
no = len(coll.objects)
nc = len(coll.children)
except (AttributeError, RuntimeError, ReferenceError):
return None
ow = _override_weight_factor(coll)
return max(64, int((512 + no * 96 + nc * 256) * ow))
def _fmt_bytes(n):
if n >= 1048576:
return f"{n / 1048576:.2f} MiB"
if n >= 1024:
return f"{n / 1024:.2f} KiB"
return f"{int(n)} B"
def format_bytes(n):
"""Human-readable size for storage estimates."""
return _fmt_bytes(n)
_STORAGE_TYPE_ICONS = {
"Mesh": "MESH_DATA",
"Image": "IMAGE_DATA",
"Armature": "ARMATURE_DATA",
"Material": "MATERIAL",
"Object": "OBJECT_DATA",
"Curve": "CURVE_DATA",
"NodeTree": "NODETREE",
"Action": "ACTION",
"Texture": "TEXTURE",
"Volume": "VOLUME_DATA",
"PointCloud": "POINTCLOUD_DATA",
"Sound": "SOUND",
"Font": "FONT_DATA",
"Collection": "OUTLINER_COLLECTION",
}
def storage_type_icon(type_name):
"""Blender UI icon for a storage row type label."""
return _STORAGE_TYPE_ICONS.get(type_name, "BLANK1")
def storage_override_icon(is_lib_override):
"""Second column: library override emblem vs empty spacer."""
return "LIBRARY_DATA_OVERRIDE" if is_lib_override else "BLANK1"
def build_report():
"""Build storage report dict. Call through get_report() for caching."""
global _storage_override_data_ids
_storage_override_data_ids = _object_data_override_ids()
rows = []
def _ov(id_block):
return is_library_override_storage(id_block)
for m in bpy.data.meshes:
sz = _mesh_size_bytes(m)
if sz is not None:
io = _ov(m)
rows.append(
{
"type": "Mesh",
"name": m.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for img in bpy.data.images:
e = _image_entry(img)
if e is None:
continue
_typ, name, emb, sz, kind = e
io = _ov(img)
rows.append(
{
"type": "Image",
"name": name,
"embedded": emb,
"size_bytes": sz,
"is_lib_override": io,
"kind": kind,
}
)
for a in bpy.data.armatures:
sz = _armature_size_bytes(a)
if sz is not None:
io = _ov(a)
rows.append(
{
"type": "Armature",
"name": a.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for c in getattr(bpy.data, "curves", []):
sz = _curve_size_bytes(c)
if sz is not None:
io = _ov(c)
rows.append(
{
"type": "Curve",
"name": c.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for ng in bpy.data.node_groups:
sz = _node_tree_size_bytes(ng)
if sz is not None:
io = _ov(ng)
rows.append(
{
"type": "NodeTree",
"name": ng.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for mat in bpy.data.materials:
if _skip_linked(mat):
continue
sz = _node_tree_size_bytes(mat.node_tree) if mat.use_nodes and mat.node_tree else 256
if sz is None:
sz = 256
ow = _override_weight_factor(mat)
sz = int(sz * ow)
io = _ov(mat)
rows.append(
{
"type": "Material",
"name": mat.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
if hasattr(bpy.data, "actions"):
for act in bpy.data.actions:
sz = _action_size_bytes(act)
if sz is not None:
io = _ov(act)
rows.append(
{
"type": "Action",
"name": act.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for tex in getattr(bpy.data, "textures", []):
sz = _texture_size_bytes(tex)
if sz is not None:
io = _ov(tex)
rows.append(
{
"type": "Texture",
"name": tex.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for ob in bpy.data.objects:
sz = _object_size_bytes(ob)
if sz is not None:
io = _ov(ob)
rows.append(
{
"type": "Object",
"name": ob.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for vol in getattr(bpy.data, "volumes", []):
sz = _volume_size_bytes(vol)
if sz is not None:
io = _ov(vol)
rows.append(
{
"type": "Volume",
"name": vol.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for pc in getattr(bpy.data, "pointclouds", []):
sz = _pointcloud_size_bytes(pc)
if sz is not None:
io = _ov(pc)
rows.append(
{
"type": "PointCloud",
"name": pc.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for snd in getattr(bpy.data, "sounds", []):
e = _sound_entry(snd)
if e is None:
continue
_typ, name, emb, sz, kind = e
io = _ov(snd)
rows.append(
{
"type": "Sound",
"name": name,
"embedded": emb,
"size_bytes": sz,
"is_lib_override": io,
"kind": kind,
}
)
for font in getattr(bpy.data, "fonts", []):
e = _font_entry(font)
if e is None:
continue
_typ, name, emb, sz, kind = e
io = _ov(font)
rows.append(
{
"type": "Font",
"name": name,
"embedded": emb,
"size_bytes": sz,
"is_lib_override": io,
"kind": kind,
}
)
for coll in bpy.data.collections:
sz = _collection_size_bytes(coll)
if sz is not None:
io = _ov(coll)
rows.append(
{
"type": "Collection",
"name": coll.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
rows.sort(key=lambda r: r["size_bytes"], reverse=True)
by_type = {}
total_estimated = 0
total_emb = 0
for r in rows:
t = r["type"]
by_type[t] = by_type.get(t, 0) + r["size_bytes"]
total_estimated += r["size_bytes"]
total_emb += r.get("embedded", 0)
type_order = sorted(by_type.keys(), key=lambda t: -by_type[t])
by_type_sizes = [(t, by_type[t]) for t in type_order]
return {
"rows": rows,
"by_type": by_type_sizes,
"total_estimated_bytes": total_estimated,
"total_embedded_packed": total_emb,
}
def get_report():
global _cache_report, _cache_light, _cache_vert_sum
light = _light_fingerprint()
vs = _mesh_vertex_sum_sample()
if (
_cache_report is not None
and _cache_light == light
and _cache_vert_sum == vs
):
return _cache_report
_cache_report = build_report()
_cache_light = light
_cache_vert_sum = vs
return _cache_report
def format_embedded_total(n):
"""Human-readable total packed bytes embedded in the .blend."""
return _fmt_bytes(n)