2025-07-01
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from . import operators, properties, ui
|
||||
|
||||
|
||||
def register():
|
||||
ui.register()
|
||||
operators.register()
|
||||
properties.register()
|
||||
|
||||
|
||||
def unregister():
|
||||
properties.unregister()
|
||||
operators.unregister()
|
||||
ui.unregister()
|
||||
@@ -0,0 +1,12 @@
|
||||
schema_version = "1.0.0"
|
||||
id = "datablock_utils"
|
||||
version = "1.2.0"
|
||||
name = "Data-Block Utilities"
|
||||
tagline = "Show users, merge duplicates, find similar, and more"
|
||||
maintainer = "Leonardo Pike-Excell <leonardopike.excell@gmail.com>"
|
||||
type = "add-on"
|
||||
website = "https://github.com/Leonardo-Pike-Excell/datablock-utils"
|
||||
tags = ["Scene", "System"]
|
||||
blender_version_min = "4.2.0"
|
||||
license = ["SPDX:GPL-3.0-or-later"]
|
||||
wheels = ["./wheels/networkx-3.4.2-py3-none-any.whl"]
|
||||
@@ -0,0 +1,97 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
import bpy
|
||||
from bpy.types import CollectionProperty, EnumProperty
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IDType:
|
||||
label: str
|
||||
icon: str
|
||||
_collection: str
|
||||
is_ntree: bool = False
|
||||
is_object_data: bool = False
|
||||
|
||||
@property
|
||||
def collection(self) -> bpy.types.bpy_prop_collection:
|
||||
return getattr(bpy.data, self._collection, {}) # type: ignore
|
||||
|
||||
|
||||
def _assign(
|
||||
key: str,
|
||||
coll: str,
|
||||
enums: list[tuple[str, Any]],
|
||||
collections: list[CollectionProperty],
|
||||
*,
|
||||
remove: bool = True,
|
||||
) -> None:
|
||||
coll_prop = next(c for c in collections if c.identifier == coll)
|
||||
if remove:
|
||||
collections.remove(coll_prop)
|
||||
|
||||
enum = next(i for i in enums if i[0] == key)
|
||||
collections.insert(enums.index(enum), coll_prop)
|
||||
|
||||
|
||||
def _generate_id_types() -> dict[str, IDType]:
|
||||
prop = cast(EnumProperty, bpy.types.KeyingSetPath.bl_rna.properties['id_type'])
|
||||
enums = [(k, v.icon) for k, v in prop.enum_items.items()]
|
||||
|
||||
collections = [
|
||||
cast(CollectionProperty, p)
|
||||
for p in bpy.types.BlendData.bl_rna.properties
|
||||
if p.type == 'COLLECTION']
|
||||
collections.sort(key=lambda c: c.identifier)
|
||||
|
||||
if bpy.app.version >= (4, 3, 0):
|
||||
_assign('CURVES', 'hair_curves', enums, collections)
|
||||
_assign('GREASEPENCIL', 'grease_pencils', enums, collections)
|
||||
_assign('GREASEPENCIL_V3', 'grease_pencils_v3', enums, collections)
|
||||
_assign('KEY', 'shape_keys', enums, collections)
|
||||
_assign('LIGHT_PROBE', 'lightprobes', enums, collections)
|
||||
else:
|
||||
_assign('CURVES', 'hair_curves', enums, collections)
|
||||
_assign('GREASEPENCIL_V3', 'grease_pencils', enums, collections, remove=False)
|
||||
_assign('KEY', 'shape_keys', enums, collections)
|
||||
_assign('LIGHT', 'lights', enums, collections)
|
||||
_assign('LIGHT_PROBE', 'lightprobes', enums, collections)
|
||||
|
||||
id_types = {'UNDEFINED': IDType("undefined", 'QUESTION', '')}
|
||||
for (key, icon), coll_prop in zip(enums, collections):
|
||||
label = coll_prop.name.lower()
|
||||
coll = coll_prop.identifier
|
||||
props = coll_prop.fixed_type.bl_rna.properties
|
||||
|
||||
is_ntree = 'node_tree' in props or 'nodes' in props
|
||||
id_types[key] = IDType(label, icon, coll, is_ntree)
|
||||
|
||||
if 'type' not in props:
|
||||
continue
|
||||
|
||||
for subkey, subval in cast(EnumProperty, props['type']).enum_items.items():
|
||||
subicon = subval.icon if subval.icon != 'NONE' else icon
|
||||
id_types[f'{subkey}_{key}'] = IDType(label, subicon, coll)
|
||||
|
||||
for key, val in id_types.items():
|
||||
val.is_object_data = f"{key.split('_')[-1]}_OBJECT" in id_types
|
||||
|
||||
id_types['SHADER_NODETREE'].icon = 'NODE_MATERIAL'
|
||||
id_types['TEXTURE_NODETREE'].icon = 'NODE_TEXTURE'
|
||||
id_types['META'].icon = 'OUTLINER_DATA_META'
|
||||
|
||||
return id_types
|
||||
|
||||
|
||||
def get_id_type(id_data: bpy.types.ID) -> str:
|
||||
id_type = getattr(id_data, 'type', '')
|
||||
|
||||
if id_type != (k := id_data.id_type):
|
||||
id_type += f'_{k}' if id_type else k
|
||||
|
||||
return id_type if id_type in ID_TYPES else 'UNDEFINED'
|
||||
|
||||
|
||||
ID_TYPES = _generate_id_types()
|
||||
@@ -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'}
|
||||
@@ -0,0 +1,262 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# type: ignore
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
import bpy
|
||||
from bpy.props import (
|
||||
BoolProperty,
|
||||
CollectionProperty,
|
||||
EnumProperty,
|
||||
FloatProperty,
|
||||
IntProperty,
|
||||
PointerProperty,
|
||||
StringProperty,
|
||||
)
|
||||
from bpy.types import Context, PropertyGroup, Scene
|
||||
from bpy.utils import register_class, unregister_class
|
||||
|
||||
from .constants import ID_TYPES, get_id_type
|
||||
|
||||
|
||||
def get_items(
|
||||
id_types: Iterable[str],
|
||||
reverse: bool = False,
|
||||
) -> tuple[tuple[str, str, str, str, int]]:
|
||||
items = []
|
||||
for i, id_type in enumerate(id_types):
|
||||
val = ID_TYPES[id_type]
|
||||
items.append((id_type, val.label.title(), "", val.icon, i))
|
||||
|
||||
if reverse:
|
||||
items.reverse()
|
||||
|
||||
return tuple(items)
|
||||
|
||||
|
||||
def add_parent_item(self: DBU_PG_UserMapSettings, context: Context) -> None:
|
||||
id_name = self.id_name
|
||||
|
||||
if not id_name:
|
||||
return
|
||||
|
||||
parents = self.parents
|
||||
bl_data = ID_TYPES[self.id_type].collection
|
||||
id_type = get_id_type(bl_data[id_name])
|
||||
|
||||
if id_name in parents:
|
||||
if any((p.name, p.id_type) == (id_name, id_type) for p in parents):
|
||||
self.id_name = ""
|
||||
return
|
||||
|
||||
parent = parents.add()
|
||||
parent.name = id_name
|
||||
parent.id_type = id_type
|
||||
|
||||
self.id_name = ""
|
||||
|
||||
update_user_map(self, context)
|
||||
|
||||
|
||||
def update_similar(self: DBU_PG_FindSimilarSettings, context: Context) -> None:
|
||||
settings = context.scene.dbu_similar_settings
|
||||
if settings.enabled:
|
||||
bpy.ops.scene.dbu_find_similar_and_duplicates()
|
||||
|
||||
|
||||
def update_user_map(self: DBU_PG_UserMapSettings, context: Context) -> None:
|
||||
settings = context.scene.dbu_users_settings
|
||||
if settings.parent_map:
|
||||
bpy.ops.scene.dbu_user_map()
|
||||
|
||||
|
||||
class DBU_PG_Item(PropertyGroup):
|
||||
pass
|
||||
|
||||
|
||||
class DBU_PG_GroupItem(PropertyGroup):
|
||||
group: CollectionProperty(type=DBU_PG_Item)
|
||||
id_type: StringProperty()
|
||||
score: FloatProperty()
|
||||
|
||||
|
||||
class DBU_PG_UserItem(PropertyGroup):
|
||||
id_type: StringProperty()
|
||||
node_names: CollectionProperty(type=DBU_PG_Item)
|
||||
as_parent_idx: IntProperty()
|
||||
|
||||
|
||||
class DBU_PG_ParentItem(PropertyGroup):
|
||||
id_type: StringProperty()
|
||||
users: CollectionProperty(type=DBU_PG_UserItem)
|
||||
|
||||
|
||||
class DBU_PG_FindSimilarSettings(PropertyGroup):
|
||||
id_type: EnumProperty(
|
||||
items=get_items(('NODETREE', 'MATERIAL', 'LIGHT', 'IMAGE', 'MESH')),
|
||||
name="Type",
|
||||
description="Data-block type",
|
||||
default='NODETREE',
|
||||
options=set(),
|
||||
update=update_similar)
|
||||
|
||||
similarity_threshold: FloatProperty(
|
||||
name="Similarity Threshold",
|
||||
description="Threshold at which two items are considered similar",
|
||||
default=0.8,
|
||||
min=0.5,
|
||||
max=1,
|
||||
step=1,
|
||||
options=set(),
|
||||
update=update_similar)
|
||||
|
||||
grouping_threshold: FloatProperty(
|
||||
name="Grouping Threshold",
|
||||
description=
|
||||
"Collections above this similarity threshold, when displayed in the results, are grouped together if they share items. One to disable",
|
||||
default=0.82,
|
||||
min=0.5,
|
||||
max=1,
|
||||
step=1,
|
||||
options=set(),
|
||||
update=update_similar)
|
||||
|
||||
exclude_unused: BoolProperty(
|
||||
name="Unused Nodes",
|
||||
description="Exclude nodes that are muted or not used by any group outputs",
|
||||
default=True,
|
||||
options=set(),
|
||||
update=update_similar)
|
||||
|
||||
exclude_organization: BoolProperty(
|
||||
name="Frames and Reroutes",
|
||||
description="Exclude frame and reroute nodes",
|
||||
default=True,
|
||||
options=set(),
|
||||
update=update_similar)
|
||||
|
||||
select_object_users: BoolProperty(
|
||||
name="Select Object Users",
|
||||
description=
|
||||
"Object users will be selected when clicking on a data-block (in addition to revealing it in the shader editor)",
|
||||
options=set())
|
||||
|
||||
unhidden_objects: CollectionProperty(type=DBU_PG_Item)
|
||||
|
||||
duplicates: CollectionProperty(type=DBU_PG_GroupItem)
|
||||
scored: CollectionProperty(type=DBU_PG_GroupItem)
|
||||
enabled: BoolProperty()
|
||||
|
||||
|
||||
class DBU_PG_UserMapSettings(PropertyGroup):
|
||||
SCENE: BoolProperty(
|
||||
name="Scenes",
|
||||
description="Show scenes",
|
||||
default=False,
|
||||
options=set(),
|
||||
update=update_user_map)
|
||||
|
||||
MATERIAL: BoolProperty(
|
||||
name="Materials",
|
||||
description="Show materials",
|
||||
default=True,
|
||||
options=set(),
|
||||
update=update_user_map)
|
||||
|
||||
NODETREE: BoolProperty(
|
||||
name="Node Groups",
|
||||
description="Show node groups",
|
||||
default=True,
|
||||
options=set(),
|
||||
update=update_user_map)
|
||||
|
||||
OBJECT: BoolProperty(
|
||||
name="Objects",
|
||||
description="Show objects",
|
||||
default=True,
|
||||
options=set(),
|
||||
update=update_user_map)
|
||||
|
||||
object_contents: BoolProperty(
|
||||
name="Object Contents",
|
||||
description="Show the contents of object elements",
|
||||
default=False,
|
||||
options=set(),
|
||||
update=update_user_map)
|
||||
|
||||
MESH: BoolProperty(
|
||||
name="Meshes",
|
||||
description="Show mesh objects",
|
||||
default=True,
|
||||
options=set(),
|
||||
update=update_user_map)
|
||||
|
||||
LIGHT: BoolProperty(
|
||||
name="Lights",
|
||||
description="Show light objects",
|
||||
default=True,
|
||||
options=set(),
|
||||
update=update_user_map)
|
||||
|
||||
others: BoolProperty(
|
||||
name="Others",
|
||||
description="Show curves, metaballs, textures, ...",
|
||||
default=True,
|
||||
options=set(),
|
||||
update=update_user_map)
|
||||
|
||||
hide: BoolProperty(name="Hide", description="Hide the list")
|
||||
|
||||
parents: CollectionProperty(type=DBU_PG_ParentItem)
|
||||
|
||||
id_type: EnumProperty(
|
||||
items=get_items(('MATERIAL', 'NODETREE', 'IMAGE', 'MESH', 'OBJECT'), reverse=True),
|
||||
name="Type",
|
||||
description="Data-block type",
|
||||
default='MATERIAL',
|
||||
options=set())
|
||||
|
||||
id_name: StringProperty(
|
||||
name="Search",
|
||||
description="Search for data-blocks and add them to the list",
|
||||
update=add_parent_item)
|
||||
|
||||
select_object_users: BoolProperty(
|
||||
name="Select Object Users",
|
||||
description=
|
||||
"When clicking on a data-block, select its object users (in addition to revealing it in the node editor)",
|
||||
options=set())
|
||||
|
||||
unhidden_objects: CollectionProperty(type=DBU_PG_Item)
|
||||
|
||||
parent_map: CollectionProperty(type=DBU_PG_ParentItem)
|
||||
user_map: CollectionProperty(type=DBU_PG_ParentItem)
|
||||
|
||||
|
||||
classes = (
|
||||
DBU_PG_Item,
|
||||
DBU_PG_GroupItem,
|
||||
DBU_PG_FindSimilarSettings,
|
||||
DBU_PG_UserItem,
|
||||
DBU_PG_ParentItem,
|
||||
DBU_PG_UserMapSettings,
|
||||
)
|
||||
|
||||
|
||||
def register() -> None:
|
||||
for cls in classes:
|
||||
register_class(cls)
|
||||
|
||||
Scene.dbu_similar_settings = PointerProperty(type=DBU_PG_FindSimilarSettings)
|
||||
Scene.dbu_users_settings = PointerProperty(type=DBU_PG_UserMapSettings)
|
||||
|
||||
|
||||
def unregister() -> None:
|
||||
del Scene.dbu_users_settings
|
||||
del Scene.dbu_similar_settings
|
||||
|
||||
for cls in reversed(classes):
|
||||
unregister_class(cls)
|
||||
@@ -0,0 +1,332 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# type: ignore
|
||||
|
||||
import bpy
|
||||
from bpy.types import Context, Panel, UILayout
|
||||
from bpy.utils import register_class, unregister_class
|
||||
|
||||
from .constants import ID_TYPES
|
||||
from .properties import DBU_PG_GroupItem, DBU_PG_ParentItem, DBU_PG_UserItem
|
||||
|
||||
|
||||
class ScenePropertiesPanel:
|
||||
bl_space_type = 'PROPERTIES'
|
||||
bl_region_type = 'WINDOW'
|
||||
bl_context = "scene"
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
|
||||
class DBU_PT_SimilarAndDuplicates(ScenePropertiesPanel, Panel):
|
||||
bl_label = "Similar & Duplicates"
|
||||
bl_idname = "SCENE_PT_DBU_similar_and_duplicates"
|
||||
|
||||
@staticmethod
|
||||
def draw_group(layout: UILayout, item: DBU_PG_GroupItem) -> None:
|
||||
id_type = item.id_type
|
||||
icon = ID_TYPES[id_type].icon
|
||||
|
||||
col = layout.box().column()
|
||||
for i in item.group:
|
||||
name = i.name
|
||||
row = col.row()
|
||||
row.alignment = 'LEFT'
|
||||
op = row.operator("scene.dbu_go_to_datablock", text=name, icon=icon, emboss=False)
|
||||
op.id_name = name
|
||||
op.id_type = id_type
|
||||
op.settings = 'dbu_similar_settings'
|
||||
|
||||
def draw_header(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
layout.label(text="", icon='VIEWZOOM')
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
layout.use_property_split = True
|
||||
|
||||
scene = context.scene
|
||||
settings = scene.dbu_similar_settings
|
||||
id_type = settings.id_type
|
||||
|
||||
is_ntree = ID_TYPES[id_type].is_ntree
|
||||
text = ID_TYPES[id_type].label
|
||||
label = text.title()
|
||||
|
||||
row = layout.row(align=True)
|
||||
text = "Find Similar and Duplicates" if is_ntree else "Find Duplicates"
|
||||
row.operator("scene.dbu_find_similar_and_duplicates", text=text)
|
||||
if settings.enabled:
|
||||
row.operator("scene.dbu_similar_and_duplicates_clear_results", text="", icon='X')
|
||||
|
||||
layout.prop(settings, "id_type")
|
||||
|
||||
col = layout.column(align=True)
|
||||
col.active = is_ntree
|
||||
col.prop(settings, "similarity_threshold")
|
||||
col.prop(settings, "grouping_threshold")
|
||||
|
||||
col = layout.column(heading="Exclude")
|
||||
col.active = is_ntree
|
||||
col.prop(settings, "exclude_unused")
|
||||
col.prop(settings, "exclude_organization")
|
||||
|
||||
row = layout.row()
|
||||
row.prop(settings, "select_object_users")
|
||||
sub = row.row()
|
||||
sub.active = settings.select_object_users
|
||||
rehide_icon = 'HIDE_OFF' if settings.unhidden_objects else 'HIDE_ON'
|
||||
op = sub.operator("scene.dbu_rehide_object_users", text="", icon=rehide_icon)
|
||||
op.settings = 'dbu_similar_settings'
|
||||
|
||||
if not settings.enabled:
|
||||
return
|
||||
|
||||
if duplicates_coll := settings.duplicates:
|
||||
for ditem in duplicates_coll:
|
||||
layout.separator(factor=0.1)
|
||||
layout.label(text=f"{len(ditem.group)} Duplicates", icon='ERROR')
|
||||
self.draw_group(layout, ditem)
|
||||
|
||||
layout.separator(factor=0.1)
|
||||
layout.operator_context = 'INVOKE_DEFAULT'
|
||||
layout.operator("scene.dbu_merge_duplicates", icon='FILE_PARENT')
|
||||
layout.separator(factor=0.3)
|
||||
|
||||
for sitem in settings.scored:
|
||||
layout.separator(factor=0.1)
|
||||
layout.label(
|
||||
text=f"{len(sitem.group)} Similar {label} ({sitem.score:.1f}%)",
|
||||
icon='INFO',
|
||||
)
|
||||
self.draw_group(layout, sitem)
|
||||
|
||||
|
||||
_NODE_NAME_SPACING = 3
|
||||
_INDENT = 0.087
|
||||
_INITIAL_INDENT_OFFSET = 0.026
|
||||
|
||||
|
||||
class DBU_PT_UserMap(ScenePropertiesPanel, Panel):
|
||||
bl_label = "Data-Block Users"
|
||||
bl_idname = "SCENE_PT_DBU_user_map"
|
||||
|
||||
@staticmethod
|
||||
def draw_datablock(layout: UILayout, item: DBU_PG_UserItem | DBU_PG_ParentItem) -> None:
|
||||
name = item.name
|
||||
id_type = item.id_type
|
||||
|
||||
row = layout.box().column().row()
|
||||
row.scale_y = 0.5
|
||||
row.alignment = 'LEFT'
|
||||
op = row.operator(
|
||||
"scene.dbu_go_to_datablock",
|
||||
text=name,
|
||||
icon=ID_TYPES[id_type].icon,
|
||||
emboss=False,
|
||||
)
|
||||
op.id_name = name
|
||||
op.id_type = id_type
|
||||
|
||||
@staticmethod
|
||||
def draw_node_names(layout: UILayout, user: DBU_PG_UserItem) -> None:
|
||||
node_names = user.node_names
|
||||
|
||||
if not node_names:
|
||||
return
|
||||
|
||||
layout.scale_y = 0.5
|
||||
layout.separator()
|
||||
col = layout.column(align=True)
|
||||
|
||||
for n in node_names:
|
||||
row = col.row(align=True)
|
||||
row.alignment = 'LEFT'
|
||||
|
||||
fac = _NODE_NAME_SPACING if n != node_names[-1] else _NODE_NAME_SPACING / 2
|
||||
col.separator(factor=fac)
|
||||
|
||||
op = row.operator("scene.dbu_go_to_datablock", text=n.name, emboss=False)
|
||||
op.id_name = user.name
|
||||
op.id_type = user.id_type
|
||||
op.node_name = n.name
|
||||
|
||||
@classmethod
|
||||
def draw_users(cls, layout: UILayout, parent: DBU_PG_ParentItem, depth: int = 1) -> None:
|
||||
settings = bpy.context.scene.dbu_users_settings
|
||||
user_map = settings.user_map
|
||||
object_contents = settings.object_contents
|
||||
|
||||
indent = _INDENT + _INITIAL_INDENT_OFFSET if depth == 1 else _INDENT * depth
|
||||
|
||||
for user in parent.users:
|
||||
idx = user.as_parent_idx
|
||||
|
||||
if not object_contents and ID_TYPES[user.id_type].is_object_data:
|
||||
cls.draw_users(layout, user_map[idx], depth)
|
||||
continue
|
||||
|
||||
split = layout.split(factor=indent)
|
||||
split.separator()
|
||||
cls.draw_datablock(split, user)
|
||||
|
||||
split = layout.split(factor=indent + 0.0574)
|
||||
cls.draw_node_names(split, user)
|
||||
|
||||
cls.draw_users(layout, user_map[idx], depth + 1)
|
||||
|
||||
def draw_header(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
layout.label(text="", icon='FAKE_USER_OFF')
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
layout.use_property_split = True
|
||||
|
||||
settings = context.scene.dbu_users_settings
|
||||
parent_map = settings.parent_map
|
||||
|
||||
split = layout.split(factor=(65 / context.area.width), align=True)
|
||||
split.popover("SCENE_PT_DBU_user_map_filter", icon='FILTER')
|
||||
row = split.row(align=True)
|
||||
row.operator("scene.dbu_user_map")
|
||||
if parent_map:
|
||||
row.operator("scene.dbu_user_map_clear_results", text="", icon='X')
|
||||
|
||||
col = layout.column(align=True)
|
||||
col.use_property_split = False
|
||||
col.separator()
|
||||
|
||||
if parents := settings.parents:
|
||||
row = col.row(align=True)
|
||||
row.alignment = 'RIGHT'
|
||||
row.prop(settings, "hide", text="Hide", toggle=1)
|
||||
row.operator("scene.dbu_user_map_remove_all", text="Clear")
|
||||
|
||||
if not settings.hide:
|
||||
box = col.box()
|
||||
box.scale_y = 0.75
|
||||
box.emboss = 'NONE'
|
||||
for i, parent in enumerate(parents):
|
||||
name = parent.name
|
||||
id_type = parent.id_type
|
||||
|
||||
split = box.split(factor=0.9)
|
||||
row = split.row()
|
||||
row.alignment = 'LEFT'
|
||||
op = row.operator(
|
||||
"scene.dbu_go_to_datablock",
|
||||
text=name,
|
||||
icon=ID_TYPES[id_type].icon,
|
||||
)
|
||||
op.id_name = name
|
||||
op.id_type = id_type
|
||||
row = split.row()
|
||||
row.alignment = 'RIGHT'
|
||||
op = row.operator("scene.dbu_user_map_remove", text="", icon='X')
|
||||
op.idx = i
|
||||
|
||||
row = col.row(align=True)
|
||||
row.prop(settings, "id_type", icon_only=True)
|
||||
row.prop_search(
|
||||
settings,
|
||||
"id_name",
|
||||
bpy.data,
|
||||
ID_TYPES[settings.id_type]._collection,
|
||||
text="",
|
||||
icon='BLANK1',
|
||||
)
|
||||
sub = row.row()
|
||||
sub.alignment = 'RIGHT'
|
||||
sub.operator("scene.dbu_user_map_add_all", text="All", icon='ADD')
|
||||
|
||||
layout.separator()
|
||||
row = layout.row(align=True)
|
||||
row.use_property_split = False
|
||||
row.alignment = 'CENTER'
|
||||
row.prop(settings, "select_object_users")
|
||||
sub = row.row()
|
||||
sub.active = settings.select_object_users
|
||||
rehide_icon = 'HIDE_OFF' if settings.unhidden_objects else 'HIDE_ON'
|
||||
op = sub.operator("scene.dbu_rehide_object_users", text="", icon=rehide_icon)
|
||||
op.settings = 'dbu_users_settings'
|
||||
|
||||
if not parent_map:
|
||||
return
|
||||
|
||||
layout.separator(factor=0.5)
|
||||
|
||||
for parent in parent_map:
|
||||
header, panel = layout.panel(parent.id_type + parent.name, default_closed=True)
|
||||
self.draw_datablock(header, parent)
|
||||
|
||||
if not panel:
|
||||
continue
|
||||
|
||||
if parent.users:
|
||||
self.draw_users(panel, parent)
|
||||
else:
|
||||
split = panel.split(factor=_INDENT + _INITIAL_INDENT_OFFSET)
|
||||
split.separator()
|
||||
row = split.row()
|
||||
row.active = False
|
||||
row.label(text="No Users Matching Filter")
|
||||
|
||||
|
||||
class DBU_PT_UserMapFilter(ScenePropertiesPanel, Panel):
|
||||
bl_context = ".scene"
|
||||
bl_idname = "SCENE_PT_DBU_user_map_filter"
|
||||
bl_label = ""
|
||||
bl_description = "Filter users by type"
|
||||
|
||||
@staticmethod
|
||||
def draw_user_type(layout: UILayout, prop_name: str) -> None:
|
||||
settings = bpy.context.scene.dbu_users_settings
|
||||
|
||||
if prop_name not in {'OBJECT', 'object_contents'}:
|
||||
if prop_name == 'others':
|
||||
enums = bpy.types.KeyingSetPath.bl_rna.properties['id_type'].enum_items.keys()
|
||||
if not any([ID_TYPES[e].collection for e in enums if e not in dir(settings)]):
|
||||
return
|
||||
elif not ID_TYPES[prop_name].collection:
|
||||
return
|
||||
|
||||
icon = ID_TYPES[prop_name].icon if prop_name in ID_TYPES else 'BLANK1'
|
||||
|
||||
row = layout.row()
|
||||
row.label(icon=icon)
|
||||
row.prop(settings, prop_name)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
settings = context.scene.dbu_users_settings
|
||||
|
||||
layout.label(text="Filter Users")
|
||||
|
||||
col = layout.column()
|
||||
self.draw_user_type(col, 'SCENE')
|
||||
self.draw_user_type(col, 'MATERIAL')
|
||||
self.draw_user_type(col, 'NODETREE')
|
||||
self.draw_user_type(col, 'OBJECT')
|
||||
sub = col.column()
|
||||
sub.enabled = settings.OBJECT
|
||||
self.draw_user_type(sub, 'object_contents')
|
||||
self.draw_user_type(sub, 'MESH')
|
||||
self.draw_user_type(sub, 'LIGHT')
|
||||
self.draw_user_type(sub, 'others')
|
||||
|
||||
|
||||
classes = (
|
||||
DBU_PT_SimilarAndDuplicates,
|
||||
DBU_PT_UserMap,
|
||||
DBU_PT_UserMapFilter,
|
||||
)
|
||||
|
||||
|
||||
def register() -> None:
|
||||
for cls in classes:
|
||||
register_class(cls)
|
||||
|
||||
|
||||
def unregister() -> None:
|
||||
for cls in reversed(classes):
|
||||
unregister_class(cls)
|
||||
Binary file not shown.
Reference in New Issue
Block a user