2025-07-01
This commit is contained in:
@@ -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'}
|
||||
Reference in New Issue
Block a user