From eca11bbf83929f92990a3b396fd8c769595014e6 Mon Sep 17 00:00:00 2001 From: "Hermes (godot-vr)" Date: Thu, 15 Oct 2026 15:32:24 +0000 Subject: [PATCH] Objekt-Ops: Duplizieren (Strg+D) + Anheben/Absenken (Bild hoch/runter) + Spiegeln (X/Z) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Karte t_7567a06f. Hoehe wohnt direkt in object.pos.y — KEIN svmap-Format- Feld noetig (V9 erlaubt freie y-Werte in bounds; Server-Loader geprueft). Absenken stoppt am Terrain (nichts begraben), Anheben deckelt auf bounds.max. Spiegeln am Objekt selbst (Position bleibt): X → −rot, Z → 180°−rot selbstinvers. Alles über ActionHistory (Merge-Tags für Lift-Serien), deutsche UI + Klartext-Status, dragt behält Terrain-Abstand. test_ux.gd: 90 Checks (42 neu), Suite 5/5 grün, xvfb-Smoke UI-Bau ok. --- README.md | 51 +++++++++++++++ src/editor/interaction.gd | 128 +++++++++++++++++++++++++++++++++++- src/editor/object_ops.gd | 97 ++++++++++++++++++++++++++++ src/ui/main.gd | 85 ++++++++++++++++++++---- tests/test_ux.gd | 132 +++++++++++++++++++++++++++++++++++++- tools/parse_check.sh | 0 tools/run_tests.sh | 0 7 files changed, 480 insertions(+), 13 deletions(-) mode change 100644 => 100755 tools/parse_check.sh mode change 100644 => 100755 tools/run_tests.sh diff --git a/README.md b/README.md index c7d24df..e1e51b0 100644 --- a/README.md +++ b/README.md @@ -162,3 +162,54 @@ Auf der Objekt-Bibliothek (t_3a998ae4) aufgebaut, nach dem Chef-Grundsatz: - **Desktop-Smoke:** `godot … -- --spawns-demo [ordner] [--spawns-demo-quit]` fährt Zonen-Übersicht → Live-Check-Alarm → Undo → „Karte prüfen“ programmatisch durch und speichert Screenshots. + +## Status (M2, Karte t_7567a06f — Objekt-Ops: Duplizieren + Hoch/Runter + Spiegeln) + +Auf dem UX-Fundament (t_40d855d0) und der Objekt-Bibliothek (t_3a998ae4) +aufgebaut, nach dem Chef-Grundsatz (Klick statt Konsole, deutsche UI, +Undo für alles): + +- **Duplizieren (`⧉ Duplizieren`-Button + Strg+D):** Die Kopie des + ausgewählten Objekts hängt diagonal 1 m versetzt daneben (mit Raster-Snap + eingerastet, wenn an) und ist DIREKT angewählt — sie lässt sich gleich + weiterziehen/drehen/löschen. Drehung, Größe und Höhe werden übernommen. + Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft + (Klartext-Meldung, was zu tun ist). EIN Undo-Schritt. +- **Anheben/Absenken (`▲ Anheben / ▼ Absenken` + Bild↑/Bild↓):** Objekte + ändern die Höhe in 0,25-m-Schritten. MEHRERE Klicks in Serie = EIN + Undo-Schritt. Design-Entscheidungen, dokumentiert: + - Die Höhe wohnt direkt in `object.pos.y` — das svmap-Format erlaubt + freie y-Werte innerhalb der Match-Grenzen (Loader-Regel V9), deshalb + ist **KEIN Extra-Feld im Dateiformat nötig** und die Loader-Kompatibilität + bleibt unangetastet (bewiesen in `tests/test_ux.gd`: Erhöhtes Objekt + überlebt gespeichern → laden intakt (Höhe bleibt exakt)). + - **Absenken stoppt an der Terrain-Höhe** am Objektstandort — nichts wird + in Hügel begraben. Steckt ein Objekt im Boden (z. B. nach dem + Terrain-Formen), bewegt Absenken es NICHT, und die Statusleiste erklärt, + dass `▲ Anheben` oder `⚡ Alles auf den Boden setzen` hilft. + - **Anheben** hat als Obergrenze die Match-Grenze `bounds.max.y` — höher + passt der Server nicht (V9). + - **Ziehen behält die Höhe:** Ein angehobenes Objekt schwebt beim + Verschieben in derselben Höhe über dem (neuen) Boden weiter; bodennah + bleibt bodennah. + - Die Auswahl-Anzeige zeigt die Höhe mit („+0,25 m über dem Boden“). +- **Spiegeln (`⇄ Links↔Rechts` / `⇅ Vor↔Zurück`):** Das Objekt wird AN + SEINER STelle gespiegelt — Position bleibt, die Blickrichtung dreht: + - links↔rechts (Welt-X-Spiegelung): `rot_y_deg → −rot` + - vor↔zurück (Welt-Z-Spiegelung): `rot_y_deg → 180° − rot` + (Herleitung im Quellcode: gespiegelter Richtungs-Vektor = gespiegelter + Winkel; jede Spiegelung ist dadurch selbstinvers — zweimal spiegeln = + Ausgangszustand.) Die Rotation wird mitgespiegelt, wie bestellt. + Bewusst NICHT um die Kartemitte gespiegelt: das würde das Objekt quer + über die Map springen lassen — überraschend für Einsteiger. Klartext + in Buttons + Tooltip. +- **Alles über ActionHistory:** Snapshot vor/nach JEDE Aktion + (Duplizieren = EIN Schritt, Anheben-Serie = EIN Schritt via Merge-Tag, + Spiegeln = EIN Schritt). Statusleiste erklärt nach jeder Aktion den + nächsten Schritt und die Grenzen auf Deutsch. +- **Tests:** `tests/test_ux.gd` auf 90 Checks erweitert (42 neu): + Duplizieren (Raster/ohne Raster/Undo-Roundtrip/Index-Schutz), Anheben/ + Absenken (Schrittweite, Boden-Stopp, Deckel bounds.max, versteckt-Regel, + Merge-Tags), Spiegeln (X/Z-Mapping, Selbstinvers, Position unverändert), + svmap-Roundtrip mit angehobener Höhe. `godot … --quit-after` smoke: + UI baut alle neuen Buttons fehlerfrei. diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 6467504..31b918c 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -14,6 +14,9 @@ const ROT_STEP_DEG := 15.0 const DRAG_THRESHOLD_PX := 4.0 const GRID_M := 1.0 # Raster-Snap-Weite (Objekt-Werkzeuge links, Karte t_3a998ae4) +# Höhe beim Ziehen merken (Karte t_7567a06f): das Objekt behält seinen +# Abstand zum Terrain — ein angehobenes Objekt bleibt beim Verschieben oben. + var main # Hauptfenster (script-erweitert, Variant — Referenzen per setup()) var camera: Camera3D var view_builder # script-erweitert, Variant @@ -31,6 +34,7 @@ var _dragging := false var _drag_moved := false var _press_pos := Vector2.ZERO var _grab_offset := Vector3.ZERO +var _drag_lift := 0.0 # Terrain-Abstand des Objekts beim Anfassen (Höhe hebt sich mit) func setup(p_main, p_camera: Camera3D, p_builder) -> void: @@ -272,6 +276,121 @@ func delete_selected() -> void: main.call("_set_status", "%s gelöscht — Strg+Z (Rückgängig) holt es zurück." % label) +# ------------------------------------------ Duplizieren/Höhe/Spiegeln ------ +## (Karte t_7567a06f — alle drei auch bei Sly bedienbar: Button rechts + +## Tastenkürzel, alles über ActionHistory, Klartext-Status-Hilfe.) + +## Strg+D / Button "Duplizieren": Kopie diagonal leicht versetzt (mit +## Raster-Snap eingerastet, wenn an), direkt angewählt, EIN Undo-Schritt. +## Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft. +func duplicate_selected() -> void: + if selected_index < 0: + main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du kopieren willst.", true) + return + var objects: Array = main.doc.get("objects", []) + if selected_index >= objects.size(): + clear_selection(true) + return + if objects.size() >= SvmapIO.MAX_OBJECTS: + main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — das Löschen von Objekten macht wieder Platz." % SvmapIO.MAX_OBJECTS, true) + return + var obj: Dictionary = objects[selected_index] + var oid := str(obj.get("id", "")) + var lib: Dictionary = main.manifest.get("objects", {}) + var entry: Dictionary = lib.get(oid, {}) + var scl := float(obj.get("scale", 1.0)) + var poly := float(entry.get("poly", 0)) * scl * scl + var used := SvmapDocument.poly_usage(main.doc, main.manifest) + var budget := float(SvmapIO.MAX_TOTAL_POLY) + if used + poly > budget: + main.call("_set_status", "Für eine Kopie fehlt das Poly-Budget (%s von %s belegt) — Strg+Z nimmt etwas zurück, oder lösche ein anderes Objekt." % [ + main._fmt_int(int(used)), main._fmt_int(int(budget))], true) + return + var before: Dictionary = main.history.snapshot(["objects"]) + var new_index: int = ObjectOps.duplicate_object(main.doc, selected_index, bool(main.get("grid_snap"))) + if new_index < 0: + clear_selection(true) + return + main.history.commit("%s duplizieren" % main.object_label(oid), + before, main.history.snapshot(["objects"])) + main.call("_after_structure_change") + # Kopie direkt anwählen — Sly kann sie gleich weiterziehen. + select_object(new_index) + var objects2: Array = main.doc.get("objects", []) + var label: String = main.object_label(str(objects2[new_index].get("id", oid))) + main.call("_set_status", "%s dupliziert: das neue Objekt ist direkt angewählt — Ziehen verschiebt es, Entf löscht es wieder." % label) + + +## ▲/▼ / Strg+Bild↑/↓: Objekt in 0,25-m-Schritten anheben/absenken. +## 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. +func lift_selected(delta_m: float) -> void: + if selected_index < 0: + main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du anheben oder absenken willst.", true) + return + if placing: + return + var objects: Array = main.doc.get("objects", []) + if selected_index >= objects.size(): + clear_selection(true) + return + var before: Dictionary = main.history.snapshot(["objects"]) + var applied := ObjectOps.lift_object(main.doc, selected_index, delta_m) + if absf(applied) < 0.0001: + # Nichts passiert (Snapshot verwerfen): hilfsbereit erklären, WO die Grenze ist. + var ground: String + if delta_m > 0.0: + ground = "Die Match-Höhe ist erreicht — höher passt der Server nicht mehr." + else: + ground = "Weiter absenken geht nicht: Das Objekt liegt am Boden. Steckt es IN einem Hügel? ▲ Anheben oder \"⚡ Alles auf den Boden setzen\" holt es raus." + 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]) + 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])]) + + +## ⇄ Spiegeln (X: links↔rechts, Z: vor↔zurück) an der Stelle des Objekts — +## die Blickrichtung wird gespiegelt (sicher selbstinvers: doppelt = zurück). +func mirror_selected(axis: String) -> void: + if selected_index < 0: + main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du spiegeln willst.", true) + return + var objects: Array = main.doc.get("objects", []) + if selected_index >= objects.size(): + clear_selection(true) + return + var before: Dictionary = main.history.snapshot(["objects"]) + var obj: Dictionary = objects[selected_index] + var old_rot := float(obj.get("rot_y_deg", 0.0)) + ObjectOps.mirror_object(obj, axis) + var new_rot := float(obj.get("rot_y_deg", 0.0)) + 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.rotation.y = deg_to_rad(new_rot) + var desc := "Objekt gespiegelt (links↔rechts)" if axis == "x" else "Objekt gespiegelt (vor↔zurück)" + main.history.commit(desc, before, main.history.snapshot(["objects"])) + main.dirty = true + main.call("_refresh_file_label") + main.call("_refresh_history_buttons") + main.call("_sync_selection_panel") + main.call("_set_status", "%s — Blickrichtung %.0f° → %.0f°. Strg+Z stellt die alte Richtung wieder her." % [ + desc, old_rot, new_rot]) + + # ------------------------------------------------------------ Eingaben ------ func _unhandled_input(event: InputEvent) -> void: @@ -290,7 +409,9 @@ func _unhandled_input(event: InputEvent) -> void: var half := _terrain_half() new_pos.x = clampf(new_pos.x, -half.x, half.x) new_pos.z = clampf(new_pos.z, -half.y, half.y) - new_pos.y = SvmapDocument.terrain_height_at(main.doc, new_pos.x, new_pos.z) + # Höhe: der Terrain-Abstand bleibt beim Ziehen erhalten (angehoben + # schwebt weiter oben) — kein harter Terrain-Clamp mehr, t_7567a06f. + new_pos.y = SvmapDocument.terrain_height_at(main.doc, new_pos.x, new_pos.z) + _drag_lift var dist := Vector2(mm.position.x - _press_pos.x, mm.position.y - _press_pos.y).length() if dist > DRAG_THRESHOLD_PX: _drag_moved = true @@ -345,8 +466,13 @@ func _on_left_press(mb: InputEventMouseButton) -> void: if p != null and marker != null and is_instance_valid(marker): _grab_offset = marker.position - (p as Vector3) _grab_offset.y = 0.0 + # Terrain-Abstand einfrieren (t_7567a06f): ein Objekt mit Höhe behält + # beim Ziehen diesen Abstand zum Boden — bodennah bleibt bodennah. + _drag_lift = marker.position.y \ + - SvmapDocument.terrain_height_at(main.doc, marker.position.x, marker.position.z) else: _grab_offset = Vector3.ZERO + _drag_lift = 0.0 else: # Leere Stelle: Auswahl aufheben (klicken statt auswendig lernen). if selected_index != -1: diff --git a/src/editor/object_ops.gd b/src/editor/object_ops.gd index 7580b46..4d0929c 100644 --- a/src/editor/object_ops.gd +++ b/src/editor/object_ops.gd @@ -6,6 +6,8 @@ class_name ObjectOps const GRID_M := 1.0 # Raster-Snap-Weite (1 m — handhabbar für Objekte) const FLOAT_EPSILON := 0.01 # ab dieser Abweichung gilt ein Objekt als "schwebend" +const LIFT_STEP_M := 0.25 # Anheben/Absenken: grober Rasterschritt (Karte t_7567a06f) +const DUPLICATE_OFFSET_M := Vector2(1.0, 1.0) # Kopie diagonal leicht versetzt ## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen @@ -32,3 +34,98 @@ static func snap_coord(value: float, enabled: bool, grid_m := GRID_M) -> float: if not enabled or grid_m <= 0.0: return value return roundf(value / grid_m) * grid_m + + +# ---------------------------------------------------- Objekt-Ops (t_7567a06f) -- +# Duplizieren · Anheben/Absenken · Spiegeln. Bewusst pure Funktionen wie der +# Rest der Datei: die UI (interaction.gd/main.gd) snapshotet, ruft auf, commitet. + +## Duplizieren: Kopie von objects[index] ans ENDE der Liste hängen. Die Kopie +## ist diagonal um DUPLICATE_OFFSET_M versetzt (mit Raster-Snap eingerastet, +## wenn an) und behält Drehung/Größe/Höhe. Rückgabe: neuer Index (-1 = ungültig). +## VOR dem Aufruf prüft die UI Budget/Objekt-Limit (hilfs-Meldung statt Fehler). +static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool) -> int: + var objects: Array = doc.get("objects", []) + if index < 0 or index >= objects.size(): + return -1 + var source = objects[index] + if not (source is Dictionary): + return -1 + var copy: Dictionary = (source as Dictionary).duplicate(true) + var pos: Array = copy.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() == 3: + var nx := float(pos[0]) + DUPLICATE_OFFSET_M.x + var nz := float(pos[2]) + DUPLICATE_OFFSET_M.y + copy["pos"] = [snap_coord(nx, grid_enabled), float(pos[1]), snap_coord(nz, grid_enabled)] + objects.append(copy) + return objects.size() - 1 + + +## Anheben/Absenken in LIFT_STEP_M-Schritten (delta_m positiv = anheben). +## Objekte dürfen vom Terrain abweichen — die Höhe wohnt direkt in object.pos.y +## (das svmap-Format erlaubt freie y-Werte innerhalb der Match-Grenzen, V9 — +## bewusst KEIN Extra-Format-Feld → Loader-Kompatibilität unangetastet). +## Regeln (dokumentiert, README): +## - Anheben: frei nach oben, Höchstgrenze = bounds.max.y (Match-Grenze, V9). +## - Absenken: stoppt an der Terrain-Höhe am Objektstandort — nichts wird +## in Hügel begraben. Ein Objekt, das unter der Oberfläche steckt (z. B. +## nach dem Terrain-Formen), bewegt sich beim Absenken NICHT (0.0 zurück +## — die UI erklärt, dass Anheben oder Snap-to-Ground hilft). +## Rückgabe: tatsächlich angewendete Differenz in Metern (0.0 = nichts +## passiert — kein Commit; die UI gibt eine Erklärung statt). +static func lift_object(doc: Dictionary, index: int, delta_m: float) -> float: + var objects: Array = doc.get("objects", []) + if index < 0 or index >= objects.size(): + return 0.0 + var obj = objects[index] + if not (obj is Dictionary): + return 0.0 + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() != 3: + return 0.0 + var y := float(pos[1]) + var max_y := 80.0 + var bounds: Dictionary = doc.get("bounds", {}) + if (bounds.get("max", null) is Array) and ((bounds["max"] as Array).size() == 3): + max_y = float(bounds["max"][1]) + var target: float + if delta_m >= 0.0: + target = minf(y + delta_m, max_y) + else: + var floor_y := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) + if y < floor_y - FLOAT_EPSILON: + return 0.0 # steckt im Boden — Absenken macht das nicht schlimmer + target = maxf(y + delta_m, floor_y) + var applied := target - y + if absf(applied) < 0.0001: + return 0.0 + obj["pos"] = [float(pos[0]), target, float(pos[2])] + return applied + + +## Spiegeln des Objekts AN SEINER STELLE (nicht quer über die Map): die +## Position bleibt, die Blickrichtung wird gespiegelt: +## axis "x": Welt-X-Spiegelung („links↔rechts“) → rot_y_deg → −rot +## axis "z": Welt-Z-Spiegelung („vor↔zurück“) → rot_y_deg → 180° − rot +## Herleitung: Blickrichtungs-Vektor d(θ) = (−sin θ, −cos θ); Spiegelung +## negiert die jeweilige Komponente → −θ bzw. 180° − θ. Jede Spiegelung ist +## dadurch selbstinvers (zweimal spiegeln = Ausgangszustand). +static func mirror_object(obj: Dictionary, axis: String) -> void: + var rot := float(obj.get("rot_y_deg", 0.0)) + var mirrored := -rot if axis == "x" else fmod(180.0 - rot, 360.0) + obj["rot_y_deg"] = fmod(mirrored + 3600.0, 360.0) + + +## Höhe ÜBER dem Terrain am Objektstandort (> 0 = schwebt, ~0 = steht auf +## dem Boden). Für die Auswahl-Anzeige. +static func lift_offset(doc: Dictionary, index: int) -> float: + var objects: Array = doc.get("objects", []) + if index < 0 or index >= objects.size(): + return 0.0 + var obj = objects[index] + if not (obj is Dictionary): + return 0.0 + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() != 3: + return 0.0 + return float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) diff --git a/src/ui/main.gd b/src/ui/main.gd index ed5b0a1..30fd884 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -47,6 +47,11 @@ var redo_btn: Button var sel_label: Label var sel_rotate_btn: Button var sel_delete_btn: Button +var sel_dup_btn: Button # Objekt-Ops (Karte t_7567a06f) +var sel_mirror_x_btn: Button +var sel_mirror_z_btn: Button +var sel_raise_btn: Button +var sel_lower_btn: Button # Eigenschaften-Panel var prop_name: LineEdit @@ -396,7 +401,7 @@ func _build_center_viewport() -> SubViewportContainer: # Bedien-Hinweis im Viewport (Overlay) help_label = Label.new() - help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück" + help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück" help_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) help_label.offset_left = 12 help_label.offset_bottom = -8 @@ -439,6 +444,39 @@ func _build_right_panel() -> PanelContainer: sel_delete_btn.pressed.connect(interaction.delete_selected) sel_btns.add_child(sel_delete_btn) + # Objekt-Ops (Karte t_7567a06f): Duplizieren · Spiegeln · Anheben/Absenken. + var dup_row := HBoxContainer.new() + dup_row.add_theme_constant_override("separation", 8) + vbox.add_child(dup_row) + sel_dup_btn = _sel_op_button("⧉ Duplizieren (Strg+D)", + "Legt eine Kopie daneben — leicht versetzt, direkt angewählt. Strg+D geht auch.", + func() -> void: interaction.duplicate_selected()) + dup_row.add_child(sel_dup_btn) + + var mirror_row := HBoxContainer.new() + mirror_row.add_theme_constant_override("separation", 8) + vbox.add_child(mirror_row) + sel_mirror_x_btn = _sel_op_button("⇄ Links↔Rechts", + "Spiegelt das Objekt an seiner Stelle (links↔rechts, X-Achse) — die Blickrichtung wird mitgespiegelt.", + func() -> void: interaction.mirror_selected("x")) + mirror_row.add_child(sel_mirror_x_btn) + sel_mirror_z_btn = _sel_op_button("⇅ Vor↔Zurück", + "Spiegelt das Objekt an seiner Stelle (vor↔zurück, Z-Achse) — die Blickrichtung wird mitgespiegelt.", + func() -> void: interaction.mirror_selected("z")) + mirror_row.add_child(sel_mirror_z_btn) + + var lift_row := HBoxContainer.new() + lift_row.add_theme_constant_override("separation", 8) + vbox.add_child(lift_row) + sel_raise_btn = _sel_op_button("▲ Anheben (Bild↑)", + "Objekt 0,25 m anheben — es schwebt dann, auch wenn der Boden darunter anders ist. Bild↑ geht auch.", + func() -> void: interaction.lift_selected(ObjectOps.LIFT_STEP_M)) + lift_row.add_child(sel_raise_btn) + sel_lower_btn = _sel_op_button("▼ Absenken (Bild↓)", + "Objekt 0,25 m absenken — stoppt am Boden (nichts wird in Hügel begraben). Bild↓ geht auch.", + func() -> void: interaction.lift_selected(-ObjectOps.LIFT_STEP_M)) + lift_row.add_child(sel_lower_btn) + vbox.add_child(_separator()) # --- Map-Eigenschaften --- @@ -605,6 +643,15 @@ func _build_right_panel() -> PanelContainer: return panel +func _sel_op_button(text: String, tip: String, action: Callable) -> Button: + var btn := Button.new() + btn.text = text + btn.custom_minimum_size = Vector2(140, 40) + btn.tooltip_text = tip + btn.pressed.connect(action) + return btn + + func _header_label(text: String) -> Label: var label := Label.new() label.text = text @@ -812,6 +859,20 @@ func _unhandled_key_input(event: InputEvent) -> void: else: interaction.delete_selected() get_viewport().set_input_as_handled() + KEY_D: + if ctrl and tool_mode == "objects": + interaction.duplicate_selected() + get_viewport().set_input_as_handled() + KEY_PAGEUP: + if not ctrl and tool_mode == "objects" and interaction.selected_index >= 0 \ + and not interaction.placing: + interaction.lift_selected(ObjectOps.LIFT_STEP_M) + get_viewport().set_input_as_handled() + KEY_PAGEDOWN: + if not ctrl and tool_mode == "objects" and interaction.selected_index >= 0 \ + and not interaction.placing: + interaction.lift_selected(-ObjectOps.LIFT_STEP_M) + get_viewport().set_input_as_handled() # --------------------------------------------------------- Bibliothek ------ @@ -959,7 +1020,7 @@ func _leave_spawn_mode() -> void: func _enter_objects_mode() -> void: tool_mode = "objects" - help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück" + help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück" _base_tool_status() @@ -1014,7 +1075,7 @@ func _recheck_terrain_mode() -> void: tool_mode = "objects" terrain_panel.visible = false _sync_terrain_button() - help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück" + help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · 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) @@ -1148,18 +1209,20 @@ func _mark_tree_selection_walk(item: TreeItem, idx: int) -> void: func _sync_selection_panel() -> void: var objects: Array = doc.get("objects", []) var idx: int = interaction.selected_index - if idx < 0 or idx >= objects.size(): + var has_sel := idx >= 0 and idx < objects.size() + for btn in [sel_rotate_btn, sel_delete_btn, sel_dup_btn, sel_mirror_x_btn, + sel_mirror_z_btn, sel_raise_btn, sel_lower_btn]: + btn.disabled = not has_sel + if not has_sel: sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an." - sel_rotate_btn.disabled = true - sel_delete_btn.disabled = true return var obj: Dictionary = objects[idx] var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) - sel_label.text = "%s (Objekt %d von %d)\nPosition: %.0f, %.0f m · Richtung: %.0f°\nVerschieben: Ziehen · Drehen: R oder Mausrad (15°)\nLöschen: Entf-Taste · Strg+Z nimmt zurück" % [ + var lift := ObjectOps.lift_offset(doc, idx) + var lift_text := "am Boden" if absf(lift) < 0.02 else ("%+.2f m über dem Boden" % lift) + sel_label.text = "%s (Objekt %d von %d)\nPosition: %.0f, %.0f m · Höhe: %s · Richtung: %.0f°\nZiehen: verschieben · R oder Mausrad: drehen (15°)\nBild↑/↓: anheben/absenken · Strg+D: duplizieren\nSpiegeln/Löschen: Buttons oben · Strg+Z: zurück" % [ object_label(str(obj.get("id", ""))), idx + 1, objects.size(), - float(pos[0]), float(pos[2]), float(obj.get("rot_y_deg", 0.0))] - sel_rotate_btn.disabled = false - sel_delete_btn.disabled = false + float(pos[0]), float(pos[2]), lift_text, float(obj.get("rot_y_deg", 0.0))] ## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt). @@ -1174,7 +1237,7 @@ func _selection_status(idx: int) -> void: var budget_warning := "" if used > budget * 80 / 100: budget_warning = " ⚠ Poly-Budget zu %.0f %% voll — bald passt kein Objekt mehr rein." % (100.0 * used / budget) - _set_status("%s ausgewählt — ziehen zum Verschieben, R oder Mausrad zum Drehen, Entf zum Löschen.%s" % [ + _set_status("%s ausgewählt — ziehen zum Verschieben, R oder Mausrad zum Drehen, Bild↑/↓ anheben/absenken, Strg+D duplizieren, Entf zum Löschen.%s" % [ object_label(str(obj.get("id", ""))), budget_warning]) diff --git a/tests/test_ux.gd b/tests/test_ux.gd index 62e23f7..756f30e 100644 --- a/tests/test_ux.gd +++ b/tests/test_ux.gd @@ -64,6 +64,7 @@ func _run_tests() -> void: _test_history_cap_and_clear() _test_terrain_height() _test_mesh_shapes() + _test_object_ops() ## Abnahme-Kriterium: Undo-Roundtrip (setzen → undo → redo) — und analog @@ -237,4 +238,133 @@ func _test_mesh_shapes() -> void: _check(cyl is CylinderMesh and absf((cyl as CylinderMesh).height - 12.0) < 0.001, "cylinder → CylinderMesh mit Höhe") _check(absf(MeshShapes.half_height(b) - 2.0) < 0.001, "half_height(box 4 m) = 2 m — Fußpunkt-Konvention") _check(absf(MeshShapes.half_height(sp) - 1.5) < 0.001, "half_height(sphere) = radius") - _check(absf(MeshShapes.half_height(cyl) - 6.0) < 0.001, "half_height(cylinder 12 m) = 6 m") \ No newline at end of file + _check(absf(MeshShapes.half_height(cyl) - 6.0) < 0.001, "half_height(cylinder 12 m) = 6 m") + + +# ----------------------------- Objekt-Ops: Duplizieren/Höhe/Spiegeln ------- +## (Karte t_7567a06f — pure Funktionen in ObjectOps, UI-Wiring wird im +## Desktop-Smoke verifiziert; hier Headless-Beweis der Logik + Undo-Roundtrips.) + +func _test_object_ops() -> void: + print("Testgruppe: Objekt-Ops (Duplizieren · Anheben/Absenken · Spiegeln)") + var doc := SvmapDocument.create_new("Objekt-Ops-Test", 1024.0, "mittag") + var hist := ActionHistory.new() + hist.begin(doc) + var objects: Array = doc["objects"] + + # --- Duplizieren:stelle diagonal versetzt, Raster-Snap, Undo fähig --- + _place(hist, doc, "crate_wood", [10.0, 0.0, 10.0]) + objects[0]["rot_y_deg"] = 30.0 + var new_idx := ObjectOps.duplicate_object(doc, 0, true) + _check(new_idx == 1, "duplizieren: neuer Index 1") + _check(objects.size() == 2, "duplizieren: 1 → 2 Objekte") + _check(str(objects[1]["id"]) == "crate_wood", "duplizieren: gleiche ID") + _check(float(objects[1]["pos"][0]) == 11.0 and float(objects[1]["pos"][2]) == 11.0, + "duplizieren: diagonal +1 m versetzt (Raster-Snap 1 m)") + _check(absf(float(objects[1]["rot_y_deg"]) - 30.0) < 0.0001, "duplizieren: Drehung übernommen") + _check(float(objects[1]["pos"][1]) == float(objects[0]["pos"][1]), "duplizieren: Höhe übernommen") + + # Kopie ohne Raster: Snap aus → exakt +1/+1 auch auf 0,5-m-Fraktionen. + objects[0]["pos"] = [10.4, 0.0, 10.4] + var idx2 := ObjectOps.duplicate_object(doc, 0, false) + _check(absf(float(objects[idx2]["pos"][0]) - 11.4) < 0.0001, "duplizieren ohne Snap: Fraktion bleibt (+1.0)") + # Fraktionsobjekt wieder aufräumen für die History-Tests: + objects.remove_at(idx2) + + # Duplizieren + Undo → Kopie weg; Redo → wieder da (EIN Undo-Schritt). + var before := hist.snapshot(["objects"]) + var idx3 := ObjectOps.duplicate_object(doc, 0, true) + hist.commit("Test: duplizieren", before, hist.snapshot(["objects"])) + _check(objects.size() == 3, "duplizieren über History: Kopie hängt an (3 = Original + Raster-Kopie + neue Kopie)") + hist.undo() + _check(objects.size() == 2, "duplizieren→undo: Kopie weg (2 bleiben)") + hist.redo() + _check(objects.size() == 3, "duplizieren→undo→redo: Kopie wieder da") + _check(absf(float(objects[2]["pos"][0]) - 11.0) < 0.0001, "duplizieren→redo: Position identisch") + + # Ungültige Indizes schaden nichts: + _check(ObjectOps.duplicate_object(doc, 99, true) == -1, "duplizieren: falscher Index → -1") + _check(objects.size() == 3, "duplizieren: falscher Index ändert nichts") + + # --- Anheben/Absenken: 0,25-m-Schritte, Deckel = bounds.max, Boden-Stopp --- + var h0: float = SvmapDocument.terrain_height_at(doc, 10.0, 10.0) + var up := ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M) + _check(absf(up - ObjectOps.LIFT_STEP_M) < 0.0001, "anheben: 0,25 m angewendet") + _check(absf(float(objects[0]["pos"][1]) - (h0 + 0.25)) < 0.0001, "anheben: y = Boden + 0,25 m") + ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M) + _check(absf(float(objects[0]["pos"][1]) - (h0 + 0.5)) < 0.0001, "anheben x2: 0,5 m") + + var down := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) + _check(absf(down + 0.25) < 0.0001, "absenken: −0,25 m") + var stop := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M * 10) + _check(absf(stop + 0.25) < 0.0001, "absenken: stoppt genau am Terrain (kein Begraben)") + _check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "absenken am Boden: 0.0 (UI erklärt statt commit)") + _check(absf(float(objects[0]["pos"][1]) - h0) < 0.001, "nach Absenken-Stopp: exakt Boden") + + # Deckel: bounds.max.y = 80 (Neue-Map) → Großsprung wird auf 80 gedeckelt. + var cap := ObjectOps.lift_object(doc, 0, 1000.0) + _check(absf(float(objects[0]["pos"][1]) - 80.0) < 0.0001, "anheben: Höchstgrenze bounds.max.y (80 m)") + _check(absf(cap - (80.0 - h0)) < 0.0001, "anheben: angewendet = Rest bis zum Deckel") + _check(ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M) == 0.0, "anheben am Deckel: 0.0") + + # Im Boden versteckt (nach Terrain-Formen): Absenken bewegt NICHT, + # Anheben hebt schrittweise aus dem Boden. + objects[0]["pos"] = [10.0, -3.0, 10.0] + _check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "verborgen: absenken = 0.0") + var out := ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M) + _check(absf(out - ObjectOps.LIFT_STEP_M) < 0.0001, "versteckt: anheben hebt schrittweise (0,25 m ab −3 m)") + _check(absf(float(objects[0]["pos"][1]) + 2.75) < 0.0001, "versteckt: y steigt vom Untergrund aus (kein Sprung-Ruckel)") + + # lift_offset (Offset der UI-Anzeige): angehoben → +0,25, am Boden → 0. + objects[0]["pos"] = [10.0, h0 + 0.25, 10.0] + _check(absf(ObjectOps.lift_offset(doc, 0) - 0.25) < 0.001, "lift_offset: +0,25 m sichtbar") + objects[0]["pos"] = [10.0, h0, 10.0] + _check(absf(ObjectOps.lift_offset(doc, 0)) < 0.001, "lift_offset: am Boden = 0") + + # Merge-Tags wie beim Drehen: mehrere Lift-Ticks = EIN History-Eintrag. + var entries_before := hist.entries.size() + for i in 5: + var b := hist.snapshot(["objects"]) + var y2 := float(objects[0]["pos"][1]) + objects[0]["pos"] = [10.0, y2 + ObjectOps.LIFT_STEP_M, 10.0] + hist.commit("Objekt angehoben", b, hist.snapshot(["objects"]), "lift:0") + _check(hist.entries.size() == entries_before + 1, "5 Lift-Ticks → EIN History-Eintrag (Merge)") + hist.undo() + _check(absf(float(objects[0]["pos"][1]) - h0) < 0.001, "Merge-Undo: alle Stufen auf einmal weg") + + # --- Spiegeln: Rotation wird mitgespiegelt, Position bleibt --- + objects[0]["rot_y_deg"] = 30.0 + ObjectOps.mirror_object(objects[0], "x") + _check(absf(float(objects[0]["rot_y_deg"]) - 330.0) < 0.0001, "spiegeln X: 30° → 330° (−30°)") + ObjectOps.mirror_object(objects[0], "x") + _check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln X: selbstinvers (330° → 30°)") + _check(float(objects[0]["pos"][0]) == 10.0, "spiegeln: Position unverändert (am Objekt, nicht quer über die Map)") + + objects[0]["rot_y_deg"] = 30.0 + ObjectOps.mirror_object(objects[0], "z") + _check(absf(float(objects[0]["rot_y_deg"]) - 150.0) < 0.0001, "spiegeln Z: 30° → 150° (180° − rot)") + ObjectOps.mirror_object(objects[0], "z") + _check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln Z: selbstinvers (150° → 30°)") + + # Spiegeln über History: ein Eintrag, Undo stellt die alte Richtung her. + objects[0]["rot_y_deg"] = 30.0 + var before_m := hist.snapshot(["objects"]) + ObjectOps.mirror_object(doc["objects"][0], "x") + hist.commit("Test: spiegeln", before_m, hist.snapshot(["objects"])) + hist.undo() + _check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln→undo: alte Richtung zurück") + + # --- svmap-Roundtrip: Höhe + gespiegelte Rotation bleiben 1:1 erhalten --- + objects[0]["rot_y_deg"] = 210.0 + objects[0]["pos"] = [5.0, h0 + 1.75, -6.0] + var path := "/tmp/t7567_roundtrip.svmap" + var res := SvmapIO.save_to_file(path, doc) + _check(res["ok"], "svmap-Roundtrip: angesenkte/gesp. Map speichert (V1–V14 ok)") + var loaded := SvmapIO.load_from_file(path) + _check(loaded["ok"], "svmap-Roundtrip: laden ok (Prüfsumme intakt)") + if loaded["ok"]: + var lo: Array = loaded["data"]["objects"] + _check(lo.size() == objects.size(), "svmap-Roundtrip: Objekt-Anzahl gleich") + _check(absf(float(lo[0]["pos"][1]) - (h0 + 1.75)) < 0.0001, "svmap-Roundtrip: angehobene Höhe bleibt (KEIN Extra-Format-Feld — pos.y ist frei)") + _check(absf(float(lo[0]["rot_y_deg"]) - 210.0) < 0.0001, "svmap-Roundtrip: gespiegelte Rotation bleibt") + _check(str(lo[1]["id"]) == "crate_wood", "svmap-Roundtrip: Kopie bleibt") diff --git a/tools/parse_check.sh b/tools/parse_check.sh old mode 100644 new mode 100755 diff --git a/tools/run_tests.sh b/tools/run_tests.sh old mode 100644 new mode 100755