diff --git a/docs/tasten.md b/docs/tasten.md index 7ce13ff..de39e3d 100644 --- a/docs/tasten.md +++ b/docs/tasten.md @@ -2,7 +2,8 @@ Deutsche Tastenbelegung des ScopedVR Map-Editors. **Diese Datei ist die Source of truth** für alle Shortcut-Karten (Karte 1/3: t_c46d31c2 — Navigation, -Auswahl, Löschen; Karten 2/3 ergänzen hier). +Auswahl, Löschen; Karte 2/3: t_55257792 — Objektmanipulation + Terrain; +Karte 3/3 ergänzt hier). Konventionen (alle Karten): @@ -13,6 +14,9 @@ Konventionen (alle Karten): - Jede Objekt-Operation = **EIN** ActionHistory-Eintrag (Strg+Z nimmt die ganze Operation zurück). Ausnahme: reine Kamera-Aktionen (Orbit/Flug/Zoom) betreffen die Map nicht und sind nicht in der Historie. +- **Strg halten = temporärer Snap-Zwang** (Karte 2/3): egal was der + Raster-Snap-Schalter sagt — solange Strg unten ist, rastet Bewegen + (Ziehen/Greifen) und Platzieren auf ganze Meter. ## Navigation (Kamera) @@ -40,30 +44,50 @@ nicht — Ziel/Winkel/Abstand werden aus der Flugposition zurückgerechnet. | Umschalt+Klick | wie Strg+Klick (Mehrfachauswahl umschalten) | | Ziehen über leeren Boden | Rahmen-Aufziehen: alle Objekte im Rahmen wählen | | Umschalt + Rahmen | Rahmen-Auswahl zur bestehenden Auswahl hinzufügen | -| Esc | Auswahl aufheben / aktives Werkzeug abbrechen / Ghost-Preview abbrechen | +| Esc | Werkzeug abbrechen → **Greifen zurückrollen** → Ghost-Preview abbrechen → Auswahl aufheben (Prioritätskette) | Alle Bestandsaktionen wirken auf die GANZE Mehrfachauswahl: Entf löscht alle, -Strg+D dupliziert alle, Ziehen verschiebt gemeinsam, Strg+G gruppieren / -Strg+Umschalt+G lösen. +Strg+D dupliziert alle, Ziehen verschiebt gemeinsam, Anheben/Absenken hebt +alle, Strg+G gruppieren / Strg+Umschalt+G lösen. -## Objekt-Operationen +## Objekt-Manipulation (Karte 2/3) | Eingabe | Wirkung | | --- | --- | +| **R** | Auswahl 90° drehen (Vierteldrehung; Umschalt+R = −90°). Szene-Objekte drehen um den Auswahl-Mittelpunkt, Bauen-Bauteile um ihren Fußpunkt. Ein Objekt aus einer Gruppe dreht die GANZE Gruppe. Dauerhaft (kein Vorschau-Zustand) | +| Mausrad (mit Auswahl) | Feindrehen im 15°-Raster (bleibt wie bisher) | +| **Pfeiltasten** | Auswahl im **2-m-Raster** verschieben — EINE Zelle pro Tastendruck, Richtung = Kamera-Ausrichtung nächste Weltachse (Godot-Editor-Konvention: ↑ = von der Kamera weg). Fußpunkt bleibt am Raster, Höhe wandert mit | +| **Bild↑ / Bild↓** | Auswahl +0,25 m / −0,25 m anheben/absenken (Mehrfachdruck = EIN Undo-Schritt) | +| **Strg+Bild↑** | Auswahl direkt auf das Terrain/Boden absetzen (Godot „Snap Object to Floor“-Äquivalent fürs Fußpunkt-Modell) | +| **G** | Auswahl **greifen** (Blender-Semantik): sie hängt an der Maus, Linksklick setzt die neue Position (mit Raster-Snap bzw. Strg-Zwang), **Esc** bricht ab und setzt die exakten alten Positionen zurück. Greifen der ganzen Gruppe/mehreren Objekte wandert gemeinsam | | **Entf (DELETE)** | Auswahl SOFORT löschen, ohne Rückfrage — Strg+Z holt alles zurück. Wirkt auf Szene-Auswahl UND Objektbaum-Markierung | | **Strg+D** | Auswahl duplizieren: Kopie +2 m in X versetzt (Rasterversatz), Füßpunkt unverändert; bei Mehrfachauswahl ALLE Objekte mit EINEM Undo-Schritt | -| R / Mausrad (mit Auswahl) | Drehen im 15°-Raster | -| Bild↑ / Bild↓ | Objekt +0,25 m / −0,25 m anheben/absenken (Mehrfachdruck = EIN Undo-Schritt) | | Strg+Z / Strg+Y | Rückgängig / Wiederherstellen | | Strg+S | Karte speichern | | Strg+G / Strg+Umschalt+G | Auswahl gruppieren / Gruppe lösen | +## Terrain-Werkzeug (Karte 2/3) — nur aktiv, wenn das Terrain-Werkzeug an ist + +| Eingabe | Wirkung | +| --- | --- | +| **[** | Pinselgröße verkleinern — durch die 3 Presets Klein (4 m) / Mittel (12 m) / Groß (32 m) zyklisch; Preset-Buttons, Slider und Label wandern mit | +| **]** | Pinselgröße vergrößern (gleicher Zyklus, andere Richtung) | +| **1** | Pinselmodus Heben | +| **2** | Pinselmodus Senken | +| **3** | Pinselmodus Glätten | +| **4** | Pinselmodus Malen (mit der zuletzt gewählten Materialfarbe) | +| Strg halten | temporärer Snap-Zwang beim Objekt-Bewegen/Platzieren (gilt editorweit, nicht nur hier) | + +Konfliktfreiheit: Die Ziffern 1–4 sind NUR im Terrain-Werkzeug belegt — im +Objekt-/Bauen-Modus sind sie unbenutzt, im Flugmodus (rechte Maustaste +gehalten) bleiben E/Q die Kamera. + ## Werkzeuge | Eingabe | Wirkung | | --- | --- | | Esc (im Terrain-/Spawn-/Bauen-Werkzeug) | Werkzeug beenden — zurück zum Objekt-Werkzeug | +| Esc (beim Greifen) | Greifen abbrechen, alte Positionen zurück | | Esc (beim Platzieren) | Ghost-Preview abbrechen | -*(Karten 2/3 ergänzen hier: R-Drehen 90°, Pfeiltasten-Raster, Bild↑/↓-Feinheiten, -Greifen mit G, Terrain-Pinsel `[` `]` und Ziffern-Modi.)* \ No newline at end of file +*(Karte 3/3 ergänzt hier ihre Belegungen.)* diff --git a/src/editor/build_tool.gd b/src/editor/build_tool.gd index 14b9362..b0841ae 100644 --- a/src/editor/build_tool.gd +++ b/src/editor/build_tool.gd @@ -266,6 +266,57 @@ func lift_selected(delta_m: float) -> void: view_builder.call("set_gebaeude_selected", selected_index) +## Pfeiltasten (Karte t_55257792): Bauteil im 2-m-Modulraster verschieben — +## EINE Zelle pro Tastendruck. Das exakte ±2-m-Delta erhält die Raster- +## Ausrichtung ("ecke" = gerade Meter, "mitte" = ungerade Meter bleibt), Y +## bleibt (Höhe ändert die Pfeiltaste nicht). EIN Undo-Schritt pro Tastendruck. +func nudge_selected(delta: Vector3) -> void: + if selected_index < 0: + main.call("_set_status", "Nichts ausgewählt — Umschalt+Klick wählt ein Bauteil aus, dann verschieben die Pfeiltasten es im 2-m-Raster.") + return + var list := BuildOps.pieces(main.doc) + if selected_index >= list.size(): + selected_index = -1 + return + var teil: Dictionary = list[selected_index] + var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) + var before: Dictionary = main.history.snapshot(["gebaeude"]) + var nx := float(pos[0]) + delta.x + var nz := float(pos[2]) + delta.z + teil["pos"] = [nx, float(pos[1]), nz] + main.history.commit("%s verschoben (Pfeiltaste)" % _piece_label(selected_index), + before, main.history.snapshot(["gebaeude"])) + _after_build_change() + view_builder.call("set_gebaeude_selected", selected_index) + main.call("_set_status", "%s — 2 m pro Tastendruck, Raster bleibt eingehalten." % _piece_label(selected_index)) + + +## Strg+Bild↑ (Karte t_55257792): Bauteil direkt auf das Terrain absetzen +## (Fußpunkt = Terrain-Höhe, auf 5 cm gerastet). EIN Undo-Schritt. +func snap_selected_to_floor() -> void: + if selected_index < 0: + main.call("_set_status", "Nichts ausgewählt — Umschalt+Klick wählt ein Bauteil aus, dann setzt Strg+Bild↑ es auf den Boden.", true) + return + var list := BuildOps.pieces(main.doc) + if selected_index >= list.size(): + selected_index = -1 + return + var teil: Dictionary = list[selected_index] + var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) + var ground := SvmapDocument.terrain_height_at(main.doc, float(pos[0]), float(pos[2])) + var target := BuildOps.snap_y(ground) + if absf(float(pos[1]) - target) < 0.0001: + main.call("_set_status", "%s liegt schon auf dem Boden." % _piece_label(selected_index)) + return + var before: Dictionary = main.history.snapshot(["gebaeude"]) + teil["pos"] = [float(pos[0]), target, float(pos[2])] + main.history.commit("%s auf den Boden gesetzt" % _piece_label(selected_index), + before, main.history.snapshot(["gebaeude"])) + _after_build_change() + view_builder.call("set_gebaeude_selected", selected_index) + main.call("_set_status", "%s auf den Boden gesetzt — Strg+Z nimmt es zurück." % _piece_label(selected_index)) + + func delete_selected() -> void: if selected_index < 0: main.call("_set_status", "Nichts ausgewählt — Umschalt+Klick wählt ein Bauteil aus (Entf löscht es dann).", true) diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 59854ff..9a753e0 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -69,6 +69,21 @@ var _rubber_active := false # Rahmen wird gerade aufgezogen var _rubber_start := Vector2.ZERO var _rubber_shift := false # Umschalt: Rahmen zur Auswahl HINZUFÜGEN +# Strg halten = temporärer Snap-Zwang (Karte t_55257792): egal was der +# Raster-Snap-Schalter sagt — solange Strg unten ist, rastet Bewegen/Platzieren. +# Aus JEDEM durchgelassenen Input-Event aktualisiert (Maus- UND Taste). +var _ctrl_held := false + +# Greifen (Karte t_55257792, G — Blender-Semantik): die Auswahl hängt an der +# Maus, Linksklick setzt die neue Position (EIN Undo-Schritt), Esc bricht ab +# und stellt die EXAKTEN alten Positionen wieder her. +var _grabbing := false +var _grab_members: Array = [] # Indizes der Greif-Menge (inkl. Gruppe) +var _grab_primary := -1 +var _grab_originals: Dictionary = {} # idx (int) → [x, y, z] exakt (Rückabwicklung) +var _grab_lifts: Dictionary = {} # idx (int) → Terrain-Abstand beim Anfassen +var _grab_before: Dictionary = {} # History-Snapshot VOR dem Greifen + func setup(p_main, p_camera: Camera3D, p_builder) -> void: main = p_main @@ -223,16 +238,22 @@ func _ensure_ghost_visible() -> void: _ghost.visible = true -## Raster-Snap (wenn in main angeschaltet): X/Z auf ganze Meter rasten — -## Objekte passen dann nebeneinander wie Bauklötze. Höhe bleibt Terrain. +## Raster-Snap (wenn in main angeschaltet ODER Strg gehalten wird — temporärer +## Snap-Zwang, Karte t_55257792): X/Z auf ganze Meter rasten — Objekte passen +## dann nebeneinander wie Bauklötze. Höhe bleibt Terrain. func _apply_grid(pos: Vector3) -> Vector3: - if not bool(main.get("grid_snap")): + if not (bool(main.get("grid_snap")) or _ctrl_held): return pos pos.x = ObjectOps.snap_coord(pos.x, true, GRID_M) pos.z = ObjectOps.snap_coord(pos.z, true, GRID_M) return pos +## Raster-Snap-Erzwingung fürs Platzieren: Schalter an ODER Strg gehalten. +func _placement_grid_enabled() -> bool: + return bool(main.get("grid_snap")) or _ctrl_held + + func _ghost_update(viewport_pos: Vector2) -> void: var p: Variant = _ground_point(viewport_pos) if p == null: @@ -243,7 +264,7 @@ func _ghost_update(viewport_pos: Vector2) -> void: # EIN Pfad für Ghost UND Platzierung (Karte t_4092e3a0): Fußpunkt = # Terrain-Höhe am gerasteten Punkt. Der sichtbare Lift hängt am Kind-Mesh. _ghost.position = ObjectOps.placement_foot( - main.doc, p as Vector3, bool(main.get("grid_snap")), _terrain_half(), true) + main.doc, p as Vector3, _placement_grid_enabled(), _terrain_half(), true) _ghost.visible = true @@ -254,7 +275,7 @@ func _place_object(viewport_pos: Vector2) -> void: return var half := _terrain_half() var foot: Vector3 = ObjectOps.placement_foot( - main.doc, p as Vector3, bool(main.get("grid_snap")), half, false) + main.doc, p as Vector3, _placement_grid_enabled(), half, false) if absf(foot.x) > half.x or absf(foot.z) > half.y: main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true) return @@ -521,7 +542,8 @@ func duplicate_selected() -> void: main.call("_set_status", "%s: die Kopie(n) liegen +2 m versetzt und sind direkt angewählt — Ziehen verschiebt, Entf löscht, Strg+Z nimmt ALLE Kopien auf einmal zurück." % label) -## ▲/▼ / Strg+Bild↑/↓: Objekt in 0,25-m-Schritten anheben/absenken. +## ▲/▼ / Strg+Bild↑/↓: AUSWAHL in 0,25-m-Schritten anheben/absenken +## (Karte t_55257792: die GANZE Auswahl, wie Entf/Strg+D auch). ## Mehrere Klicks in Serie = EIN Undo-Schritt (Merge-Tag, wie beim Drehen). ## Absenken stoppt am Terrain (nichts wird begraben), Anheben an der ## Match-Grenze (bounds.max) — Regeln in ObjectOps.lift_object. @@ -535,9 +557,12 @@ func lift_selected(delta_m: float) -> void: if selected_index >= objects.size(): clear_selection(true) return + var members := _lift_members() var before: Dictionary = main.history.snapshot(["objects"]) - var applied := ObjectOps.lift_object(main.doc, selected_index, delta_m) - if absf(applied) < 0.0001: + var applied_sum := 0.0 + for i in members: + applied_sum += ObjectOps.lift_object(main.doc, i, delta_m) + if absf(applied_sum) < 0.0001: # Nichts passiert (Snapshot verwerfen): hilfsbereit erklären, WO die Grenze ist. var ground: String if delta_m > 0.0: @@ -547,20 +572,211 @@ func lift_selected(delta_m: float) -> void: main.call("_set_status", ground) return var after: Dictionary = main.history.snapshot(["objects"]) - main.history.commit("Objekt %s (%s)" % [ - "angehoben" if delta_m > 0.0 else "abgesenkt", - ("%.2f m" % applied)], - before, after, "lift:%d" % selected_index) - var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null - if marker != null and is_instance_valid(marker): - marker.position.y = float((objects[selected_index]["pos"] as Array)[1]) + var tag := "lift:%d" % selected_index if members.size() == 1 \ + else "liftmulti:%s" % str(members) + main.history.commit(_lift_label(members, delta_m, applied_sum), + before, after, tag) + for i in members: + if i >= view_builder.object_markers.size(): + continue + var marker: Node3D = view_builder.object_markers[i] + if marker != null and is_instance_valid(marker): + marker.position.y = float((objects[i]["pos"] as Array)[1]) main.dirty = true main.call("_refresh_file_label") main.call("_refresh_stats") main.call("_refresh_history_buttons") main.call("_sync_selection_panel") - main.call("_set_status", "Objekt %s auf %.2f m — mehrmals drücken stapelt weiter; Strg+Z nimmt alle Stufen auf einmal zurück." % [ - "angehoben" if delta_m > 0.0 else "abgesenkt", float((objects[selected_index]["pos"] as Array)[1])]) + main.call("_set_status", "%s auf %.2f m — mehrmals drücken stapelt weiter; Strg+Z nimmt alle Stufen auf einmal zurück." % [ + _lift_label(members, delta_m, applied_sum), float((objects[selected_index]["pos"] as Array)[1])]) + + +## Heben/Absenken wirkt auf die GANZE Auswahl (Karte t_55257792) — ein einzelnes +## Objekt, das in einer Gruppe steckt, zieht die ganze Gruppe mit (wie Drehen). +func _lift_members() -> Array: + var members := selected_indices.duplicate() + if members.size() == 1: + var objects: Array = main.doc.get("objects", []) + var gid := GroupOps.group_id(objects[members[0]]) + if gid != GroupOps.UNGROUPED: + members = GroupOps.members_of(main.doc, gid) + select_many(members, true) + main.call("_sync_selection_panel") + return members + + +func _lift_label(members: Array, delta_m: float, applied_sum: float) -> String: + var dir := "angehoben" if delta_m > 0.0 else "abgesenkt" + if members.size() == 1: + return "Objekt %s (%s)" % [dir, "%.2f m" % applied_sum] + return "%d Objekte %s (%.2f m)" % [members.size(), dir, applied_sum] + + +## Strg+Bild↑ (Karte t_55257792): die AUSWAHL direkt auf das Terrain/Boden +## absetzen (Godot "Snap Object to Floor"-Äquivalent fürs Fußpunkt-Modell). +## EIN Undo-Schritt; steht alles schon am Boden → Meldung statt leerem Commit. +func snap_selection_to_floor() -> void: + if selected_indices.is_empty() or selected_index < 0: + main.call("_set_status", "Nichts ausgewählt — Strg+Bild↑ setzt die Auswahl auf den Boden. Klicke zuerst ein Objekt an.", true) + return + if placing: + return + var members := _lift_members() + var before: Dictionary = main.history.snapshot(["objects"]) + var changed := ObjectOps.snap_selection_to_floor(main.doc, members) + if changed == 0: + main.call("_set_status", "Steht schon alles auf dem Boden — nichts zu setzen.") + return + main.history.commit("Auswahl auf Boden gesetzt (%d Objekte)" % changed, + before, main.history.snapshot(["objects"])) + for i in members: + if i >= view_builder.object_markers.size(): + continue + var marker: Node3D = view_builder.object_markers[i] + if marker != null and is_instance_valid(marker): + var pos: Array = ((main.doc.get("objects", []) as Array)[i] as Dictionary).get("pos", [0.0, 0.0, 0.0]) + marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + _mark_dirty() + main.call("_refresh_stats") + main.call("_refresh_history_buttons") + main.call("_sync_selection_panel") + main.call("_set_status", "%d Objekt(e) auf den Boden gesetzt — Strg+Z nimmt es zurück." % changed) + + +## Pfeiltasten (Karte t_55257792): Auswahl im 2-m-Raster verschieben — EINE +## Zelle pro Tastendruck, Richtung = Kamera-Ausrichtung nächste Weltachse +## (MoveMath). Fußpunkt bleibt am Raster, Höhe wandert mit (t_7567a06f). +## EIN Undo-Schritt pro Tastendruck. +func nudge_selection(delta: Vector3) -> void: + if selected_indices.is_empty() or selected_index < 0: + main.call("_set_status", "Nichts ausgewählt — Pfeiltasten verschieben die Auswahl im 2-m-Raster. Klicke zuerst ein Objekt an.") + return + if placing: + return + var members := selected_indices.duplicate() + var objects: Array = main.doc.get("objects", []) + if members.size() == 1: + var gid := GroupOps.group_id(objects[members[0]]) + if gid != GroupOps.UNGROUPED: + members = GroupOps.members_of(main.doc, gid) + var before: Dictionary = main.history.snapshot(["objects"]) + var changed := ObjectOps.nudge_objects(main.doc, members, delta) + if changed == 0: + return # am Map-Rand geklemmt — Snapshot verwerfen, kein Commit + _sync_members_to_markers(members) + var desc := "Objekt verschoben (Pfeiltaste)" if members.size() == 1 \ + else "%d Objekte verschoben (Pfeiltasten)" % members.size() + main.history.commit(desc, before, main.history.snapshot(["objects"])) + _mark_dirty() + main.call("_refresh_history_buttons") + main.call("_refresh_stats") + main.call("_sync_selection_panel") + main.call("_set_status", "%s — 2 m pro Tastendruck. Greifen (G) setzt frei, Strg+Bild↑ setzt auf den Boden." % desc) + + +# ------------------------------------------------------------ Greifen (G) ---- +## Blender-Semantik (Karte t_55257792): G hängt die Auswahl an die Maus, +## Linksklick setzt die neue Position (EIN Undo-Schritt, Raster-Snap wie +## eingestellt bzw. Strg-Zwang), Esc bricht ab und setzt die EXAKTEN alten +## Positionen zurück. Die Auswahl wandert GEMEINSAM (Gruppe/Mehrfachauswahl), +## relative Anordnung bleibt exakt erhalten, Terrain-Abstand je Objekt bleibt. + +## Greifen starten. false = nichts ausgewählt (Meldung steht dann). +func grab_begin() -> bool: + if _grabbing: + return true + if placing: + return false + var objects: Array = main.doc.get("objects", []) + var members := selected_indices.duplicate() + if members.size() == 1: + var gid := GroupOps.group_id(objects[members[0]]) + if gid != GroupOps.UNGROUPED: + members = GroupOps.members_of(main.doc, gid) + select_many(members, true) + main.call("_sync_selection_panel") + if members.is_empty() or selected_index < 0: + main.call("_set_status", "Nichts ausgewählt — G greift die Auswahl, damit du sie mit der Maus frei setzen kannst. Klicke zuerst ein Objekt an.") + return false + _grab_members = members + _grab_primary = selected_index + _grab_originals = {} + _grab_lifts = {} + for i in members: + var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0]) + _grab_originals[i] = [float(pos[0]), float(pos[1]), float(pos[2])] + _grab_lifts[i] = float(pos[1]) - SvmapDocument.terrain_height_at( + main.doc, float(pos[0]), float(pos[2])) + _grabbing = true + _grab_before = main.history.snapshot(["objects"]) + main.call("_set_status", "GREIFEN an: die Auswahl hängt an der Maus — Linksklick setzt sie neu (mit Raster-Snap), Esc bricht ab und setzt zurück.") + return true + + +## Auswahl live zur Cursor-Bodenposition ziehen (Maus-Bewegung ODER Demo-Hook +## mit Weltpunkt). Der Primär-Anker geht EXAKT zum Punkt (rastet, wenn Snap +## an bzw. Strg), die Mitglieder wandern mit demselben Delta mit. +func grab_move(hit: Vector3) -> void: + if not _grabbing: + return + var target := _apply_grid(hit) + var half := _terrain_half() + var primary_pos: Array = _grab_originals[_grab_primary] + var delta := Vector3(target.x - float(primary_pos[0]), 0.0, + target.z - float(primary_pos[2])) + var objects: Array = main.doc.get("objects", []) + for i in _grab_members: + var orig: Array = _grab_originals[i] + var nx := clampf(float(orig[0]) + delta.x, -half.x, half.x) + var nz := clampf(float(orig[2]) + delta.z, -half.y, half.y) + var ny := SvmapDocument.terrain_height_at(main.doc, nx, nz) + float(_grab_lifts[i]) + (objects[i] as Dictionary)["pos"] = [nx, ny, nz] + if i < view_builder.object_markers.size(): + var marker: Node3D = view_builder.object_markers[i] + if marker != null and is_instance_valid(marker): + marker.position = Vector3(nx, ny, nz) + + +## Linksklick im Greifen: Positionen festschreiben — EIN Undo-Schritt. +func grab_confirm() -> void: + if not _grabbing: + return + _grabbing = false + var desc := "Objekt verschoben (greifen)" if _grab_members.size() == 1 \ + else "%d Objekte verschoben (greifen)" % _grab_members.size() + main.history.commit(desc, _grab_before, main.history.snapshot(["objects"])) + _grab_members = [] + _grab_originals = {} + _grab_lifts = {} + _grab_before = {} + _mark_dirty() + main.call("_refresh_history_buttons") + main.call("_refresh_stats") + main.call("_sync_selection_panel") + main.call("_set_status", "%s — Strg+Z nimmt den ganzen Zug zurück. G greift erneut, Esc hebt immer ab." % desc) + + +## Esc im Greifen: EXAKTE Rückabwicklung auf die Positionen von grab_begin — +## byte-identisch (keine Float-Re-Rechnung), KEIN History-Eintrag. +func grab_cancel() -> void: + if not _grabbing: + return + _grabbing = false + var objects: Array = main.doc.get("objects", []) + for i in _grab_members: + var orig: Array = _grab_originals[i] + (objects[i] as Dictionary)["pos"] = [float(orig[0]), float(orig[1]), float(orig[2])] + if i < view_builder.object_markers.size(): + var marker: Node3D = view_builder.object_markers[i] + if marker != null and is_instance_valid(marker): + marker.position = Vector3(float(orig[0]), float(orig[1]), float(orig[2])) + _grab_members = [] + _grab_originals = {} + _grab_lifts = {} + _grab_before = {} + main.call("_set_status", "Greifen abgebrochen — alles ist wieder, wie es war.") + + ## ⇄ Spiegeln (X: links↔rechts, Z: vor↔zurück) an der Stelle des Objekts — @@ -594,6 +810,14 @@ func mirror_selected(axis: String) -> void: # ------------------------------------------------------------ Eingaben ------ func _unhandled_input(event: InputEvent) -> void: + # Strg-Zwang (Karte t_55257792) aus JEDEM durchgelassenen Event verfolgen — + # Maus-Events tragen den Modifier, Tasten-Events ebenso (nicht konsumieren!). + if event is InputEventMouseMotion: + _ctrl_held = (event as InputEventMouseMotion).ctrl_pressed + elif event is InputEventMouseButton: + _ctrl_held = (event as InputEventMouseButton).ctrl_pressed + elif event is InputEventKey: + _ctrl_held = (event as InputEventKey).ctrl_pressed var mode: String = main.get("tool_mode") if mode == "terrain" or mode == "spawns": return # Terrain-Pinsel / Spawn-Zonen haben das Zepter (eigene Werkzeuge) @@ -601,6 +825,11 @@ func _unhandled_input(event: InputEvent) -> void: var mm := event as InputEventMouseMotion if placing: _ghost_update(mm.position) + if _grabbing: + var gh: Variant = _ground_point(mm.position) + if gh != null: + grab_move(gh as Vector3) + return if _rubber_pending or _rubber_active: var dist := Vector2(mm.position.x - _press_pos.x, mm.position.y - _press_pos.y).length() if dist > DRAG_THRESHOLD_PX: @@ -634,6 +863,10 @@ func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: + if _grabbing: + grab_confirm() + get_viewport().set_input_as_handled() + return _on_left_press(mb) get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_LEFT and not mb.pressed: @@ -650,7 +883,7 @@ func _unhandled_input(event: InputEvent) -> void: place_rot_deg = fmod(place_rot_deg + ROT_STEP_DEG + 3600.0, 360.0) _ghost.rotation.y = deg_to_rad(place_rot_deg) get_viewport().set_input_as_handled() - elif selected_index >= 0: + elif selected_index >= 0 and not _grabbing: rotate_selected(ROT_STEP_DEG) get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: @@ -660,7 +893,7 @@ func _unhandled_input(event: InputEvent) -> void: place_rot_deg = fmod(place_rot_deg - ROT_STEP_DEG + 3600.0, 360.0) _ghost.rotation.y = deg_to_rad(place_rot_deg) get_viewport().set_input_as_handled() - elif selected_index >= 0: + elif selected_index >= 0 and not _grabbing: rotate_selected(-ROT_STEP_DEG) get_viewport().set_input_as_handled() diff --git a/src/editor/move_math.gd b/src/editor/move_math.gd new file mode 100644 index 0000000..e43fb18 --- /dev/null +++ b/src/editor/move_math.gd @@ -0,0 +1,61 @@ +extends RefCounted +class_name MoveMath +## Shortcuts-Mathe für Karte 2/3 (t_55257792) — bewusst pure Funktionen +## (kein Knoten, keine Kamera): Pfeiltasten-Raster-Schritt + Terrain- +## Pinselgrößen-Zyklus. tests/test_shortcuts.gd prüft headless; main.gd und +## interaction.gd sind nur die dünnen Anwender. +## +## Pfeiltasten-Konvention (Editor-Üblichkeit): „↑" bewegt die Auswahl VON der +## Kamera WEG (in Blickrichtung, horizontal), „↓" auf sie zu, links/rechts +## entsprechend quer. Die Blickrichtung wird auf die NÄCHSTE Weltachse +## gerastet (Godot-Editor-Konvention) — eine Pfeiltaste verschiebt also immer +## exakt entlang X oder Z, nie diagonal. + +const NUDGE_STEP_M := 2.0 # eine Rasterzelle pro Tastendruck (Karte 2/3) + + +## Pfeil-Richtung → Welt-Delta (2 m). yaw_deg = Kamera-Gierwinkel (Orbit ODER +## Flug), fwd = +1 (↑) / −1 (↓), side = +1 (rechts) / −1 (links). +## Die horizontale Blickrichtung (FlyMath.forward_from_euler(yaw, 0)) wird auf +## die nächste Weltachse gerastet: |z| >= |x| → Z-Achse, sonst X-Achse +## (Gleichstand → Z, deterministisch). Rechts = Blickrichtung × oben gekreuzt. +static func nudge_delta(yaw_deg: float, fwd: int, side: int) -> Vector3: + var f := forward_from_euler(yaw_deg, 0.0) + var axis := Vector3.ZERO + if absf(f.z) >= absf(f.x): + axis = Vector3(0.0, 0.0, -1.0) if f.z <= 0.0 else Vector3(0.0, 0.0, 1.0) + else: + axis = Vector3(-1.0, 0.0, 0.0) if f.x <= 0.0 else Vector3(1.0, 0.0, 0.0) + var right := axis.cross(Vector3.UP) + return (axis * float(fwd) + right * float(side)) * NUDGE_STEP_M + + +## Horizontale Blickrichtung (nur Yaw — identische Formel wie FlyMath, hier +## kopiert statt Abhängigkeit, damit die Datei ohne FlyMath-Load testbar bleibt). +static func forward_from_euler(yaw_deg: float, _pitch_deg: float) -> Vector3: + var yaw := deg_to_rad(yaw_deg) + return Vector3(-sin(yaw), 0.0, -cos(yaw)) + + +# ------------------------------------------------- Terrain-Pinsel-Presets ---- + +## Pinselgröße-Zyklus durch die 3 Presets (Klein 4 m / Mittel 12 m / Groß 32 m, +## wie die Buttons im Terrain-Panel). up = ] (vergrößern), down = [ (verkleinern). +## Bei einem FREIEN Slider-Wert (nutzergezogen) geht ] zum nächsten Preset +## ÜBER dem Wert, [ zum nächsten DARUNTER — am Rand wird zyklisch gewrappt. +## Rückgabe: der neue Radius in Metern. +static func brush_next_preset(current_m: float, up: bool) -> float: + var presets := [4.0, 12.0, 32.0] + if up: + for r in presets: + if r > current_m + 0.001: + return r + return presets[0] # zyklisch: über dem größten → wieder Klein + # Abwärts: das NÄCHSTE Preset UNTER dem Wert (größtes darunter) — + # rückwärts iterieren, sonst fände die Schleife immer Klein zuerst. + var i := presets.size() - 1 + while i >= 0: + if presets[i] < current_m - 0.001: + return presets[i] + i -= 1 + return presets[presets.size() - 1] # zyklisch: unter dem kleinsten → Groß diff --git a/src/editor/move_math.gd.uid b/src/editor/move_math.gd.uid new file mode 100644 index 0000000..9001904 --- /dev/null +++ b/src/editor/move_math.gd.uid @@ -0,0 +1 @@ +uid://behfbtvkyai65 diff --git a/src/editor/object_ops.gd b/src/editor/object_ops.gd index ae9f6de..a6eff52 100644 --- a/src/editor/object_ops.gd +++ b/src/editor/object_ops.gd @@ -11,6 +11,58 @@ const DUPLICATE_OFFSET_M := Vector2(2.0, 0.0) # Kopie +2 m in X (Rasterversatz, # Karte t_c46d31c2) +## Auswahl per Pfeiltaste im 2-m-Raster verschieben (Karte t_55257792) — pure. +## Genau das gegebene Delta (±2 m pro Tastendruck, Richtung von MoveMath), +## X/Z an der Map geklemmt. Die HÖHE bleibt relativ zum Terrain erhalten +## (t_7567a06f-Semantik wie beim Ziehen): ein angehobenes Objekt schwebt +## weiter, ein bodennahes bleibt bodennah — ein Objekt auf dem 2-m-Raster +## landet wieder exakt auf dem Raster. Rückgabe: Anzahl geänderter Objekte. +static func nudge_objects(doc: Dictionary, indices: Array, delta: Vector3) -> int: + var objects: Array = doc.get("objects", []) + var half := _terrain_half_size(doc) + var changed := 0 + for i in indices: + if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary): + continue + var obj: Dictionary = objects[i] + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() != 3: + continue + var x := clampf(float(pos[0]) + delta.x, -half.x, half.x) + var z := clampf(float(pos[2]) + delta.z, -half.y, half.y) + var lift := float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) + var y := SvmapDocument.terrain_height_at(doc, x, z) + lift + if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001: + changed += 1 + obj["pos"] = [x, y, z] + return changed + + +## Die AUSWAHL auf den Boden absetzen (Strg+Bild↑, Karte t_55257792 — das +## Godot-"Snap Object to Floor"-Äquivalent fürs Fußpunkt-Modell): pos.y wird +## exakt die Terrain-Höhe an (x, z). X/Z bleiben unangetastet. Rückgabe: +## Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden). +static func snap_selection_to_floor(doc: Dictionary, indices: Array) -> int: + var objects: Array = doc.get("objects", []) + var changed := 0 + for i in indices: + if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary): + continue + var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() != 3: + continue + var h := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) + if absf(float(pos[1]) - h) > FLOAT_EPSILON: + (objects[i] as Dictionary)["pos"] = [float(pos[0]), h, float(pos[2])] + changed += 1 + return changed + + +static func _terrain_half_size(doc: Dictionary) -> Vector2: + var terrain: Dictionary = doc.get("terrain", {}) + return Vector2(float(terrain.get("size_x_m", 1024.0)), float(terrain.get("size_z_m", 1024.0))) * 0.5 + + ## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen ## z. B. nach dem Terrain-Formen in der Luft — ein Klick holt sie runter. ## Rückgabe: Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden). diff --git a/src/editor/shortcut_keys.gd b/src/editor/shortcut_keys.gd index 290c10a..24a180b 100644 --- a/src/editor/shortcut_keys.gd +++ b/src/editor/shortcut_keys.gd @@ -35,6 +35,21 @@ const ACTIONS := { # Strg+LMB = Mehrfachauswahl umschalten (Maus-Event, handler prüft den # Modifier direkt am InputEventMouseButton — Action dokumentiert die Idee). "editor_multi_select": [{"key": KEY_NONE, "mouse_left_ctrl": true}], + # --- Objekt-Manipulation (Karte t_55257792, Karte 2/3) ------------------- + "editor_rotate_90": [KEY_R], + "editor_nudge_up": [KEY_UP], + "editor_nudge_down": [KEY_DOWN], + "editor_nudge_left": [KEY_LEFT], + "editor_nudge_right": [KEY_RIGHT], + "editor_lift_to_floor": [{"key": KEY_PAGEUP, "ctrl": true}], + "editor_grab": [KEY_G], + # --- Terrain-Pinsel (nur im Terrain-Werkzeug aktiv, main gated) ---------- + "editor_brush_smaller": [KEY_BRACKETLEFT], + "editor_brush_larger": [KEY_BRACKETRIGHT], + "editor_brush_raise": [KEY_1], + "editor_brush_lower": [KEY_2], + "editor_brush_smooth": [KEY_3], + "editor_brush_paint": [KEY_4], } diff --git a/src/editor/shortcut_policy.gd b/src/editor/shortcut_policy.gd index ad046bd..b375834 100644 --- a/src/editor/shortcut_policy.gd +++ b/src/editor/shortcut_policy.gd @@ -9,15 +9,21 @@ class_name ShortcutPolicy ## 2. Ghost-Preview (Platzieren) abbrechen ## 3. Auswahl aufheben ## Rückgabe-Werte: -## "exit_terrain" | "exit_spawns" | "exit_bauen" | "cancel_placement" -## "clear_selection" | "none" -static func esc_action(tool_mode: String, placing: bool, has_selection: bool) -> String: +## "exit_terrain" | "exit_spawns" | "exit_bauen" | "cancel_grab" +## "cancel_placement" | "clear_selection" | "none" +## grabbing (Karte t_55257792): G-Greifen läuft — Esc rollt die Positionen +## zurück (Blender-Semantik). Vorrang NACH Werkzeug-Exit (im Terrain-/Bauen- +## Werkzeug gibt es kein Greifen), VOR Platzieren/Greifen-Auswahlheben. +static func esc_action(tool_mode: String, placing: bool, has_selection: bool, + grabbing := false) -> String: if tool_mode == "terrain": return "exit_terrain" if tool_mode == "spawns": return "exit_spawns" if tool_mode == "bauen": return "exit_bauen" + if grabbing: + return "cancel_grab" if placing: return "cancel_placement" if has_selection: diff --git a/src/editor/terrain_tool.gd b/src/editor/terrain_tool.gd index c47e33b..be9e5a8 100644 --- a/src/editor/terrain_tool.gd +++ b/src/editor/terrain_tool.gd @@ -119,7 +119,9 @@ func _refresh_from_doc() -> bool: # ------------------------------------------------------------ Pinsel-Optionen func set_brush_mode(mode: int) -> void: - brush_mode = clampi(mode, 0, 2) + # Clamp bis PAINT (3) — der alte Clamp 0..2 hätte den Malen-Modus still + # zu Glätten degradiert (Karte t_55257792, Ziffer 4 = Malen). + brush_mode = clampi(mode, 0, MODE_LABELS.size() - 1) _refresh_ring_mesh() diff --git a/src/ui/main.gd b/src/ui/main.gd index a1ae8b3..4babfa1 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -19,6 +19,8 @@ const APP_TITLE := "ScopedVR Map-Editor" # Shortcut-Infrastruktur (Karte t_c46d31c2): InputMap-Actions + Esc-Priorität. const ShortcutKeys := preload("res://src/editor/shortcut_keys.gd") const ShortcutPolicy := preload("res://src/editor/shortcut_policy.gd") +# Shortcuts-Mathe Karte 2/3 (t_55257792): Pfeil-Raster + Pinsel-Presets. +const MoveMath := preload("res://src/editor/move_math.gd") var manifest: Dictionary = {} var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format) @@ -1098,17 +1100,26 @@ func _unhandled_key_input(event: InputEvent) -> void: else: interaction.group_selection() get_viewport().set_input_as_handled() + elif tool_mode == "objects" and not interaction.placing: + # G = Greifen (Karte t_55257792, Blender-Semantik): Auswahl + # hängt an der Maus, Linksklick setzt neu, Esc bricht ab. + interaction.grab_begin() + get_viewport().set_input_as_handled() KEY_R: if not ctrl: + # R = Vierteldrehung 90° (Karte t_55257792): Bauen-Modus dreht + # Vorschau/Auswahl (wie bisher), Objekt-Auswahl UND Ghost drehen + # jetzt auch in 90°-Schritten (Umschalt+R = zurück). Feindrehen + # 15° bleibt am Mausrad. + var r_delta := -90.0 if event.shift_pressed else 90.0 if tool_mode == "bauen": build_tool.call("rotate_input") get_viewport().set_input_as_handled() return - var delta := -15.0 if event.shift_pressed else 15.0 if interaction.placing: - interaction.place_rot_deg = wrapf(interaction.place_rot_deg + delta, 0.0, 360.0) + interaction.place_rot_deg = wrapf(interaction.place_rot_deg + r_delta, 0.0, 360.0) else: - interaction.rotate_selected(delta) + interaction.rotate_selected(r_delta) get_viewport().set_input_as_handled() KEY_F: if not ctrl: @@ -1122,10 +1133,12 @@ func _unhandled_key_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() KEY_ESCAPE: # Esc-Priorität über pure Funktion (Karte t_c46d31c2, testbar): - # Werkzeug verlassen → Ghost-Preview abbrechen → Auswahl aufheben. + # Werkzeug verlassen → Greifen zurückrollen → Ghost-Preview + # abbrechen → Auswahl aufheben (Greifen: Karte t_55257792). var esc := ShortcutPolicy.esc_action(tool_mode, interaction.placing, - interaction.selected_index != -1 or not interaction.selected_indices.is_empty()) + interaction.selected_index != -1 or not interaction.selected_indices.is_empty(), + bool(interaction.get("_grabbing"))) match esc: "exit_terrain": _set_tool_mode("objects") @@ -1136,6 +1149,8 @@ func _unhandled_key_input(event: InputEvent) -> void: "exit_bauen": _set_tool_mode("objects") _set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.") + "cancel_grab": + interaction.grab_cancel() "cancel_placement": interaction.cancel_placement() _set_status("Platzieren abgebrochen.") @@ -1160,7 +1175,17 @@ func _unhandled_key_input(event: InputEvent) -> void: interaction.duplicate_selected() get_viewport().set_input_as_handled() KEY_PAGEUP: - if not ctrl and not interaction.placing: + if interaction.placing: + return + if ctrl: + # Strg+Bild↑ (Karte t_55257792): Auswahl/Bauteil auf den Boden. + if tool_mode == "bauen": + build_tool.call("snap_selected_to_floor") + get_viewport().set_input_as_handled() + elif tool_mode == "objects" and interaction.selected_index >= 0: + interaction.snap_selection_to_floor() + get_viewport().set_input_as_handled() + elif not ctrl: if tool_mode == "bauen": build_tool.call("lift_selected", ObjectOps.LIFT_STEP_M) get_viewport().set_input_as_handled() @@ -1175,6 +1200,44 @@ func _unhandled_key_input(event: InputEvent) -> void: elif tool_mode == "objects" and interaction.selected_index >= 0: interaction.lift_selected(-ObjectOps.LIFT_STEP_M) get_viewport().set_input_as_handled() + KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT: + # Pfeiltasten (Karte t_55257792): Auswahl im 2-m-Raster verschieben, + # Richtung = Kamera-Ausrichtung nächste Weltachse (MoveMath). Eine + # Zelle pro Tastendruck; beim Platzieren bleibt der Ghost herrschend. + if interaction.placing: + return + var fwd := 0 + var side := 0 + match key: + KEY_UP: + fwd = 1 + KEY_DOWN: + fwd = -1 + KEY_RIGHT: + side = 1 + KEY_LEFT: + side = -1 + var delta := MoveMath.nudge_delta( + float(camera.call("view_yaw_deg")), fwd, side) + if tool_mode == "bauen": + build_tool.call("nudge_selected", delta) + get_viewport().set_input_as_handled() + elif tool_mode == "objects" and interaction.selected_index >= 0: + interaction.nudge_selection(delta) + get_viewport().set_input_as_handled() + KEY_BRACKETLEFT, KEY_BRACKETRIGHT: + # Terrain-Pinsel [ / ] (Karte t_55257792): Größe durch die 3 Presets + # zyklisch — nur im Terrain-Werkzeug (Ziffern-Freiheit, Punkt 6). + if tool_mode == "terrain": + _terrain_brush_size_step(key == KEY_BRACKETRIGHT) + get_viewport().set_input_as_handled() + KEY_1, KEY_2, KEY_3, KEY_4: + # Terrain-Pinsel-Modi 1–4 (Karte t_55257792): Heben/Senken/Glätten/ + # Malen — NUR im Terrain-Werkzeug (Konfliktfreiheit, Punkt 6; im + # Objekt-/Bauen-Modus bleiben die Ziffern unbenutzt). + if tool_mode == "terrain": + _terrain_brush_mode_key(int(key) - int(KEY_1)) + get_viewport().set_input_as_handled() # --------------------------------------------------------- Bibliothek ------ @@ -1292,7 +1355,7 @@ func _enter_terrain_mode() -> void: return tool_mode = "terrain" terrain_panel.visible = true - help_label.text = "Linksklick-Ziehen: Boden formen oder malen · Rechtsklick-Ziehen: Gegenteil / Gras zurück ·\nPinselgröße & Material rechts · Mittlere Maustaste: Kamera · Strg+Z: letzter Strich weg" + help_label.text = "Linksklick-Ziehen: Boden formen oder malen · Rechtsklick-Ziehen: Gegenteil / Gras zurück ·\n1/2/3/4: Heben/Senken/Glätten/Malen · [ / ]: Pinselgröße Klein/Mittel/Groß ·\nPinselgröße & Material rechts · Mittlere Maustaste: Kamera · Strg+Z: letzter Strich weg" terrain_edit_btn.button_pressed = true terrain_tool.call("set_brush_mode", _selected_terrain_mode()) _set_status("Terrain-Modus an: LINKSKLICK-Ziehen formt den Boden, im Malen-Modus färbt er das Gelände (Gras/Sand/Fels). Regler rechts stellen Pinsel und Stärke ein.") @@ -1330,7 +1393,7 @@ func _leave_spawn_mode() -> void: func _enter_objects_mode() -> void: tool_mode = "objects" - help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR / Mausrad: drehen · Strg+D: duplizieren · Bild↑/↓: anheben/absenken · Spiegeln rechts · Entf: löschen · Strg+Z: zurück" + help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR: 90° drehen (Mausrad: 15° fein) · Pfeiltasten: 2-m-Raster · G: greifen (Esc: zurück) · Strg+D: duplizieren · Bild↑/↓: anheben/absenken\nStrg+Bild↑: auf Boden setzen · Strg halten: Snap-Zwang · Spiegeln rechts · Entf: löschen · Strg+Z: zurück" _base_tool_status() @@ -1359,7 +1422,7 @@ func _enter_build_mode() -> void: interaction.call("clear_selection", true) tool_mode = "bauen" build_panel.visible = true - help_label.text = "Linksklick: Bauteil setzen (auch auf andere Teile = stapeln) · Umschalt+Klick: Bauteil auswählen ·\nR: drehen (90°) · Bild↑/↓: anheben/absenken · Entf: löschen · Mittlere Maustaste: Kamera · Strg+Z: zurück" + help_label.text = "Linksklick: Bauteil setzen (auch auf andere Teile = stapeln) · Umschalt+Klick: Bauteil auswählen ·\nR: drehen (90°) · Pfeiltasten: im 2-m-Raster verschieben · Bild↑/↓: anheben/absenken · Strg+Bild↑: auf Boden · Entf: löschen · Mittlere Maustaste: Kamera · Strg+Z: zurück" build_edit_btn.button_pressed = true build_tool.call("activate") _set_status("Bauen-Modus an: Bauteil rechts anklicken, dann in die Karte klicken — alles rastet aufs 2-m-Raster.") @@ -1425,6 +1488,55 @@ func _on_terrain_size_preset(pressed: bool, radius_m: float) -> void: terrain_radius_slider.value = radius_m +## Terrain-Pinsel-Größe per [ / ] (Karte t_55257792): durch die 3 Presets +## Klein/Mittel/Groß zyklisch — der Preset-Button wird mit gesetzt (set_pressed +## feuert toggled → Slider + Label + Pinssel-Ring wandern mit). +func _terrain_brush_size_step(up: bool) -> void: + if terrain_radius_slider == null: + return + var next := MoveMath.brush_next_preset(float(terrain_radius_slider.value), up) + var btn: Button = null + match next: + 4.0: + btn = terrain_size_btn_small + 12.0: + btn = terrain_size_btn_medium + 32.0: + btn = terrain_size_btn_large + if btn != null: + btn.set_pressed(true) # feuert _on_terrain_size_preset → Slider/Label + else: + terrain_radius_slider.value = next + terrain_tool.call("set_radius", next) + _set_status("Pinsel-Größe: %d m (mit [ / ] durch Klein/Mittel/Groß zyklisch)." % int(next)) + + +## Terrain-Pinsel-Modus per Ziffer 1–4 (Karte t_55257792): Heben/Senken/ +## Glätten/Malen — der Modus-Button wird mit gesetzt (toggled feuert die +## bestehende Sync-Kette inkl. Material-Palette im Malen-Modus). Malen startet +## mit der zuletzt gewählten Materialfarbe (paint_material bleibt stehen). +func _terrain_brush_mode_key(mode_idx: int) -> void: + var btn: Button = null + match mode_idx: + 0: + btn = terrain_mode_raise + 1: + btn = terrain_mode_lower + 2: + btn = terrain_mode_smooth + 3: + btn = terrain_mode_paint + if btn == null or terrain_tool == null: + return + btn.set_pressed(true) + var labels := ["Heben", "Senken", "Glätten", "Malen"] + var extra := "" + if mode_idx == 3: + var mats: Array = SvmapIO.MATERIAL_IDS + extra = " (Material: %s — mit der Palette rechts wechseln)" % str(mats[clampi(int(terrain_tool.get("paint_material")), 0, mats.size() - 1)]) + _set_status("Pinsel-Modus: %s%s — Linksklick-Ziehen formt/malt jetzt so (Ziffern 1–4 wechseln)." % [str(labels[mode_idx]), extra]) + + func _on_terrain_radius_changed(value: float) -> void: terrain_radius_label.text = "Pinsel-Größe: %d m" % int(value) if terrain_tool != null: @@ -1458,7 +1570,7 @@ func _recheck_terrain_mode() -> void: tool_mode = "objects" terrain_panel.visible = false _sync_terrain_button() - help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR / Mausrad: drehen · Strg+D: duplizieren · Bild↑/↓: anheben/absenken · Spiegeln rechts · Entf: löschen · Strg+Z: zurück" + help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR: 90° drehen (Mausrad: 15° fein) · Pfeiltasten: 2-m-Raster · G: greifen (Esc: zurück) · Strg+D: duplizieren · Bild↑/↓: anheben/absenken\nStrg+Bild↑: auf Boden setzen · Strg halten: Snap-Zwang · Spiegeln rechts · Entf: löschen · Strg+Z: zurück" _set_status("Terrain-Modus aus: Diese Map passt nicht ins Editor-MVP (Terrain-Auflösung über %d) — die Objekte bleiben normal bearbeitbar." % TerrainField.EDITOR_MAX_RESOLUTION, true) diff --git a/src/viewport/orbit_camera.gd b/src/viewport/orbit_camera.gd index 280851d..56c9133 100644 --- a/src/viewport/orbit_camera.gd +++ b/src/viewport/orbit_camera.gd @@ -160,6 +160,13 @@ func fly_active() -> bool: return _flying +## Aktueller Gierwinkel der BLICKRICHTUNG (Karte t_55257792): im Flug der +## Flug-Yaw, im Orbit der Orbit-Yaw. Für die Pfeiltasten-Richtung (MoveMath +## rastet sie auf die nächste Weltachse). +func view_yaw_deg() -> float: + return _fly_yaw if _flying else yaw_deg + + ## Auswahl ins Bild zentrieren (F): Orbit → Ziel-Punkt auf den Auswahl-Mittelpunkt; ## Flug → Blickrichtung drehen, Position bleibt. func focus_on(center: Vector3) -> void: diff --git a/tests/test_gui_smoke.gd b/tests/test_gui_smoke.gd index 01c619a..dad7a83 100644 --- a/tests/test_gui_smoke.gd +++ b/tests/test_gui_smoke.gd @@ -40,6 +40,7 @@ func _run_all() -> void: _test_ui_built() _test_terrain_presets() _test_shortcut_undo() + _test_terrain_hotkeys() _finish() @@ -123,12 +124,11 @@ func _test_shortcut_undo() -> void: main.history.snapshot(["objects"])) _check((main.doc["objects"] as Array).size() == 1, "Test-Objekt platziert") - # Vorher-Zustand für die unbehandelte Taste merken. - var status_vorher := str(main.status_label.text) - + # Karte 1 (t_c46d31c2) hat F belegt: F zentriert die Auswahl — ohne + # Auswahl gibt es den freundlichen Hinweis (kein Crash, kein Wirken). main._unhandled_key_input(_press_key(KEY_F, false)) - _check(str(main.status_label.text) == status_vorher, - "unbehandelte Taste (F) ändert nichts, läuft aber sauber durch") + _check(str(main.status_label.text).contains("Nichts ausgewählt"), + "F ohne Auswahl: freundlicher Hinweis, läuft sauber durch") main._unhandled_key_input(_press_key(KEY_Z, true)) _check((main.doc["objects"] as Array).is_empty(), "Strg+Z hat das Objekt entfernt (Undo wirkt)") @@ -145,3 +145,63 @@ func _test_shortcut_undo() -> void: main._unhandled_key_input(_press_key(KEY_Z, true)) _check(str(main.status_label.text).contains("Nichts zum Rückgängigmachen"), "Strg+Z bei leerer History: Statusmeldung, kein Absturz") + + +## Karte 2/3 (t_55257792): Terrain-Ziffern 1–4 + [ / ] am ECHTEN main — +## Buttons/Slider/Label müssen mitwandern, Ziffern außerhalb des Terrain- +## Werkzeugs dürfen NICHTS tun (Konfliktfreiheit). +func _test_terrain_hotkeys() -> void: + print("Testgruppe: Terrain-Pinsel per Tastatur (1–4, [ und ])") + main._set_tool_mode("terrain") + _check(str(main.get("tool_mode")) == "terrain", "Terrain-Modus aktiv") + + # Ziffer 2 = Senken: Modus-Button + Pinsel-Modus wandern mit. + main._unhandled_key_input(_press_key(KEY_2, false)) + _check(main.terrain_mode_lower.button_pressed, "Ziffer 2: Button „Senken“ aktiv") + _check(int(main.terrain_tool.get("brush_mode")) == TerrainField.Mode.LOWER, + "Ziffer 2: Pinsel-Modus = Senken") + # Ziffer 4 = Malen: Palette sichtbar, Modus wirklich PAINT (alter + # set_brush_mode-Clamp hätte das still zu Glätten degradiert). + main._unhandled_key_input(_press_key(KEY_4, false)) + _check(int(main.terrain_tool.get("brush_mode")) == TerrainField.Mode.PAINT, + "Ziffer 4: Pinsel-Modus = Malen (Clamp-Fix)") + _check(main.terrain_palette.visible, "Ziffer 4: Material-Palette sichtbar") + # Ziffer 1 zurück: Palette verschwindet wieder. + main._unhandled_key_input(_press_key(KEY_1, false)) + _check(int(main.terrain_tool.get("brush_mode")) == TerrainField.Mode.RAISE, + "Ziffer 1: Pinsel-Modus = Heben") + _check(not main.terrain_palette.visible, "Ziffer 1: Material-Palette weg") + + # [ / ] : Preset-Zyklus Klein(4)/Mittel(12)/Groß(32), Slider + Label mit. + # Slider steht nach dem Klein-Klick oben noch auf 4 m — die Kette startet + # also von Klein: ] ] ] = 12 → 32 → wrap 4, [ = 32, [ [ = 12 → 4. + main._unhandled_key_input(_press_key(KEY_BRACKETRIGHT, false)) + _check(is_equal_approx(float(main.terrain_radius_slider.value), 12.0), + "] ab 4 m: Slider auf 12 m (Mittel)") + _check(str(main.terrain_radius_label.text).contains("12"), "]: Label zeigt 12 m") + _check(main.terrain_size_btn_medium.button_pressed, "]: Preset-Button Mittel aktiv") + main._unhandled_key_input(_press_key(KEY_BRACKETRIGHT, false)) + _check(is_equal_approx(float(main.terrain_radius_slider.value), 32.0), + "] ab 12 m: Slider auf 32 m (Groß)") + _check(str(main.terrain_radius_label.text).contains("32"), "]: Label zeigt 32 m") + _check(main.terrain_size_btn_large.button_pressed, "]: Preset-Button Groß aktiv") + main._unhandled_key_input(_press_key(KEY_BRACKETRIGHT, false)) + _check(is_equal_approx(float(main.terrain_radius_slider.value), 4.0), + "] ab 32 m: Zyklus wrappt auf Klein (4 m)") + main._unhandled_key_input(_press_key(KEY_BRACKETLEFT, false)) + _check(is_equal_approx(float(main.terrain_radius_slider.value), 32.0), + "[ ab 4 m: wrappt zurück auf Groß (32 m)") + main._unhandled_key_input(_press_key(KEY_BRACKETLEFT, false)) + main._unhandled_key_input(_press_key(KEY_BRACKETLEFT, false)) + _check(is_equal_approx(float(main.terrain_radius_slider.value), 4.0), + "[ zweimal ab 32 m: 12 → 4 (Klein)") + + # Konfliktfreiheit: außerhalb des Terrain-Werkzeugs tun Ziffern/[ NICHTS. + main._set_tool_mode("objects") + var slider_vorher: float = main.terrain_radius_slider.value + main._unhandled_key_input(_press_key(KEY_2, false)) + main._unhandled_key_input(_press_key(KEY_BRACKETRIGHT, false)) + _check(is_equal_approx(float(main.terrain_radius_slider.value), slider_vorher), + "Ziffern/[ im Objekt-Modus: Pinsel bleibt unangetastet") + _check(int(main.terrain_tool.get("brush_mode")) == TerrainField.Mode.RAISE, + "Ziffern im Objekt-Modus: Pinsel-Modus bleibt unangetastet") diff --git a/tests/test_shortcuts.gd b/tests/test_shortcuts.gd index ad5ce33..427b205 100644 --- a/tests/test_shortcuts.gd +++ b/tests/test_shortcuts.gd @@ -14,13 +14,22 @@ func _init() -> void: _test_inputmap_actions() _test_fly_math() _test_esc_policy() + _test_esc_grab_policy() _test_duplicate_offset() + _test_move_math() # Node-basierte Tests (global_position, look_at) brauchen einen fertigen # Baum — in _init ist root noch nicht "inside tree" (Gui-Smoke-Muster). await process_frame _test_multi_delete() _test_focus_center() _test_camera_fly_roundtrip() + _test_rotate_90() + _test_nudge_grid() + _test_lift_multi() + _test_snap_to_floor() + _test_grab() + _test_build_nudge_floor() + _test_brush_paint_mode() if failures == 0: print("ALL OK: %d checks" % checks) quit(0) @@ -88,10 +97,14 @@ class ProbeMain: class ProbeBuilder extends Node3D: var object_markers: Array = [] var last_selection: Array = [] + var last_gebaeude_selection := -99 func set_selected_multi(indices: Array) -> void: last_selection = indices.duplicate() + func set_gebaeude_selected(idx: int) -> void: + last_gebaeude_selection = idx + # ------------------------------------------------------------ InputMap ------- @@ -149,6 +162,36 @@ func _test_inputmap_actions() -> void: if ev is InputEventMouseButton and (ev as InputEventMouseButton).button_index == MOUSE_BUTTON_RIGHT: freelook_rmb = true _check(freelook_rmb, "editor_freelook → Rechte Maustaste") + # Karte 2/3 (t_55257792): neue Actions mit physischen Tasten. + var expected_keys_2 := { + "editor_rotate_90": KEY_R, + "editor_nudge_up": KEY_UP, + "editor_nudge_down": KEY_DOWN, + "editor_nudge_left": KEY_LEFT, + "editor_nudge_right": KEY_RIGHT, + "editor_grab": KEY_G, + "editor_brush_smaller": KEY_BRACKETLEFT, + "editor_brush_larger": KEY_BRACKETRIGHT, + "editor_brush_raise": KEY_1, + "editor_brush_lower": KEY_2, + "editor_brush_smooth": KEY_3, + "editor_brush_paint": KEY_4, + } + for action in expected_keys_2: + if not InputMap.has_action(action): + _check(false, "Action existiert: %s" % action) + continue + var ok := false + for ev in InputMap.action_get_events(action): + if ev is InputEventKey and (ev as InputEventKey).physical_keycode == int(expected_keys_2[action]): + ok = true + _check(ok, "%s → physische Taste %s" % [action, OS.get_keycode_string(int(expected_keys_2[action]))]) + var lift_floor := false + for ev in InputMap.action_get_events("editor_lift_to_floor"): + if ev is InputEventKey and (ev as InputEventKey).physical_keycode == KEY_PAGEUP \ + and (ev as InputEventKey).ctrl_pressed: + lift_floor = true + _check(lift_floor, "editor_lift_to_floor → Strg+Bild↑") # ------------------------------------------------------------ FlyMath -------- @@ -348,4 +391,349 @@ func _test_camera_fly_roundtrip() -> void: cam.call("toggle_fly_lock") _check(bool(cam.call("fly_active")), "Umschalt+F: Dauerflug an (ohne RMT)") cam.call("toggle_fly_lock") - _check(not bool(cam.call("fly_active")), "Umschalt+F nochmal: zurück zum Orbit") \ No newline at end of file + _check(not bool(cam.call("fly_active")), "Umschalt+F nochmal: zurück zum Orbit") + # Blick-Yaw-Accessor (Karte t_55257792): Orbit → yaw_deg, Flug → Flug-Yaw. + _check(absf(float(cam.call("view_yaw_deg")) - 30.0) < 0.0001, + "view_yaw_deg im Orbit = Orbit-Yaw (30°)") + cam.set("_fly_yaw", -120.0) + cam.set("_flying", true) + _check(absf(float(cam.call("view_yaw_deg")) + 120.0) < 0.0001, + "view_yaw_deg im Flug = Flug-Yaw") + + +# --------------------------------------------------------- Esc + Greifen ----- + +func _test_esc_grab_policy() -> void: + print("Testgruppe: Esc-Priorität mit Greifen (ShortcutPolicy)") + _check(ShortcutPolicy.esc_action("objects", false, false, true) == "cancel_grab", + "Esc beim Greifen → Greifen zurückrollen") + _check(ShortcutPolicy.esc_action("objects", false, true, true) == "cancel_grab", + "Greifen hat VORRANG vor Auswahl-Aufheben") + _check(ShortcutPolicy.esc_action("objects", true, true, true) == "cancel_grab", + "Greifen hat VORRANG vor Platzieren (kann nicht koexistieren)") + _check(ShortcutPolicy.esc_action("terrain", false, false, true) == "exit_terrain", + "Werkzeug-Exit bleibt VOR Greifen (im Terrain gibt es kein Greifen)") + _check(ShortcutPolicy.esc_action("objects", true, true, false) == "cancel_placement", + "alter 3-Argument-Aufruf bleibt unverändert (Rückwärtskompatibel)") + + +# ------------------------------------------------------ MoveMath (Karte 2) ---- + +func _test_move_math() -> void: + print("Testgruppe: Pfeil-Raster + Pinsel-Presets (MoveMath)") + # Blick -Z (yaw 0): ↑ = -Z, ↓ = +Z, rechts = +X, links = -X. + _check(MoveMath.nudge_delta(0.0, 1, 0) == Vector3(0, 0, -2), "yaw 0: ↑ = 2 m nach -Z") + _check(MoveMath.nudge_delta(0.0, -1, 0) == Vector3(0, 0, 2), "yaw 0: ↓ = 2 m nach +Z") + _check(MoveMath.nudge_delta(0.0, 0, 1) == Vector3(2, 0, 0), "yaw 0: → = 2 m nach +X") + _check(MoveMath.nudge_delta(0.0, 0, -1) == Vector3(-2, 0, 0), "yaw 0: ← = 2 m nach -X") + # Blick -X (yaw 90): ↑ = -X, rechts = -Z (rechts vom Blick). + _check(MoveMath.nudge_delta(90.0, 1, 0) == Vector3(-2, 0, 0), "yaw 90: ↑ = 2 m nach -X") + _check(MoveMath.nudge_delta(90.0, 0, 1) == Vector3(0, 0, -2), "yaw 90: → = 2 m nach -Z") + # Blick +Z (yaw 180): ↑ = +Z. + _check(MoveMath.nudge_delta(180.0, 1, 0) == Vector3(0, 0, 2), "yaw 180: ↑ = 2 m nach +Z") + # Blick +X (yaw -90): ↑ = +X, → = +Z. + _check(MoveMath.nudge_delta(-90.0, 1, 0) == Vector3(2, 0, 0), "yaw -90: ↑ = 2 m nach +X") + _check(MoveMath.nudge_delta(-90.0, 0, 1) == Vector3(0, 0, 2), "yaw -90: → = 2 m nach +Z") + # Schräger Blick rastet auf die NÄCHSTE Weltachse (Editor-Konvention): + # yaw 20 → Blick überwiegend -Z → ↑ = -Z; yaw 70 → überwiegend -X → ↑ = -X. + _check(MoveMath.nudge_delta(20.0, 1, 0) == Vector3(0, 0, -2), "yaw 20 (schräg): ↑ rastet auf -Z") + _check(MoveMath.nudge_delta(70.0, 1, 0) == Vector3(-2, 0, 0), "yaw 70 (schräg): ↑ rastet auf -X") + # Pinsel-Presets: Zyklus Klein(4) → Mittel(12) → Groß(32) → Klein ... + _check(MoveMath.brush_next_preset(4.0, true) == 12.0, "[ ]-Zyklus: Klein → Mittel") + _check(MoveMath.brush_next_preset(12.0, true) == 32.0, "[ ]-Zyklus: Mittel → Groß") + _check(MoveMath.brush_next_preset(32.0, true) == 4.0, "[ ]-Zyklus: Groß → Klein (wrap)") + _check(MoveMath.brush_next_preset(32.0, false) == 12.0, "[ ]-Zyklus zurück: Groß → Mittel") + _check(MoveMath.brush_next_preset(4.0, false) == 32.0, "[ ]-Zyklus zurück: Klein → Groß (wrap)") + # Freier Slider-Wert: nächstes Preset ÜBER bzw. UNTER dem Wert. + _check(MoveMath.brush_next_preset(20.0, true) == 32.0, "freier Wert 20: ] → nächstes Preset darüber (32)") + _check(MoveMath.brush_next_preset(20.0, false) == 12.0, "freier Wert 20: [ → nächstes Preset darunter (12)") + + +# --------------------------------------------------------- R = 90°-Drehen ---- + +func _make_interaction(doc: Dictionary, markers: int) -> Array: + var fake := ProbeMain.new() + fake.doc = doc + fake.history.begin(fake.doc) + var vp := SubViewport.new() + vp.size = Vector2i(1280, 720) + root.add_child(vp) + var cam := Camera3D.new() + vp.add_child(cam) + cam.current = true + var builder := ProbeBuilder.new() + vp.add_child(builder) + for i in markers: + var m := Node3D.new() + m.set_meta("half_height", 1.0) + builder.object_markers.append(m) + var interaction := Node3D.new() + interaction.set_script(load("res://src/editor/interaction.gd")) + vp.add_child(interaction) + interaction.setup(fake, cam, builder) + return [fake, interaction, builder] + + +func _test_rotate_90() -> void: + print("Testgruppe: R = Auswahl 90° drehen (byte-genau)") + var doc := {"objects": [ + {"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 30.0}, + {"id": "tree_pine", "pos": [20.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "crate_wood", "pos": [10.0, 0.0, 20.0], "scale": 1.0, "rot_y_deg": 0.0}, + ]} + var pair := _make_interaction(doc, 3) + var fake: ProbeMain = pair[0] + var interaction = pair[1] + # Einzelnes Objekt: R dreht rot_y_deg exakt +90, Position bleibt (Drehung + # um die eigene Mitte = Fußpunkt bleibt an Ort und Stelle). + interaction.select_object(0) + interaction.rotate_selected(90.0) + var objects: Array = doc["objects"] + _check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001, "R: 30° → 120° (exakt +90)") + _check(float(objects[0]["pos"][0]) == 10.0 and float(objects[0]["pos"][2]) == 10.0, + "R: Einzelobjekt bleibt auf seinem Fußpunkt stehen") + _check(float(objects[0]["pos"][1]) == 0.0, "R: Füßpunkt-Y byte-genau unverändert") + _check(fake.history.can_undo(), "R: EIN History-Eintrag pro Drehung") + # Mehrfachauswahl: beide drehen um die GEMEINSAME Mitte — beide rotieren + # +90, die Mitte bleibt (byte-genau: gleicher Code-Pfad wie Mausrad). + interaction.select_many([0, 2]) + var c_before := GroupOps.centroid(doc, [0, 2]) + interaction.rotate_selected(90.0) + var c_after := GroupOps.centroid(doc, [0, 2]) + _check(absf(float(objects[0]["rot_y_deg"]) - 210.0) < 0.0001 + and absf(float(objects[2]["rot_y_deg"]) - 90.0) < 0.0001, + "R bei Mehrfachauswahl: beide Objekte +90°") + _check((c_after - c_before).length() < 0.0001, + "R: Auswahl-Mittelpunkt bleibt exakt (Drehung um die Mitte)") + # EIN History-Eintrag pro Operation: das erste Undo nimmt nur die + # Mehrfach-Drehung zurück (210°→120°, Fußpunkte wieder am alten Ort), + # das zweite die Einzel-Drehung davor (120°→30°). + fake.history.undo() + _check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001 + and absf(float(objects[2]["rot_y_deg"])) < 0.0001 + and float(objects[0]["pos"][0]) == 10.0 and float(objects[0]["pos"][2]) == 10.0 + and float(objects[2]["pos"][0]) == 10.0 and float(objects[2]["pos"][2]) == 20.0, + "Strg+Z: Mehrfach-Drehung komplett zurück (Rotation + Fußpunkte byte-genau)") + fake.history.undo() + _check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, + "Strg+Z: zweite Ebene — Einzel-Drehung auch zurück (30°)") + # Bauen-Modus: Bauteil rotiert um seinen Fußpunkt (Position unverändert). + var build_doc := {"gebaeude": [ + {"id": "wand", "pos": [4.0, 0.0, 6.0], "rot": 0.0, "punkte": [[0, 0, 0], [2, 0, 0]]}, + ]} + var bfake := ProbeMain.new() + bfake.doc = build_doc + bfake.history.begin(bfake.doc) + var bbuilder := ProbeBuilder.new() + root.add_child(bbuilder) + var btool := Node3D.new() + btool.set_script(load("res://src/editor/build_tool.gd")) + root.add_child(btool) + btool.setup(bfake, Camera3D.new(), bbuilder, interaction) + btool.select_index(0) + btool.rotate_input() + var list: Array = BuildOps.pieces(build_doc) + _check(absf(float(list[0]["rot"]) - 90.0) < 0.0001, "Bauen: R dreht Bauteil 90°") + _check(float(list[0]["pos"][0]) == 4.0 and float(list[0]["pos"][2]) == 6.0, + "Bauen: Fußpunkt bleibt (Drehung um den Fußpunkt)") + + +# ---------------------------------------------------- Pfeiltasten 2-m-Raster -- + +func _test_nudge_grid() -> void: + print("Testgruppe: Pfeiltasten — 2-m-Raster, Höhe wandert mit, EIN Undo pro Druck") + var doc := {"objects": [ + {"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "tree_pine", "pos": [10.0, 3.0, 12.0], "scale": 1.0, "rot_y_deg": 0.0}, + ]} + var pair := _make_interaction(doc, 2) + var fake: ProbeMain = pair[0] + var interaction = pair[1] + var objects: Array = doc["objects"] + interaction.select_object(0) + interaction.nudge_selection(Vector3(0, 0, -2)) + _check(float(objects[0]["pos"][0]) == 10.0 and float(objects[0]["pos"][2]) == 8.0, + "↑: exakt 2 m in -Z, Fußpunkt bleibt im Raster") + _check(fake.history.can_undo(), "Pfeiltaste: EIN History-Eintrag") + fake.history.undo() + _check(float(objects[0]["pos"][2]) == 10.0, "Strg+Z: eine Zelle zurück") + # Höhe wandert mit (t_7567a06f-Semantik): angehobenes Objekt (y=3 über + # flachem Terrain) bleibt exakt auf 3 m. + interaction.select_object(1) + interaction.nudge_selection(Vector3(2, 0, 0)) + _check(float(objects[1]["pos"][1]) == 3.0, "Pfeiltaste: Terrain-Abstand bleibt (3 m über flachem Boden)") + _check(float(objects[1]["pos"][0]) == 12.0, "Pfeiltaste: 2 m in +X") + # Mehrfachauswahl wandert gemeinsam, relative Anordnung exakt erhalten. + interaction.select_many([0, 1]) + var d0: Array = [float(objects[0]["pos"][0]) - float(objects[1]["pos"][0]), + float(objects[0]["pos"][2]) - float(objects[1]["pos"][2])] + interaction.nudge_selection(Vector3(-2, 0, 0)) + var d1: Array = [float(objects[0]["pos"][0]) - float(objects[1]["pos"][0]), + float(objects[0]["pos"][2]) - float(objects[1]["pos"][2])] + _check(absf(d0[0] - d1[0]) < 0.0001 and absf(d0[1] - d1[1]) < 0.0001, + "Mehrfachauswahl: relative Anordnung byte-genau erhalten") + # Map-Rand: außerhalb klemmt — komplett geklemmter Druck committet NICHT. + var small := {"terrain": {"size_x_m": 100.0, "size_z_m": 100.0, "resolution": 0}, + "objects": [{"id": "crate_wood", "pos": [50.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}]} + var pair2 := _make_interaction(small, 1) + var fake2: ProbeMain = pair2[0] + var interaction2 = pair2[1] + var sobjects: Array = small["objects"] + interaction2.select_object(0) + var had_undo: bool = fake2.history.can_undo() + interaction2.nudge_selection(Vector3(2, 0, 0)) + _check(float(sobjects[0]["pos"][0]) == 50.0 and fake2.history.can_undo() == had_undo, + "Map-Rand: komplett geklemmte Zelle → keine Bewegung, KEIN History-Eintrag") + + +# ------------------------------------------- Bild↑/↓ Mehrfach + Boden-Snap ---- + +func _test_lift_multi() -> void: + print("Testgruppe: Bild↑/↓ auf der GANZEN Auswahl (EIN Undo-Schritt)") + var doc := {"objects": [ + {"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "tree_pine", "pos": [20.0, 0.5, 20.0], "scale": 1.0, "rot_y_deg": 0.0}, + ]} + var pair := _make_interaction(doc, 2) + var interaction = pair[1] + var objects: Array = doc["objects"] + interaction.select_many([0, 1]) + interaction.lift_selected(ObjectOps.LIFT_STEP_M) + _check(absf(float(objects[0]["pos"][1]) - 0.25) < 0.0001, + "Bild↑: Objekt 1 +0,25 m") + _check(absf(float(objects[1]["pos"][1]) - 0.75) < 0.0001, + "Bild↑: Objekt 2 +0,25 m (0,5 → 0,75)") + var marker_y: float = (pair[2].object_markers[0] as Node3D).position.y + _check(absf(marker_y - 0.25) < 0.0001, "Bild↑: Marker wandert mit") + interaction.lift_selected(-ObjectOps.LIFT_STEP_M) + _check(absf(float(objects[0]["pos"][1])) < 0.0001, "Bild↓: wieder −0,25 m") + # Objekt am Boden: weiter absenken geht nicht — kein Commit, Meldung kommt. + var can_undo: bool = (pair[0].history as ActionHistory).can_undo() + interaction.lift_selected(-ObjectOps.LIFT_STEP_M) + _check(absf(float(objects[0]["pos"][1])) < 0.0001, "Bild↓ am Boden: y bleibt 0") + _check((pair[0].history as ActionHistory).can_undo() == can_undo, + "Bild↓ am Boden: nichts committet (Snapshot verworfen)") + + +func _test_snap_to_floor() -> void: + print("Testgruppe: Strg+Bild↑ — Auswahl auf den Boden setzen") + var doc := {"objects": [ + {"id": "crate_wood", "pos": [10.0, 3.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "tree_pine", "pos": [20.0, 0.0, 20.0], "scale": 1.0, "rot_y_deg": 0.0}, + ]} + var pair := _make_interaction(doc, 2) + var fake: ProbeMain = pair[0] + var interaction = pair[1] + var objects: Array = doc["objects"] + interaction.select_many([0, 1]) + interaction.snap_selection_to_floor() + _check(float(objects[0]["pos"][1]) == 0.0, "Strg+Bild↑: schwebendes Objekt auf Terrain-Höhe (0 m)") + _check(float(objects[0]["pos"][0]) == 10.0 and float(objects[0]["pos"][2]) == 10.0, + "Strg+Bild↑: X/Z bleiben unangetastet") + _check(fake.history.can_undo(), "Strg+Bild↑: EIN History-Eintrag") + # Zweiter Druck: alles steht schon am Boden → keine Aktion, kein Eintrag. + var can_undo: bool = fake.history.can_undo() + interaction.snap_selection_to_floor() + _check(fake.history.can_undo() == can_undo, "Strg+Bild↑ am Boden: kein leerer Commit") + + +# --------------------------------------------------------------- Greifen (G) -- + +func _test_grab() -> void: + print("Testgruppe: G — Greifen mit Esc-Rückabwicklung (byte-genau)") + var doc := {"objects": [ + {"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "tree_pine", "pos": [12.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0}, + ]} + var pair := _make_interaction(doc, 2) + var fake: ProbeMain = pair[0] + var interaction = pair[1] + var builder: ProbeBuilder = pair[2] + var objects: Array = doc["objects"] + fake.grid_snap = true + interaction.select_many([0, 1]) + var ok: bool = interaction.grab_begin() + _check(ok, "G mit Auswahl: Greifen startet") + # Linksklick-Setzen: Primär (letzter Auswahl-Index = 1) rastet auf ganze + # Meter, das andere Auswahl-Objekt wandert exakt mit demselben Delta mit. + interaction.grab_move(Vector3(20.3, 0.0, 8.6)) + interaction.grab_confirm() + _check(float(objects[1]["pos"][0]) == 20.0 and float(objects[1]["pos"][2]) == 9.0, + "Greifen + LMB: Primär rastet aufs Raster (20, 9)") + _check(float(objects[0]["pos"][0]) == 18.0 and float(objects[0]["pos"][2]) == 9.0, + "Greifen: zweites Auswahl-Objekt wandert exakt mit (+8, −1)") + _check(float(objects[1]["pos"][1]) == 0.0, "Greifen: Füßpunkt auf Terrain-Höhe") + _check(fake.history.can_undo(), "Greifen: EIN History-Eintrag für den ganzen Zug") + fake.history.undo() + _check(float(objects[0]["pos"][0]) == 10.0 and float(objects[1]["pos"][0]) == 12.0, + "Strg+Z: Greifen-Zug komplett zurück") + # Esc-Rückabwicklung: Positionen byte-identisch zum Zustand vor dem Greifen. + interaction.grab_begin() + interaction.grab_move(Vector3(30.7, 0.0, 1.2)) + _check(float(objects[1]["pos"][0]) == 31.0, "Greifen: live am Cursor (rastet)") + var can_undo: bool = fake.history.can_undo() + interaction.grab_cancel() + _check(float(objects[0]["pos"][0]) == 10.0 and float(objects[0]["pos"][1]) == 0.0 + and float(objects[0]["pos"][2]) == 10.0, + "Esc: exakte Rückabwicklung auf die Ausgangsposition (byte-identisch)") + _check(float(objects[1]["pos"][0]) == 12.0 and float(objects[1]["pos"][2]) == 10.0, + "Esc: auch das zweite Objekt zurück") + _check(fake.history.can_undo() == can_undo, "Esc: KEIN History-Eintrag beim Abbruch") + _check(not bool(interaction.get("_grabbing")), "Esc: Greifen-Zustand beendet") + # Marker sind nach dem Abbruch wieder synchron (Viewport = Doc). + _check(absf((builder.object_markers[0] as Node3D).position.x - 10.0) < 0.0001, + "Esc: Viewport-Marker zurückgesetzt") + # G ohne Auswahl: hilfreiche Meldung, kein Crash. + interaction.clear_selection(true) + _check(not interaction.grab_begin(), "G ohne Auswahl: Greifen startet nicht") + _check(fake.statuses.size() > 0, "G ohne Auswahl: hilfreiche Meldung statt Stille") + + +# ------------------------------------------- Bauen: Pfeile + Strg+Bild↑ ------- + +func _test_build_nudge_floor() -> void: + print("Testgruppe: Bauen-Modus — Pfeiltasten (2-m-Raster) + Strg+Bild↑") + var doc := {"gebaeude": [ + {"id": "wand", "pos": [4.0, 0.0, 6.0], "rot": 0.0, "punkte": [[0, 0, 0], [2, 0, 0]]}, + ]} + var bfake := ProbeMain.new() + bfake.doc = doc + bfake.history.begin(bfake.doc) + var bbuilder := ProbeBuilder.new() + root.add_child(bbuilder) + var btool := Node3D.new() + btool.set_script(load("res://src/editor/build_tool.gd")) + root.add_child(btool) + btool.setup(bfake, Camera3D.new(), bbuilder, null) + btool.select_index(0) + btool.nudge_selected(Vector3(0, 0, -2)) + var list: Array = BuildOps.pieces(doc) + _check(float(list[0]["pos"][2]) == 4.0 and float(list[0]["pos"][0]) == 4.0, + "Bauen-Pfeil: exakt 2 m, Raster-Ausrichtung bleibt") + _check(bfake.history.can_undo(), "Bauen-Pfeil: EIN History-Eintrag") + bfake.history.undo() + _check(float(list[0]["pos"][2]) == 6.0, "Bauen-Pfeil: Strg+Z zurück") + # Strg+Bild↑: Bauteil auf Terrain-Höhe (flaches Terrain → 0 m, 5-cm-Raster). + list[0]["pos"] = [4.0, 2.5, 6.0] + btool.snap_selected_to_floor() + _check(absf(float(list[0]["pos"][1])) < 0.0001, "Bauen Strg+Bild↑: Bauteil auf dem Boden (0 m)") + # Erneut: liegt schon am Boden → Meldung, kein Commit. + var can_undo: bool = bfake.history.can_undo() + btool.snap_selected_to_floor() + _check(bfake.history.can_undo() == can_undo, "Bauen Strg+Bild↑ am Boden: kein leerer Commit") + + +# ------------------------------------------ Terrain-Ziffer 4 = Malen (Fix) ---- + +func _test_brush_paint_mode() -> void: + print("Testgruppe: Terrain-Pinsel — Modus 3 (Malen) kommt durch set_brush_mode") + var tool := Node3D.new() + tool.set_script(load("res://src/editor/terrain_tool.gd")) + root.add_child(tool) + tool.call("set_brush_mode", TerrainField.Mode.PAINT) + _check(int(tool.get("brush_mode")) == TerrainField.Mode.PAINT, + "set_brush_mode(Malen) bleibt Malen (alter Clamp 0..2 degradierte still zu Glätten)") + tool.call("set_brush_mode", TerrainField.Mode.RAISE) + _check(int(tool.get("brush_mode")) == TerrainField.Mode.RAISE, "set_brush_mode(Heben) bleibt Heben") + tool.call("set_brush_mode", 99) + _check(int(tool.get("brush_mode")) == TerrainField.Mode.PAINT, "Out-of-Range clampet auf den höchsten Modus") \ No newline at end of file diff --git a/tools/parse_check_shortcuts2.sh b/tools/parse_check_shortcuts2.sh new file mode 100644 index 0000000..2ce7396 --- /dev/null +++ b/tools/parse_check_shortcuts2.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Parse-Check der Shortcuts-2-Karte (t_55257792): alle geänderten/neuen Scripts. +cd "$(dirname "$0")/.." +GODOT=/opt/data/home/bin/godot4 +fail=0 +for f in src/ui/main.gd src/viewport/orbit_camera.gd src/editor/shortcut_keys.gd \ + src/editor/move_math.gd src/editor/shortcut_policy.gd \ + src/editor/interaction.gd src/editor/object_ops.gd \ + src/editor/build_tool.gd src/editor/terrain_tool.gd \ + tests/test_shortcuts.gd tests/test_gui_smoke.gd; do + out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true) + if [ -n "$out" ]; then + echo "FAIL $f:" + echo "$out" + fail=1 + else + echo "OK $f" + fi +done +exit $fail diff --git a/tools/run_tests.sh b/tools/run_tests.sh index d04a075..559e1bc 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -3,7 +3,7 @@ cd "$(dirname "$0")/.." GODOT=~/bin/godot4 rc=0 -for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_spawns test_testrun test_bauen; do +for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_spawns test_testrun test_bauen test_gui_smoke; do "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1 code=$? line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)