work
save startup blend for animation tab & whatnot
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user