The current placement of the operators make it easy for them to blend in with other group nodes in that menu. This patch adds an icon to better indicate the nature of these operators, and have separators between it and other entries of the menu. Pull Request: https://projects.blender.org/blender/blender/pulls/147330
498 lines
17 KiB
Python
498 lines
17 KiB
Python
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
__all__ = (
|
|
"add_closure_zone",
|
|
"add_color_mix_node",
|
|
"add_foreach_geometry_element_zone",
|
|
"add_node_type",
|
|
"add_node_type_with_outputs",
|
|
"add_node_type_with_searchable_enum",
|
|
"add_node_type_with_searchable_enum_socket",
|
|
"add_repeat_zone",
|
|
"add_simulation_zone",
|
|
"draw_node_group_add_menu",
|
|
)
|
|
|
|
import bpy
|
|
from bpy.types import Menu
|
|
from bpy.app.translations import (
|
|
pgettext_iface as iface_,
|
|
contexts as i18n_contexts,
|
|
)
|
|
|
|
|
|
# NOTE: This is kept for compatibility's sake, as some scripts import node_add_menu.add_node_type.
|
|
def add_node_type(layout, node_type, *, label=None, poll=None, search_weight=0.0, translate=True):
|
|
"""Add a node type to a menu."""
|
|
return AddNodeMenu.node_operator(
|
|
layout,
|
|
node_type,
|
|
label=label,
|
|
poll=poll,
|
|
search_weight=search_weight,
|
|
translate=translate)
|
|
|
|
|
|
def add_node_type_with_searchable_enum(context, layout, node_idname, property_name, search_weight=0.0):
|
|
return AddNodeMenu.node_operator_with_searchable_enum(context, layout, node_idname, property_name, search_weight)
|
|
|
|
|
|
def add_node_type_with_searchable_enum_socket(
|
|
context,
|
|
layout,
|
|
node_idname,
|
|
socket_identifier,
|
|
enum_names,
|
|
search_weight=0.0):
|
|
return AddNodeMenu.node_operator_with_searchable_enum_socket(
|
|
context, layout, node_idname, socket_identifier, enum_names, search_weight)
|
|
|
|
|
|
def add_node_type_with_outputs(context, layout, node_type, subnames, *, label=None, search_weight=0.0):
|
|
return AddNodeMenu.node_operator_with_outputs(
|
|
context,
|
|
layout,
|
|
node_type,
|
|
subnames,
|
|
label=label,
|
|
search_weight=search_weight)
|
|
|
|
|
|
def add_color_mix_node(context, layout):
|
|
return AddNodeMenu.color_mix_node(context, layout)
|
|
|
|
|
|
def add_empty_group(layout):
|
|
return AddNodeMenu.new_empty_group(layout)
|
|
|
|
|
|
def draw_node_group_add_menu(context, layout):
|
|
"""Add items to the layout used for interacting with node groups."""
|
|
return AddNodeMenu.draw_group_menu(context, layout)
|
|
|
|
|
|
def add_simulation_zone(layout, label):
|
|
"""Add simulation zone to a menu."""
|
|
props = layout.operator("node.add_simulation_zone", text=label, text_ctxt=i18n_contexts.default)
|
|
props.use_transform = True
|
|
return props
|
|
|
|
|
|
def add_repeat_zone(layout, label):
|
|
props = layout.operator("node.add_repeat_zone", text=label, text_ctxt=i18n_contexts.default)
|
|
props.use_transform = True
|
|
return props
|
|
|
|
|
|
def add_foreach_geometry_element_zone(layout, label):
|
|
props = layout.operator(
|
|
"node.add_foreach_geometry_element_zone",
|
|
text=label,
|
|
text_ctxt=i18n_contexts.default,
|
|
)
|
|
props.use_transform = True
|
|
return props
|
|
|
|
|
|
def add_closure_zone(layout, label):
|
|
props = layout.operator(
|
|
"node.add_closure_zone", text=label, text_ctxt=i18n_contexts.default)
|
|
props.use_transform = True
|
|
return props
|
|
|
|
|
|
class NodeMenu(Menu):
|
|
"""A baseclass defining the shared methods for AddNodeMenu and SwapNodeMenu."""
|
|
draw_assets: bool
|
|
use_transform: bool
|
|
|
|
main_operator_id: str
|
|
zone_operator_id: str
|
|
new_empty_group_operator_id: str
|
|
|
|
root_asset_menu: str
|
|
pathing_dict: dict[str, str]
|
|
|
|
@classmethod
|
|
def node_operator(cls, layout, node_type, *, label=None, poll=None, search_weight=0.0, translate=True):
|
|
"""The main operator defined for the node menu.
|
|
\n(e.g. 'Add Node' for AddNodeMenu, or 'Swap Node' for SwapNodeMenu)."""
|
|
|
|
bl_rna = bpy.types.Node.bl_rna_get_subclass(node_type)
|
|
if not label:
|
|
label = bl_rna.name if bl_rna else iface_("Unknown")
|
|
|
|
if poll is True or poll is None:
|
|
translation_context = bl_rna.translation_context if bl_rna else i18n_contexts.default
|
|
props = layout.operator(
|
|
cls.main_operator_id,
|
|
text=label,
|
|
text_ctxt=translation_context,
|
|
translate=translate,
|
|
search_weight=search_weight)
|
|
props.type = node_type
|
|
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return props
|
|
|
|
return None
|
|
|
|
@classmethod
|
|
def node_operator_with_searchable_enum(cls, context, layout, node_idname, property_name, search_weight=0.0):
|
|
"""Similar to `node_operator`, but with extra entries based on a enum property while in search."""
|
|
operators = []
|
|
operators.append(cls.node_operator(layout, node_idname, search_weight=search_weight))
|
|
|
|
if getattr(context, "is_menu_search", False):
|
|
node_type = getattr(bpy.types, node_idname)
|
|
translation_context = node_type.bl_rna.properties[property_name].translation_context
|
|
for item in node_type.bl_rna.properties[property_name].enum_items_static:
|
|
props = cls.node_operator(
|
|
layout,
|
|
node_idname,
|
|
label="{:s} \u25B8 {:s}".format(
|
|
iface_(
|
|
node_type.bl_rna.name),
|
|
iface_(
|
|
item.name,
|
|
translation_context)),
|
|
translate=False,
|
|
search_weight=search_weight)
|
|
prop = props.settings.add()
|
|
prop.name = property_name
|
|
prop.value = repr(item.identifier)
|
|
operators.append(props)
|
|
|
|
for props in operators:
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return operators
|
|
|
|
@classmethod
|
|
def node_operator_with_searchable_enum_socket(
|
|
cls,
|
|
context,
|
|
layout,
|
|
node_idname,
|
|
socket_identifier,
|
|
enum_names,
|
|
search_weight=0.0,
|
|
):
|
|
"""Similar to `node_operator`, but with extra entries based on a enum socket while in search."""
|
|
operators = []
|
|
operators.append(cls.node_operator(layout, node_idname, search_weight=search_weight))
|
|
if getattr(context, "is_menu_search", False):
|
|
node_type = getattr(bpy.types, node_idname)
|
|
for enum_name in enum_names:
|
|
props = cls.node_operator(
|
|
layout,
|
|
node_idname,
|
|
label="{:s} \u25B8 {:s}".format(iface_(node_type.bl_rna.name), iface_(enum_name)),
|
|
translate=False,
|
|
search_weight=search_weight)
|
|
prop = props.settings.add()
|
|
prop.name = "inputs[\"{:s}\"].default_value".format(bpy.utils.escape_identifier(socket_identifier))
|
|
prop.value = repr(enum_name)
|
|
operators.append(props)
|
|
|
|
for props in operators:
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return operators
|
|
|
|
@classmethod
|
|
def node_operator_with_outputs(cls, context, layout, node_type, subnames, *, label=None, search_weight=0.0):
|
|
"""Similar to `node_operator`, but with extra entries based on a enum socket while in search."""
|
|
bl_rna = bpy.types.Node.bl_rna_get_subclass(node_type)
|
|
if not label:
|
|
label = bl_rna.name if bl_rna else "Unknown"
|
|
|
|
operators = []
|
|
operators.append(cls.node_operator(layout, node_type, label=label, search_weight=search_weight))
|
|
|
|
if getattr(context, "is_menu_search", False):
|
|
for subname in subnames:
|
|
item_props = cls.node_operator(layout, node_type, label="{:s} \u25B8 {:s}".format(
|
|
iface_(label), iface_(subname)), search_weight=search_weight, translate=False)
|
|
item_props.visible_output = subname
|
|
operators.append(item_props)
|
|
|
|
for props in operators:
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return operators
|
|
|
|
@classmethod
|
|
def color_mix_node(cls, context, layout):
|
|
"""The 'Mix Color' node, with its different blend modes available while in search."""
|
|
label = iface_("Mix Color")
|
|
|
|
operators = []
|
|
props = cls.node_operator(layout, "ShaderNodeMix", label=label, translate=False)
|
|
ops = props.settings.add()
|
|
ops.name = "data_type"
|
|
ops.value = "'RGBA'"
|
|
operators.append(props)
|
|
|
|
if getattr(context, "is_menu_search", False):
|
|
translation_context = bpy.types.ShaderNodeMix.bl_rna.properties["blend_type"].translation_context
|
|
for item in bpy.types.ShaderNodeMix.bl_rna.properties["blend_type"].enum_items_static:
|
|
props = cls.node_operator(
|
|
layout,
|
|
"ShaderNodeMix",
|
|
label="{:s} \u25B8 {:s}".format(
|
|
label,
|
|
iface_(
|
|
item.name,
|
|
translation_context)),
|
|
translate=False)
|
|
prop = props.settings.add()
|
|
prop.name = "data_type"
|
|
prop.value = "'RGBA'"
|
|
prop = props.settings.add()
|
|
prop.name = "blend_type"
|
|
prop.value = repr(item.identifier)
|
|
operators.append(props)
|
|
|
|
for props in operators:
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return operators
|
|
|
|
@classmethod
|
|
def new_empty_group(cls, layout):
|
|
"""Group Node with a newly created empty group as its assigned nodetree."""
|
|
props = layout.operator(
|
|
cls.new_empty_group_operator_id,
|
|
text="New Group",
|
|
text_ctxt=i18n_contexts.default,
|
|
icon='ADD')
|
|
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return props
|
|
|
|
@classmethod
|
|
def draw_group_menu(cls, context, layout):
|
|
"""Show operators used for interacting with node groups."""
|
|
space_node = context.space_data
|
|
node_tree = space_node.edit_tree
|
|
all_node_groups = context.blend_data.node_groups
|
|
|
|
operators = []
|
|
operators.append(cls.new_empty_group(layout))
|
|
|
|
if node_tree in all_node_groups.values():
|
|
layout.separator()
|
|
cls.node_operator(layout, "NodeGroupInput")
|
|
cls.node_operator(layout, "NodeGroupOutput")
|
|
|
|
if node_tree:
|
|
from nodeitems_builtins import node_tree_group_type
|
|
|
|
prefs = bpy.context.preferences
|
|
show_hidden = prefs.filepaths.show_hidden_files_datablocks
|
|
|
|
groups = [
|
|
group for group in context.blend_data.node_groups
|
|
if (group.bl_idname == node_tree.bl_idname and
|
|
not group.contains_tree(node_tree) and
|
|
(show_hidden or not group.name.startswith('.')))
|
|
]
|
|
if groups:
|
|
layout.separator()
|
|
for group in groups:
|
|
props = cls.node_operator(layout, node_tree_group_type[group.bl_idname], label=group.name)
|
|
ops = props.settings.add()
|
|
ops.name = "node_tree"
|
|
ops.value = "bpy.data.node_groups[{!r}]".format(group.name)
|
|
ops = props.settings.add()
|
|
ops.name = "width"
|
|
ops.value = repr(group.default_group_node_width)
|
|
ops = props.settings.add()
|
|
ops.name = "name"
|
|
ops.value = repr(group.name)
|
|
operators.append(props)
|
|
|
|
for props in operators:
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return operators
|
|
|
|
@classmethod
|
|
def draw_menu(cls, layout, path):
|
|
"""
|
|
Takes the given menu path and draws the corresponding menu.
|
|
Menu paths are either explicitly defined, or based on bl_label if not.
|
|
"""
|
|
if cls.pathing_dict is None:
|
|
raise ValueError("`pathing_dict` was not set for {!s}".format(cls))
|
|
|
|
layout.menu(cls.pathing_dict[path])
|
|
|
|
@classmethod
|
|
def simulation_zone(cls, layout, label):
|
|
props = layout.operator(cls.zone_operator_id, text=label)
|
|
props.input_node_type = "GeometryNodeSimulationInput"
|
|
props.output_node_type = "GeometryNodeSimulationOutput"
|
|
props.add_default_geometry_link = True
|
|
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return props
|
|
|
|
@classmethod
|
|
def repeat_zone(cls, layout, label):
|
|
props = layout.operator(cls.zone_operator_id, text=label)
|
|
props.input_node_type = "GeometryNodeRepeatInput"
|
|
props.output_node_type = "GeometryNodeRepeatOutput"
|
|
props.add_default_geometry_link = True
|
|
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return props
|
|
|
|
@classmethod
|
|
def for_each_element_zone(cls, layout, label):
|
|
props = layout.operator(cls.zone_operator_id, text=label)
|
|
props.input_node_type = "GeometryNodeForeachGeometryElementInput"
|
|
props.output_node_type = "GeometryNodeForeachGeometryElementOutput"
|
|
props.add_default_geometry_link = False
|
|
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return props
|
|
|
|
@classmethod
|
|
def closure_zone(cls, layout, label):
|
|
props = layout.operator(cls.zone_operator_id, text=label)
|
|
props.input_node_type = "NodeClosureInput"
|
|
props.output_node_type = "NodeClosureOutput"
|
|
props.add_default_geometry_link = False
|
|
|
|
if hasattr(props, "use_transform"):
|
|
props.use_transform = cls.use_transform
|
|
|
|
return props
|
|
|
|
@classmethod
|
|
def draw_root_assets(cls, layout):
|
|
if cls.draw_assets:
|
|
layout.menu_contents(cls.root_asset_menu)
|
|
|
|
|
|
class AddNodeMenu(NodeMenu):
|
|
draw_assets = True
|
|
use_transform = True
|
|
|
|
main_operator_id = "node.add_node"
|
|
zone_operator_id = "node.add_zone"
|
|
new_empty_group_operator_id = "node.add_empty_group"
|
|
|
|
root_asset_menu = "NODE_MT_node_add_root_catalogs"
|
|
|
|
@classmethod
|
|
def draw_assets_for_catalog(cls, layout, catalog_path):
|
|
if cls.draw_assets:
|
|
layout.template_node_asset_menu_items(catalog_path=catalog_path, operator='ADD')
|
|
|
|
|
|
class SwapNodeMenu(NodeMenu):
|
|
draw_assets = True
|
|
# NOTE: Swap operators don't have a `use_transform` property, so defining it here has no effect.
|
|
|
|
main_operator_id = "node.swap_node"
|
|
zone_operator_id = "node.swap_zone"
|
|
new_empty_group_operator_id = "node.swap_empty_group"
|
|
|
|
root_asset_menu = "NODE_MT_node_swap_root_catalogs"
|
|
|
|
@classmethod
|
|
def draw_assets_for_catalog(cls, layout, catalog_path):
|
|
if cls.draw_assets:
|
|
layout.template_node_asset_menu_items(catalog_path=catalog_path, operator='SWAP')
|
|
|
|
|
|
class NODE_MT_group_base(NodeMenu):
|
|
bl_label = "Group"
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
self.draw_group_menu(context, layout)
|
|
|
|
self.draw_assets_for_catalog(layout, self.bl_label)
|
|
|
|
|
|
class NODE_MT_layout_base(NodeMenu):
|
|
bl_label = "Layout"
|
|
|
|
def draw(self, _context):
|
|
layout = self.layout
|
|
self.node_operator(layout, "NodeFrame", search_weight=-1)
|
|
self.node_operator(layout, "NodeReroute")
|
|
|
|
self.draw_assets_for_catalog(layout, self.bl_label)
|
|
|
|
|
|
add_base_pathing_dict = {
|
|
"Group": "NODE_MT_group_add",
|
|
"Layout": "NODE_MT_category_layout",
|
|
}
|
|
|
|
|
|
swap_base_pathing_dict = {
|
|
"Group": "NODE_MT_group_swap",
|
|
"Layout": "NODE_MT_layout_swap",
|
|
}
|
|
|
|
|
|
def generate_menu(bl_idname: str, template: Menu, layout_base: Menu, pathing_dict: dict = None):
|
|
return type(bl_idname, (template, layout_base), {"bl_idname": bl_idname, "pathing_dict": pathing_dict})
|
|
|
|
|
|
def generate_menus(menus: dict, template: Menu, base_dict: dict):
|
|
import copy
|
|
pathing_dict = copy.copy(base_dict)
|
|
menus = tuple(
|
|
generate_menu(bl_idname, template, layout_base, pathing_dict)
|
|
for bl_idname, layout_base in menus.items()
|
|
)
|
|
generate_pathing_dict(pathing_dict, menus)
|
|
return menus
|
|
|
|
|
|
def generate_pathing_dict(pathing_dict, menus):
|
|
for menu in menus:
|
|
if hasattr(menu, "menu_path"):
|
|
menu_path = menu.menu_path
|
|
else:
|
|
menu_path = menu.bl_label
|
|
|
|
pathing_dict[menu_path] = menu.bl_idname
|
|
|
|
|
|
classes = (
|
|
generate_menu("NODE_MT_group_add", template=AddNodeMenu, layout_base=NODE_MT_group_base),
|
|
generate_menu("NODE_MT_group_swap", template=SwapNodeMenu, layout_base=NODE_MT_group_base),
|
|
generate_menu("NODE_MT_category_layout", template=AddNodeMenu, layout_base=NODE_MT_layout_base),
|
|
generate_menu("NODE_MT_layout_swap", template=SwapNodeMenu, layout_base=NODE_MT_layout_base),
|
|
)
|
|
|
|
if __name__ == "__main__": # only for live edit.
|
|
from bpy.utils import register_class
|
|
for cls in classes:
|
|
register_class(cls)
|