2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
@@ -0,0 +1,27 @@
# SPDX-License-Identifier: GPL-2.0-or-later
import importlib
import inspect
import pkgutil
from collections.abc import Iterator
from typing import Type
import bpy
def classes() -> Iterator[Type]:
for _, module_name, _ in pkgutil.iter_modules(__path__):
module = importlib.import_module(f'{__name__}.{module_name}')
for cls_name, cls in inspect.getmembers(module, inspect.isclass):
if cls_name.startswith('DBU_OT'):
yield cls
def register() -> None:
for cls in classes():
bpy.utils.register_class(cls)
def unregister() -> None:
for cls in classes():
bpy.utils.unregister_class(cls)
@@ -0,0 +1,537 @@
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
from collections import defaultdict
from collections.abc import Collection, Iterable, Iterator, Sequence
from dataclasses import dataclass, field
from functools import cached_property
from itertools import chain, groupby, product, zip_longest
from math import floor, sqrt
from operator import itemgetter
from statistics import fmean
from typing import Any, cast
import bpy
import networkx as nx
from bpy.types import Context, Event, Node, NodeLink, NodeSocket, NodeTree, Operator
from ..constants import ID_TYPES, get_id_type
from ..properties import DBU_PG_FindSimilarSettings
def get_settings() -> DBU_PG_FindSimilarSettings:
return bpy.context.scene.dbu_similar_settings # type: ignore
def get_invalid_nodes(ntree: NodeTree) -> set[Node]:
settings = get_settings()
invalid_nodes = set()
if settings.exclude_organization:
invalid_nodes.update([
n for n in ntree.nodes if n.bl_idname in {'NodeReroute', 'NodeFrame'}])
if settings.exclude_unused:
G = nx.DiGraph([(l.from_node, l.to_node) for l in ntree.links])
G.add_nodes_from(ntree.nodes)
output_nodes = [n for n in G if not n.outputs and G.pred[n]]
used_nodes = chain(*[nx.ancestors(G, n) for n in output_nodes], output_nodes)
invalid_nodes.update(set(G).difference(used_nodes) | {n for n in G if n.mute})
return invalid_nodes
def get_precomputed_root_link(link: NodeLink, links: dict[NodeSocket, NodeLink]) -> NodeLink:
if link.from_node.bl_idname != 'NodeReroute':
return link
try:
prev_link = links[link.from_node.inputs[0]]
except (IndexError, KeyError):
return link
return get_precomputed_root_link(prev_link, links) if prev_link.is_valid else link
def get_root_link(link: NodeLink) -> NodeLink:
if link.from_node.bl_idname != 'NodeReroute':
return link
if links := link.from_node.inputs[0].links:
prev_link = links[0]
else:
return link
return get_root_link(prev_link) if prev_link.is_valid else link
@dataclass
class Link:
from_socket_idx: int
linked_props: NodeProperties
@cached_property
def reduced_props(self) -> tuple[int, list[Any]]:
return (self.from_socket_idx, [p for p in self.linked_props if not isinstance(p, Link)])
def __eq__(self, other: Any) -> bool:
return isinstance(other, Link) and self.reduced_props == other.reduced_props
def get_non_socket_prop_names(node: Node) -> tuple[str, ...]:
if not node.is_registered_node_type():
return ()
node_type = type(node)
node_props = set(node_type.bl_rna.properties.keys())
parent_props = set(node_type.__mro__[1].bl_rna.properties.keys()) # type: ignore
return tuple(node_props - parent_props)
def get_image_props(img: bpy.types.Image) -> tuple[Any, ...]:
return (
img.filepath,
img.source,
img.colorspace_settings.name,
img.alpha_mode,
get_id_type(img),
)
@dataclass(slots=True)
class NodeProperties:
id_data: Node | NodeTree
props: list[Link | Any] = field(default_factory=list)
def __post_init__(self) -> None:
if isinstance(self.id_data, Node):
self.props.extend((self.id_data.bl_idname, self.id_data.mute))
def __eq__(self, other: Any) -> bool:
return isinstance(other, NodeProperties) and self.props == other.props
def __iter__(self) -> Iterator[Any]:
return iter(self.props)
def __len__(self) -> int:
return len(self.props)
def _add_link(self, link: NodeLink, node_map: dict[str, NodeProperties]) -> None:
i = int(link.from_socket.path_from_id()[-2:-1])
self.props.append(Link(i, node_map[link.from_node.name]))
def add_inputs(
self,
links: dict[NodeSocket, NodeLink],
node_map: dict[str, NodeProperties],
) -> None:
node = self.id_data
if not isinstance(node, Node):
return
props = self.props
for socket in node.inputs:
if socket.is_linked:
if socket.is_multi_input:
assert socket.links is not None
for link in socket.links:
root_link = get_root_link(link)
if not root_link.from_node.mute:
self._add_link(root_link, node_map)
continue
if not links[socket].from_node.mute:
self._add_link(links[socket], node_map)
continue
if socket.hide_value or socket.type in {'SHADER', 'GEOMETRY'}:
props.append((socket.bl_idname, socket.name))
continue
try:
props.append(socket.default_value) # type: ignore
except AttributeError:
continue
if node.bl_idname in {'ShaderNodeValue', 'ShaderNodeRGB', 'ShaderNodeNormal'}:
props.append(node.outputs[0].default_value) # type: ignore
def add_other_props(self) -> None:
node = self.id_data
if not isinstance(node, Node):
return
non_socket_props = get_non_socket_prop_names(node)
if not non_socket_props:
return
props = self.props
for prop_name in non_socket_props:
if prop_name in {'color_mapping', 'texture_mapping', 'image_user', 'lightmixer'}:
continue
# yapf: disable
prop = getattr(node, prop_name)
if isinstance(prop, bpy.types.CurveMapping):
curve_points = [(p.location, p.handle_type) for c in prop.curves for p in c.points]
props.extend((
prop.black_level,
prop.white_level,
prop.extend,
prop.tone,
prop.use_clip,
prop.clip_max_x,
prop.clip_max_y,
prop.clip_min_x,
prop.clip_min_y,
curve_points))
elif isinstance(prop, bpy.types.ColorRamp):
elm_positions = [prop.evaluate(e.position) for e in prop.elements]
props.extend((
prop.color_mode,
prop.hue_interpolation,
prop.interpolation,
*elm_positions))
elif isinstance(prop, bpy.types.Image):
props.extend(get_image_props(prop))
if prop.source in {'SEQUENCE', 'MOVIE'}:
img_user: bpy.types.ImageUser = node.image_user # type: ignore
props.extend((
img_user.frame_duration,
img_user.frame_start,
img_user.frame_offset,
img_user.use_cyclic,
img_user.use_auto_refresh))
else:
props.append(prop)
# yapf: enable
def contents_of_ntrees(
bl_data: Iterable[NodeTree | bpy.types.Material | bpy.types.Light]
) -> defaultdict[str, list[NodeProperties]]:
content_map = defaultdict(list)
for id_data in bl_data:
if id_data.library or (not isinstance(id_data, NodeTree) and not id_data.use_nodes):
continue
ntree = id_data if isinstance(id_data, NodeTree) else id_data.node_tree
assert ntree is not None
# Precompute links to avoid `O(len(ntree.links))` time
links = {l.to_socket: l for l in ntree.links}
root_links = {i: get_precomputed_root_link(l, links) for i, l in links.items()}
contents = content_map[id_data.name]
invalid_nodes = get_invalid_nodes(ntree)
node_map = {n.name: NodeProperties(n) for n in ntree.nodes if n not in invalid_nodes}
for props in node_map.values():
props.add_inputs(root_links, node_map)
props.add_other_props()
props.props = [
tuple(p) if isinstance(p, bpy.types.bpy_prop_array) else p for p in props]
contents.append(props)
if not isinstance(id_data, NodeTree):
continue
tree_sockets = [#
(i.bl_socket_idname, i.name)
for i in id_data.interface.items_tree
if isinstance(i, bpy.types.NodeTreeInterfaceSocket)]
contents.append(NodeProperties(ntree, ['TREE SOCKETS'] + tree_sockets))
return content_map
_SENTINEL = object()
def pair_nodes(nodes1: Collection[NodeProperties], nodes2: Collection[NodeProperties]) -> int:
diff_map = {}
for props1 in nodes1:
props1_len = len(props1.props[1:])
for props2 in nodes2:
zipped = zip_longest(props1.props[1:], props2.props[1:], fillvalue=_SENTINEL)
dot = sum([1 for a, b in zipped if a == b])
diff_map[(props1.id_data, props2.id_data)] = (props1_len - dot, dot)
sums = []
seen = set()
for key in sorted(diff_map, key=lambda k: diff_map[k][0]):
if not seen.intersection(key):
sums.append(diff_map[key][1])
seen.update(key)
return sum(sums)
def cosine_similarity(A: list[NodeProperties], B: list[NodeProperties]) -> float:
# Nodes from A are compared with nodes from B of the same type. The most similar are paired
# together, and their dot product is returned in `pair_nodes()`.
bl_idname = lambda p: p.props[0]
A.sort(key=bl_idname)
B.sort(key=bl_idname)
if A == B:
return 1
ntypes1 = {t1: list(g1) for t1, g1 in groupby(A, bl_idname)}
ntypes2 = {t2: list(g2) for t2, g2 in groupby(B, bl_idname)}
s1 = sum([len(p1) - 1 for p1 in A])
s2 = sum([pair_nodes(g1, ntypes2[t1]) for t1, g1 in ntypes1.items() if t1 in ntypes2])
try:
return s2 / sqrt(s1 * s2)
except ZeroDivisionError:
return 0
_Scores = dict[tuple[str, str], float]
def find_similar(contents: dict[str, list[NodeProperties]], results: _Scores) -> None:
items = contents.items()
seen = set()
threshold = get_settings().similarity_threshold
for k1, A in items:
for k2, B in items:
if {k1, k2} in seen or k1 == k2:
continue
seen.add(frozenset((k1, k2)))
smallest, largest = sorted((A, B), key=lambda c: sum([len(p) - 1 for p in c]))
# To avoid as many `cosine_similarity()` calls as possible, check for large
# differences in length.
if len(largest) != 0 and (len(smallest) / len(largest)) + 0.14 < threshold:
continue
score = cosine_similarity(largest, smallest)
if score >= threshold:
results[(k1, k2)] = score
def process(results: _Scores) -> tuple[list[tuple[str, ...]], _Scores]:
graphs = defaultdict(nx.Graph)
for (p, q), score in results.items():
graphs[score].add_edge(p, q)
cliques = {}
for score, G in graphs.items():
for c in nx.find_cliques(G):
cliques[tuple(sorted(c))] = score # type: ignore
threshold = round(get_settings().grouping_threshold, 2)
G = nx.Graph()
for group, score in cliques.items():
if 1 > score >= threshold:
G.add_edges_from(product(group, group), score=score)
groups = {}
for c in nx.connected_components(G):
if len(c) > 2:
H = G.subgraph(c)
groups[tuple(sorted(c))] = fmean([d for *_, d in H.edges.data('score')])
seen = set(chain(*groups))
raw_scored = {g: s for g, s in cliques.items() if s < 1 and not seen.intersection(g)} | groups
scored = {
g: floor((s * 100) * 10**1) / 10**1
for g, s in sorted(raw_scored.items(), key=itemgetter(1), reverse=True)}
duplicates = [g for g, s in cliques.items() if s >= 1]
return duplicates, scored
# -------------------------------------------------------------------
def update_collections(
bl_data: bpy.types.bpy_prop_collection,
duplicates: Iterable[Sequence[str]],
scored: _Scores | None = None,
) -> None:
settings = get_settings()
duplicates_coll = settings.duplicates
scored_coll = settings.scored
duplicates_coll.clear()
scored_coll.clear()
for dgroup in duplicates:
ditem = duplicates_coll.add()
ditem.id_type = get_id_type(bl_data[dgroup[0]])
for name in dgroup:
i = ditem.group.add()
i.name = name
if not scored:
return
for sgroup, score in scored.items():
sitem = scored_coll.add()
sitem.id_type = get_id_type(bl_data[sgroup[0]])
sitem.score = score
for name in sgroup:
i = sitem.group.add()
i.name = name
def find_similar_and_duplicate_ntrees(id_type: str) -> None:
bl_data = ID_TYPES[id_type].collection
results = {}
for key, sub_data in groupby(sorted(bl_data, key=get_id_type), get_id_type):
if 'UNDEFINED' not in key:
content_map = contents_of_ntrees(tuple(sub_data))
find_similar(content_map, results)
duplicates, scored = process(results)
update_collections(bl_data, duplicates, scored)
def find_duplicate_images() -> None:
duplicates = []
for _, raw_group in groupby(sorted(bpy.data.images, key=get_image_props), get_image_props):
group = [i.name for i in raw_group]
if len(group) > 1:
duplicates.append(group)
update_collections(bpy.data.images, duplicates)
def find_duplicate_meshes() -> None:
meshes = [m for m in bpy.data.meshes if not m.library]
seen = set()
results = []
for m1 in meshes:
for m2 in meshes:
if {m1, m2} in seen or m1 == m2:
continue
if m1.unit_test_compare(mesh=m2) == 'Same':
results.append((m1, m2))
seen.add(frozenset((m1, m2)))
G = nx.Graph()
for group in results:
G.add_edges_from(product(group, group))
duplicates = [sorted([m.name for m in c]) for c in nx.connected_components(G)]
update_collections(bpy.data.meshes, duplicates)
class DBU_OT_FindSimilarAndDuplicates(Operator):
bl_idname = "scene.dbu_find_similar_and_duplicates"
bl_label = ""
bl_options = {'INTERNAL', 'UNDO'}
@classmethod
def description(cls, context: Context, event: DBU_OT_FindSimilarAndDuplicates) -> str:
id_type = get_settings().id_type
text = ID_TYPES[id_type].label
if ID_TYPES[id_type].is_ntree:
return f"Show {text} with the highest similarity to each other"
elif id_type == 'IMAGE':
return f"Show {text} with identical filepaths and settings"
else:
return f"Show duplicate {text}"
def invoke(self, context: Context, event: Event) -> set[str]:
settings = get_settings()
settings.enabled = True
return self.execute(context)
def execute(self, context: Context) -> set[str]:
settings = get_settings()
id_type = settings.id_type
if ID_TYPES[id_type].is_ntree:
find_similar_and_duplicate_ntrees(id_type)
elif id_type == 'IMAGE':
find_duplicate_images()
elif id_type == 'MESH':
find_duplicate_meshes()
if not settings.duplicates and not settings.scored:
word = "similar" if ID_TYPES[id_type].is_ntree else "duplicate"
self.report({'INFO'}, f"No {word} {ID_TYPES[id_type].label} found")
settings.enabled = False
return {'FINISHED'}
class DBU_OT_SimilarAndDuplicatesClearResults(Operator):
bl_idname = "scene.dbu_similar_and_duplicates_clear_results"
bl_label = "Clear"
bl_description = "Clear the results"
bl_options = {'INTERNAL', 'UNDO'}
def execute(self, context: Context) -> set[str]:
settings = get_settings()
settings.enabled = False
return {'FINISHED'}
# -------------------------------------------------------------------
def merge_ids(duplicate_ids: Iterable[Iterable[bpy.types.ID]]) -> int:
to_remove = []
for target, *junk in duplicate_ids:
to_remove.extend(junk)
for id_data in junk:
id_data.user_remap(target)
bpy.data.batch_remove(to_remove)
return len(to_remove)
class DBU_OT_MergeDuplicates(Operator):
bl_idname = "scene.dbu_merge_duplicates"
bl_label = "Merge Duplicates"
bl_options = {'INTERNAL', 'UNDO'}
@classmethod
def description(cls, context: Context, event: DBU_OT_MergeDuplicates) -> str:
id_type = get_settings().id_type
desc = f"Merge duplicate {ID_TYPES[id_type].label}"
if id_type == 'MESH':
desc += ". Equivalent to having them as if they were linked"
return desc
def invoke(self, context: Context, event: Event) -> set[str]:
wm = context.window_manager
return cast(set[str], wm.invoke_confirm(self, event))
def execute(self, context: Context) -> set[str]:
settings = get_settings()
id_type = settings.id_type
bl_data = ID_TYPES[id_type].collection
duplicate_ids = []
for group in settings.duplicates:
# Reporting that IDs are missing could give the false impression that stale data is
# always checked for, including changed node trees.
if new_group := [bl_data[i.name] for i in group.group if i.name in bl_data]:
duplicate_ids.append(new_group)
count = merge_ids(duplicate_ids)
bpy.ops.scene.dbu_find_similar_and_duplicates() # type: ignore
text = f"{ID_TYPES[id_type].label[:-1]}(s)" if id_type != 'MESH' else "mesh(s)"
self.report({'INFO'}, f"Cleared {count} {text}")
return {'FINISHED'}
@@ -0,0 +1,286 @@
# SPDX-License-Identifier: GPL-2.0-or-later
from collections.abc import Collection, Iterator, Sequence
from itertools import chain
from typing import cast
import bpy
from bpy.props import StringProperty
from bpy.types import ID, Context, Light, Material, Operator, ShaderNodeTree, SpaceNodeEditor
from ..constants import ID_TYPES
def get_users(subset: Sequence[ID], value_types: set[str]) -> list[ID]:
users = bpy.data.user_map(subset=subset, value_types=value_types) # type: ignore
return list(chain(*users.values()))
def get_users_recursive(subset: Sequence[ID], value_types: set[str]) -> Iterator[ID]:
value_types.add('NODETREE')
for user in get_users(subset, value_types):
if not hasattr(user, 'nodes'):
yield user
else:
yield from get_users_recursive([user], value_types)
def get_path_to_material(
users: Sequence[Material | ShaderNodeTree],
obj_data_users: Collection[ID],
container: ShaderNodeTree | None = None,
) -> tuple[Material, ShaderNodeTree | None]:
try:
mat = next(
u for u in users
if isinstance(u, Material) and any(m.user_of_id(u) for m in obj_data_users))
return mat, container
except StopIteration:
nested_users = get_users([users[0]], {'MATERIAL', 'NODETREE'})
return get_path_to_material(nested_users, obj_data_users, users[0]) # type: ignore
def get_path_to_light(
users: Sequence[Light | ShaderNodeTree],
container: ShaderNodeTree | None = None,
) -> tuple[Light, ShaderNodeTree | None]:
try:
light = next(u for u in users if isinstance(u, Light))
return light, container
except StopIteration:
nested_users = get_users([users[0]], {'LIGHT', 'NODETREE'})
return get_path_to_light(nested_users, users[0]) # type: ignore
def get_node_editor() -> tuple[bpy.types.Area, bpy.types.Region]:
assert bpy.context
areas = [a for a in bpy.context.window.screen.areas if a.type == 'NODE_EDITOR']
area = areas[0] if len(areas) == 1 else next(
a for a in areas if not cast(SpaceNodeEditor, a.spaces[0]).pin)
region = next(r for r in area.regions if r.type == 'WINDOW')
return area, region
def get_geometry_node_group(
space: SpaceNodeEditor,
id_data: bpy.types.GeometryNodeTree,
) -> bpy.types.GeometryNodeGroup:
# Use `isinstance()` over `bl_idname` to satisfy Pyright
nodes = [n for n in space.edit_tree.nodes if isinstance(n, bpy.types.GeometryNodeGroup)]
try:
node = next(n for n in nodes if n.node_tree == id_data)
except StopIteration:
container = next(
n.node_tree for n in nodes if n.node_tree and n.node_tree.contains_tree(id_data))
space.path.append(container)
return get_geometry_node_group(space, id_data)
return node
class DBU_OT_GoToDatablock(Operator):
bl_idname = "scene.dbu_go_to_datablock"
bl_label = "Go To Data-Block"
bl_description = "See where this data-block is used"
bl_options = {'INTERNAL'}
id_name: StringProperty() # type: ignore
id_type: StringProperty() # type: ignore
node_name: StringProperty() # type: ignore
settings: StringProperty(default='dbu_users_settings') # type: ignore
def execute(self, context: Context) -> set[str]:
id_type = self.id_type
if 'TEXTURE' in id_type or id_type == 'GREASEPENCIL_V3':
return {'CANCELLED'}
try:
id_data = ID_TYPES[id_type].collection[self.id_name]
except KeyError:
self.report({'WARNING'}, "Data-block not found")
return {'CANCELLED'}
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
is_mat = id_type == 'MATERIAL'
is_obj = 'OBJECT' in id_type
shr_obj_users = geo_obj_users = subset = None
if id_type in {'MATERIAL', 'SHADER_NODETREE', 'IMAGE'}:
if not is_mat:
subset = list(get_users_recursive([id_data], {'MATERIAL'}))
else:
subset = [id_data]
raw_mesh_users = get_users(subset, {'MESH'})
light_users = list(get_users_recursive([id_data], {'LIGHT'}))
shr_obj_users = get_users(raw_mesh_users + light_users, {'OBJECT'})
geo_obj_users = get_users([id_data], {'OBJECT'})
raw_obj_users = list(set(shr_obj_users + geo_obj_users))
elif is_obj:
raw_obj_users = [id_data] + get_users([id_data], {'OBJECT'})
else:
raw_obj_users = list(set(get_users_recursive([id_data], {'OBJECT'})))
raw_obj_users = cast(list[bpy.types.Object], raw_obj_users)
if not raw_obj_users:
self.report({'WARNING'}, "No object users")
return {'CANCELLED'}
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
node_name = self.node_name
is_obj_data = ID_TYPES[id_type].is_object_data and not node_name
view_layer = context.view_layer.objects
obj_users = [o for o in raw_obj_users if not o.library and o.name in view_layer]
settings = getattr(context.scene, self.settings)
if settings.select_object_users or (is_obj or is_obj_data) or not obj_users:
if count := len(raw_obj_users) - len(obj_users):
self.report({'WARNING'},
f"Unable to select {count} object(s) in excluded collection(s)")
if not obj_users:
return {'CANCELLED'}
for obj in obj_users:
if obj.hide_get():
obj.hide_set(False)
item = settings.unhidden_objects.add()
item.name = obj.name
obj.select_set(True)
if is_obj:
view_layer.active = id_data
return {'FINISHED'}
if is_obj_data:
return {'FINISHED'}
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
is_geo = id_type == 'GEOMETRY_NODETREE'
is_light = 'LIGHT' in id_type
container = None
if is_light:
obj = next(o for o in obj_users if o.user_of_id(id_data))
elif not is_geo:
if geo_obj_users and not shr_obj_users:
return {'FINISHED'}
if subset:
if not is_mat:
users = get_users([id_data], {'MATERIAL', 'NODETREE'})
obj_data_users = [o.data for o in obj_users if o.data]
mat, container = get_path_to_material(users, obj_data_users) # type: ignore
else:
mat = id_data
for obj in obj_users:
slots = obj.material_slots
if mat.name in slots:
obj.active_material_index = slots[mat.name].slot_index
break
else:
users = get_users([id_data], {'LIGHT', 'NODETREE'})
light, container = get_path_to_light(users) # type: ignore
obj = next(o for o in obj_users if o.user_of_id(light)) # type: ignore
else:
obj = obj_users[0]
obj.modifiers.active = next(
m for m in obj.modifiers
if (t := getattr(m, 'node_group', None)) and t.contains_tree(id_data))
view_layer.active = obj # pyright: ignore [reportPossiblyUnboundVariable]
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
assert bpy.context
try:
area, region = get_node_editor()
except StopIteration:
self.report({'WARNING'}, "Node editor not open")
return {'FINISHED'}
area.ui_type = 'GeometryNodeTree' if is_geo else 'ShaderNodeTree'
with bpy.context.temp_override(area=area, region=region):
space = cast(SpaceNodeEditor, context.space_data)
if not is_geo:
space.shader_type = 'OBJECT'
space.path.clear()
if not node_name and (is_mat or (is_geo and space.edit_tree == id_data)):
bpy.ops.node.view_all('INVOKE_DEFAULT')
return {'FINISHED'}
if not is_mat and not is_light:
if node_name:
space.path.append(id_data)
elif container:
space.path.append(container)
nodes = space.edit_tree.nodes
if node_name:
node = nodes[node_name]
elif id_type == 'SHADER_NODETREE':
# Use `isinstance()` over `bl_idname` to satisfy Pyright
node = next(
n for n in nodes
if isinstance(n, bpy.types.ShaderNodeGroup) and n.node_tree == id_data)
elif is_geo:
node = get_geometry_node_group(space, id_data)
nodes = space.edit_tree.nodes # In case the current tree changed
else:
node = next(
n for n in nodes
if isinstance(n, bpy.types.ShaderNodeTexImage) and n.image == id_data)
for n in nodes:
n.select = n == node
nodes.active = node
bpy.ops.view2d.reset()
bpy.ops.wm.redraw_timer(type='DRAW', iterations=0)
bpy.ops.node.view_selected('INVOKE_DEFAULT')
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
self.node_name = ''
self.settings = 'dbu_users_settings'
return {'FINISHED'}
class DBU_OT_RehideObjectsUsers(Operator):
bl_idname = "scene.dbu_rehide_object_users"
bl_label = ""
bl_description = "Rehide object users that were previously hidden"
bl_options = {'INTERNAL', 'UNDO'}
settings: StringProperty() # type: ignore
def execute(self, context: Context) -> set[str]:
settings = getattr(context.scene, self.settings)
unhidden_objects = settings.unhidden_objects
if not unhidden_objects:
return {'CANCELLED'}
for obj_item in unhidden_objects:
try:
obj = bpy.data.objects[obj_item.name]
except KeyError:
continue
obj.hide_set(True)
unhidden_objects.clear()
return {'FINISHED'}
@@ -0,0 +1,195 @@
# SPDX-License-Identifier: GPL-2.0-or-later
from typing import cast
import bpy
from bpy.props import IntProperty
from bpy.types import ID, Context, EnumProperty, Operator
from ..constants import ID_TYPES, get_id_type
from ..properties import DBU_PG_ParentItem, DBU_PG_UserItem, DBU_PG_UserMapSettings
_EXCLUDED_VALUE_TYPES = {'COLLECTION', 'WINDOWMANAGER', 'WORKSPACE'}
def get_settings() -> DBU_PG_UserMapSettings:
return bpy.context.scene.dbu_users_settings # type: ignore
class DBU_OT_UserMap(Operator):
bl_idname = "scene.dbu_user_map"
bl_label = "Show Data-Block Users"
bl_description = "List the users of the specified data-blocks"
bl_options = {'INTERNAL', 'UNDO'}
@staticmethod
def get_node_names(nodes: bpy.types.Nodes, parent: DBU_PG_ParentItem) -> list[str]:
id_type = parent.id_type
if 'NODETREE' in id_type:
prop = 'node_tree'
elif 'IMAGE' in id_type:
prop = 'image'
elif 'MATERIAL' in id_type:
prop = 'material'
elif 'OBJECT' in id_type and not ID_TYPES[id_type].is_object_data:
prop = 'object'
else:
return []
id_data = ID_TYPES[id_type].collection[parent.name]
node_names = [
n.name for n in nodes if getattr(n, prop, None) == id_data or any(
not i.is_linked and getattr(i, 'default_value', None) == id_data for i in n.inputs)]
node_names.sort()
return node_names
@classmethod
def add_users(
cls,
parent: DBU_PG_ParentItem,
user: ID,
precomputed: dict[ID, set[ID]],
ancestors: set[ID],
) -> None:
settings = get_settings()
user_map = settings.user_map
name = user.name
id_type = get_id_type(user)
if not settings.OBJECT and ID_TYPES[id_type].is_object_data:
return
as_parent: DBU_PG_ParentItem = user_map.add()
as_parent.name = name
as_parent.id_type = id_type
as_user: DBU_PG_UserItem = parent.users.add()
as_user.name = name
as_user.id_type = id_type
as_user.as_parent_idx = len(user_map) - 1
if user in ancestors:
return
if nodes := getattr(getattr(user, 'node_tree', user), 'nodes', None):
for name in cls.get_node_names(nodes, parent):
item = as_user.node_names.add()
item.name = name
for u in precomputed[user]:
if u != user:
cls.add_users(as_parent, u, precomputed, ancestors | {user})
def execute(self, context: Context) -> set[str]:
settings = get_settings()
parents = [p for p in settings.parents if p.id_type != 'UNDEFINED']
if not parents:
return {'CANCELLED'}
parent_map = settings.parent_map
parent_map.clear()
settings.user_map.clear()
setting_enums = {e for e in dir(settings) if e.isupper()}
value_types = {e for e in setting_enums if getattr(settings, e)}
if settings.others:
prop = cast(EnumProperty, bpy.types.KeyingSetPath.bl_rna.properties['id_type'])
value_types.update(set(prop.enum_items.keys()) - setting_enums)
value_types -= _EXCLUDED_VALUE_TYPES
prop = cast(EnumProperty, settings.bl_rna.properties['id_type'])
key_types = value_types.union(prop.enum_items.keys())
precomputed = bpy.data.user_map(
key_types=key_types, value_types=value_types) # type: ignore
for temp_parent in parents:
name = temp_parent.name
id_type = temp_parent.id_type
as_parent = parent_map.add()
as_parent.name = name
as_parent.id_type = id_type
id_data = ID_TYPES[id_type].collection[name]
for user in precomputed[id_data]:
self.add_users(as_parent, user, precomputed, {id_data})
return {'FINISHED'}
class DBU_OT_UserMapClearResults(Operator):
bl_idname = "scene.dbu_user_map_clear_results"
bl_label = "Clear"
bl_description = "Clear the results"
bl_options = {'INTERNAL', 'UNDO'}
def execute(self, context: Context) -> set[str]:
settings = get_settings()
settings.parent_map.clear()
settings.user_map.clear()
return {'FINISHED'}
class DBU_OT_UserMapAddAll(Operator):
bl_idname = "scene.dbu_user_map_add_all"
bl_label = "Add All"
bl_description = "Add all data-blocks of this type"
bl_options = {'INTERNAL', 'UNDO'}
def execute(self, context: Context) -> set[str]:
settings = get_settings()
parents = settings.parents
bl_data = ID_TYPES[settings.id_type].collection
for id_data in bl_data:
name = id_data.name
id_type = get_id_type(id_data)
if name in parents:
if any((p.name, p.id_type) == (name, id_type) for p in parents):
continue
parent = parents.add()
parent.name = name
parent.id_type = id_type
return {'FINISHED'}
class DBU_OT_UserMapRemove(Operator):
bl_idname = "scene.dbu_user_map_remove"
bl_label = "Remove"
bl_description = "Remove item"
bl_options = {'INTERNAL', 'UNDO'}
idx: IntProperty() # type: ignore
def execute(self, context: Context) -> set[str]:
settings = get_settings()
idx = self.idx
settings.parents.remove(idx)
settings.parent_map.remove(idx)
return {'FINISHED'}
class DBU_OT_UserMapRemoveAll(Operator):
bl_idname = "scene.dbu_user_map_remove_all"
bl_label = "Clear"
bl_description = "Clear the list"
bl_options = {'INTERNAL', 'UNDO'}
def execute(self, context: Context) -> set[str]:
settings = get_settings()
settings.parents.clear()
settings.parent_map.clear()
return {'FINISHED'}