From 849129d67fecc609ea2d79df6d61cf731d743558 Mon Sep 17 00:00:00 2001 From: Hermes godot-vr Date: Thu, 15 Oct 2026 15:31:19 +0000 Subject: [PATCH] Auswahl-System: Mehrfachauswahl (Umschalt+Klick, Rubberband) + Gruppieren + Gruppen-Verschieben (Karte t_f6f29d86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mehrfachauswahl: Umschalt+Klick addiert/entfernt, Rahmen-Aufziehen über den Viewport wählt alles im Rahmen (SelectionMath, headless getestet); alle Markierten leuchten orange, Panel listet sie namentlich. - Gruppieren/Lösen: Buttons im Auswahl-Panel + Strg+G / Strg+Umschalt+G; Gruppe zieht und dreht GEMEINSAM um die eigene Mitte, Anordnung bleibt. - ENTSCHIEDUNG Persistenz: optionales Objekt-Feld "group" (int >= 0) direkt im svmap-Format — Loader-Kompatibilität nachgewiesen (Server-Loader prüft nur Pflichtfelder; Prüfsumme läuft über das ganze Dict), alte Maps bleiben ladbar, format_version bleibt 1, kein Server-Loader-Change nötig. Gruppen überleben Save/Load UND Undo/Redo. - Gruppen-Verschieben: Terrain-Clamp pro Objekt, EINE Aktion = EIN ActionHistory-Schritt (auch Drehen/Mehrfach-Löschen). - Tests: test_ux.gd auf 52 Checks erweitert (Gruppen, Undo-Ein-Schritt, svmap-Roundtrip + Alte-Map-Kompatibilität, Rubberband-Projektion); run_tests.sh komplett grün (206 Checks). - Desktop-Smoke: --groups-demo [ordner] [--groups-demo-quit] --- README.md | 46 ++++ src/editor/group_ops.gd | 193 ++++++++++++++ src/editor/group_ops.gd.uid | 1 + src/editor/interaction.gd | 429 +++++++++++++++++++++++++++---- src/editor/selection_math.gd | 50 ++++ src/editor/selection_math.gd.uid | 1 + src/ui/main.gd | 187 ++++++++++++-- src/viewport/map_view_builder.gd | 18 +- tests/test_ux.gd | 183 +++++++++++++ 9 files changed, 1035 insertions(+), 73 deletions(-) create mode 100644 src/editor/group_ops.gd create mode 100644 src/editor/group_ops.gd.uid create mode 100644 src/editor/selection_math.gd create mode 100644 src/editor/selection_math.gd.uid diff --git a/README.md b/README.md index e1e51b0..898f7c2 100644 --- a/README.md +++ b/README.md @@ -213,3 +213,49 @@ Undo für alles): 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. +## Status (M2, Karte t_f6f29d86 — Auswahl-System FERTIG) + +Mehrfachauswahl + Gruppieren + Gruppen-Verschieben, auf dem UX-Fundament +(t_40d855d0) aufgebaut, nach dem Chef-Grundsatz: + +- **Mehrfachauswahl (`src/editor/interaction.gd`):** Umschalt+Klick fügt + ein Objekt hinzu/entfernt es, Rahmen-Aufziehen (Rubberband) über leeren + Boden wählt ALLE Objekte im Rahmen (Umschalt + Rahmen = zusätzliche zur + Auswahl). Loser Klick auf leere Fläche hebt die Auswahl auf. ALLE + markierten Objekte färben sich orange, das Auswahl-Panel rechts listet + sie namentlich auf (ab 9: „… und N weitere“). Projektions-Mathe liegt + in `src/editor/selection_math.gd` (headless getestet). +- **Gruppieren:** Buttons „🔗 Gruppieren / ⛓ Gruppe lösen“ im Auswahl-Panel + + Kürzel Strg+G / Strg+Umschalt+G. Eine Gruppe braucht ≥ 2 Objekte. + Gruppen-Mitglieder ziehen und drehen GEMEINSAM — ein Griff an ein + Gruppen-Mitglied nimmt automatisch die ganze Gruppe (und markiert sie), + die relative Anordnung bleibt exakt erhalten. Drehen geschieht um den + Gruppen-Mittelpunkt. „Gruppe lösen“ befreit ALLE Mitglieder (auch die + nicht mitgewählten), Strg+Z baut sie wieder zusammen. +- **ENTSCHIEDUNG Gruppen-Persistenz:** Gruppen-Zugehörigkeit liegt als + OPTIONALES Objekt-Feld `"group"` (int ≥ 0, abwesend = ungruppiert) + DIREKT im svmap-Format — nicht als Editor-Sitzungsstatus. Damit + überleben Gruppen Speichern/Laden UND Undo/Redo (ActionHistory kopiert + den objects-Block tief, das Feld reist mit). Loader-Kompatibilität + nachgewiesen (scopedvr-match-server `src/maps/svmap_loader.gd`): + V4 prüft nur Pflichtfelder, unbekannte Objekt-Keys werden nicht + abgelehnt, die SHA-256-Prüfsumme läuft über das ganze kanonisierte + Dict (Feld reist automatisch mit). Alte Maps ohne Feld bleiben 1:1 + ladbar, `format_version` bleibt 1 — KEIN Version-Bump, KEINE + Server-Loader-Änderung nötig. Format-Doku-Anpassung (map_format.md im + Server-Repo) als Folge-Karte. +- **Gruppen-Verschieben + Undo:** Ziehen einer Mehrfachauswahl oder Gruppe + bewegt ALLE beteiligten Objekte (Terrain-Clamp PRO Objekt: X/Z bleiben + in der Map, Y = Terrain-Höhe an der neuen Stelle) — EINE Aktion = + EIN ActionHistory-Schritt (auch gemeinsames Drehen, Mausrad-Ticks + derselben Auswahl sind merge-gekoppelt). Löschen löscht die ganze + Auswahl in EINEM Schritt. +- **Tests:** `tests/test_ux.gd` erweitert auf 52 Checks (Gruppen- + gruppieren/lösen/IDs, gemeinsames Verschieben mit Terrain-Clamp, + Drehen um die Mitte mit Anordnungs-Erhalt, EIN-Schritt-Undo/Redo, + svmap-Roundtrip inkl. Prüfsumme + alte-Map-Kompatibilität, + Rubberband-Projektion inkl. Kamera-Rückseiten-Filter). Komplette + `run_tests.sh`-Suite grün (206 Checks). +- **Desktop-Smoke:** `godot … -- --groups-demo [ordner] [--groups-demo-quit]` + fährt Mehrfachauswahl → Gruppieren → gemeinsames Ziehen/Drehen → + Undo programmatisch durch und speichert Screenshots. diff --git a/src/editor/group_ops.gd b/src/editor/group_ops.gd new file mode 100644 index 0000000..768b339 --- /dev/null +++ b/src/editor/group_ops.gd @@ -0,0 +1,193 @@ +extends RefCounted +class_name GroupOps +## Gruppen-Logik des Map-Editors (Karte t_f6f29d86) — pure, statische Funktionen +## (kein UI, kein Knoten), headless testbar via tests/test_ux.gd. +## +## ENTSCHIEDUNG Gruppen-Persistenz (Karte t_f6f29d86, 15.10.): +## Gruppen-Zugehörigkeit liegt als OPTIONALES Objekt-Feld "group" (int ≥ 0, +## abwesend oder < 0 = ungruppiert) direkt im svmap-Format — NICHT als +## Editor-Sitzungsstatus. Begründung (Loader-Kompatibilität nachgewiesen, +## src/maps/svmap_loader.gd im Match-Server): +## - V4 prüft nur PFLICHT-Felder; unbekannte Zusatz-Keys je Objekt werden +## nicht abgelehnt (Loader liest nur id/pos/scale/rot_y_deg). +## - Die sha256-Prüfsumme läuft über das GESAMTE kanonisierte Dict — das +## Feld reist automatisch mit, ohne dass Prüfsummen-Code sich ändert. +## - Alte Maps ohne "group"-Feld bleiben 1:1 ladbar (Feld abwesend = +## ungruppiert), neue Maps laufen auf unverändertem Server-Loader. +## - Kein format_version-Bump nötig (additiv + optional, Vorwärts- UND +## Rückwärtskompatibel). Format-Doku-Anpassung (map_format.md) als +## Folge-Karte im Match-Server-Repo. +## Vorteil gegenüber Sitzungsstatus: Gruppen überleben Speichern/Laden +## UND Undo/Redo (ActionHistory kopiert den objects-Block tief — das Feld +## reist mit jedem Snapshot mit). +## +## Konvention: Gruppen-IDs werden bei jeder "Gruppieren"-Aktion neu vergeben +## (max + 1). Lücken (nach Löschen/Lösen) sind erlaubt und harmlos. + +const UNGROUPED := -1 + + +## Gruppen-ID eines Objekts (ungültig/abwesend → UNGROUPED). +static func group_id(obj) -> int: + if not (obj is Dictionary): + return UNGROUPED + var g = obj.get("group", UNGROUPED) + if not (g is int or g is float) or not is_finite(float(g)): + return UNGROUPED + var gi := int(float(g)) + return gi if gi >= 0 else UNGROUPED + + +## Objekt-Feld hart auf "ungruppiert" setzen (Feld entfernen — sauberes Format). +static func _clear_group(obj: Dictionary) -> void: + obj.erase("group") + + +## Aktive Gruppen-IDs (aufsteigend sortiert) — nur IDs mit ≥ 1 Mitglied. +static func active_group_ids(doc: Dictionary) -> Array: + var ids := {} + for obj in (doc.get("objects", []) as Array): + var g := group_id(obj) + if g != UNGROUPED: + ids[g] = true + var out: Array = [] + for g in ids.keys(): + out.append(int(g)) + out.sort() + return out + + +## Nächste freie Gruppen-ID (max aktiv + 1; leere Map → 0). +static func next_group_id(doc: Dictionary) -> int: + var max_id := UNGROUPED + for obj in (doc.get("objects", []) as Array): + var g := group_id(obj) + if g > max_id: + max_id = g + return max_id + 1 + + +## Indizes aller Mitglieder von Gruppe gid (aufsteigend). +static func members_of(doc: Dictionary, gid: int) -> Array: + var out: Array = [] + var objects: Array = doc.get("objects", []) + for i in objects.size(): + if group_id(objects[i]) == gid: + out.append(i) + return out + + +## Die aktiven Gruppen der gegebenen Indizes (für "Gruppe lösen"). +static func groups_of(doc: Dictionary, indices: Array) -> Array: + var ids := {} + var objects: Array = doc.get("objects", []) + for i in indices: + if i is int and i >= 0 and i < objects.size(): + var g := group_id(objects[i]) + if g != UNGROUPED: + ids[g] = true + return ids.keys() + + +## Ausgewählte Objekte zu EINER Gruppe machen. Gibt die neue Gruppen-ID +## zurück (−1, wenn weniger als 2 gültige Indizes — eine Gruppe braucht ≥ 2). +## Objekte, die schon in anderen Gruppen waren, verlassen diese. +static func group(doc: Dictionary, indices: Array) -> int: + var valid := _valid_indices(doc, indices) + if valid.size() < 2: + return UNGROUPED + var gid := next_group_id(doc) + var objects: Array = doc["objects"] + for i in valid: + objects[i]["group"] = gid + return gid + + +## Gruppe(n) der gegebenen Indizes KOMPLETT lösen (alle Mitglieder, nicht nur +## die Auswahl — sonst bliebe eine Rumpfgruppe zurück). Rückgabe: Anzahl +## befreiter Objekte. +static func ungroup(doc: Dictionary, indices: Array) -> int: + var objects: Array = doc.get("objects", []) + var freed := 0 + for gid in groups_of(doc, indices): + for i in members_of(doc, gid): + _clear_group(objects[i]) + freed += 1 + return freed + + +## Mittel-Punkt (XZ) der gegebenen Objekt-Indizes (Dreh-Mitte). +static func centroid(doc: Dictionary, indices: Array) -> Vector3: + var objects: Array = doc.get("objects", []) + var sum := Vector3.ZERO + var n := 0 + for i in _valid_indices(doc, indices): + var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0]) + sum += Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + n += 1 + return sum / float(maxi(n, 1)) + + +## Mitglieder gemeinsam verschieben (XZ + delta), Terrain-Clamp PRO Objekt: +## X/Z bleiben in der Map, Y wird auf die Terrain-Höhe an der neuen Stelle +## gesetzt. Rückgabe: Anzahl tatsächlich geänderter Objekte. +static func move_members(doc: Dictionary, indices: Array, delta: Vector3) -> int: + var objects: Array = doc.get("objects", []) + var half := _terrain_half(doc) + var moved := 0 + for i in _valid_indices(doc, indices): + var obj: Dictionary = objects[i] + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + 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 y := SvmapDocument.terrain_height_at(doc, x, z) + if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001 \ + or absf(y - float(pos[1])) > 0.0001: + moved += 1 + obj["pos"] = [x, y, z] + return moved + + +## Mitglieder gemeinsam DREHEN: Positionen rotieren um den Gruppen-Mittelpunkt +## (XZ, Godot-Y-Konvention wie marker.rotation.y) und rot_y_deg je Objekt +## += delta_deg. Terrain-Clamp PRO Objekt danach (die Gruppe kann über eine +## Kante wandern). Rückgabe: Anzahl geänderter Objekte. +static func rotate_members(doc: Dictionary, indices: Array, delta_deg: float) -> int: + var objects: Array = doc.get("objects", []) + var valid := _valid_indices(doc, indices) + if valid.is_empty(): + return 0 + var c := centroid(doc, valid) + var rad := deg_to_rad(delta_deg) + var cos_a := cos(rad) + var sin_a := sin(rad) + var half := _terrain_half(doc) + var changed := 0 + for i in valid: + var obj: Dictionary = objects[i] + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + var dx := float(pos[0]) - c.x + var dz := float(pos[2]) - c.z + var x := clampf(c.x + dx * cos_a + dz * sin_a, -half.x, half.x) + var z := clampf(c.z - dx * sin_a + dz * cos_a, -half.y, half.y) + var y := SvmapDocument.terrain_height_at(doc, x, z) + if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001 \ + or absf(y - float(pos[1])) > 0.0001: + changed += 1 + obj["pos"] = [x, y, z] + obj["rot_y_deg"] = fmod(float(obj.get("rot_y_deg", 0.0)) + delta_deg + 3600.0, 360.0) + return changed + + +static func _valid_indices(doc: Dictionary, indices: Array) -> Array: + var objects: Array = doc.get("objects", []) + var out: Array = [] + for i in indices: + if i is int and i >= 0 and i < objects.size() and (objects[i] is Dictionary): + out.append(i) + return out + + +static func _terrain_half(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 diff --git a/src/editor/group_ops.gd.uid b/src/editor/group_ops.gd.uid new file mode 100644 index 0000000..1f8ad6e --- /dev/null +++ b/src/editor/group_ops.gd.uid @@ -0,0 +1 @@ +uid://d0luucpxtrabo diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 31b918c..72941dc 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -1,9 +1,15 @@ extends Node3D -## Maus-Interaktion im Editor-Viewport (M2 UX-Fundament, Karte t_40d855d0): +## Maus-Interaktion im Editor-Viewport (M2 UX-Fundament, Karte t_40d855d0; +## Auswahl-System Erweiterung: Karte t_f6f29d86): ## - Ghost-Preview: beim Platzieren schwebt eine transparente Vorschau am ## Cursor, Klick setzt das Objekt (kein "überraschtes" Ergebnis). ## - Auswahl: angeklickte Objekte färben sich orange, Ziehen verschiebt, ## R-Taste ODER Mausrad dreht (15°-Raster), Entf löscht. +## - Mehrfachauswahl (Karte t_f6f29d86): Umschalt+Klick fügt hinzu/entfernt, +## Rahmen-Aufziehen über leeren Boden wählt alles im Rahmen. +## - Gruppen: Objekte mit gleicher "group"-ID ziehen/drehen gemeinsam +## (GroupOps), Gruppieren/Lösen über main-Buttons (Strg+G / Strg+Umschalt+G). +## - Undo: Jede Aktion = EINE ActionHistory-Stufe (auch Gruppen-Ziehen). ## Alle Meldungen laufen über main._set_status — Klartext + nächsten Schritt ## vorschlagen (Chef-Grundsatz, map-editor-README). ## @@ -29,13 +35,27 @@ var _ghost: MeshInstance3D var _ghost_active := false # Ghost sichtbar weil Cursor über der Map ist # Auswahl + Greifen -var selected_index := -1 +var selected_index := -1 # PRIMÄR-Objekt (letzter Klick) — Grab/Status-Anker +var selected_indices: Array = [] # komplette Auswahl in Klick-Reihenfolge (int-Indizes) var _dragging := false var _drag_moved := false var _press_pos := Vector2.ZERO -var _grab_offset := Vector3.ZERO +var _grab_offset := Vector3.ZERO # Einzel-Greifen (Rückwärtskompatibilität) var _drag_lift := 0.0 # Terrain-Abstand des Objekts beim Anfassen (Höhe hebt sich mit) +# Gruppen-/Mehrfach-Ziehen (Karte t_f6f29d86) +var _drag_members: Array = [] # Indizes, die GEMEINSAM ziehen +var _grab_offsets: Dictionary = {} # index (int) → Vector3 (y = 0) +var _drag_lifts: Dictionary = {} # index (int) → float (Terrain-Abstand beim Anfassen, t_7567a06f) +var _drag_primary := -1 # Anker-Objekt (Raster-Snap & Delta-Herkunft) +var _drag_is_group := false # Zieh-Menge = komplette svmap-Gruppe + +# Rahmen-Ausziehen (Rubberband) über leeren Boden +var _rubber_pending := false # Press auf leerer Fläche, noch nicht geschwenkt +var _rubber_active := false # Rahmen wird gerade aufgezogen +var _rubber_start := Vector2.ZERO +var _rubber_shift := false # Umschalt: Rahmen zur Auswahl HINZUFÜGEN + func setup(p_main, p_camera: Camera3D, p_builder) -> void: main = p_main @@ -222,58 +242,177 @@ func _place_object(viewport_pos: Vector2) -> void: # --------------------------------------------------------------- Auswahl ---- +## Einzel-Auswahl (Klick ins Objekt / Klick im Baum): ersetzt die Auswahl. func select_object(idx: int, from_tree := false) -> void: + selected_indices = [idx] if idx >= 0 else [] selected_index = idx - view_builder.call("set_selected", idx) + _apply_selection_view() if not from_tree: main.call("_mark_tree_selection", idx) - main.call("_sync_selection_panel") main.call("_selection_status", idx) +## Mehrere auf einmal (Rahmen-Ausziehen, Demo) — Reihenfolge bleibt erhalten. +func select_many(indices: Array, silent := false) -> void: + var objects: Array = main.doc.get("objects", []) + selected_indices = [] + for i in indices: + if i is int and i >= 0 and i < objects.size(): + selected_indices.append(i) + selected_index = selected_indices.back() if not selected_indices.is_empty() else -1 + _apply_selection_view() + if not silent: + main.call("_selection_status", selected_index) + + +## Umschalt+Klick: Objekt zur Auswahl HINZUFÜGEN (oder wieder rausnehmen). +func select_toggle(idx: int) -> void: + var objects: Array = main.doc.get("objects", []) + if idx < 0 or idx >= objects.size(): + return + var label: String = main.object_label(str(objects[idx].get("id", ""))) + if selected_indices.has(idx): + selected_indices.erase(idx) + if selected_index == idx: + selected_index = selected_indices.back() if not selected_indices.is_empty() else -1 + main.call("_set_status", "%s aus der Auswahl genommen — noch %d Objekte markiert. Umschalt+Klick fügt wieder hinzu." % [label, selected_indices.size()]) + else: + selected_indices.append(idx) + selected_index = idx + main.call("_set_status", "%s zur Auswahl dazugeholt — jetzt %d Objekte. Mehr umschalten, Rahmen aufziehen oder Strg+G zum Gruppieren." % [label, selected_indices.size()]) + _apply_selection_view() + main.call("_sync_selection_panel") + + func clear_selection(silent := false) -> void: - if selected_index != -1: + if selected_index != -1 or not selected_indices.is_empty(): + selected_indices = [] selected_index = -1 - view_builder.call("set_selected", -1) + view_builder.call("set_selected_multi", []) if not silent: main.call("_set_status", "Auswahl aufgehoben.") main.call("_sync_selection_panel") +## Auswahl-Anzeige (Viewport-Färbung + Baum + Panel) aus dem aktuellen Stand. +func _apply_selection_view() -> void: + view_builder.call("set_selected_multi", selected_indices) + if selected_index >= 0: + main.call("_mark_tree_selection", selected_index) + + +## Ausgewählte Objekte zu EINER Gruppe machen (main-Button/Strg+G). +func group_selection() -> void: + var objects: Array = main.doc.get("objects", []) + var valid: Array = [] + for i in selected_indices: + if i is int and i >= 0 and i < objects.size(): + valid.append(i) + if valid.size() < 2: + main.call("_set_status", "Eine Gruppe braucht mindestens 2 Objekte — markiere zuerst mehrere (Umschalt+Klick oder Rahmen aufziehen).", true) + return + var before: Dictionary = main.history.snapshot(["objects"]) + var gid := GroupOps.group(main.doc, valid) + main.history.commit("Auswahl gruppieren (%d Objekte → Gruppe %d)" % [valid.size(), gid + 1], + before, main.history.snapshot(["objects"])) + _mark_dirty() + main.call("_refresh_history_buttons") + _apply_selection_view() + main.call("_sync_selection_panel") + main.call("_set_status", "Gruppe %d gebaut: %d Objekte ziehen und drehen jetzt GEMEINSAM. „Gruppe lösen“ (Strg+Umschalt+G) macht es rückgängig — die Objekte bleiben, nur die Bindung löst sich." % [gid + 1, valid.size()]) + + +## Gruppe(n) der Auswahl komplett lösen (main-Button/Strg+Umschalt+G). +func ungroup_selection() -> void: + var groups: Array = GroupOps.groups_of(main.doc, selected_indices) + if groups.is_empty(): + main.call("_set_status", "In der Auswahl steckt keine Gruppe — markiere zuerst ein Objekt aus einer Gruppe.", true) + return + var before: Dictionary = main.history.snapshot(["objects"]) + var freed := GroupOps.ungroup(main.doc, selected_indices) + main.history.commit("Gruppe gelöst (%d Objekte)" % freed, + before, main.history.snapshot(["objects"])) + _mark_dirty() + main.call("_refresh_history_buttons") + _apply_selection_view() + main.call("_sync_selection_panel") + main.call("_set_status", "Gruppe gelöst: %d Objekte sind wieder einzeln verschiebbar — Strg+Z baut die Gruppe wieder zusammen." % freed) + + +# ---------------------------------------------------- Drehen / Löschen ------ + +## Auswahl drehen (R / Mausrad / Button): bei mehreren Objekten oder einer +## Gruppe rotiert ALLES gemeinsam um den Gruppen-Mittelpunkt. Mausrad-Ticks +## derselben Auswahl laufen zu EINEM Undo-Schritt zusammen (merge_tag). func rotate_selected(delta_deg: float) -> void: - if selected_index < 0: + if selected_index < 0 or selected_indices.is_empty(): return var objects: Array = main.doc.get("objects", []) if selected_index >= objects.size(): return + var members := selected_indices.duplicate() + # Einzelnes Objekt, das in einer Gruppe steckt → die GANZE Gruppe drehen + # (Sly-Erwartung: die Gruppe dreht gemeinsam) und sichtbar markieren. + 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") var before: Dictionary = main.history.snapshot(["objects"]) - var obj: Dictionary = objects[selected_index] - var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null - var new_rot := fmod(float(obj.get("rot_y_deg", 0.0)) + delta_deg + 3600.0, 360.0) - obj["rot_y_deg"] = float(new_rot) - if marker != null and is_instance_valid(marker): - marker.rotation.y = deg_to_rad(new_rot) - main.history.commit("Objekt gedreht", before, main.history.snapshot(["objects"]), - "rot:%d" % selected_index) + GroupOps.rotate_members(main.doc, members, delta_deg) + _sync_members_to_markers(members) + var tag := "rotmulti:%s" % str(members) + main.history.commit("Auswahl gedreht (%d Objekte)" % members.size(), + before, main.history.snapshot(["objects"]), tag) + _mark_dirty() main.call("_refresh_history_buttons") main.call("_refresh_stats") + main.call("_sync_selection_panel") + + +## Nach Doc-Änderung die betroffenen Marker positionieren/drehen (kein Rebuild). +func _sync_members_to_markers(members: Array) -> void: + var objects: Array = main.doc.get("objects", []) + for i in members: + if i >= view_builder.object_markers.size(): + continue + var marker: Node3D = view_builder.object_markers[i] + if marker == null or not is_instance_valid(marker): + continue + var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0]) + marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + marker.rotation.y = deg_to_rad(float((objects[i] as Dictionary).get("rot_y_deg", 0.0))) func delete_selected() -> void: - if selected_index < 0: + if selected_indices.is_empty() or selected_index < 0: main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das gelöscht werden soll.", true) return var objects: Array = main.doc.get("objects", []) - if selected_index >= objects.size(): + var doomed: Array = [] + for i in selected_indices: + if i is int and i >= 0 and i < objects.size(): + doomed.append(i) + if doomed.is_empty(): clear_selection(true) return var before: Dictionary = main.history.snapshot(["objects"]) - var label: String = main.object_label(str(objects[selected_index].get("id", ""))) - objects.remove_at(selected_index) - main.history.commit("%s löschen" % label, before, main.history.snapshot(["objects"])) + var label: String + if doomed.size() == 1: + label = "%s löschen" % main.object_label(str(objects[doomed[0]].get("id", ""))) + else: + label = "%d Objekte löschen" % doomed.size() + # Rückwärts löschen, damit die Indizes währenddessen stimmen. + doomed.sort() + doomed.reverse() + for i in doomed: + objects.remove_at(i) + main.history.commit(label, before, main.history.snapshot(["objects"])) clear_selection(true) main.call("_after_structure_change") - main.call("_set_status", "%s gelöscht — Strg+Z (Rückgängig) holt es zurück." % label) + main.call("_set_status", "%s ist erledigt — Strg+Z (Rückgängig) holt %s zurück." % [ + label, "alles" if doomed.size() > 1 else "es"]) # ------------------------------------------ Duplizieren/Höhe/Spiegeln ------ @@ -401,7 +540,18 @@ func _unhandled_input(event: InputEvent) -> void: var mm := event as InputEventMouseMotion if placing: _ghost_update(mm.position) - if _dragging and not placing and selected_index >= 0: + 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: + _rubber_active = true + _rubber_pending = false + if _rubber_active: + main.call("_rubberband_update", _rubber_start, mm.position) + return + if _dragging and not placing and not _drag_members.is_empty(): + _drag_members_move(mm.position) + elif _dragging and not placing and selected_index >= 0: + # Einzel-Greifen (alter Pfad, ohne Gruppe/Mehrfachauswahl) var p: Variant = _ground_point(mm.position) if p == null: return @@ -457,44 +607,192 @@ func _on_left_press(mb: InputEventMouseButton) -> void: _press_pos = mb.position var hit := _pick_object(mb.position) if hit >= 0: - if selected_index != hit: + if mb.shift_pressed: + # Umschalt+Klick: zur Auswahl hinzufügen/entfernen — kein Ziehen. + select_toggle(hit) + return + if not selected_indices.has(hit): select_object(hit) - _dragging = true - _drag_moved = false - var p: Variant = _ground_point(mb.position) - var marker: Node3D = view_builder.object_markers[selected_index] - 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 + _begin_drag(hit, mb.position) else: - # Leere Stelle: Auswahl aufheben (klicken statt auswendig lernen). - if selected_index != -1: - clear_selection() + # Leere Stelle: Rahmen-Ausziehen vorbereiten; loser Klick = Auswahl weg. + _rubber_pending = true + _rubber_active = false + _rubber_start = mb.position + _rubber_shift = mb.shift_pressed -func _on_left_release(_mb: InputEventMouseButton) -> void: +## Zieh-Menge festlegen: Mehrfachauswahl oder komplette Gruppe ziehen GEMEINSAM. +func _begin_drag(hit: int, viewport_pos: Vector2) -> void: + var objects: Array = main.doc.get("objects", []) + _drag_members = [] + _grab_offsets = {} + _drag_lifts = {} + _drag_is_group = false + if selected_indices.size() > 1: + _drag_members = selected_indices.duplicate() + else: + var gid := GroupOps.group_id(objects[hit]) + if gid != GroupOps.UNGROUPED: + var members := GroupOps.members_of(main.doc, gid) + if members.size() > 1: + _drag_members = members + _drag_is_group = true + select_many(members, true) # ganze Gruppe sichtbar markieren + if _drag_members.is_empty(): + _drag_members = [selected_index] + _drag_primary = selected_index if selected_index >= 0 else hit + _dragging = true + _drag_moved = false + var p: Variant = _ground_point(viewport_pos) + if p != null: + for i in _drag_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 off: Vector3 = marker.position - (p as Vector3) + off.y = 0.0 + _grab_offsets[i] = off + # Terrain-Abstand einfrieren (t_7567a06f, für die GANZE Zieh- + # Menge): ein Objekt mit Höhe behält beim Ziehen diesen Abstand + # zum Boden — bodennah bleibt bodennah, angehoben schwebt weiter. + var lift := marker.position.y \ + - SvmapDocument.terrain_height_at(main.doc, marker.position.x, marker.position.z) + _drag_lifts[i] = lift + if i == _drag_primary: + _grab_offset = off + _drag_lift = lift + else: + for i in _drag_members: + _grab_offsets[i] = Vector3.ZERO + _drag_lifts[i] = 0.0 + _grab_offset = Vector3.ZERO + _drag_lift = 0.0 + + +## Gemeinsames Ziehen: Delta vom Primär-Objekt auf ALLE Mitglieder — relative +## Anordnung bleibt exakt erhalten, Raster-Snap rastet die GANZE Gruppe. +func _drag_members_move(viewport_pos: Vector2) -> void: + var p: Variant = _ground_point(viewport_pos) + if p == null or not _grab_offsets.has(_drag_primary): + return + var half := _terrain_half() + var new_primary: Vector3 = _apply_grid((p as Vector3) + (_grab_offsets[_drag_primary] as Vector3)) + new_primary.x = clampf(new_primary.x, -half.x, half.x) + new_primary.z = clampf(new_primary.z, -half.y, half.y) + new_primary.y = SvmapDocument.terrain_height_at(main.doc, new_primary.x, new_primary.z) + var dist := Vector2(viewport_pos.x - _press_pos.x, viewport_pos.y - _press_pos.y).length() + if dist > DRAG_THRESHOLD_PX: + _drag_moved = true + if not _drag_moved: + return + var primary_marker: Node3D = view_builder.object_markers[_drag_primary] + if primary_marker == null or not is_instance_valid(primary_marker): + return + var delta: Vector3 = new_primary - primary_marker.position + delta.y = 0.0 + if delta.length_squared() < 0.0000001: + return + for i in _drag_members: + if i >= view_builder.object_markers.size(): + continue + var marker: Node3D = view_builder.object_markers[i] + if marker == null or not is_instance_valid(marker): + continue + var np: Vector3 = marker.position + delta + np.x = clampf(np.x, -half.x, half.x) + np.z = clampf(np.z, -half.y, half.y) + # Höhe: Terrain-Abstand bleibt erhalten (t_7567a06f, pro Objekt — + # angehoben schwebt weiter, bodennah bleibt bodennah). + np.y = SvmapDocument.terrain_height_at(main.doc, np.x, np.z) \ + + float(_drag_lifts.get(i, 0.0)) + marker.position = np + + +func _on_left_release(mb: InputEventMouseButton) -> void: + if _rubber_pending or _rubber_active: + var was_active := _rubber_active + _rubber_pending = false + _rubber_active = false + main.call("_rubberband_clear") + if was_active: + _finish_rubberband(mb.position) + elif not _rubber_shift: + # Loser Klick auf leere Fläche: Auswahl aufheben (Klick statt Auswendiglernen). + if not selected_indices.is_empty(): + clear_selection() + return if _dragging: _dragging = false - if _drag_moved and selected_index >= 0: - var objects: Array = main.doc.get("objects", []) - var marker: Node3D = null - if selected_index < view_builder.object_markers.size(): - marker = view_builder.object_markers[selected_index] - if selected_index < objects.size() and marker != null and is_instance_valid(marker): - var before: Dictionary = main.history.snapshot(["objects"]) - var pos := marker.position - objects[selected_index]["pos"] = [float(pos.x), float(pos.y), float(pos.z)] - main.history.commit("Objekt verschoben", - before, main.history.snapshot(["objects"]), "move:%d" % selected_index) - main.call("_refresh_history_buttons") + if _drag_moved and not _drag_members.is_empty(): + _commit_drag() _drag_moved = false + _drag_members = [] + _grab_offsets = {} + _drag_is_group = false + + +## Rahmen-Ausziehen abgeschlossen: alle Marker im Rahmen auswählen. +func _finish_rubberband(end_pos: Vector2) -> void: + var rect := SelectionMath.rect_from(_rubber_start, end_pos) + var positions: Array = [] + for marker in view_builder.object_markers: + if marker != null and is_instance_valid(marker): + positions.append(marker.global_position) + else: + positions.append(Vector3(INF, INF, INF)) # kaputter Marker: nie im Rahmen + var picked := SelectionMath.pick_in_rect(camera, positions, rect) + if _rubber_shift and not picked.is_empty(): + for i in picked: + if not selected_indices.has(i): + selected_indices.append(i) + selected_index = picked[picked.size() - 1] + elif not _rubber_shift: + selected_indices = picked + selected_index = picked.back() if not picked.is_empty() else -1 + _apply_selection_view() + main.call("_sync_selection_panel") + if selected_indices.is_empty(): + main.call("_set_status", "Der Rahmen hat kein Objekt erwischt — zieh ihn über die Objekte, die du zusammen bearbeiten willst.") + else: + main.call("_set_status", "Rahmen-Auswahl: %d Objekte markiert. Strg+G gruppier sie, oder ziehe sie gemeinsam weiter." % selected_indices.size()) + + +## Bewegte Marker → Doc schreiben, EIN Undo-Schritt für den ganzen Zug. +func _commit_drag() -> void: + var objects: Array = main.doc.get("objects", []) + var moved: Array = [] + for i in _drag_members: + if i >= objects.size() or i >= view_builder.object_markers.size(): + continue + var marker: Node3D = view_builder.object_markers[i] + if marker == null or not is_instance_valid(marker): + continue + moved.append(i) + if moved.is_empty(): + return + var before: Dictionary = main.history.snapshot(["objects"]) + for i in moved: + var pos: Vector3 = (view_builder.object_markers[i] as Node3D).position + objects[i]["pos"] = [float(pos.x), float(pos.y), float(pos.z)] + var desc: String + if _drag_is_group: + desc = "Gruppe verschoben (%d Objekte)" % moved.size() + elif moved.size() > 1: + desc = "%d Objekte verschoben" % moved.size() + else: + desc = "Objekt verschoben" + main.history.commit(desc, before, main.history.snapshot(["objects"])) + _mark_dirty() + main.call("_refresh_history_buttons") + main.call("_refresh_stats") + + +## Dirty-Markierung ohne Full-Rebuild (Auswahl/Marker bleiben bestehen). +func _mark_dirty() -> void: + main.set("dirty", true) + main.call("_refresh_file_label") # ---------------------------------------------------- Screenshot-Hilfe ------ @@ -511,4 +809,25 @@ func demo_park_ghost(world_pos: Vector3, oid: String) -> void: _ghost.visible = true _ghost_active = true main.call("_highlight_library", oid) - main.call("_set_status", "%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, Rechtsklick/ESC bricht ab." % main.object_label(oid)) \ No newline at end of file + main.call("_set_status", "%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, Rechtsklick/ESC bricht ab." % main.object_label(oid)) + + +## Demo-Hook (Gruppen-Smoke, Karte t_f6f29d86): die Auswahl EXAKT so ziehen +## wie ein echter Drag-Release (Marker bewegen + EIN Commit). +func demo_drag_selection(delta: Vector3) -> void: + if selected_indices.is_empty(): + return + _drag_members = selected_indices.duplicate() + _drag_is_group = false + _drag_primary = selected_index + _dragging = true + _drag_moved = true + for i in _drag_members: + 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 += delta + _commit_drag() + _dragging = false + _drag_members = [] + _drag_moved = false diff --git a/src/editor/selection_math.gd b/src/editor/selection_math.gd new file mode 100644 index 0000000..1428fd0 --- /dev/null +++ b/src/editor/selection_math.gd @@ -0,0 +1,50 @@ +extends RefCounted +class_name SelectionMath +## Rubberband-Auswahl-Mathematik (Karte t_f6f29d86) — pure, statisch, +## headless testbar (kein Viewport nötig — Transform + Projection genügen). +## +## Ein Objekt gilt als "im Rahmen", wenn sein Anker-Punkt (Fußpunkt der +## Objekt-Marker) im Bildschirm-Rechteck liegt. Anker statt Mesh-Exakt: +## schnell und fürs Auge richtig — Marker sind die Klick-Flächen. + + +## Kern (getestet): Weltpunkte → Bildschirmprojektion mit MITGEBENGER +## Kamera-Transform + Projektionsmatrix. Punkte hinter der Kamera +## (Blickrichtung -Z, view.z >= 0) werden ignoriert. +static func pick_in_rect_projection(cam_xform: Transform3D, proj: Projection, + viewport_size: Vector2, world_positions: Array, rect: Rect2) -> Array: + var out: Array = [] + if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: + return out + var inv := cam_xform.affine_inverse() + for i in world_positions.size(): + var p: Variant = world_positions[i] + if not (p is Vector3): + continue + var view: Vector3 = inv * (p as Vector3) + if view.z >= 0.0: + continue # hinter der Kamera (kein Spiegel-Treffer) + var clip: Vector4 = proj * Vector4(view.x, view.y, view.z, 1.0) + if absf(clip.w) < 0.0000001: + continue + var ndc := Vector2(clip.x / clip.w, clip.y / clip.w) + var screen := Vector2((ndc.x * 0.5 + 0.5) * viewport_size.x, + (1.0 - (ndc.y * 0.5 + 0.5)) * viewport_size.y) + if rect.has_point(screen): + out.append(i) + return out + + +## Runtime-Variante (Editor): nimmt die Viewport-Kamera direkt. +static func pick_in_rect(cam: Camera3D, world_positions: Array, rect: Rect2) -> Array: + if cam == null or not is_instance_valid(cam): + return [] + var viewport_size := Vector2(cam.get_viewport().size) + return pick_in_rect_projection(cam.global_transform, cam.get_camera_projection(), + viewport_size, world_positions, rect) + + +## Normalisiertes Rechteck aus zwei Ecken (auch "rückwärts" aufgezogen). +static func rect_from(a: Vector2, b: Vector2) -> Rect2: + return Rect2(Vector2(minf(a.x, b.x), minf(a.y, b.y)), + Vector2(absf(b.x - a.x), absf(b.y - a.y))) diff --git a/src/editor/selection_math.gd.uid b/src/editor/selection_math.gd.uid new file mode 100644 index 0000000..77dcae2 --- /dev/null +++ b/src/editor/selection_math.gd.uid @@ -0,0 +1 @@ +uid://00t31s6wj67c diff --git a/src/ui/main.gd b/src/ui/main.gd index 30fd884..795b055 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -52,6 +52,12 @@ var sel_mirror_x_btn: Button var sel_mirror_z_btn: Button var sel_raise_btn: Button var sel_lower_btn: Button +var sel_group_btn: Button # „Gruppieren“ (Karte t_f6f29d86) +var sel_ungroup_btn: Button # „Gruppe lösen“ (Karte t_f6f29d86) + +# Rahmen-Ausziehen (Rubberband) — Overlay über dem 3D-Viewport +var rubber_overlay: Control +var rubber_rect := Rect2() # leer = Overlay aus # Eigenschaften-Panel var prop_name: LineEdit @@ -401,16 +407,46 @@ 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 · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück" + 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.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) help_label.offset_left = 12 help_label.offset_bottom = -8 help_label.offset_top = -46 help_label.modulate = Color(1, 1, 1, 0.65) container.add_child(help_label) + + # Rahmen-Ausziehen (Karte t_f6f29d86): halbtransparentes Rechteck über + # dem Viewport — mouse_filter IGNORE, stört keine Maus-Ereignisse. + rubber_overlay = Control.new() + rubber_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) + rubber_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE + rubber_overlay.draw.connect(_draw_rubberband) + rubber_overlay.visible = false + container.add_child(rubber_overlay) return container +## Rahmen-Rechteck aktualisieren (aus interaction.gd, Viewport-Koordinaten). +func _rubberband_update(start_pos: Vector2, cur_pos: Vector2) -> void: + rubber_rect = SelectionMath.rect_from(start_pos, cur_pos) + rubber_overlay.visible = true + rubber_overlay.queue_redraw() + + +## Rahmen-Overlay ausblenden (Release/Abbruch). +func _rubberband_clear() -> void: + rubber_rect = Rect2() + rubber_overlay.visible = false + rubber_overlay.queue_redraw() + + +func _draw_rubberband() -> void: + if rubber_rect.size.x <= 0.0 or rubber_rect.size.y <= 0.0: + return + rubber_overlay.draw_rect(rubber_rect, Color(0.45, 0.8, 1.0, 0.16), true) + rubber_overlay.draw_rect(rubber_rect, Color(0.45, 0.8, 1.0, 0.9), false, 2.0) + + func _build_right_panel() -> PanelContainer: var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(320, 0) @@ -477,6 +513,24 @@ func _build_right_panel() -> PanelContainer: func() -> void: interaction.lift_selected(-ObjectOps.LIFT_STEP_M)) lift_row.add_child(sel_lower_btn) + # Gruppen-Buttons (Karte t_f6f29d86): Mehrfachauswahl → Gruppe + var group_btns := HBoxContainer.new() + group_btns.add_theme_constant_override("separation", 8) + vbox.add_child(group_btns) + sel_group_btn = Button.new() + sel_group_btn.text = "🔗 Gruppieren" + sel_group_btn.custom_minimum_size = Vector2(150, 40) + sel_group_btn.tooltip_text = "Ausgewählte Objekte zu einer Gruppe verbinden (Strg+G) — sie ziehen und drehen dann gemeinsam" + sel_group_btn.pressed.connect(func() -> void: interaction.group_selection()) + group_btns.add_child(sel_group_btn) + sel_ungroup_btn = Button.new() + sel_ungroup_btn.text = "⛓ Gruppe lösen" + sel_ungroup_btn.custom_minimum_size = Vector2(150, 40) + sel_ungroup_btn.tooltip_text = "Die Gruppe der Auswahl wieder auflösen (Strg+Umschalt+G) — Objekte bleiben, nur die Bindung löst sich" + sel_ungroup_btn.pressed.connect(func() -> void: interaction.ungroup_selection()) + group_btns.add_child(sel_ungroup_btn) + vbox.add_child(_muted_label("Mehrfachauswahl: Umschalt+Klick oder Rahmen aufziehen. Eine Gruppe wandert und dreht gemeinsam — auch nach Speichern/Laden.")) + vbox.add_child(_separator()) # --- Map-Eigenschaften --- @@ -826,6 +880,14 @@ func _unhandled_key_input(event: InputEvent) -> void: if ctrl: _save() get_viewport().set_input_as_handled() + KEY_G: + if ctrl: + # Gruppieren/Lösen (Karte t_f6f29d86) + if event.shift_pressed: + interaction.ungroup_selection() + else: + interaction.group_selection() + get_viewport().set_input_as_handled() KEY_R: if not ctrl: var delta := -15.0 if event.shift_pressed else 15.0 @@ -1020,7 +1082,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 · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück" + 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" _base_tool_status() @@ -1075,7 +1137,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 · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück" + 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" _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) @@ -1206,39 +1268,80 @@ func _mark_tree_selection_walk(item: TreeItem, idx: int) -> void: ## Rechte Panel-Sektion synchronisieren OHNE Statuszeilen-Text zu ändern ## (sonst überschreibt ein Stummsync die gerade gesetzte Hilfs-Meldung). +## Mehrfachauswahl/Gruppen-fähig (Karte t_f6f29d86): Panel listet ALLE +## gewählten Objekte auf + zeigt die Gruppen-Bindung an. func _sync_selection_panel() -> void: var objects: Array = doc.get("objects", []) - var idx: int = interaction.selected_index - var has_sel := idx >= 0 and idx < objects.size() + var sel: Array = interaction.selected_indices + var valid: Array = [] + for i in sel: + if i is int and i >= 0 and i < objects.size(): + valid.append(i) + var has_sel := not valid.is_empty() 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 + var has_group := false + for i in valid: + if GroupOps.group_id(objects[i]) != GroupOps.UNGROUPED: + has_group = true + break + sel_group_btn.disabled = valid.size() < 2 + sel_ungroup_btn.disabled = not has_group if not has_sel: sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an." return - var obj: Dictionary = objects[idx] - var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) - 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]), lift_text, float(obj.get("rot_y_deg", 0.0))] + if valid.size() == 1: + var idx: int = valid[0] + var obj: Dictionary = objects[idx] + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + var lift := ObjectOps.lift_offset(doc, idx) + var lift_text := "am Boden" if absf(lift) < 0.02 else ("%+.2f m über dem Boden" % lift) + var group_line := "" + var gid := GroupOps.group_id(obj) + if gid != GroupOps.UNGROUPED: + group_line = "\nGruppe %d — zieht & dreht GEMEINSAM (Lösen: Button unten)" % (gid + 1) + sel_label.text = "%s (Objekt %d von %d)\nPosition: %.0f, %.0f m · Höhe: %s · Richtung: %.0f°%s\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]), lift_text, float(obj.get("rot_y_deg", 0.0)), group_line] + return + # Mehrfachauswahl: Liste aller gewählten Objekte (max. 8 Zeilen, dann „…“). + var names := PackedStringArray() + for i in valid: + if names.size() < 8: + names.append(object_label(str((objects[i] as Dictionary).get("id", "?")))) + var more := "" + if valid.size() > 8: + more = "\n… und %d weitere" % (valid.size() - 8) + sel_label.text = "%d Objekte ausgewählt:\n%s%s\nGemeinsam: ziehen (Gruppe wandert zusammen), drehen um die Mitte, Entf löscht alle." % [ + valid.size(), " · ".join(names), more] ## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt). func _selection_status(idx: int) -> void: var objects: Array = doc.get("objects", []) - if idx < 0 or idx >= objects.size(): + var sel: Array = interaction.selected_indices + if (idx < 0 or idx >= objects.size()) and sel.is_empty(): _base_tool_status() return - var obj: Dictionary = objects[idx] var used := int(SvmapDocument.poly_usage(doc, manifest)) var budget := int(SvmapIO.MAX_TOTAL_POLY) 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, Bild↑/↓ anheben/absenken, Strg+D duplizieren, Entf zum Löschen.%s" % [ - object_label(str(obj.get("id", ""))), budget_warning]) + if sel.size() > 1: + _set_status("%d Objekte ausgewählt — ziehen bewegt ALLE gemeinsam, R/Mausrad dreht sie um die Mitte, Entf löscht alle, Strg+G gruppiert.%s" % [sel.size(), budget_warning]) + return + if idx < 0 or idx >= objects.size(): + _base_tool_status() + return + var obj: Dictionary = objects[idx] + var gid := GroupOps.group_id(obj) + var group_hint := "" + if gid != GroupOps.UNGROUPED: + group_hint = " (Gruppe %d — die zieht GEMEINSAM)" % (gid + 1) + _set_status("%s%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", ""))), group_hint, budget_warning]) # ------------------------------------------------------- Eigenschaften ----- @@ -1423,6 +1526,9 @@ func _maybe_start_demo_mode() -> void: if args.has("--spawns-demo"): _start_spawns_demo(args) return + if args.has("--groups-demo"): + _start_groups_demo(args) + return if not args.has("--ghost-demo"): return var oid := "crate_wood" @@ -1629,4 +1735,53 @@ func _start_spawns_demo(args: PackedStringArray) -> void: await _demo_shot(out_dir + "/spawns_ready.png") print("SPAWNS_DEMO_OK: 4 Screenshots in %s" % out_dir) if args.has("--spawns-demo-quit"): + get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind + + +## `godot ... -- --groups-demo [ordner]` — Desktop-Smoke (Karte t_f6f29d86): +## Auswahl-System programmatisch durchspielen, PNGs speichern: +## Mehrfachauswahl (Rahmen simuliert) → gruppieren → Gruppe gemeinsam ziehen → +## gemeinsames Drehen → Undo-Nachweis. Die App bleibt offen (Sly sieht live). +func _start_groups_demo(args: PackedStringArray) -> void: + var out_dir := "/tmp/scopedvr_groups" + var idx := args.find("--groups-demo") + if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"): + out_dir = args[idx + 1] + DirAccess.make_dir_recursive_absolute(out_dir) + + # Aufbau: 4 Objekte in zwei Wohnclustern (2 nahe beieinander = spätere Gruppe). + var before: Dictionary = history.snapshot(["objects"]) + (doc["objects"] as Array).append({"id": "watchtower_wood", "pos": [10.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 0.0}) + (doc["objects"] as Array).append({"id": "crate_wood", "pos": [14.0, 0.0, 9.0], "scale": 1.0, "rot_y_deg": 0.0}) + (doc["objects"] as Array).append({"id": "crate_metal", "pos": [14.0, 0.0, 13.0], "scale": 1.0, "rot_y_deg": 45.0}) + (doc["objects"] as Array).append({"id": "tree_pine", "pos": [-30.0, 0.0, 24.0], "scale": 1.0, "rot_y_deg": 0.0}) + history.commit("Demo: Objekte gesetzt", before, history.snapshot(["objects"])) + _refresh_all() + + # 1) Mehrfachauswahl: Objekte 0–2 markiert (wie nach Rahmen-Ausziehen). + interaction.call("select_many", [0, 1, 2], true) + _demo_camera(Vector3(0.0, 2.0, 0.0), 130.0, 40.0, -35.0) + _set_status("Demo 1/4: Mehrfachauswahl — 3 Objekte orange (Umschalt+Klick oder Rahmen aufziehen). Der Baum links bleibt außen vor.") + await _demo_shot(out_dir + "/groups_select.png") + + # 2) Gruppieren: EIN Klick, die 3 gehören ab jetzt zusammen. + interaction.call("group_selection") + _set_status("Demo 2/4: „Gruppieren“ — die 3 Objekte sind jetzt Gruppe 1. Der Baum links ist NICHT Teil der Gruppe.") + await _demo_shot(out_dir + "/groups_grouped.png") + + # 3) Gruppe gemeinsam ziehen (exakt der Drag-Release-Pfad) + gemeinsames Drehen. + interaction.call("demo_drag_selection", Vector3(24.0, 0.0, -18.0)) + interaction.call("rotate_selected", 30.0) + _demo_camera(Vector3(20.0, 2.0, 0.0), 130.0, 42.0, -20.0) + _set_status("Demo 3/4: Gruppe GEMEINSAM verschoben und um die eigene Mitte gedreht — die Anordnung bleibt erhalten, der Baum stand still.") + await _demo_shot(out_dir + "/groups_moved.png") + + # 4) Undo-Nachweis: Strg+Z nimmt die Drehung zurück (EIN Schritt), die + # zweite Stufe würde den Zug zurücknehmen — jede Aktion = EIN Undo-Schritt. + _do_undo() + _demo_camera(Vector3(20.0, 2.0, 0.0), 130.0, 42.0, -20.0) + _set_status("Demo 4/4: Strg+Z — die Drehung ist zurück (Zug & Drehen sind getrennte Schritte); die Gruppe bleibt gebunden.") + await _demo_shot(out_dir + "/groups_undo.png") + print("GROUPS_DEMO_OK: 4 Screenshots in %s" % out_dir) + if args.has("--groups-demo-quit"): get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind \ No newline at end of file diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index 1c74c1e..036e4b0 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -22,6 +22,7 @@ const WEATHER_LIGHT := { } var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt) +var selected_indices: Array = [] # komplette Auswahl (Karte t_f6f29d86 — Mehrfachauswahl) var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects) var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius) var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant) @@ -188,7 +189,7 @@ func _build_objects(objects: Array, manifest: Dictionary) -> void: var mi := MeshInstance3D.new() mi.mesh = mesh mi.position.y = MeshShapes.half_height(mesh) - mi.material_override = _select_mat if i == selected_index else _base_mat + mi.material_override = _select_mat if _is_selected(i) else _base_mat marker.add_child(mi) var half := (mesh.get_aabb().size * 0.5) as Vector3 marker.set_meta("pick_radius", half.length() + maxf(0.6, half.y * 0.25)) @@ -198,13 +199,26 @@ func _build_objects(objects: Array, manifest: Dictionary) -> void: ## Auswahl nachträglich umfärben (ohne ganzen Rebuild) — z. B. nach Drag. +## Einzel-Variante bleibt für Rückwärtskompatibilität bestehen. func set_selected(idx: int) -> void: selected_index = idx + set_selected_multi([idx] if idx >= 0 else []) + + +## Mehrfachauswahl (Karte t_f6f29d86): ALLE markierten Objekte färben sich +## orange — Gruppen/Mehrfachauswahl sind auf einen Blick erkennbar. +func set_selected_multi(indices: Array) -> void: + selected_indices = indices.duplicate() + selected_index = indices.back() if not indices.is_empty() else -1 for marker in object_markers: if marker == null or not is_instance_valid(marker): continue var mi: MeshInstance3D = marker.get_child(0) - mi.material_override = _select_mat if int(marker.get_meta("obj_index", -1)) == idx else _base_mat + mi.material_override = _select_mat if _is_selected(int(marker.get_meta("obj_index", -1))) else _base_mat + + +func _is_selected(idx: int) -> bool: + return selected_indices.has(idx) ## Spawn-Zonen (Karte t_c4162c5d): JEDE Zone ist ein flacher, teamfarbiger diff --git a/tests/test_ux.gd b/tests/test_ux.gd index 756f30e..53f757b 100644 --- a/tests/test_ux.gd +++ b/tests/test_ux.gd @@ -65,6 +65,10 @@ func _run_tests() -> void: _test_terrain_height() _test_mesh_shapes() _test_object_ops() + _test_group_ops() + _test_group_move_rotate_undo() + _test_group_svmap_roundtrip() + _test_rubberband_pick() ## Abnahme-Kriterium: Undo-Roundtrip (setzen → undo → redo) — und analog @@ -368,3 +372,182 @@ func _test_object_ops() -> void: _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") + +## Gruppen-Logik (Karte t_f6f29d86): gruppieren/lösen, Mitglieder, IDs. +func _test_group_ops() -> void: + print("Testgruppe: Gruppen (gruppieren/lösen, Mitglieder, IDs)") + var doc := SvmapDocument.create_new("Gruppen", 256.0, "mittag") + var objects: Array = doc["objects"] + objects.append(_obj("crate_wood", [0.0, 0.0, 0.0])) + objects.append(_obj("crate_metal", [4.0, 0.0, 0.0])) + objects.append(_obj("tree_pine", [40.0, 0.0, 40.0])) + _check(GroupOps.group_id(objects[0]) == GroupOps.UNGROUPED, "frisches Objekt: ungruppiert") + _check(GroupOps.next_group_id(doc) == 0, "erste freie Gruppen-ID = 0") + + var gid := GroupOps.group(doc, [0, 1]) + _check(gid == 0, "gruppieren(2 Objekte) → Gruppe 0") + _check(GroupOps.group_id(objects[0]) == 0 and GroupOps.group_id(objects[1]) == 0, "beide Mitglieder tragen die ID") + _check(GroupOps.group_id(objects[2]) == GroupOps.UNGROUPED, "Drittes Objekt bleibt draußen") + _check(GroupOps.members_of(doc, 0) == [0, 1], "members_of liefert [0, 1]") + _check(GroupOps.next_group_id(doc) == 1, "nächste freie ID = 1") + _check(GroupOps.group(doc, [2]) == GroupOps.UNGROUPED, "einzelnes Objekt kann NICHT gruppiert werden (< 2)") + + # Cross-Gruppierung: Objekt 2 kommt zur bestehenden Auswahl — verlässt + # seine alte Bindung nicht, weil es keine hatte; neue Gruppe = neue ID. + var gid2 := GroupOps.group(doc, [1, 2]) + _check(gid2 == 1, "zweite Gruppe → ID 1") + _check(GroupOps.group_id(objects[0]) == 0, "Objekt 0 bleibt in Gruppe 0") + _check(GroupOps.group_id(objects[1]) == 1 and GroupOps.group_id(objects[2]) == 1, "Objekt 1+2 wandern in Gruppe 1") + _check(GroupOps.active_group_ids(doc) == [0, 1], "aktive Gruppen: [0, 1]") + + # Lösen über EINEM Mitglied löst die GANZE Gruppe. + var freed := GroupOps.ungroup(doc, [1]) + _check(freed == 2, "lösen über Objekt 1 befreit beide Mitglieder von Gruppe 1") + _check(GroupOps.group_id(objects[1]) == GroupOps.UNGROUPED and GroupOps.group_id(objects[2]) == GroupOps.UNGROUPED, "Feld entfernt (nicht nur -1)") + _check(GroupOps.active_group_ids(doc) == [0], "nur Gruppe 0 übrig") + + # Robustheit: kaputte Werte (float von JSON, Unsinn) → ungruppiert. + objects[2]["group"] = 0.0 + _check(GroupOps.group_id(objects[2]) == 0, "float-Gruppen-ID (aus JSON) wird erkannt") + objects[2]["group"] = "Quatsch" + _check(GroupOps.group_id(objects[2]) == GroupOps.UNGROUPED, "Unsinn-Wert → ungruppiert") + objects[2].erase("group") + + +## Gruppen-Verschieben/Drehen + Undo (Karte t_f6f29d86): EINE Aktion = +## EIN Undo-Schritt; Terrain-Clamp pro Objekt; Anordnung bleibt erhalten. +func _test_group_move_rotate_undo() -> void: + print("Testgruppe: Gruppen-Verschieben/Drehen + ActionHistory") + var doc := SvmapDocument.create_new("Zug", 256.0, "mittag") + var hist := ActionHistory.new() + hist.begin(doc) + var objects: Array = doc["objects"] + objects.append(_obj("crate_wood", [0.0, 0.0, 0.0])) + objects.append(_obj("crate_metal", [6.0, 0.0, 2.0])) + objects.append(_obj("tree_pine", [60.0, 0.0, 60.0])) + var gid := GroupOps.group(doc, [0, 1]) + _check(gid == 0, "Vorbereitung: Objekte 0+1 = Gruppe 0") + + # Gemeinsames Verschieben — EIN commit. + var before := hist.snapshot(["objects"]) + GroupOps.move_members(doc, [0, 1], Vector3(4.0, 0.0, -3.0)) + hist.commit("Gruppe verschoben (2 Objekte)", before, hist.snapshot(["objects"])) + _check(absf(float(objects[0]["pos"][0]) - 4.0) < 0.001, "Mitglied 0: x = 4 (gezogen)") + _check(absf(float(objects[1]["pos"][0]) - 10.0) < 0.001, "Mitglied 1: x = 10 (gemeinsam gezogen)") + _check(absf(float(objects[1]["pos"][2]) - -1.0) < 0.001, "Mitglied 1: z = -1 (Delta geteilt)") + _check(absf(float(objects[2]["pos"][0]) - 60.0) < 0.001, "Fremdes Objekt 2: unbewegt") + + # Terrain-Clamp: Delta über die Map-Kante → X/Z geclampt, Y = Terrainhöhe. + var before2 := hist.snapshot(["objects"]) + GroupOps.move_members(doc, [0, 1], Vector3(400.0, 0.0, 0.0)) + hist.commit("Gruppe an die Kante gezogen", before2, hist.snapshot(["objects"])) + _check(float(objects[0]["pos"][0]) <= 128.0, "Clamp: x bleibt innerhalb der Map (≤ 128)") + _check(absf(float(objects[0]["pos"][1]) - 0.0) < 0.001, "Terrain-Clamp: y = Terrainhöhe an neuer Stelle (flache Map: 0)") + + # Rotation um den Gruppen-Mittelpunkt: Anordnung (Abstände) bleibt. + var before3 := hist.snapshot(["objects"]) + GroupOps.move_members(doc, [0, 1], Vector3(-100.0, 0.0, 0.0)) # zurück ins Feld + hist.commit("Gruppe zurück ins Feld", before3, hist.snapshot(["objects"])) + var pos0_before := Vector3(float(objects[0]["pos"][0]), 0.0, float(objects[0]["pos"][2])) + var pos1_before := Vector3(float(objects[1]["pos"][0]), 0.0, float(objects[1]["pos"][2])) + var dist_before := pos0_before.distance_to(pos1_before) + var center := GroupOps.centroid(doc, [0, 1]) + var before4 := hist.snapshot(["objects"]) + GroupOps.rotate_members(doc, [0, 1], 90.0) + hist.commit("Gruppe gedreht", before4, hist.snapshot(["objects"])) + var dist_after := Vector3(float(objects[0]["pos"][0]), 0.0, float(objects[0]["pos"][2])) \ + .distance_to(Vector3(float(objects[1]["pos"][0]), 0.0, float(objects[1]["pos"][2]))) + _check(absf(dist_after - dist_before) < 0.01, "Drehen: Abstand der Mitglieder bleibt (Anordnung erhalten)") + var rot0 := float(objects[0]["rot_y_deg"]) + _check(absf(rot0 - 90.0) < 0.001, "Drehen: rot_y_deg += 90") + var center_after := GroupOps.centroid(doc, [0, 1]) + _check(center_after.distance_to(center) < 0.5, "Drehen: Schwerpunkt bleibt (Rotation um die Mitte)") + + # UND JETZT das Kern-Versprechen: EINE Aktion = EIN Undo-Schritt. + var steps_before := hist.entries.size() + hist.undo() # nimmt Rotation zurück → Zustand nach "zurück ins Feld" + _check(absf(float(objects[0]["rot_y_deg"]) - 0.0) < 0.001, "undo: Rotation zurück") + _check(GroupOps.group_id(objects[0]) == 0 and GroupOps.group_id(objects[1]) == 0, "undo: Gruppen-Bindung bleibt (Feld reist im objects-Snapshot)") + _check(hist.entries.size() == steps_before, "kein Eintrag verlorengegangen") + hist.undo() # nimmt den letzten Zug zurück → Zustand nach move1 (4/-3) + _check(absf(float(objects[0]["pos"][0]) - 4.0) < 0.001 and absf(float(objects[0]["pos"][2]) - -3.0) < 0.001, + "undo: Zug komplett zurück (beide Mitglieder EIN Schritt)") + hist.redo() # setzt die geclampte Kanten-Position wieder ein + _check(float(objects[0]["pos"][0]) <= 128.0 and absf(float(objects[0]["pos"][1]) - 0.0) < 0.001, + "redo: Zug wieder da (geclampt auf die gleiche Stelle)") + _check(GroupOps.group_id(objects[1]) == 0, "redo: Gruppe bleibt gebunden") + + +## Persistenz-ENTSCHIEDUNG (Karte t_f6f29d86): Gruppen als optionales +## Objekt-Feld "group" im svmap-Format — Roundtrip über Datei + Prüfsumme, +## alte Maps ohne Feld bleiben ladbar. +func _test_group_svmap_roundtrip() -> void: + print("Testgruppe: Gruppen im svmap-Roundtrip (optionales Feld)") + var doc := SvmapDocument.create_new("Gruppen-Map", 256.0, "mittag") + var objects: Array = doc["objects"] + objects.append(_obj("crate_wood", [0.0, 0.0, 0.0])) + objects.append(_obj("crate_metal", [4.0, 0.0, 0.0])) + GroupOps.group(doc, [0, 1]) + var save := SvmapIO.save_to_file("user://test_groups.svmap", doc) + _check(save["ok"], "Speichern mit Gruppen-Feld ok (Validierung akzeptiert Zusatzfeld): %s" % str(save["errors"])) + var load := SvmapIO.load_from_file("user://test_groups.svmap") + _check(load["ok"], "Laden mit Gruppen-Feld ok (Prüfsumme deckt Feld ab): %s" % str(load["errors"])) + var doc2: Dictionary = load["data"] + _check(int(GroupOps.group_id(doc2["objects"][0])) == 0, "Gruppe 0 überlebt Speichern→Laden (float → int)") + _check(int(GroupOps.group_id(doc2["objects"][1])) == 0, "Mitglied 2 auch") + _check(GroupOps.members_of(doc2, 0) == [0, 1], "Mitglieder nach Laden: [0, 1]") + + # ALTE Map (ohne group-Feld): lädt unverändert, alles ungruppiert. + var old_doc := SvmapDocument.create_new("Alte Map", 256.0, "nacht") + var old_save := SvmapIO.save_to_file("user://test_old.svmap", old_doc) + _check(old_save["ok"], "alte Map (ohne Feld) speichert") + var old_load := SvmapIO.load_from_file("user://test_old.svmap") + _check(old_load["ok"], "alte Map lädt 1:1 (Loader-Kompatibilität)") + _check(GroupOps.active_group_ids(old_load["data"]).is_empty(), "alte Map: keine Gruppen (Feld abwesend)") + + # Neue Map mit Feld auf ALTEM Pfad: kein format_version-Bump nötig. + _check(int(doc2["format_version"]) == SvmapIO.FORMAT_VERSION, "format_version bleibt 1 (additive Erweiterung)") + + for path in ["user://test_groups.svmap", "user://test_old.svmap"]: + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +## Rubberband (Karte t_f6f29d86): Projektion + Rahmen-Auswahl — reine +## Mathe ohne Viewport (Transform + Projektionsmatrix, wie Kamera.unproject). +func _test_rubberband_pick() -> void: + print("Testgruppe: Rahmen-Ausziehen (Rubberband-Projektion)") + var size := Vector2(800.0, 600.0) + var xform := Transform3D(Basis.IDENTITY, Vector3(0.0, 12.0, 18.0)) + xform = xform.looking_at(Vector3.ZERO, Vector3.UP) + var proj := Projection.create_perspective(75.0, size.x / size.y, 0.05, 4000.0) + var positions := [ + Vector3(-6.0, 0.0, 0.0), # Objekt A (links) + Vector3(0.0, 0.0, 0.0), # Objekt B (Mitte) + Vector3(6.0, 0.0, 0.0), # Objekt C (rechts) + ] + var proj_a := _project(xform, proj, size, positions[0]) + var proj_c := _project(xform, proj, size, positions[2]) + _check(proj_a.x < proj_c.x, "Projektion: links < rechts (Kamera-Basistest)") + + # Rahmen über die linke Hälfte → nur Objekt A. + var half_rect := SelectionMath.rect_from(Vector2(0, 0), Vector2(400, 600)) + var left := SelectionMath.pick_in_rect_projection(xform, proj, size, positions, half_rect) + _check(left == [0], "linke Hälfte: nur Objekt A im Rahmen") + + # Rahmen über ALLES → alle drei, Reihenfolge = Objekt-Reihenfolge. + var all_rect := SelectionMath.rect_from(proj_a - Vector2(40, 40), proj_c + Vector2(40, 40)) + var all := SelectionMath.pick_in_rect_projection(xform, proj, size, positions, all_rect) + _check(all == [0, 1, 2], "großer Rahmen: alle drei Objekte") + + # Kamera-Rückseite wird ignoriert (kein Spiegel-Treffer hinter der Kamera). + var back: Vector3 = xform.origin + xform.basis.z.normalized() * 5.0 # +Z = HINTER der Kamera + _check(SelectionMath.pick_in_rect_projection(xform, proj, size, [back], Rect2(0, 0, 800, 600)).is_empty(), + "Objekt hinter der Kamera: nie im Rahmen") + + +## Spiegel der Formel aus selection_math.gd — nur für den Projektions-Basistest. +func _project(xform: Transform3D, proj: Projection, size: Vector2, p: Vector3) -> Vector2: + var view: Vector3 = xform.affine_inverse() * p + var clip: Vector4 = proj * Vector4(view.x, view.y, view.z, 1.0) + var ndc := Vector2(clip.x / clip.w, clip.y / clip.w) + return Vector2((ndc.x * 0.5 + 0.5) * size.x, (1.0 - (ndc.y * 0.5 + 0.5)) * size.y)