From f025637e3b45700e22b3ae8677751957a5ef7e5a Mon Sep 17 00:00:00 2001 From: Jacques Lucke Date: Mon, 29 Sep 2025 13:58:27 +0200 Subject: [PATCH] Nodes: new operator to join Group Input nodes This adds a new operator which can join multiple nodes together. Currently, it only supports joining Group Input nodes. However, in the future it could be extended to join e.g. Bake and Capture Attribute nodes. This uses the recently freed up ctrl+J shortcut for this functionality, which feels natural to me. The implementation is fairly straight forward. The main tricky aspect is sometimes the nodes can't be joined when that would result in two sockets being linked to each other twice. In this case, the a separate Group Input node is kept. The selected nodes are merged into the active node (in case the active node is part of the selection, otherwise there is a fallback). Pull Request: https://projects.blender.org/blender/blender/pulls/146894 --- .../keyconfig/keymap_data/blender_default.py | 1 + scripts/startup/bl_ui/space_node.py | 1 + .../blender/editors/space_node/node_intern.hh | 1 + source/blender/editors/space_node/node_ops.cc | 1 + .../editors/space_node/node_relationships.cc | 124 +++++++++++++++++- 5 files changed, 121 insertions(+), 7 deletions(-) diff --git a/scripts/presets/keyconfig/keymap_data/blender_default.py b/scripts/presets/keyconfig/keymap_data/blender_default.py index 277edcf85d9..c94f119714e 100644 --- a/scripts/presets/keyconfig/keymap_data/blender_default.py +++ b/scripts/presets/keyconfig/keymap_data/blender_default.py @@ -2230,6 +2230,7 @@ def km_node_editor(params): {"properties": [("replace", False)]}), ("node.link_make", {"type": 'J', "value": 'PRESS', "shift": True}, {"properties": [("replace", True)]}), + ("node.join_nodes", {"type": 'J', "value": 'PRESS', "ctrl": True}, None), op_menu("NODE_MT_add", {"type": 'A', "value": 'PRESS', "shift": True}), op_menu("NODE_MT_swap", {"type": 'S', "value": 'PRESS', "shift": True}), ("node.duplicate_move", {"type": 'D', "value": 'PRESS', "shift": True}, diff --git a/scripts/startup/bl_ui/space_node.py b/scripts/startup/bl_ui/space_node.py index b028b0fe437..398bb067435 100644 --- a/scripts/startup/bl_ui/space_node.py +++ b/scripts/startup/bl_ui/space_node.py @@ -440,6 +440,7 @@ class NODE_MT_node(Menu): layout.separator() layout.operator("node.join", text="Join in New Frame") layout.operator("node.detach", text="Remove from Frame") + layout.operator("node.join_nodes", text="Join Group Inputs") layout.separator() props = layout.operator("wm.call_panel", text="Rename...") diff --git a/source/blender/editors/space_node/node_intern.hh b/source/blender/editors/space_node/node_intern.hh index a13d2d0c664..6cb295eddda 100644 --- a/source/blender/editors/space_node/node_intern.hh +++ b/source/blender/editors/space_node/node_intern.hh @@ -341,6 +341,7 @@ void NODE_OT_parent_set(wmOperatorType *ot); void NODE_OT_join(wmOperatorType *ot); void NODE_OT_attach(wmOperatorType *ot); void NODE_OT_detach(wmOperatorType *ot); +void NODE_OT_join_nodes(wmOperatorType *ot); void NODE_OT_link_viewer(wmOperatorType *ot); diff --git a/source/blender/editors/space_node/node_ops.cc b/source/blender/editors/space_node/node_ops.cc index 7c27b98fa57..70cdb44fa62 100644 --- a/source/blender/editors/space_node/node_ops.cc +++ b/source/blender/editors/space_node/node_ops.cc @@ -101,6 +101,7 @@ void node_operatortypes() WM_operatortype_append(NODE_OT_join); WM_operatortype_append(NODE_OT_attach); WM_operatortype_append(NODE_OT_detach); + WM_operatortype_append(NODE_OT_join_nodes); WM_operatortype_append(NODE_OT_clipboard_copy); WM_operatortype_append(NODE_OT_clipboard_paste); diff --git a/source/blender/editors/space_node/node_relationships.cc b/source/blender/editors/space_node/node_relationships.cc index c01f3d8e645..9dc702d7297 100644 --- a/source/blender/editors/space_node/node_relationships.cc +++ b/source/blender/editors/space_node/node_relationships.cc @@ -2095,7 +2095,7 @@ void NODE_OT_parent_set(wmOperatorType *ot) /** \} */ /* -------------------------------------------------------------------- */ -/** \name Join Nodes Operator +/** \name Join Nodes in Frame Operator * \{ */ struct NodeJoinState { @@ -2176,7 +2176,7 @@ static const bNode *find_common_parent_node(const Span nodes) return candidates.last(); } -static wmOperatorStatus node_join_exec(bContext *C, wmOperator * /*op*/) +static wmOperatorStatus node_join_in_frame_exec(bContext *C, wmOperator * /*op*/) { Main &bmain = *CTX_data_main(C); SpaceNode &snode = *CTX_wm_space_node(C); @@ -2205,7 +2205,9 @@ static wmOperatorStatus node_join_exec(bContext *C, wmOperator * /*op*/) return OPERATOR_FINISHED; } -static wmOperatorStatus node_join_invoke(bContext *C, wmOperator *op, const wmEvent *event) +static wmOperatorStatus node_join_in_frame_invoke(bContext *C, + wmOperator *op, + const wmEvent *event) { ARegion *region = CTX_wm_region(C); SpaceNode *snode = CTX_wm_space_node(C); @@ -2220,19 +2222,19 @@ static wmOperatorStatus node_join_invoke(bContext *C, wmOperator *op, const wmEv snode->runtime->cursor[0] /= UI_SCALE_FAC; snode->runtime->cursor[1] /= UI_SCALE_FAC; - return node_join_exec(C, op); + return node_join_in_frame_exec(C, op); } void NODE_OT_join(wmOperatorType *ot) { /* identifiers */ - ot->name = "Join Nodes"; + ot->name = "Join Nodes in Frame"; ot->description = "Attach selected nodes to a new common frame"; ot->idname = "NODE_OT_join"; /* API callbacks. */ - ot->exec = node_join_exec; - ot->invoke = node_join_invoke; + ot->exec = node_join_in_frame_exec; + ot->invoke = node_join_in_frame_invoke; ot->poll = ED_operator_node_editable; /* flags */ @@ -2241,6 +2243,114 @@ void NODE_OT_join(wmOperatorType *ot) /** \} */ +/* -------------------------------------------------------------------- */ +/** \name Join Nodes Operator + * \{ */ + +static void join_group_inputs(bNodeTree &tree, VectorSet group_inputs, bNode *active_node) +{ + bNode *main_node = nullptr; + if (group_inputs.contains(active_node)) { + main_node = active_node; + } + else { + main_node = group_inputs[0]; + /* Move main node to average of all group inputs. */ + float2 location{}; + for (const bNode *node : group_inputs) { + location += node->location; + } + location /= float(group_inputs.size()); + copy_v2_v2(main_node->location, location); + } + tree.ensure_topology_cache(); + MultiValueMap old_link_map; + for (bNode *node : group_inputs) { + for (bNodeSocket *socket : node->output_sockets().drop_back(1)) { + old_link_map.add_multiple(socket, socket->directly_linked_links()); + } + } + MultiValueMap used_link_targets; + for (bNodeSocket *socket : main_node->output_sockets()) { + used_link_targets.add_multiple(socket, socket->directly_linked_sockets()); + } + for (bNode *node : group_inputs) { + if (node == main_node) { + continue; + } + bool keep_node = false; + + /* Using runtime data directly because we know the parts that are used are still valid. */ + for (const int group_input_i : node->runtime->outputs.index_range().drop_back(1)) { + bool keep_socket = false; + bNodeSocket &new_socket = *main_node->runtime->outputs[group_input_i]; + bNodeSocket &old_socket = *node->runtime->outputs[group_input_i]; + for (bNodeLink *link : old_link_map.lookup(&old_socket)) { + bNodeSocket &to_socket = *link->tosock; + if (used_link_targets.lookup(&new_socket).contains(&to_socket)) { + keep_node = true; + keep_socket = true; + continue; + } + used_link_targets.add(&new_socket, &to_socket); + link->fromsock = &new_socket; + link->fromnode = main_node; + new_socket.flag &= ~SOCK_HIDDEN; + BKE_ntree_update_tag_link_changed(&tree); + } + if (!keep_socket) { + old_socket.flag |= SOCK_HIDDEN; + } + } + if (!keep_node) { + bke::node_free_node(&tree, *node); + } + } +} + +static wmOperatorStatus node_join_nodes_exec(bContext *C, wmOperator *op) +{ + Main &bmain = *CTX_data_main(C); + SpaceNode &snode = *CTX_wm_space_node(C); + bNodeTree &ntree = *snode.edittree; + + bNode *active_node = bke::node_get_active(ntree); + VectorSet selected_nodes = get_selected_nodes(ntree); + if (selected_nodes.size() <= 1) { + return OPERATOR_CANCELLED; + } + + if (std::all_of(selected_nodes.begin(), selected_nodes.end(), [](const bNode *node) { + return node->is_group_input(); + })) + { + join_group_inputs(ntree, std::move(selected_nodes), active_node); + } + else { + BKE_report(op->reports, RPT_ERROR, "Selected nodes can't be joined"); + return OPERATOR_CANCELLED; + } + + BKE_main_ensure_invariants(bmain, snode.edittree->id); + WM_event_add_notifier(C, NC_NODE | ND_DISPLAY, nullptr); + + return OPERATOR_FINISHED; +} + +void NODE_OT_join_nodes(wmOperatorType *ot) +{ + ot->name = "Join Nodes"; + ot->description = "Merge selected group input nodes into one if possible"; + ot->idname = "NODE_OT_join_nodes"; + + ot->exec = node_join_nodes_exec; + ot->poll = ED_operator_node_editable; + + ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO; +} + +/** \} */ + /* -------------------------------------------------------------------- */ /** \name Attach Operator * \{ */