From 93cdcb0cd45d9e1d1880464370f70ceaff75ffd1 Mon Sep 17 00:00:00 2001 From: slyphax Date: Sun, 25 Oct 2026 10:33:37 +0100 Subject: [PATCH] 3D-Werkzeugleiste, Dreh-/Kipp-Gizmo um alle Achsen mit Undo, Test --- src/editor/interaction.gd | 10 +- src/ui/main.gd | 6 ++ src/ui/viewport_toolbar.gd | 78 +++++++++++++++ src/viewport/gizmo.gd | 194 +++++++++++++++++++++++++++++++++++- tests/test_ui_aufraeumen.gd | 101 +++++++++++++++++++ 5 files changed, 381 insertions(+), 8 deletions(-) create mode 100644 src/ui/viewport_toolbar.gd create mode 100644 tests/test_ui_aufraeumen.gd diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index d50579d..a05e092 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -522,7 +522,7 @@ func ungroup_selection() -> void: ## Auswahl drehen (R / Mausrad / Button): bei mehreren Objekten oder einer ## Gruppe rotiert ALLES gemeinsam um den Gruppen-Mittelpunkt. Mausrad-Ticks ## derselben Auswahl laufen zu EINEM Undo-Schritt zusammen (merge_tag). -func rotate_selected(delta_deg: float) -> void: +func rotate_selected(delta_deg: float, merge_suffix: String = "") -> void: if selected_index < 0 or selected_indices.is_empty(): return var objects: Array = main.doc.get("objects", []) @@ -545,7 +545,7 @@ func rotate_selected(delta_deg: float) -> void: var twins: Array = ObjectOps.symmetry_sync_pairs( main.doc, members, _sym_mode(), selected_index) _sync_members_to_markers(twins) - var tag := "rotmulti:%s" % str(members) + var tag := "rotmulti:%s%s" % [str(members), merge_suffix] main.history.commit("Auswahl gedreht (%d Objekte)" % members.size(), before, main.history.snapshot(["objects"]), tag) _mark_dirty() @@ -560,7 +560,7 @@ func rotate_selected(delta_deg: float) -> void: ## einer Gruppe kippt die GANZE Gruppe (wie beim Drehen), Mehrfachauswahl ## kippt jedes Objekt um denselben Betrag, Symmetrie-Zwillinge laufen im ## SELBEN Commit mit. rot/rot_y_deg bleiben über SvmapIO.rot_setzen synchron. -func tilt_selected(axis_x: bool, delta_deg: float) -> void: +func tilt_selected(axis_x: bool, delta_deg: float, merge_suffix: String = "") -> void: if selected_index < 0 or selected_indices.is_empty(): return var objects: Array = main.doc.get("objects", []) @@ -588,8 +588,8 @@ func tilt_selected(axis_x: bool, delta_deg: float) -> void: var twins: Array = ObjectOps.symmetry_sync_pairs( main.doc, members, _sym_mode(), selected_index) _sync_members_to_markers(twins) - var tag := "tilt:%d" % selected_index if members.size() == 1 \ - else "tiltmulti:%s" % str(members) + var tag := ("tilt:%d" % selected_index if members.size() == 1 \ + else "tiltmulti:%s" % str(members)) + merge_suffix main.history.commit("Auswahl gekippt (%s, %d Objekte)" % [axis, members.size()], before, main.history.snapshot(["objects"]), tag) _mark_dirty() diff --git a/src/ui/main.gd b/src/ui/main.gd index 4cfa379..a4201ad 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -52,6 +52,7 @@ var grid_snap := false # Raster-Snap (1 m) an/aus — Objekt-Werkzeug var history := ActionHistory.new() var interaction # src/editor/interaction.gd (script-erweitert → untypisiert) +var viewport_toolbar # src/ui/viewport_toolbar.gd (Buttons oben links in der 3D-Ansicht) var gizmo # src/viewport/gizmo.gd (Achsen-Pfeile, Kind von interaction) var gizmo_step_m := 0.5 # Gizmo-Schrittweite in m (Panel-Feld; überlebt UI-Rebuilds) var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1) @@ -629,6 +630,11 @@ func _build_center_viewport() -> SubViewportContainer: world.add_child(test_start_tool) test_start_tool.setup(self, camera, view_builder, interaction) + # Werkzeugleiste oben links (Verschieben / Drehen & Kippen / Aktionen) + viewport_toolbar = preload("res://src/ui/viewport_toolbar.gd").new() + container.add_child(viewport_toolbar) + viewport_toolbar.setup(self) + # Bedien-Hinweis im Viewport (Overlay) help_label = Label.new() help_label.text = "Linksklick: auswählen · Rahmen: mehrere · R: drehen · Entf: löschen · H: Hilfe" diff --git a/src/ui/viewport_toolbar.gd b/src/ui/viewport_toolbar.gd new file mode 100644 index 0000000..54d4ac4 --- /dev/null +++ b/src/ui/viewport_toolbar.gd @@ -0,0 +1,78 @@ +extends PanelContainer +## Werkzeugleiste oben links in der 3D-Ansicht (25.10.): erscheint, sobald ein +## Objekt ausgewählt ist. Umschalter Verschieben / Drehen & Kippen (steuert den +## Gizmo) plus schnelle Aktionen (Auf den Boden, Duplizieren, Löschen). +## Objekte werden bewusst NIE skaliert — es gibt kein Skalieren-Werkzeug. + +var main +var move_btn: Button +var rotate_btn: Button +var ground_btn: Button +var dup_btn: Button +var delete_btn: Button + + +func setup(p_main) -> void: + main = p_main + set_anchors_preset(Control.PRESET_TOP_LEFT) + offset_left = UIScale.px(12) + offset_top = UIScale.px(12) + mouse_filter = Control.MOUSE_FILTER_STOP + visible = false + var box := HBoxContainer.new() + box.add_theme_constant_override("separation", UIScale.px(6)) + add_child(box) + var group := ButtonGroup.new() + move_btn = _btn("↔ Verschieben", "Pfeile am Objekt ziehen: X rot, Y grün, Z blau", true, group) + move_btn.pressed.connect(func() -> void: _set_mode("move")) + box.add_child(move_btn) + rotate_btn = _btn("⟳ Drehen / Kippen", "Ringe am Objekt ziehen: um alle drei Achsen, in 15°-Schritten", true, group) + rotate_btn.pressed.connect(func() -> void: _set_mode("rotate")) + box.add_child(rotate_btn) + move_btn.set_pressed_no_signal(true) + box.add_child(VSeparator.new()) + ground_btn = _btn("⤓ Auf den Boden", "Auswahl aufs Terrain absetzen (Strg+Bild↑)", false, null) + ground_btn.pressed.connect(func() -> void: main.interaction.snap_selection_to_floor()) + box.add_child(ground_btn) + dup_btn = _btn("⧉ Kopie", "Duplizieren (Strg+D)", false, null) + dup_btn.pressed.connect(func() -> void: main.interaction.duplicate_selected()) + box.add_child(dup_btn) + delete_btn = _btn("🗑 Löschen", "Löschen (Entf) — Strg+Z holt es zurück", false, null) + delete_btn.pressed.connect(func() -> void: main.interaction.delete_selected()) + box.add_child(delete_btn) + + +func _btn(text: String, tip: String, toggle: bool, group: ButtonGroup) -> Button: + var b := Button.new() + b.text = text + b.tooltip_text = tip + b.toggle_mode = toggle + if group != null: + b.button_group = group + b.custom_minimum_size = UIScale.v2(0, 36) + b.focus_mode = Control.FOCUS_NONE # Leertaste/Pfeile bleiben beim Viewport + return b + + +func _set_mode(m: String) -> void: + if main.gizmo != null: + main.gizmo.set_mode(m) + + +## Sichtbarkeit pro Frame aus der Auswahl ableiten (wie der Gizmo selbst). +func _process(_delta: float) -> void: + if main == null or main.get("interaction") == null: + return + var inter = main.interaction + var mode: String = str(main.get("tool_mode")) + var want: bool = (mode == "objects" or mode == "bauen") \ + and not inter.selected_indices.is_empty() \ + and not bool(inter.get("placing")) \ + and main.get("playtest") == null + if want != visible: + visible = want + if visible and main.gizmo != null: + var rotate_on: bool = main.gizmo.mode == "rotate" + if rotate_btn.button_pressed != rotate_on: + rotate_btn.set_pressed_no_signal(rotate_on) + move_btn.set_pressed_no_signal(not rotate_on) diff --git a/src/viewport/gizmo.gd b/src/viewport/gizmo.gd index a176db7..da9030d 100644 --- a/src/viewport/gizmo.gd +++ b/src/viewport/gizmo.gd @@ -70,12 +70,29 @@ var _drag_before: Dictionary = {} # History-Snapshot VOR dem Zug var _arrows := {} # "x"/"y"/"z" → Node3D (Pfeil-Root) +# --- Dreh-/Kipp-Modus (25.10.): Ringe um alle drei Achsen ------------------- +const RING_R := 1.5 # Ring-Radius in Metern +const RING_SEGMENTS := 48 +const ROT_STEP_DEG := 15.0 +var mode := "move" # "move" = Pfeile, "rotate" = Ringe +var rot_step_deg := ROT_STEP_DEG +var _rings := {} # "x"/"y"/"z" → MeshInstance3D +var _rot_dragging := false +var _rot_axis := "" +var _rot_center := Vector2.ZERO +var _rot_start_angle := 0.0 +var _rot_applied := 0.0 # bisher angewendete (gerastete) Grad +var _rot_suffix := "" # merge-Kennung: EIN Undo-Schritt pro Zug +var _rot_serial := 0 + func setup(p_main, p_camera: Camera3D, p_builder) -> void: main = p_main camera = p_camera view_builder = p_builder _build_arrows() + _build_rings() + _apply_mode_visibility() visible = false @@ -119,6 +136,53 @@ func _build_arrows() -> void: _arrows[a] = root +## Drehringe bauen: je Achse ein dünner Torus, ungeschattet, ohne Tiefentest. +func _build_rings() -> void: + for a in AXES: + var mat := StandardMaterial3D.new() + mat.albedo_color = AXIS_COLORS[a] + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.no_depth_test = true + mat.render_priority = 20 + var ring := MeshInstance3D.new() + var torus := TorusMesh.new() + torus.inner_radius = RING_R - 0.04 + torus.outer_radius = RING_R + 0.04 + torus.rings = 48 + torus.ring_segments = 8 + ring.mesh = torus + ring.material_override = mat + ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + # Torus liegt lokal in der XZ-Ebene (Achse = Y). + if a == "x": + ring.rotation.z = PI / 2.0 + elif a == "z": + ring.rotation.x = PI / 2.0 + ring.visible = false + add_child(ring) + _rings[a] = ring + + +## Modus wechseln ("move" = Verschieben, "rotate" = Drehen/Kippen). +func set_mode(m: String) -> void: + if m != "move" and m != "rotate": + return + if _dragging: + _finish_drag(false) + if _rot_dragging: + _finish_rotate(false) + mode = m + _apply_mode_visibility() + + +func _apply_mode_visibility() -> void: + for a in _arrows: + (_arrows[a] as Node3D).visible = mode == "move" + for a in _rings: + (_rings[a] as Node3D).visible = mode == "rotate" + + func _axis_vec(a: String) -> Vector3: return AXES.get(a, Vector3.ZERO) as Vector3 @@ -169,26 +233,43 @@ func _unhandled_input(event: InputEvent) -> void: var inter = main.get("interaction") if inter == null or bool(inter.get("placing")): return + if mode == "rotate": + var ring_axis := pick_ring(mb.position) + if ring_axis != "": + _begin_rotate(ring_axis, mb.position) + get_viewport().set_input_as_handled() + return var axis := pick(mb.position) if axis != "": _begin_drag(axis, mb.position) get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_LEFT and not mb.pressed: - if _dragging: + if _rot_dragging: + _finish_rotate(true) + get_viewport().set_input_as_handled() + elif _dragging: _finish_drag(true) get_viewport().set_input_as_handled() elif (mb.button_index == MOUSE_BUTTON_WHEEL_UP \ or mb.button_index == MOUSE_BUTTON_WHEEL_DOWN) and mb.pressed: - if _dragging: + if _rot_dragging: + get_viewport().set_input_as_handled() # Mausrad zoomt nicht mitten im Dreh-Zug + elif _dragging: var dir := 1 if mb.button_index == MOUSE_BUTTON_WHEEL_UP else -1 cycle_step(dir) get_viewport().set_input_as_handled() + elif event is InputEventMouseMotion and _rot_dragging: + _rotate_motion((event as InputEventMouseMotion).position) + get_viewport().set_input_as_handled() elif event is InputEventMouseMotion and _dragging: _drag_motion((event as InputEventMouseMotion).position) get_viewport().set_input_as_handled() elif event is InputEventKey and (event as InputEventKey).pressed \ and (event as InputEventKey).keycode == KEY_ESCAPE: - if _dragging: + if _rot_dragging: + _finish_rotate(false) + get_viewport().set_input_as_handled() + elif _dragging: _finish_drag(false) get_viewport().set_input_as_handled() @@ -215,6 +296,35 @@ func pick(screen_pos: Vector2) -> String: return best +## Ring unter dem Cursor (Abstand zum projizierten Kreis) — "" = keiner. +func pick_ring(screen_pos: Vector2) -> String: + if not visible or mode != "rotate": + return "" + var best := "" + var best_d := HIT_PX + for a in AXES: + var pts := _ring_screen_points(a) + for i in pts.size() - 1: + if pts[i] == Vector2.INF or pts[i + 1] == Vector2.INF: + continue + var d := _dist_point_segment(screen_pos, pts[i], pts[i + 1]) + if d < best_d: + best_d = d + best = a + return best + + +func _ring_screen_points(a: String) -> Array: + var axis := _axis_vec(a) + var u := axis.cross(Vector3.UP if absf(axis.dot(Vector3.UP)) < 0.9 else Vector3.RIGHT).normalized() + var v := axis.cross(u).normalized() + var out: Array = [] + for i in RING_SEGMENTS + 1: + var t := TAU * float(i) / float(RING_SEGMENTS) + out.append(_safe_unproject(global_position + (u * cos(t) + v * sin(t)) * RING_R)) + return out + + func _safe_unproject(p: Vector3) -> Vector2: if camera == null: return Vector2.INF @@ -372,12 +482,90 @@ func _finish_drag(commit: bool) -> void: ## Für die Esc-Kette in main.gd: Zug abbrechen, falls einer läuft. true = ## es lief einer und wurde abgebrochen (idempotent, weil _dragging prüft). func cancel_drag_if_active() -> bool: + if _rot_dragging: + _finish_rotate(false) + return true if not _dragging: return false _finish_drag(false) return true +# --------------------------------------------------- Drehen / Kippen ------- + +func _ring_angle(mouse: Vector2) -> float: + var d := mouse - _rot_center + return atan2(d.y, d.x) + + +func _begin_rotate(axis: String, mouse: Vector2) -> void: + var inter = main.get("interaction") + if inter == null or inter.selected_indices.is_empty(): + return + var c := _safe_unproject(global_position) + if c == Vector2.INF: + return + _rot_axis = axis + _rot_center = c + _rot_start_angle = _ring_angle(mouse) + _rot_applied = 0.0 + _rot_serial += 1 + _rot_suffix = "#g%d" % _rot_serial + _rot_dragging = true + var name := "Drehen (Y)" if axis == "y" else ("Kippen um X" if axis == "x" else "Kippen um Z") + main.call("_set_status", "%s — Ring ziehen, rastet in %.0f°-Schritten; Esc bricht ab, Strg+Z nimmt den Zug zurück." % [name, rot_step_deg]) + + +func _rotate_motion(mouse: Vector2) -> void: + # Bildschirmwinkel → Drehwinkel um die Achse (Vorzeichen je nach Blickrichtung). + var delta_screen := wrapf(_ring_angle(mouse) - _rot_start_angle, -PI, PI) + var facing := _axis_vec(_rot_axis).dot(camera.global_transform.basis.z) + var deg := -rad_to_deg(delta_screen) * (1.0 if facing >= 0.0 else -1.0) + var snapped_deg := snappedf(deg, rot_step_deg) + _rotate_to(snapped_deg) + + +## Auf einen Gesamtwinkel (gerastet) nachführen — in Schritten, EIN Undo-Eintrag. +func _rotate_to(total_deg: float) -> void: + var diff := total_deg - _rot_applied + if absf(diff) < 0.001: + return + var inter = main.get("interaction") + if inter == null: + return + if _rot_axis == "y": + inter.rotate_selected(diff, _rot_suffix) + else: + inter.tilt_selected(_rot_axis == "x", diff, _rot_suffix) + _rot_applied = total_deg + + +func _finish_rotate(commit: bool) -> void: + if not _rot_dragging: + return + _rot_dragging = false + if not commit and absf(_rot_applied) > 0.001: + main.call("_do_undo") # der ganze Zug ist EIN Eintrag → komplett zurück + main.call("_set_status", "Dreh-Zug abgebrochen — Objekt wie vorher.") + _rot_axis = "" + _rot_applied = 0.0 + + +## Probes/Tests: derselbe Pfad wie ein echter Ring-Zug (ohne Maus-Mathe). +func demo_rotate_axis(axis: String, deg: float) -> bool: + var inter = main.get("interaction") if main != null else null + if inter == null or inter.selected_indices.is_empty() or absf(deg) < 0.001: + return false + _rot_axis = axis + _rot_serial += 1 + _rot_suffix = "#g%d" % _rot_serial + _rot_applied = 0.0 + _rotate_to(snappedf(deg, rot_step_deg)) + _rot_axis = "" + _rot_applied = 0.0 + return true + + # ------------------------------------------------------ Schrittweite ------- ## Mausrad WÄHREND des Ziehens: durch die Voreinstellungen blättern diff --git a/tests/test_ui_aufraeumen.gd b/tests/test_ui_aufraeumen.gd new file mode 100644 index 0000000..48127e6 --- /dev/null +++ b/tests/test_ui_aufraeumen.gd @@ -0,0 +1,101 @@ +extends SceneTree +## Editor-Test (headless): UI-Aufräumen 25.10. — eigene Tabs Terrain/Malen/Bauen, +## Viewport-Werkzeugleiste, Dreh-/Kipp-Gizmo (alle Achsen, Undo), Kontrast. +## Ausführen: godot --headless --path . --script res://tests/test_ui_aufraeumen.gd + +var failures := 0 +var checks := 0 +var main: Control + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + main = (load("res://scenes/main.tscn") as PackedScene).instantiate() + root.add_child(main) + await process_frame + await process_frame + (main.doc["objects"] as Array).append({ + "id": "crate_wood", "pos": [4.0, 0.0, 4.0], "scale": 1.0, "rot_y_deg": 0.0}) + main.call("_after_structure_change") + main.history.clear() + await process_frame + + print("== Tabs ==") + var tabs: TabContainer = main.ui_right_panel + var titel: Array = [] + for i in tabs.get_tab_count(): + titel.append(tabs.get_tab_title(i)) + _check(titel.has("Terrain") and titel.has("Malen") and titel.has("Bauen"), "Tabs Terrain, Malen, Bauen vorhanden") + _check(main.malen_mod != null and main.bauen_mod != null, "Module malen/bauen gebaut") + _check(main.terrain_mod.terrain_mode_paint.get_parent() == main.terrain_mod.terrain_panel and not main.terrain_mod.terrain_mode_paint.visible, + "Mal-Modus nicht mehr als Knopf im Terrain-Tab") + _check(main.build_edit_btn.get_parent() == main.bauen_mod, "Bauen-Knopf wohnt im Bauen-Tab") + _check(main.malen_mod.mat_buttons.size() == SvmapIO.MATERIAL_IDS.size(), "Malen: eine Kachel je Material") + + print("== Malen-Tab ==") + main.call("_set_tool_mode", "terrain") + _check(main.terrain_mod.terrain_panel.visible and not main.malen_mod.malen_panel.visible, "Terrain aktiv: nur Terrain-Panel") + main.terrain_mod.terrain_mode_paint.button_pressed = true + _check(main.malen_mod.malen_panel.visible and main.malen_mod.palette.visible and not main.terrain_mod.terrain_panel.visible, + "Malen aktiv: Malen-Panel statt Terrain-Panel") + _check(tabs.current_tab == main.malen_mod.get_index(), "Tab springt auf Malen") + main.malen_mod.radius_slider.value = 20.0 + _check(int(main.terrain_mod.terrain_radius_slider.value) == 20, "Malen-Radius steuert den Pinsel") + main.call("_set_tool_mode", "objects") + + print("== Lesbarkeit ==") + var muted: Color = main.malen_mod.theme_color("muted") + _check(muted.a > 0.7 and muted.r > 0.9, "muted-Farbe ist hell (nicht Schwarz)") + _check(main.status_mod.stats_objects.get_theme_color("font_color").a > 0.0, "Statuszeile lesbar") + + print("== Werkzeugleiste + Dreh-Gizmo ==") + _check(not main.viewport_toolbar.visible, "ohne Auswahl keine Werkzeugleiste") + main.interaction.call("select_object", 0) + await process_frame + await process_frame + _check(main.viewport_toolbar.visible, "mit Auswahl erscheint die Werkzeugleiste") + _check(main.gizmo.mode == "move", "Standard: Verschieben") + main.viewport_toolbar.rotate_btn.pressed.emit() + _check(main.gizmo.mode == "rotate", "Knopf Drehen schaltet den Gizmo um") + var obj: Dictionary = main.doc["objects"][0] + var n0: int = main.history.entries.size() + _check(main.gizmo.demo_rotate_axis("x", 30.0), "Kippen um X") + _check(main.gizmo.demo_rotate_axis("z", -15.0), "Kippen um Z") + _check(main.gizmo.demo_rotate_axis("y", 45.0), "Drehen um Y") + var rot := SvmapIO.objekt_rotation(obj) + _check(is_equal_approx(rot.x, 30.0) and is_equal_approx(fposmod(rot.z, 360.0), 345.0) and is_equal_approx(rot.y, 45.0), + "Euler (%s) = (30, 45, 345)" % str(rot)) + _check(main.history.entries.size() == n0 + 3, "jeder Zug = ein Undo-Schritt (%d neu)" % (main.history.entries.size() - n0)) + main.call("_do_undo") + obj = main.doc["objects"][0] + _check(is_equal_approx(SvmapIO.objekt_rotation(obj).y, 0.0), "Undo nimmt den Y-Zug zurück") + main.call("_do_undo") + main.call("_do_undo") + obj = main.doc["objects"][0] + _check(SvmapIO.objekt_rotation(obj).is_equal_approx(Vector3.ZERO), "dreimal Undo = Ausgangslage") + main.viewport_toolbar.move_btn.pressed.emit() + _check(main.gizmo.mode == "move", "Verschieben schaltet zurück") + var scale_btns := 0 + for c in main.viewport_toolbar.find_children("*", "Button", true, false): + if str((c as Button).text).to_lower().contains("skal"): + scale_btns += 1 + _check(scale_btns == 0, "kein Skalieren-Werkzeug (Objekte behalten ihre Größe)") + + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) + + +func _check(cond: bool, label: String) -> void: + checks += 1 + if cond: + print(" ok: ", label) + else: + failures += 1 + print(" FAIL: ", label)