From f9788c69ee5e5297e66ab89484c09f9ab9360e2d Mon Sep 17 00:00:00 2001 From: GeneralProtectionFault Date: Sun, 20 Sep 2026 13:05:03 -0400 Subject: [PATCH] Add multi-select/move to 2D polygons --- .../scene/2d/abstract_polygon_2d_editor.cpp | 249 ++++++++++++++++-- editor/scene/2d/abstract_polygon_2d_editor.h | 28 ++ 2 files changed, 261 insertions(+), 16 deletions(-) diff --git a/editor/scene/2d/abstract_polygon_2d_editor.cpp b/editor/scene/2d/abstract_polygon_2d_editor.cpp index 42430e8ccf8..f07d5b385e4 100644 --- a/editor/scene/2d/abstract_polygon_2d_editor.cpp +++ b/editor/scene/2d/abstract_polygon_2d_editor.cpp @@ -391,53 +391,101 @@ bool AbstractPolygon2DEditor::forward_gui_input(const Ref &p_event) if (mode == MODE_EDIT || (_is_line() && mode == MODE_CREATE)) { if (mb->get_button_index() == MouseButton::LEFT) { if (mb->is_pressed()) { - if (mb->is_meta_pressed() || mb->is_ctrl_pressed() || mb->is_shift_pressed() || mb->is_alt_pressed()) { + if (mb->is_meta_pressed() || mb->is_alt_pressed()) { return false; } + bool ctrl_or_cmd = mb->is_ctrl_pressed(); + bool shift = mb->is_shift_pressed(); + const PosVertex closest = closest_point(gpoint); if (closest.valid()) { + if (ctrl_or_cmd && selected_points.has(closest)) { + // Ctrl-click on an already-selected point removes it. + selected_points.erase(closest); + canvas_item_editor->update_viewport(); + return true; + } + if (!selected_points.has(closest)) { + _select_point(closest, ctrl_or_cmd, false); + } + original_mouse_pos = gpoint; pre_move_edit = _get_polygon(closest.polygon); edited_point = PosVertex(closest, xform.affine_inverse().xform(closest.pos)); selected_point = closest; edge_point = PosVertex(); + + multi_move_start_positions.clear(); + if (selected_points.size() > 1) { + pre_move_edit = _get_polygon(closest.polygon); // already set above; reuse it as the "before" snapshot + for (const Vertex &v : selected_points) { + multi_move_start_positions[v] = pre_move_edit[v.vertex]; + } + } + canvas_item_editor->update_viewport(); return true; - } else { + } else if (!ctrl_or_cmd) { selected_point = Vertex(); const PosVertex insert = closest_edge_point(gpoint); if (insert.valid()) { Vector vertices = _get_polygon(insert.polygon); + Vertex new_selection; if (vertices.size() < (_is_line() ? 2 : 3)) { vertices.push_back(cpoint); undo_redo->create_action(TTR("Edit Polygon")); - selected_point = Vertex(insert.polygon, vertices.size()); - _action_set_polygon(insert.polygon, vertices); - _commit_action(); - return true; + new_selection = Vertex(insert.polygon, vertices.size() - 1); } else { edited_point = PosVertex(insert.polygon, insert.vertex + 1, xform.affine_inverse().xform(insert.pos)); vertices.insert(edited_point.vertex, edited_point.pos); pre_move_edit = vertices; - selected_point = Vertex(edited_point.polygon, edited_point.vertex); + new_selection = Vertex(edited_point.polygon, edited_point.vertex); edge_point = PosVertex(); undo_redo->create_action(TTR("Insert Point")); - _action_set_polygon(insert.polygon, vertices); - _commit_action(); - return true; } + + _action_set_polygon(insert.polygon, vertices); + _commit_action(); + + selected_point = new_selection; + selected_points.clear(); + selected_points.insert(selected_point); + + return true; } + + // Hit nothing at all (no point, no edge): start box-selection. + box_selecting = true; + box_select_from = gpoint; + box_select_to = gpoint; + box_select_additive = shift; + if (!box_select_additive) { + selected_points.clear(); + } + canvas_item_editor->update_viewport(); + return true; } } else { if (edited_point.valid()) { if (original_mouse_pos != gpoint) { Vector vertices = _get_polygon(edited_point.polygon); ERR_FAIL_INDEX_V(edited_point.vertex, vertices.size(), false); - vertices.write[edited_point.vertex] = edited_point.pos - _get_offset(edited_point.polygon); + + Vector2 final_pos = edited_point.pos - _get_offset(edited_point.polygon); + + if (multi_move_start_positions.size() > 1) { + Vector2 delta = final_pos - multi_move_start_positions[selected_point]; + for (const KeyValue &kv : multi_move_start_positions) { + ERR_FAIL_INDEX_V(kv.key.vertex, vertices.size(), false); + vertices.write[kv.key.vertex] = kv.value + delta; + } + } else { + vertices.write[edited_point.vertex] = final_pos; + } undo_redo->create_action(TTR("Edit Polygon")); _action_set_polygon(edited_point.polygon, pre_move_edit, vertices); @@ -445,12 +493,26 @@ bool AbstractPolygon2DEditor::forward_gui_input(const Ref &p_event) } edited_point = PosVertex(); + multi_move_start_positions.clear(); + return true; + } + if (box_selecting) { + box_selecting = false; + _box_select_confirm(box_select_additive); + canvas_item_editor->update_viewport(); return true; } } } else if (mb->get_button_index() == MouseButton::RIGHT && mb->is_pressed() && !edited_point.valid()) { const PosVertex closest = closest_point(gpoint); + if (closest.valid()) { + remove_point(closest); + return true; + } + } else if (mb->get_button_index() == MouseButton::RIGHT && mb->is_pressed() && !edited_point.valid()) { + const PosVertex closest = closest_point(gpoint); + if (closest.valid()) { remove_point(closest); return true; @@ -545,6 +607,12 @@ bool AbstractPolygon2DEditor::forward_gui_input(const Ref &p_event) if (mm.is_valid()) { Vector2 gpoint = mm->get_position(); + if (box_selecting) { + box_select_to = gpoint; + canvas_item_editor->update_viewport(); + return true; + } + if (center_drag) { Vector2 cpoint = canvas_item_editor->snap_point(canvas_item_editor->get_canvas_transform().affine_inverse().xform(gpoint)); cpoint = _get_node()->get_screen_transform().affine_inverse().xform(cpoint); @@ -580,10 +648,20 @@ bool AbstractPolygon2DEditor::forward_gui_input(const Ref &p_event) edited_point = PosVertex(edited_point, cpoint); if (!wip_active) { - Vector vertices = _get_polygon(edited_point.polygon); - ERR_FAIL_INDEX_V(edited_point.vertex, vertices.size(), false); - vertices.write[edited_point.vertex] = cpoint - _get_offset(edited_point.polygon); - _set_polygon(edited_point.polygon, vertices); + if (multi_move_start_positions.size() > 1) { + Vector2 delta = (cpoint - _get_offset(edited_point.polygon)) - multi_move_start_positions[selected_point]; + Vector vertices = _get_polygon(edited_point.polygon); + for (const KeyValue &kv : multi_move_start_positions) { + ERR_FAIL_INDEX_V(kv.key.vertex, vertices.size(), false); + vertices.write[kv.key.vertex] = kv.value + delta; + } + _set_polygon(edited_point.polygon, vertices); + } else { + Vector vertices = _get_polygon(edited_point.polygon); + ERR_FAIL_INDEX_V(edited_point.vertex, vertices.size(), false); + vertices.write[edited_point.vertex] = cpoint - _get_offset(edited_point.polygon); + _set_polygon(edited_point.polygon, vertices); + } } canvas_item_editor->update_viewport(); @@ -615,8 +693,15 @@ bool AbstractPolygon2DEditor::forward_gui_input(const Ref &p_event) Ref k = p_event; + // Key handling if (k.is_valid() && k->is_pressed()) { + // Point deletion if (k->get_keycode() == Key::KEY_DELETE || k->get_keycode() == Key::BACKSPACE) { + // Delete multiple points if multiple are selected + if (selected_points.size() > 1) { + _delete_selection(); + return true; + } if (wip_active && selected_point.polygon == -1) { if (wip.size() > selected_point.vertex) { wip.remove_at(selected_point.vertex); @@ -753,7 +838,7 @@ void AbstractPolygon2DEditor::forward_canvas_draw_over_viewport(Control *p_overl const Vector2 p = (vertex == edited_point) ? edited_point.pos : (points[i] + offset); const Vector2 point = xform.xform(p); - const Color overlay_modulate = vertex == active_point ? Color(0.4, 1, 1) : Color(1, 1, 1); + const Color overlay_modulate = (vertex == active_point || selected_points.has(vertex)) ? Color(0.4, 1, 1) : Color(1, 1, 1); p_overlay->draw_texture(handle, point - handle->get_size() * 0.5, overlay_modulate); if (vertex == hover_point) { @@ -769,6 +854,19 @@ void AbstractPolygon2DEditor::forward_canvas_draw_over_viewport(Control *p_overl Ref add_handle = get_editor_theme_icon(SNAME("EditorHandleAdd")); p_overlay->draw_texture(add_handle, edge_point.pos - add_handle->get_size() * 0.5); } + + if (box_selecting) { + Point2 bsfrom = box_select_from; + Point2 bsto = box_select_to; + if (bsfrom.x > bsto.x) { + SWAP(bsfrom.x, bsto.x); + } + if (bsfrom.y > bsto.y) { + SWAP(bsfrom.y, bsto.y); + } + p_overlay->draw_rect(Rect2(bsfrom, bsto - bsfrom), get_theme_color(SNAME("box_selection_fill_color"), EditorStringName(Editor))); + p_overlay->draw_rect(Rect2(bsfrom, bsto - bsfrom), get_theme_color(SNAME("box_selection_stroke_color"), EditorStringName(Editor)), false, Math::round(EDSCALE)); + } } void AbstractPolygon2DEditor::set_edit_origin_and_center(bool p_enabled) { @@ -783,6 +881,8 @@ void AbstractPolygon2DEditor::edit(Node *p_polygon) { canvas_item_editor = CanvasItemEditor::get_singleton(); } + _clear_selection(); + if (p_polygon) { _set_node(p_polygon); @@ -810,6 +910,7 @@ void AbstractPolygon2DEditor::remove_point(const Vertex &p_vertex) { EditorUndoRedoManager *undo_redo = EditorUndoRedoManager::get_singleton(); Vector vertices = _get_polygon(p_vertex.polygon); + bool whole_polygon_removed = false; if (vertices.size() > (_is_line() ? 2 : 3)) { vertices.remove_at(p_vertex.vertex); @@ -817,6 +918,7 @@ void AbstractPolygon2DEditor::remove_point(const Vertex &p_vertex) { _action_set_polygon(p_vertex.polygon, vertices); _commit_action(); } else { + whole_polygon_removed = true; undo_redo->create_action(TTR("Remove Polygon And Point")); _action_remove_polygon(p_vertex.polygon); _commit_action(); @@ -830,6 +932,22 @@ void AbstractPolygon2DEditor::remove_point(const Vertex &p_vertex) { if (selected_point == p_vertex) { selected_point = Vertex(); } + + // Re-index (or drop) multi-selected vertices affected by this removal, + // since remove_at() shifts every subsequent vertex's index down by one. + HashSet updated_selection; + for (const Vertex &v : selected_points) { + if (v.polygon != p_vertex.polygon) { + updated_selection.insert(v); + } else if (whole_polygon_removed || v == p_vertex) { + continue; // Drop: it no longer exists. + } else if (v.vertex > p_vertex.vertex) { + updated_selection.insert(Vertex(v.polygon, v.vertex - 1)); + } else { + updated_selection.insert(v); + } + } + selected_points = updated_selection; } AbstractPolygon2DEditor::Vertex AbstractPolygon2DEditor::get_active_point() const { @@ -902,6 +1020,101 @@ AbstractPolygon2DEditor::PosVertex AbstractPolygon2DEditor::closest_edge_point(c return closest; } +void AbstractPolygon2DEditor::_select_point(const Vertex &p_vertex, bool p_add_to_selection, bool p_toggle) { + if (!p_add_to_selection) { + selected_points.clear(); + } + if (p_toggle && selected_points.has(p_vertex)) { + selected_points.erase(p_vertex); + } else { + selected_points.insert(p_vertex); + } + canvas_item_editor->update_viewport(); +} + +void AbstractPolygon2DEditor::_box_select_confirm(bool p_additive) { + Transform2D xform = canvas_item_editor->get_canvas_transform() * _get_node()->get_screen_transform(); + + Point2 bsfrom = box_select_from; + Point2 bsto = box_select_to; + if (bsfrom.x > bsto.x) { + SWAP(bsfrom.x, bsto.x); + } + if (bsfrom.y > bsto.y) { + SWAP(bsfrom.y, bsto.y); + } + Rect2 rect(bsfrom, bsto - bsfrom); + + if (!p_additive) { + selected_points.clear(); + } + + int n_polygons = _get_polygon_count(); + for (int j = 0; j < n_polygons; j++) { + Vector points = _get_polygon(j); + Vector2 offset = _get_offset(j); + for (int i = 0; i < points.size(); i++) { + if (rect.has_point(xform.xform(points[i] + offset))) { + selected_points.insert(Vertex(j, i)); + } + } + } + canvas_item_editor->update_viewport(); +} + +void AbstractPolygon2DEditor::_clear_selection() { + selected_points.clear(); + multi_move_start_positions.clear(); + box_selecting = false; +} + +void AbstractPolygon2DEditor::_clear_point_selection() { + selected_points.clear(); +} + +void AbstractPolygon2DEditor::_delete_selection() { + if (selected_points.is_empty()) { + return; + } + + EditorUndoRedoManager *undo_redo = EditorUndoRedoManager::get_singleton(); + + int polygon_idx = selected_points.begin()->polygon; + + Vector vertex_indices; + for (const Vertex &v : selected_points) { + vertex_indices.push_back(v.vertex); + } + vertex_indices.sort(); + + Vector before = _get_polygon(polygon_idx); + Vector after = before; + + for (int i = vertex_indices.size() - 1; i >= 0; i--) { + after.remove_at(vertex_indices[i]); + } + + undo_redo->create_action(TTR("Delete Points from Polygon")); + + if (after.size() < (_is_line() ? 2 : 3)) { + _action_remove_polygon(polygon_idx); + } else { + _action_set_polygon(polygon_idx, before, after); + } + + undo_redo->add_do_method(this, "_clear_point_selection"); + undo_redo->add_undo_method(this, "_clear_point_selection"); + + _commit_action(); + + hover_point = Vertex(); + selected_point = Vertex(); + + if (_is_empty()) { + _menu_option(MODE_CREATE); + } +} + AbstractPolygon2DEditor::AbstractPolygon2DEditor(bool p_wip_destructive) { edited_point = PosVertex(); center_drag = false; @@ -968,3 +1181,7 @@ AbstractPolygon2DEditorPlugin::AbstractPolygon2DEditorPlugin(AbstractPolygon2DEd CanvasItemEditor::get_singleton()->add_control_to_menu_panel(polygon_editor); polygon_editor->hide(); } + +void AbstractPolygon2DEditor::_bind_methods() { + ClassDB::bind_method(D_METHOD("_clear_point_selection"), &AbstractPolygon2DEditor::_clear_point_selection); +} diff --git a/editor/scene/2d/abstract_polygon_2d_editor.h b/editor/scene/2d/abstract_polygon_2d_editor.h index a4c3c02e5fa..e7b66e7ecef 100644 --- a/editor/scene/2d/abstract_polygon_2d_editor.h +++ b/editor/scene/2d/abstract_polygon_2d_editor.h @@ -69,6 +69,16 @@ class AbstractPolygon2DEditor : public HBoxContainer { int polygon = -1; int vertex = -1; + + uint32_t hash() const { + return hash_murmur3_buffer(this, sizeof(Vertex)); + } + }; + + struct VertexHasher { + static uint32_t hash(const Vertex &p_vertex) { + return hash_one_uint64((uint64_t(uint32_t(p_vertex.polygon)) << 32) | uint32_t(p_vertex.vertex)); + } }; struct PosVertex : public Vertex { @@ -89,6 +99,17 @@ class AbstractPolygon2DEditor : public HBoxContainer { PosVertex edge_point; ///< adding an edge point? Vector2 original_mouse_pos; + /// @name Multi-select state + /// @{ + HashSet selected_points; + HashMap multi_move_start_positions; ///< Per-vertex position snapshot at drag-start. + + bool box_selecting = false; + bool box_select_additive = false; ///< True = Shift held when the box-select drag started; preserves prior selection instead of replacing it. + Point2 box_select_from; + Point2 box_select_to; + /// @} + Vector pre_move_edit; Vector wip; bool wip_active = false; @@ -121,6 +142,7 @@ class AbstractPolygon2DEditor : public HBoxContainer { void _wip_close(); void _wip_cancel(); + static void _bind_methods(); void _notification(int p_what); void _node_removed(Node *p_node); @@ -131,6 +153,12 @@ class AbstractPolygon2DEditor : public HBoxContainer { PosVertex closest_point(const Vector2 &p_pos) const; PosVertex closest_edge_point(const Vector2 &p_pos) const; + void _select_point(const Vertex &p_vertex, bool p_add_to_selection, bool p_toggle); + void _box_select_confirm(bool p_additive); + void _delete_selection(); + void _clear_selection(); + void _clear_point_selection(); + bool _is_empty() const; virtual Node2D *_get_node() const = 0;