diff --git a/material_maker/doc/user_interface_main_menu.rst b/material_maker/doc/user_interface_main_menu.rst index e1578e880..050dfe6b4 100644 --- a/material_maker/doc/user_interface_main_menu.rst +++ b/material_maker/doc/user_interface_main_menu.rst @@ -60,6 +60,10 @@ Edit menu * *Duplicate with inputs* is similar to *Duplicate*, but with input links kept +* *Detach Connections* detaches selection from existing connections + +* *Remove with Reconnect* is similar to *Detach Connections* but removes the selection + * *Select all* selects all nodes in the current graph view * *Select none* clears the selection in the current graph view @@ -146,4 +150,3 @@ Help menu do not hesitate to use it to suggest improvements for Material Maker. * *About* Shows the about dialog. - diff --git a/material_maker/icons/grab.svg b/material_maker/icons/grab.svg new file mode 100644 index 000000000..72665d45e --- /dev/null +++ b/material_maker/icons/grab.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/material_maker/icons/grab.svg.import b/material_maker/icons/grab.svg.import new file mode 100644 index 000000000..4db2aac9a --- /dev/null +++ b/material_maker/icons/grab.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://stiim75e0out" +path="res://.godot/imported/grab.svg-ddec24461dce91faf127dda78b6db9a7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://material_maker/icons/grab.svg" +dest_files=["res://.godot/imported/grab.svg-ddec24461dce91faf127dda78b6db9a7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=0.75 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/material_maker/main_window.gd b/material_maker/main_window.gd index abf9a8411..f4f2c17c5 100644 --- a/material_maker/main_window.gd +++ b/material_maker/main_window.gd @@ -73,6 +73,9 @@ const MENU : Array[Dictionary] = [ { menu="Edit/Duplicate", command="edit_duplicate", shortcut="Control+D" }, { menu="Edit/Duplicate with inputs", command="edit_duplicate_with_inputs", shortcut="Control+Shift+D" }, { menu="Edit/-" }, + { menu="Edit/Detach Connections", command="edit_detach_node_connections", shortcut="Control+Shift+C" }, + { menu="Edit/Remove with Reconnect", command="edit_remove_with_reconnections", shortcut="Control+Shift+X" }, + { menu="Edit/-" }, { menu="Edit/Select All", command="edit_select_all", shortcut="Control+A" }, { menu="Edit/Select None", command="edit_select_none", shortcut="Control+Shift+A" }, { menu="Edit/Invert Selection", command="edit_select_invert", shortcut="Control+I" }, @@ -774,6 +777,16 @@ func edit_duplicate_with_inputs() -> void: func edit_duplicate_with_inputs_is_disabled() -> bool: return edit_cut_is_disabled() +func edit_remove_with_reconnections() -> void: + var graph_edit : MMGraphEdit = get_current_graph_edit() + if graph_edit != null: + graph_edit.remove_with_reconnections() + +func edit_detach_node_connections() -> void: + var graph_edit : MMGraphEdit = get_current_graph_edit() + if graph_edit != null: + graph_edit.remove_with_reconnections(true) + func edit_select_all() -> void: var graph_edit : MMGraphEdit = get_current_graph_edit() if graph_edit != null: diff --git a/material_maker/nodes/reroute/reroute.gd b/material_maker/nodes/reroute/reroute.gd index 8009041e9..3bc8b7dd4 100644 --- a/material_maker/nodes/reroute/reroute.gd +++ b/material_maker/nodes/reroute/reroute.gd @@ -1,5 +1,6 @@ extends MMGraphNodeMinimal +class_name MMGraphReroute const PREVIEW_SIZES : Array[int] = [ 0, 64, 128, 192] diff --git a/material_maker/panels/graph_edit/graph_edit.gd b/material_maker/panels/graph_edit/graph_edit.gd index 3bb33367a..4787fc719 100644 --- a/material_maker/panels/graph_edit/graph_edit.gd +++ b/material_maker/panels/graph_edit/graph_edit.gd @@ -21,9 +21,27 @@ var need_save : bool = false var save_crash_recovery_path = "" var need_save_crash_recovery : bool = false + var top_generator = null var generator = null +var target_drop_connection : Dictionary +var target_drop_node : GraphNode + +@onready var grab_icon : Texture2D = preload("res://material_maker/icons/grab.svg") +var has_grab : bool = false: + set(v): + has_grab = v + if has_grab: + Input.set_custom_mouse_cursor( + grab_icon, Input.CURSOR_ARROW, + grab_icon.get_size() * 0.5) + else: + Input.set_custom_mouse_cursor(null) + if not target_drop_connection.is_empty(): + drop_node_on_connection(target_drop_node, target_drop_connection) + target_drop_connection.clear() + const PREVIEW_COUNT = 2 var current_preview : Array = [ null, null ] var locked_preview : Array = [ null, null ] @@ -109,6 +127,29 @@ func process_port_click(pressed : bool): port_click_port_index = -1 return + +func _input(event: InputEvent) -> void: + if has_grab: + # Handle node grab + var selected_nodes := get_selected_nodes() + if event is InputEventMouseMotion: + for node : GraphElement in selected_nodes: + if node is not MMGraphComment: + node.move_to_front() + node.position_offset += event.relative / zoom + elif (event is InputEventMouseButton + and event.button_index == MOUSE_BUTTON_LEFT): + accept_event() + has_grab = false + # Prevent unintended control activations on grab release + for node : GraphElement in selected_nodes: + if event.pressed: + node.grab_click_focus.call_deferred() + # keep selection on release in an empty area + if get_nodes_under_mouse().is_empty(): + node.set_deferred("selected", true) + + func _gui_input(event) -> void: if ( event.is_action_pressed("ui_library_popup") @@ -162,10 +203,26 @@ func _gui_input(event) -> void: return # Only popup the UI library if Ctrl is not pressed to avoid conflicting # with the Ctrl + Space shortcut. - node_popup.position = Vector2i(get_screen_transform()*get_local_mouse_position()) - node_popup.show_popup() + var closest_connection : Dictionary = get_closest_connection_at_point( + get_local_mouse_position(), connection_lines_thickness + 2.0) + if not closest_connection.is_empty(): + node_popup.target_connection = closest_connection + request_popup(closest_connection.from_node, closest_connection.from_port, + get_local_mouse_position(), false) + else: + node_popup.position = Vector2i(get_screen_transform()*get_local_mouse_position()) + node_popup.show_popup() else: if event.button_index == MOUSE_BUTTON_LEFT: + if not event.pressed: + if not target_drop_connection.is_empty() and not event.alt_pressed: + drop_node_on_connection(target_drop_node, target_drop_connection) + remove_theme_color_override("activity") + remove_theme_color_override("connection_hover_tint_color") + target_drop_connection.clear() + if event.pressed: + has_grab = false + if event.double_click: if get_nodes_under_mouse().is_empty(): on_ButtonUp_pressed() @@ -195,6 +252,22 @@ func _gui_input(event) -> void: KEY_DOWN: scroll_offset.y += 0.5*size.y accept_event() + KEY_ALT: + if (Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) + and has_graph_node_selection()): + hint_node_drop_allowed(false) + highlight_connection(target_drop_connection) + KEY_G: + if not get_selected_nodes().is_empty(): + has_grab = true + elif not event.pressed: + var scancode_with_modifiers = event.get_keycode_with_modifiers() + match scancode_with_modifiers: + KEY_ALT: + if has_graph_node_selection(): + hint_node_drop_allowed(true) + KEY_ESCAPE: + has_grab = false match event.get_keycode(): KEY_SHIFT, KEY_CTRL, KEY_ALT: var found_tip : bool = false @@ -221,6 +294,119 @@ func _gui_input(event) -> void: if rect.has_point(get_global_mouse_position()): mm_globals.set_tip_text("Space/#RMB: Nodes menu, Arrow keys: Pan, Mouse wheel: Zoom", 3) + handle_node_detach(event) + handle_node_drop(event) + + +## Detaches node(s) from connections if alt is held down +## while dragging a node. +## [br][br]Unsets [param target_drop_connection] +func handle_node_detach(event: InputEventMouseMotion) -> void: + if (event.alt_pressed and event.button_mask & MOUSE_BUTTON_MASK_LEFT != 0 + and get_selected_nodes().size() and event.relative.length() > 0.0): + remove_with_reconnections(true) + target_drop_connection.clear() + + +## Checks whether a node can be dropped onto a connection (Alt key blocks the action) +## and sets [param target_drop_connection] and [param target_drop_node] +## [br][br]Node connections are performed by [method drop_node_on_connection] +func handle_node_drop(event: InputEventMouseMotion) -> void: + var single_node_selected := get_selected_nodes().size() == 1 + if (single_node_selected and event.button_mask & MOUSE_BUTTON_MASK_LEFT != 0 + and event.relative.length() > 0.0) or (has_grab and single_node_selected): + hint_node_drop_allowed(not event.alt_pressed) + var node : GraphElement = get_selected_nodes()[0] + if node is not GraphNode: + return + target_drop_node = node + for c in get_connection_list(): + if node.name in c.values(): + return + var active_conn : Dictionary + var node_rect : Rect2 = node.get_rect() + if node is MMGraphReroute: + node_rect.size.y = node_rect.size.x + node_rect.position.y += node_rect.size.y - 5.0 + var conns := get_connections_intersecting_with_rect(node_rect) + if not conns.is_empty(): + if conns.size() > 1: + conns.sort_custom(compare_connection_by_port_height) + active_conn = conns.front() + highlight_connection(active_conn) + if not event.alt_pressed: + target_drop_connection = active_conn + else: + target_drop_connection.clear() + for c in get_connection_list(): + if c != active_conn: + highlight_connection(c, 0.0) + else: + remove_theme_color_override("activity") + remove_theme_color_override("connection_hover_tint_color") + + +func compare_connection_by_port_height(a, b) -> bool: + var target := target_drop_node + if not target: + return false + var from_node : GraphNode = get_node(NodePath(a.from_node)) + var to_node : GraphNode = get_node(NodePath(a.to_node)) + var from_node_dist_to_target := ( + target.position_offset.distance_squared_to(from_node.position_offset)) + var to_node_dist_to_target := ( + target.position_offset.distance_squared_to(to_node.position_offset)) + if from_node_dist_to_target < to_node_dist_to_target: + var upper : GraphNode = get_node(NodePath(a.from_node)) + var lower : GraphNode = get_node(NodePath(b.from_node)) + var upper_slot_pos := upper.get_output_port_position(a.from_port) + upper.position_offset + var lower_slot_pos := lower.get_output_port_position(b.from_port) + lower.position_offset + return upper_slot_pos.y < lower_slot_pos.y + else: + var upper : GraphNode = get_node(NodePath(a.to_node)) + var lower : GraphNode = get_node(NodePath(b.to_node)) + var upper_slot_pos := upper.get_input_port_position(a.to_port) + upper.position_offset + var lower_slot_pos := lower.get_input_port_position(b.to_port) + lower.position_offset + return upper_slot_pos.y < lower_slot_pos.y + + +func drop_node_on_connection(node : GraphNode, connection : Dictionary) -> void: + undoredo.start_group() + if node != null: + on_disconnect_node( + connection.from_node, connection.from_port, + connection.to_node, connection.to_port) + for new_slot in node.get_input_port_count(): + var slot_type : int = node.get_input_port_type(new_slot) + var from_node = get_node(NodePath(connection.from_node)) + var from_slot = from_node.get_output_port_type(connection.from_port) + if (from_slot == slot_type or slot_type == 42 or from_slot == 42): + on_connect_node(connection.from_node, + connection.from_port, node.name, new_slot) + break + for new_slot in node.get_output_port_count(): + var slot_type : int = node.get_output_port_type(new_slot) + var to_node = get_node(NodePath(connection.to_node)) + var to_slot = to_node.get_input_port_type(connection.to_port) + if (to_slot == slot_type or slot_type == 42 or to_slot == 42): + on_connect_node(node.name, new_slot, + connection.to_node, connection.to_port) + break + undoredo.end_group() + + +func hint_node_drop_allowed(should_allow: bool) -> void: + add_theme_color_override("activity", Color(1.5,1.5,1.5,1.0) if should_allow else Color.BLACK) + add_theme_color_override("connection_hover_tint_color", Color.TRANSPARENT) + get_node("_connection_layer").queue_redraw() + + +func highlight_connection(connection: Dictionary, amount: float = 0.5) -> void: + if not connection.is_empty(): + set_connection_activity(connection.from_node, connection.from_port, + connection.to_node, connection.to_port, amount) + + func get_padded_node_rect(graph_node:GraphNode) -> Rect2: var rect : Rect2 = graph_node.get_global_rect() var padding := 8 * graph_node.get_global_transform().get_scale().x @@ -230,6 +416,10 @@ func get_padded_node_rect(graph_node:GraphNode) -> Rect2: # Misc. useful functions + +func has_graph_node_selection() -> bool: + return get_selected_nodes().filter(func(n): return n is GraphNode).size() + func get_source(node, port) -> Dictionary: for c in get_connection_list(): if c.to_node == node and c.to_port == port: @@ -764,6 +954,46 @@ func duplicate_selected() -> void: func duplicate_selected_with_inputs() -> void: do_paste(serialize_selection([], true)) + +## Detaches a node from an existing connection. +## Nodes are kept if [param keep_nodes] is set +func remove_with_reconnections(keep_nodes: bool = false) -> void: + var selection := get_selected_nodes() + var from_node : GraphNode + var to_node : GraphNode + var from_slot : int = -1 + var to_slot : int = -1 + undoredo.start_group() + for node in selection: + if node is not GraphNode: + node.selected = false + var connection_list : Array[Dictionary] = get_connection_list() + connection_list.sort_custom(func(a, b): return a.to_port < b.to_port) + for c in connection_list: + var from : GraphNode = get_node(NodePath(c.from_node)) + var to : GraphNode = get_node(NodePath(c.to_node)) + if from == node or to == node: + on_disconnect_node(c.from_node, c.from_port, c.to_node, c.to_port) + if from == node: + to_node = to + to_slot = c.to_port + if to == node: + from_node = from + from_slot = c.from_port + if from_node and to_node and from_slot != -1 and to_slot != -1: + var from_slot_type := from_node.get_output_port_type(from_slot) + var to_slot_type := to_node.get_input_port_type(to_slot) + if (from_slot_type == to_slot_type + or from_slot_type == 42 or to_slot_type == 42): + on_connect_node(from_node.name, from_slot, to_node.name, to_slot) + from_node = null + to_node = null + from_slot = -1 + to_slot = -1 + if not keep_nodes: + remove_selection() + undoredo.end_group() + func select_all() -> void: for c in get_children(): if c is GraphElement: @@ -864,7 +1094,9 @@ func highlight_connections() -> void: while Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT): await get_tree().process_frame for c in get_connection_list(): - set_connection_activity(c.from_node, c.from_port, c.to_node, c.to_port, 1.0 if get_node(NodePath(c.from_node)).selected or get_node(NodePath(c.to_node)).selected else 0.0) + set_connection_activity(c.from_node, c.from_port, c.to_node, c.to_port, + 1.0 if get_node(NodePath(c.from_node)).selected + or get_node(NodePath(c.to_node)).selected else 0.0) highlighting_connections = false func _on_GraphEdit_node_selected(node : GraphElement) -> void: diff --git a/material_maker/windows/add_node_popup/add_node_popup.gd b/material_maker/windows/add_node_popup/add_node_popup.gd index 114fa6b5d..9d99ca018 100644 --- a/material_maker/windows/add_node_popup/add_node_popup.gd +++ b/material_maker/windows/add_node_popup/add_node_popup.gd @@ -11,6 +11,8 @@ var qc_slot : int var qc_slot_type : int var qc_is_output : bool +var target_connection : Dictionary + @onready var library_manager = get_node("/root/MainWindow/NodeLibraryManager") @@ -26,38 +28,71 @@ func _ready() -> void: %Filter.get_menu().about_to_popup.connect( _context_menu_about_to_popup.bind(%Filter.get_menu())) + func _context_menu_about_to_popup(context_menu : PopupMenu) -> void: context_menu.position = get_window().position + Vector2i( get_mouse_position() * get_window().content_scale_factor) + func filter_entered(_filter) -> void: _on_list_item_activated(0) -func add_node(node_data) -> void: + +func add_node_to_connection(node_data) -> void: var current_graph : GraphEdit = get_current_graph() current_graph.undoredo.start_group() - var nodes : Array = await current_graph.create_nodes(node_data, insert_position) - if not nodes.is_empty(): - var node : GraphNode = nodes[0] as GraphNode - if node != null: - if qc_node != "": # dragged from port - var port_position : Vector2 - if qc_is_output: - for new_slot in node.get_output_port_count(): - var slot_type : int = node.get_output_port_type(new_slot) - if qc_slot_type == slot_type or slot_type == 42 or qc_slot_type == 42: - current_graph.on_connect_node(node.name, new_slot, qc_node, qc_slot) - port_position = node.get_output_port_position(new_slot) - break - else: - for new_slot in node.get_input_port_count(): - var slot_type : int = node.get_input_port_type(new_slot) - if qc_slot_type == slot_type or slot_type == 42 or qc_slot_type == 42: - current_graph.on_connect_node(qc_node, qc_slot, node.name, new_slot) - port_position = node.get_input_port_position(new_slot) - break - node.position_offset -= port_position/current_graph.zoom - current_graph.undoredo.end_group() + var nodes : Array = current_graph.create_nodes(node_data, insert_position) + var node : GraphNode = nodes[0] + if node != null: + current_graph.on_disconnect_node( + target_connection.from_node, target_connection.from_port, + target_connection.to_node, target_connection.to_port) + for new_slot in node.get_input_port_count(): + var slot_type : int = node.get_input_port_type(new_slot) + var from_node = current_graph.get_node(NodePath(target_connection.from_node)) + var from_slot = from_node.get_output_port_type(target_connection.from_port) + if (from_slot == slot_type or slot_type == 42 or from_slot == 42): + current_graph.on_connect_node(target_connection.from_node, + target_connection.from_port, node.name, new_slot) + break + for new_slot in node.get_output_port_count(): + var slot_type : int = node.get_output_port_type(new_slot) + var to_node = current_graph.get_node(NodePath(target_connection.to_node)) + var to_slot = to_node.get_input_port_type(target_connection.to_port) + if (to_slot == slot_type or slot_type == 42 or to_slot == 42): + current_graph.on_connect_node(node.name, new_slot, + target_connection.to_node, target_connection.to_port) + break + current_graph.undoredo.end_group() + +func add_node(node_data) -> void: + var current_graph : GraphEdit = get_current_graph() + if not target_connection.is_empty(): + add_node_to_connection(node_data) + else: + current_graph.undoredo.start_group() + var nodes : Array = current_graph.create_nodes(node_data, insert_position) + if not nodes.is_empty(): + var node : GraphNode = nodes[0] as GraphNode + if node != null: + if qc_node != "": # dragged from port + var port_position : Vector2 + if qc_is_output: + for new_slot in node.get_output_port_count(): + var slot_type : int = node.get_output_port_type(new_slot) + if qc_slot_type == slot_type or slot_type == 42 or qc_slot_type == 42: + current_graph.on_connect_node(node.name, new_slot, qc_node, qc_slot) + port_position = node.get_output_port_position(new_slot) + break + else: + for new_slot in node.get_input_port_count(): + var slot_type : int = node.get_input_port_type(new_slot) + if qc_slot_type == slot_type or slot_type == 42 or qc_slot_type == 42: + current_graph.on_connect_node(qc_node, qc_slot, node.name, new_slot) + port_position = node.get_input_port_position(new_slot) + break + node.position_offset -= port_position/current_graph.zoom + current_graph.undoredo.end_group() get_node("/root/MainWindow/NodeLibraryManager").item_created(node_data.tree_item) todo_renamed_hide() @@ -232,3 +267,7 @@ func _on_list_item_activated(index: int) -> void: if not data == null: add_node(data.item) todo_renamed_hide() + + +func _on_popup_hide() -> void: + target_connection.clear() diff --git a/material_maker/windows/add_node_popup/add_node_popup.tscn b/material_maker/windows/add_node_popup/add_node_popup.tscn index f1b9d1eb8..6d61664d2 100644 --- a/material_maker/windows/add_node_popup/add_node_popup.tscn +++ b/material_maker/windows/add_node_popup/add_node_popup.tscn @@ -216,6 +216,7 @@ theme_type_variation = &"MM_AddNodePanelList" same_column_width = true fixed_icon_size = Vector2i(18, 18) +[connection signal="popup_hide" from="." to="." method="_on_popup_hide"] [connection signal="object_selected" from="PanelContainer/VBoxContainer/Buttons/Button1" to="." method="add_node"] [connection signal="object_selected" from="PanelContainer/VBoxContainer/Buttons/Button2" to="." method="add_node"] [connection signal="object_selected" from="PanelContainer/VBoxContainer/Buttons/Button3" to="." method="add_node"]