update addons (for 5.2)

This commit is contained in:
Nathan
2026-07-14 14:44:58 -06:00
parent a232ea1c9c
commit e3310263f3
246 changed files with 21097 additions and 9607 deletions
@@ -1,3 +1,21 @@
## [v2.7.0] - 2026-07-10
### Features
- **Stats → Storage (#17)**: index **in-blend caches** — packed **GeoNodes bakes** (exact `bake_size` via DNA) and **physics point caches** (cloth, soft body, particles, rigid body, dynamic paint) when baked without disk/external storage; Mantaflow/fluid disk caches remain out of scope.
- **Stats → Storage (#18)**: **click a row name** to find scene users and select/activate the object; materials open the Shader Editor, actions open the Dope Sheet. Multiple users show a picker popup.
- **Preferences**: **Frame View on Storage Navigate** (off by default) — optionally frame the user in the 3D viewport after clicking a storage row.
- **Storage row icons**: GeoNodes bakes show **geometry-nodes** then **package** (packed) icons to distinguish baked data from node groups.
### Internal
- **Dev fixtures**: `testdata/` storage test `.blend` files (cloth, dynpaint, GeoNodes bakes, Mantaflow, particles, rigid body, soft body).
- **Release / LFS**: track `*.blend` via Git LFS; release workflow sparse-checkout and zip guard exclude `testdata/` from shipped builds.
### Documentation
- **README**: **Stats → Storage** section documents scope, icons, navigation, and exclusions (panel help text removed).
## [v2.6.3] - 2026-04-22
### Fixes
@@ -27,6 +27,20 @@ Atomic Data Manager offers Blender artists an intelligent data management soluti
Pie Menu Controls, Advanced Fake User Options, Mass Delete Categories, Undo Accidental Deletions, Save Data-Blocks, Delete Data-Blocks, Replace Data-Blocks, Rename Data-Blocks, Reload Missing Files, Remove Missing Files, Replace Missing Files, and Search for Missing Files.
## Stats → Storage
**Stats for Nerds → Storage** lists local datablocks ranked by estimated footprint inside the current `.blend`. Library-linked IDs from other files are excluded.
| What | Notes |
|------|--------|
| **Packed total** | Exact bytes embedded in the file (packed images, fonts, sounds, GeoNodes bakes). |
| **Row sizes** | Most types are estimates; disk compression may differ. Physics point caches (cloth, soft body, particles, rigid body, dynamic paint) are in-blend estimates when baked with disk cache off. |
| **GeoNodes bakes** | Packed bake data, not the node group. Icons: geometry nodes, then package (packed). |
| **Mantaflow / fluid** | Disk-only caches are not indexed. |
| **Icons** | Type icon(s), then library-override emblem when applicable. |
| **Click a row name** | Selects and activates a scene user (material → Shader Editor, action → Dope Sheet). Optional **Frame View on Storage Navigate** in add-on preferences. |
## TAKE A VIDEO TOUR
[![video-thumb](https://remington.pro/wp-content/uploads/2019/08/atomic_addon_tour_play_button-min.jpg)](https://remington.pro/software/blender/atomic/#tour)
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "atomic_data_manager"
name = "Atomic Data Manager"
version = "2.6.3"
version = "2.7.0"
type = "add-on"
author = "RaincloudTheDragon"
maintainer = "RaincloudTheDragon"
@@ -33,6 +33,7 @@ enable_missing_file_warning = True
include_fake_users = False
enable_pie_menu_ui = True
enable_debug_prints = False
storage_navigate_frame_view = False
# hidden atomic preferences
pie_menu_type = "D"
@@ -26,6 +26,7 @@ from . import main_ops
from . import inspect_ops
from . import direct_use_ops
from . import missing_file_ops
from . import storage_navigate_ops
def register():
@@ -33,10 +34,12 @@ def register():
inspect_ops.register()
direct_use_ops.register()
missing_file_ops.register()
storage_navigate_ops.register()
def unregister():
main_ops.unregister()
inspect_ops.unregister()
direct_use_ops.unregister()
missing_file_ops.unregister()
missing_file_ops.unregister()
storage_navigate_ops.unregister()
@@ -0,0 +1,113 @@
"""
Operators for navigating from Atomic Storage rows to datablock users.
"""
import bpy
from bpy.props import StringProperty
from bpy.utils import register_class
from ..utils import compat
from ..utils import storage_nav
class ATOMIC_OT_storage_navigate_pick(bpy.types.Operator):
"""Navigate to a specific object from a storage row."""
bl_idname = "atomic.storage_navigate_pick"
bl_label = "Show User"
bl_options = {"INTERNAL"}
object_name: StringProperty(name="Object")
material_name: StringProperty(name="Material", default="")
action_name: StringProperty(name="Action", default="")
nav_mode: StringProperty(name="Navigation", default="viewport")
def execute(self, context):
target = {
"object": self.object_name,
"material": self.material_name,
"action": self.action_name,
"nav": self.nav_mode,
}
if storage_nav.apply_target(context, target):
return {"FINISHED"}
self.report({"WARNING"}, "Could not select '%s' in the current view layer" % self.object_name)
return {"CANCELLED"}
class ATOMIC_OT_storage_navigate(bpy.types.Operator):
"""Find and show users of a storage-index datablock."""
bl_idname = "atomic.storage_navigate"
bl_label = "Show Storage User"
bl_description = "Select and activate user object(s) from a storage row"
storage_type: StringProperty(name="Storage Type")
id_name: StringProperty(name="ID Name")
owner_object: StringProperty(name="Owner Object", default="")
owner_scene: StringProperty(name="Owner Scene", default="")
modifier_name: StringProperty(name="Modifier", default="")
def invoke(self, context, event):
self._targets = storage_nav.resolve_targets(
self.storage_type,
self.id_name,
owner_object=self.owner_object,
owner_scene=self.owner_scene,
modifier_name=self.modifier_name,
)
if not self._targets:
self.report({"WARNING"}, "No scene users found for '%s'" % self.id_name)
return {"CANCELLED"}
if len(self._targets) == 1:
return self.execute(context)
return context.window_manager.invoke_popup(self, width=240)
def draw(self, context):
layout = self.layout
layout.label(text="Select user:")
for target in self._targets:
op = layout.operator(
"atomic.storage_navigate_pick",
text=target["object"],
icon="OBJECT_DATA",
)
op.object_name = target["object"]
op.material_name = target.get("material", "")
op.action_name = target.get("action", "")
op.nav_mode = target.get("nav", "viewport")
def execute(self, context):
targets = getattr(self, "_targets", None)
if targets is None:
targets = storage_nav.resolve_targets(
self.storage_type,
self.id_name,
owner_object=self.owner_object,
owner_scene=self.owner_scene,
modifier_name=self.modifier_name,
)
if not targets:
self.report({"WARNING"}, "No scene users found for '%s'" % self.id_name)
return {"CANCELLED"}
if storage_nav.apply_target(context, targets[0]):
if len(targets) > 1:
self.report({"INFO"}, "Showing first of %d users" % len(targets))
return {"FINISHED"}
self.report({"WARNING"}, "Could not select user in the current view layer")
return {"CANCELLED"}
reg_list = (
ATOMIC_OT_storage_navigate,
ATOMIC_OT_storage_navigate_pick,
)
def register():
for item in reg_list:
register_class(item)
def unregister():
for item in reg_list:
compat.safe_unregister_class(item)
@@ -1564,6 +1564,105 @@ def armature_all(armature_key):
return distinct(users)
def _obdata_viewport_objects(obdata_key, obj_type=None):
"""Objects in a scene whose data block matches obdata_key."""
users = []
for obj in bpy.data.objects:
if compat.is_library_or_override(obj):
continue
data = getattr(obj, "data", None)
if data is None or getattr(data, "name", None) != obdata_key:
continue
if obj_type and obj.type != obj_type:
continue
if object_all(obj.name):
users.append(obj.name)
return distinct(users)
def mesh_objects(mesh_key):
return _obdata_viewport_objects(mesh_key, "MESH")
def curve_objects(curve_key):
return _obdata_viewport_objects(curve_key, "CURVE")
def volume_objects(volume_key):
return _obdata_viewport_objects(volume_key, "VOLUME")
def pointcloud_objects(pointcloud_key):
return _obdata_viewport_objects(pointcloud_key, "POINTCLOUD")
def action_objects(action_key):
users = []
try:
action = bpy.data.actions[action_key]
except (KeyError, AttributeError):
return []
for obj in bpy.data.objects:
if compat.is_library_or_override(obj):
continue
ad = getattr(obj, "animation_data", None)
if ad and ad.action == action and object_all(obj.name):
users.append(obj.name)
return distinct(users)
def image_viewport_objects(image_key):
users = []
users.extend(image_geometry_nodes(image_key))
for mat_name in image_materials(image_key):
users.extend(material_objects(mat_name))
return distinct(users)
def node_group_viewport_objects(node_group_key):
users = []
users.extend(node_group_objects(node_group_key))
for mat_name in node_group_materials(node_group_key):
users.extend(material_objects(mat_name))
return distinct(users)
def collection_viewport_objects(collection_key):
users = []
users.extend(collection_meshes(collection_key))
users.extend(collection_lights(collection_key))
users.extend(collection_cameras(collection_key))
users.extend(collection_others(collection_key))
return distinct([name for name in users if object_all(name)])
def sound_viewport_objects(sound_key):
users = []
try:
sound = bpy.data.sounds[sound_key]
except (KeyError, AttributeError):
return []
for obj in bpy.data.objects:
if obj.type != "SPEAKER":
continue
data = getattr(obj, "data", None)
if data and getattr(data, "sound", None) == sound and object_all(obj.name):
users.append(obj.name)
return distinct(users)
def font_viewport_objects(font_key):
users = []
try:
font = bpy.data.fonts[font_key]
except (KeyError, AttributeError):
return []
for obj in bpy.data.objects:
if obj.type == "FONT" and obj.data == font and object_all(obj.name):
users.append(obj.name)
return distinct(users)
def distinct(seq):
# returns a list of distinct elements
@@ -139,6 +139,9 @@ def copy_prefs_to_config(self, context):
config.enable_debug_prints = \
atomic_preferences.enable_debug_prints
config.storage_navigate_frame_view = \
atomic_preferences.storage_navigate_frame_view
# hidden atomic preferences
config.pie_menu_type = \
atomic_preferences.pie_menu_type
@@ -254,6 +257,14 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
default=False
)
storage_navigate_frame_view: bpy.props.BoolProperty(
name="Frame View on Storage Navigate",
description="When clicking a storage index row, frame the user object "
"in the 3D viewport (off by default: only selects and "
"activates)",
default=False,
)
# hidden atomic preferences
pie_menu_type: bpy.props.StringProperty(
default="D"
@@ -316,6 +327,13 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
text="Enable Debug Prints"
)
# storage navigation
col.prop(
self,
"storage_navigate_frame_view",
text="Frame View on Storage Navigate",
)
# pie menu settings
pie_split = col.split(factor=0.55) # nice
@@ -35,10 +35,10 @@ from ..utils.compat import (
format_bytes,
format_embedded_total,
get_report,
storage_packed_icon,
storage_override_icon,
storage_type_icon,
)
from .utils import ui_layouts
# Atomic Data Manager Statistics SubPanel
@@ -139,13 +139,6 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
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(
@@ -166,11 +159,21 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
split = col.split(factor=0.68)
left = split.row(align=True)
left.label(icon=storage_type_icon(r["type"]), text="")
left.label(icon=storage_packed_icon(r["type"]), text="")
left.label(
icon=storage_override_icon(r.get("is_lib_override", False)),
text="",
)
left.label(text=r["name"])
nav = left.operator(
"atomic.storage_navigate",
text=r["name"],
emboss=False,
)
nav.storage_type = r["type"]
nav.id_name = r.get("id_name", r["name"])
nav.owner_object = r.get("owner_object", "")
nav.owner_scene = r.get("owner_scene", "")
nav.modifier_name = r.get("modifier_name", "")
split.label(text=format_bytes(r["size_bytes"]))
if len(rep["rows"]) > 40:
@@ -4,6 +4,7 @@ between Blender 4.2 LTS, 4.5 LTS, and 5.0.
"""
import ctypes
import os
import bpy
from bpy.utils import register_class, unregister_class
@@ -220,6 +221,31 @@ def _mesh_vertex_sum_sample():
return s
def _cache_modifier_counts():
"""Cheap counts so storage cache invalidates when physics/GeoNodes mods change."""
nodes = cloth = soft = dp = particles = 0
for ob in bpy.data.objects:
try:
for mod in ob.modifiers:
t = getattr(mod, "type", None)
if t == "NODES":
nodes += 1
elif t == "CLOTH":
cloth += 1
elif t == "SOFT_BODY":
soft += 1
elif t == "DYNAMIC_PAINT":
dp += 1
particles += len(getattr(ob, "particle_systems", []))
except (AttributeError, RuntimeError, ReferenceError):
pass
rb = 0
for sc in bpy.data.scenes:
if getattr(sc, "rigidbody_world", None):
rb += 1
return (nodes, cloth, soft, dp, particles, rb)
def _light_fingerprint():
fp = bpy.data.filepath
try:
@@ -236,6 +262,7 @@ def _light_fingerprint():
len(bpy.data.objects),
len(bpy.data.armatures),
len(bpy.data.collections),
_cache_modifier_counts(),
)
@@ -499,6 +526,269 @@ def format_bytes(n):
return _fmt_bytes(n)
# DNA layout of blender::NodesModifierBake (64-bit). bake_size is not in RNA.
# See source/blender/makesdna/DNA_modifier_types.h
class _NodesModifierBakeDNA(ctypes.Structure):
_fields_ = [
("id", ctypes.c_int32),
("flag", ctypes.c_uint32),
("bake_mode", ctypes.c_uint8),
("bake_target", ctypes.c_int8),
("_pad", ctypes.c_char * 6),
("directory", ctypes.c_void_p),
("frame_start", ctypes.c_int32),
("frame_end", ctypes.c_int32),
("data_blocks_num", ctypes.c_int32),
("active_data_block", ctypes.c_int32),
("data_blocks", ctypes.c_void_p),
("packed", ctypes.c_void_p),
("_pad2", ctypes.c_void_p),
("bake_size", ctypes.c_int64),
]
_PHYS_BYTES_PER_POINT = 32
def _geonodes_bake_dna_sizes(bake):
"""
Read packed pointer and bake_size from NodesModifierBake DNA.
Returns (bake_size, is_packed) or None if unavailable.
"""
if bake is None or ctypes.sizeof(ctypes.c_void_p) != 8:
return None
try:
ptr = bake.as_pointer()
if not ptr:
return None
dna = _NodesModifierBakeDNA.from_address(ptr)
size = int(dna.bake_size)
packed = bool(dna.packed)
if size < 0:
return None
return (size, packed)
except (AttributeError, TypeError, ValueError, OSError, RuntimeError):
return None
def _append_geonodes_bake_rows(rows, is_override):
"""Append rows for GeoNodes bakes packed into the .blend."""
# packed + bake_size DNA fields exist since Blender 4.3
if not version.is_version_at_least(4, 3):
return
for ob in bpy.data.objects:
if _skip_linked(ob):
continue
try:
mods = ob.modifiers
except (AttributeError, RuntimeError, ReferenceError):
continue
io = is_override(ob)
ow = 0.08 if io else 1.0
for mod in mods:
if not is_geometry_nodes_modifier(mod):
continue
bakes = getattr(mod, "bakes", None)
if not bakes:
continue
for bake in bakes:
info = _geonodes_bake_dna_sizes(bake)
if info is None:
continue
size, packed = info
if not packed or size <= 0:
continue
node = getattr(bake, "node", None)
node_name = getattr(node, "name", None) if node else None
label = node_name or ("Bake %s" % getattr(bake, "bake_id", "?"))
name = "%s / %s / %s" % (ob.name, mod.name, label)
emb = max(1, int(size * ow))
rows.append(
{
"type": "GeoNodesBake",
"name": name,
"embedded": emb,
"size_bytes": emb,
"is_lib_override": io,
"kind": "packed",
"owner_object": ob.name,
"modifier_name": mod.name,
}
)
def _point_cache_in_blend(cache):
"""True when a point cache is baked into the .blend (not disk/external)."""
if cache is None:
return False
try:
if not cache.is_baked:
return False
if getattr(cache, "use_disk_cache", False):
return False
if getattr(cache, "use_external", False):
return False
return True
except (AttributeError, RuntimeError, ReferenceError):
return False
def _iter_point_cache_items(pc):
if pc is None:
return
items = getattr(pc, "point_caches", None)
try:
if items is not None and len(items) > 0:
for item in items:
yield item
return
except (AttributeError, RuntimeError, TypeError):
pass
yield pc
def _physics_cache_size_bytes(cache, point_count):
try:
fs = int(cache.frame_start)
fe = int(cache.frame_end)
step = max(1, int(getattr(cache, "frame_step", 1) or 1))
except (AttributeError, TypeError, ValueError, RuntimeError):
return None
frames = max(1, (fe - fs) // step + 1)
pts = max(1, int(point_count) if point_count else 1)
return max(64, frames * pts * _PHYS_BYTES_PER_POINT)
def _object_mesh_vert_count(ob):
data = getattr(ob, "data", None)
if data is None:
return 1
try:
return max(1, len(data.vertices))
except (AttributeError, RuntimeError, ReferenceError, TypeError):
return 1
def _append_physics_cache_row(
rows, name, cache, point_count, is_lib_override, owner_object="", owner_scene=""
):
if not _point_cache_in_blend(cache):
return
sz = _physics_cache_size_bytes(cache, point_count)
if sz is None:
return
ow = 0.08 if is_lib_override else 1.0
sz = max(64, int(sz * ow))
cname = getattr(cache, "name", "") or ""
display = "%s (%s)" % (name, cname) if cname else name
row = {
"type": "PhysicsCache",
"name": display,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": is_lib_override,
"kind": "override" if is_lib_override else "local",
}
if owner_object:
row["owner_object"] = owner_object
if owner_scene:
row["owner_scene"] = owner_scene
rows.append(row)
def _append_physics_cache_rows(rows, is_override):
"""Append rows for physics point caches stored inside the .blend."""
for ob in bpy.data.objects:
if _skip_linked(ob):
continue
io = is_override(ob)
verts = _object_mesh_vert_count(ob)
try:
mods = ob.modifiers
except (AttributeError, RuntimeError, ReferenceError):
mods = []
for mod in mods:
mtype = getattr(mod, "type", None)
if mtype == "CLOTH":
pc = getattr(mod, "point_cache", None)
for item in _iter_point_cache_items(pc):
_append_physics_cache_row(
rows, "%s / Cloth" % ob.name, item, verts, io, owner_object=ob.name
)
elif mtype == "SOFT_BODY":
pc = getattr(mod, "point_cache", None)
for item in _iter_point_cache_items(pc):
_append_physics_cache_row(
rows, "%s / Soft Body" % ob.name, item, verts, io, owner_object=ob.name
)
elif mtype == "DYNAMIC_PAINT":
canvas = getattr(mod, "canvas_settings", None)
surfaces = getattr(canvas, "canvas_surfaces", None) if canvas else None
if not surfaces:
continue
for surf in surfaces:
pc = getattr(surf, "point_cache", None)
fmt = getattr(surf, "surface_format", None)
if fmt == "IMAGE":
res = int(getattr(surf, "image_resolution", 256) or 256)
pts = max(1, res * res)
else:
pts = verts
sname = getattr(surf, "name", "Surface") or "Surface"
for item in _iter_point_cache_items(pc):
_append_physics_cache_row(
rows,
"%s / Dynamic Paint / %s" % (ob.name, sname),
item,
pts,
io,
owner_object=ob.name,
)
try:
psystems = ob.particle_systems
except (AttributeError, RuntimeError, ReferenceError):
psystems = []
for ps in psystems:
pc = getattr(ps, "point_cache", None)
settings = getattr(ps, "settings", None)
count = 1
if settings is not None:
try:
count = max(1, int(getattr(settings, "count", 1) or 1))
except (TypeError, ValueError):
count = 1
ps_name = getattr(ps, "name", "Particles") or "Particles"
for item in _iter_point_cache_items(pc):
_append_physics_cache_row(
rows, "%s / %s" % (ob.name, ps_name), item, count, io, owner_object=ob.name
)
for sc in bpy.data.scenes:
if _skip_linked(sc):
continue
rbw = getattr(sc, "rigidbody_world", None)
if not rbw:
continue
pc = getattr(rbw, "point_cache", None)
coll = getattr(rbw, "collection", None)
nobj = 1
if coll is not None:
try:
nobj = max(1, len(coll.objects))
except (AttributeError, RuntimeError, ReferenceError):
nobj = 1
io = is_override(sc)
for item in _iter_point_cache_items(pc):
_append_physics_cache_row(
rows,
"%s / Rigid Body" % sc.name,
item,
nobj,
io,
owner_scene=sc.name,
)
_STORAGE_TYPE_ICONS = {
"Mesh": "MESH_DATA",
"Image": "IMAGE_DATA",
@@ -514,6 +804,8 @@ _STORAGE_TYPE_ICONS = {
"Sound": "SOUND",
"Font": "FONT_DATA",
"Collection": "OUTLINER_COLLECTION",
"GeoNodesBake": "GEOMETRY_NODES",
"PhysicsCache": "PHYSICS",
}
@@ -522,8 +814,13 @@ def storage_type_icon(type_name):
return _STORAGE_TYPE_ICONS.get(type_name, "BLANK1")
def storage_packed_icon(type_name):
"""Packed-in-blend indicator (GeoNodes bake rows only)."""
return "PACKAGE" if type_name == "GeoNodesBake" else "BLANK1"
def storage_override_icon(is_lib_override):
"""Second column: library override emblem vs empty spacer."""
"""Library override emblem vs empty spacer."""
return "LIBRARY_DATA_OVERRIDE" if is_lib_override else "BLANK1"
@@ -758,6 +1055,9 @@ def build_report():
}
)
_append_geonodes_bake_rows(rows, _ov)
_append_physics_cache_rows(rows, _ov)
rows.sort(key=lambda r: r["size_bytes"], reverse=True)
by_type = {}
@@ -0,0 +1,185 @@
"""
Navigate from Atomic Storage rows to datablock users in the Blender UI.
"""
import bpy
from .. import config
from ..stats import users
def _find_area(context, area_type):
screen = context.screen
if context.window:
screen = context.window.screen
for area in screen.areas:
if area.type == area_type:
region = next((r for r in area.regions if r.type == "WINDOW"), None)
return context.window, area, region
return context.window, None, None
def resolve_targets(storage_type, id_name, owner_object="", owner_scene="", modifier_name=""):
"""
Return navigation targets as dicts:
object, material, action, nav (viewport|shader|dopesheet).
"""
targets = []
def add_objects(names, nav="viewport", material="", action=""):
for name in names:
if name in bpy.data.objects:
targets.append(
{
"object": name,
"material": material,
"action": action,
"nav": nav,
"modifier": modifier_name if owner_object == name else "",
}
)
if storage_type == "Object":
if id_name in bpy.data.objects:
add_objects([id_name])
return targets
if storage_type == "GeoNodesBake":
if owner_object:
add_objects([owner_object])
return targets
if storage_type == "PhysicsCache":
if owner_object:
add_objects([owner_object])
return targets
if owner_scene:
scene = bpy.data.scenes.get(owner_scene)
rbw = getattr(scene, "rigidbody_world", None) if scene else None
coll = getattr(rbw, "collection", None) if rbw else None
if coll:
add_objects([ob.name for ob in coll.objects])
return targets
if storage_type == "Mesh":
add_objects(users.mesh_objects(id_name))
elif storage_type == "Curve":
add_objects(users.curve_objects(id_name))
elif storage_type == "Armature":
add_objects(users.armature_all(id_name))
elif storage_type == "Volume":
add_objects(users.volume_objects(id_name))
elif storage_type == "PointCloud":
add_objects(users.pointcloud_objects(id_name))
elif storage_type == "Image":
add_objects(users.image_viewport_objects(id_name))
elif storage_type == "Material":
seen = set()
for name in users.material_objects(id_name):
if name not in seen:
seen.add(name)
add_objects([name], nav="shader", material=id_name)
for name in users.material_geometry_nodes(id_name):
if name not in seen:
seen.add(name)
add_objects([name], nav="shader", material=id_name)
elif storage_type == "Action":
for name in users.action_objects(id_name):
add_objects([name], nav="dopesheet", action=id_name)
elif storage_type == "NodeTree":
add_objects(users.node_group_viewport_objects(id_name))
elif storage_type == "Texture":
add_objects(users.texture_objects(id_name))
elif storage_type == "Collection":
add_objects(users.collection_viewport_objects(id_name))
elif storage_type == "Sound":
add_objects(users.sound_viewport_objects(id_name))
elif storage_type == "Font":
add_objects(users.font_viewport_objects(id_name))
return targets
def _select_object(context, object_name):
vl = context.view_layer
if object_name not in vl.objects:
return False
for ob in vl.objects:
ob.select_set(False)
ob = vl.objects[object_name]
ob.select_set(True)
vl.objects.active = ob
return True
def _frame_view3d(context):
window, area, region = _find_area(context, "VIEW_3D")
if not area or not region:
return
with context.temp_override(window=window, area=area, region=region):
try:
bpy.ops.view3d.view_selected()
except RuntimeError:
pass
def _outliner_show_active(context):
window, area, region = _find_area(context, "OUTLINER")
if not area or not region:
return
with context.temp_override(window=window, area=area, region=region):
try:
bpy.ops.outliner.show_active()
except RuntimeError:
pass
def _focus_shader_editor(context, material_name):
material = bpy.data.materials.get(material_name)
if not material:
return
ob = context.view_layer.objects.active
if ob and hasattr(ob, "material_slots"):
for i, slot in enumerate(ob.material_slots):
if slot.material == material:
ob.active_material_index = i
break
window, area, _region = _find_area(context, "NODE_EDITOR")
if not area:
return
space = area.spaces.active
if space.type != "NODE_EDITOR":
return
space.tree_type = "ShaderNodeTree"
if hasattr(space, "shader_type"):
space.shader_type = "OBJECT"
if hasattr(space, "pin"):
space.pin = False
def _focus_dopesheet(context, action_name):
action = bpy.data.actions.get(action_name)
window, area, _region = _find_area(context, "DOPESHEET_EDITOR")
if not area:
return
space = area.spaces.active
if space.type != "DOPESHEET_EDITOR":
return
if action and hasattr(space, "action"):
space.action = action
def apply_target(context, target):
"""Select object and open the appropriate editor for one target."""
if not _select_object(context, target["object"]):
return False
nav = target.get("nav", "viewport")
if nav == "shader" and target.get("material"):
_focus_shader_editor(context, target["material"])
elif nav == "dopesheet" and target.get("action"):
_focus_dopesheet(context, target["action"])
if config.storage_navigate_frame_view:
_frame_view3d(context)
_outliner_show_active(context)
return True