Auswahl-System: Mehrfachauswahl (Umschalt+Klick, Rubberband) + Gruppieren + Gruppen-Verschieben (Karte t_f6f29d86)
- 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]
This commit is contained in:
parent
eca11bbf83
commit
849129d67f
9 changed files with 1035 additions and 73 deletions
46
README.md
46
README.md
|
|
@ -213,3 +213,49 @@ Undo für alles):
|
||||||
Merge-Tags), Spiegeln (X/Z-Mapping, Selbstinvers, Position unverändert),
|
Merge-Tags), Spiegeln (X/Z-Mapping, Selbstinvers, Position unverändert),
|
||||||
svmap-Roundtrip mit angehobener Höhe. `godot … --quit-after` smoke:
|
svmap-Roundtrip mit angehobener Höhe. `godot … --quit-after` smoke:
|
||||||
UI baut alle neuen Buttons fehlerfrei.
|
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.
|
||||||
|
|
|
||||||
193
src/editor/group_ops.gd
Normal file
193
src/editor/group_ops.gd
Normal file
|
|
@ -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
|
||||||
1
src/editor/group_ops.gd.uid
Normal file
1
src/editor/group_ops.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://d0luucpxtrabo
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
extends Node3D
|
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
|
## - Ghost-Preview: beim Platzieren schwebt eine transparente Vorschau am
|
||||||
## Cursor, Klick setzt das Objekt (kein "überraschtes" Ergebnis).
|
## Cursor, Klick setzt das Objekt (kein "überraschtes" Ergebnis).
|
||||||
## - Auswahl: angeklickte Objekte färben sich orange, Ziehen verschiebt,
|
## - Auswahl: angeklickte Objekte färben sich orange, Ziehen verschiebt,
|
||||||
## R-Taste ODER Mausrad dreht (15°-Raster), Entf löscht.
|
## 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
|
## Alle Meldungen laufen über main._set_status — Klartext + nächsten Schritt
|
||||||
## vorschlagen (Chef-Grundsatz, map-editor-README).
|
## 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
|
var _ghost_active := false # Ghost sichtbar weil Cursor über der Map ist
|
||||||
|
|
||||||
# Auswahl + Greifen
|
# 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 _dragging := false
|
||||||
var _drag_moved := false
|
var _drag_moved := false
|
||||||
var _press_pos := Vector2.ZERO
|
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)
|
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:
|
func setup(p_main, p_camera: Camera3D, p_builder) -> void:
|
||||||
main = p_main
|
main = p_main
|
||||||
|
|
@ -222,58 +242,177 @@ func _place_object(viewport_pos: Vector2) -> void:
|
||||||
|
|
||||||
# --------------------------------------------------------------- Auswahl ----
|
# --------------------------------------------------------------- Auswahl ----
|
||||||
|
|
||||||
|
## Einzel-Auswahl (Klick ins Objekt / Klick im Baum): ersetzt die Auswahl.
|
||||||
func select_object(idx: int, from_tree := false) -> void:
|
func select_object(idx: int, from_tree := false) -> void:
|
||||||
|
selected_indices = [idx] if idx >= 0 else []
|
||||||
selected_index = idx
|
selected_index = idx
|
||||||
view_builder.call("set_selected", idx)
|
_apply_selection_view()
|
||||||
if not from_tree:
|
if not from_tree:
|
||||||
main.call("_mark_tree_selection", idx)
|
main.call("_mark_tree_selection", idx)
|
||||||
main.call("_sync_selection_panel")
|
|
||||||
main.call("_selection_status", idx)
|
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:
|
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
|
selected_index = -1
|
||||||
view_builder.call("set_selected", -1)
|
view_builder.call("set_selected_multi", [])
|
||||||
if not silent:
|
if not silent:
|
||||||
main.call("_set_status", "Auswahl aufgehoben.")
|
main.call("_set_status", "Auswahl aufgehoben.")
|
||||||
main.call("_sync_selection_panel")
|
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:
|
func rotate_selected(delta_deg: float) -> void:
|
||||||
if selected_index < 0:
|
if selected_index < 0 or selected_indices.is_empty():
|
||||||
return
|
return
|
||||||
var objects: Array = main.doc.get("objects", [])
|
var objects: Array = main.doc.get("objects", [])
|
||||||
if selected_index >= objects.size():
|
if selected_index >= objects.size():
|
||||||
return
|
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 before: Dictionary = main.history.snapshot(["objects"])
|
||||||
var obj: Dictionary = objects[selected_index]
|
GroupOps.rotate_members(main.doc, members, delta_deg)
|
||||||
var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null
|
_sync_members_to_markers(members)
|
||||||
var new_rot := fmod(float(obj.get("rot_y_deg", 0.0)) + delta_deg + 3600.0, 360.0)
|
var tag := "rotmulti:%s" % str(members)
|
||||||
obj["rot_y_deg"] = float(new_rot)
|
main.history.commit("Auswahl gedreht (%d Objekte)" % members.size(),
|
||||||
if marker != null and is_instance_valid(marker):
|
before, main.history.snapshot(["objects"]), tag)
|
||||||
marker.rotation.y = deg_to_rad(new_rot)
|
_mark_dirty()
|
||||||
main.history.commit("Objekt gedreht", before, main.history.snapshot(["objects"]),
|
|
||||||
"rot:%d" % selected_index)
|
|
||||||
main.call("_refresh_history_buttons")
|
main.call("_refresh_history_buttons")
|
||||||
main.call("_refresh_stats")
|
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:
|
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)
|
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das gelöscht werden soll.", true)
|
||||||
return
|
return
|
||||||
var objects: Array = main.doc.get("objects", [])
|
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)
|
clear_selection(true)
|
||||||
return
|
return
|
||||||
var before: Dictionary = main.history.snapshot(["objects"])
|
var before: Dictionary = main.history.snapshot(["objects"])
|
||||||
var label: String = main.object_label(str(objects[selected_index].get("id", "")))
|
var label: String
|
||||||
objects.remove_at(selected_index)
|
if doomed.size() == 1:
|
||||||
main.history.commit("%s löschen" % label, before, main.history.snapshot(["objects"]))
|
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)
|
clear_selection(true)
|
||||||
main.call("_after_structure_change")
|
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 ------
|
# ------------------------------------------ Duplizieren/Höhe/Spiegeln ------
|
||||||
|
|
@ -401,7 +540,18 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||||
var mm := event as InputEventMouseMotion
|
var mm := event as InputEventMouseMotion
|
||||||
if placing:
|
if placing:
|
||||||
_ghost_update(mm.position)
|
_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)
|
var p: Variant = _ground_point(mm.position)
|
||||||
if p == null:
|
if p == null:
|
||||||
return
|
return
|
||||||
|
|
@ -457,44 +607,192 @@ func _on_left_press(mb: InputEventMouseButton) -> void:
|
||||||
_press_pos = mb.position
|
_press_pos = mb.position
|
||||||
var hit := _pick_object(mb.position)
|
var hit := _pick_object(mb.position)
|
||||||
if hit >= 0:
|
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)
|
select_object(hit)
|
||||||
_dragging = true
|
_begin_drag(hit, mb.position)
|
||||||
_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
|
|
||||||
else:
|
else:
|
||||||
# Leere Stelle: Auswahl aufheben (klicken statt auswendig lernen).
|
# Leere Stelle: Rahmen-Ausziehen vorbereiten; loser Klick = Auswahl weg.
|
||||||
if selected_index != -1:
|
_rubber_pending = true
|
||||||
clear_selection()
|
_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:
|
if _dragging:
|
||||||
_dragging = false
|
_dragging = false
|
||||||
if _drag_moved and selected_index >= 0:
|
if _drag_moved and not _drag_members.is_empty():
|
||||||
var objects: Array = main.doc.get("objects", [])
|
_commit_drag()
|
||||||
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")
|
|
||||||
_drag_moved = false
|
_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 ------
|
# ---------------------------------------------------- Screenshot-Hilfe ------
|
||||||
|
|
@ -511,4 +809,25 @@ func demo_park_ghost(world_pos: Vector3, oid: String) -> void:
|
||||||
_ghost.visible = true
|
_ghost.visible = true
|
||||||
_ghost_active = true
|
_ghost_active = true
|
||||||
main.call("_highlight_library", oid)
|
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))
|
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
|
||||||
|
|
|
||||||
50
src/editor/selection_math.gd
Normal file
50
src/editor/selection_math.gd
Normal file
|
|
@ -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)))
|
||||||
1
src/editor/selection_math.gd.uid
Normal file
1
src/editor/selection_math.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://00t31s6wj67c
|
||||||
187
src/ui/main.gd
187
src/ui/main.gd
|
|
@ -52,6 +52,12 @@ var sel_mirror_x_btn: Button
|
||||||
var sel_mirror_z_btn: Button
|
var sel_mirror_z_btn: Button
|
||||||
var sel_raise_btn: Button
|
var sel_raise_btn: Button
|
||||||
var sel_lower_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
|
# Eigenschaften-Panel
|
||||||
var prop_name: LineEdit
|
var prop_name: LineEdit
|
||||||
|
|
@ -401,16 +407,46 @@ func _build_center_viewport() -> SubViewportContainer:
|
||||||
|
|
||||||
# Bedien-Hinweis im Viewport (Overlay)
|
# Bedien-Hinweis im Viewport (Overlay)
|
||||||
help_label = Label.new()
|
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.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
||||||
help_label.offset_left = 12
|
help_label.offset_left = 12
|
||||||
help_label.offset_bottom = -8
|
help_label.offset_bottom = -8
|
||||||
help_label.offset_top = -46
|
help_label.offset_top = -46
|
||||||
help_label.modulate = Color(1, 1, 1, 0.65)
|
help_label.modulate = Color(1, 1, 1, 0.65)
|
||||||
container.add_child(help_label)
|
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
|
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:
|
func _build_right_panel() -> PanelContainer:
|
||||||
var panel := PanelContainer.new()
|
var panel := PanelContainer.new()
|
||||||
panel.custom_minimum_size = Vector2(320, 0)
|
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))
|
func() -> void: interaction.lift_selected(-ObjectOps.LIFT_STEP_M))
|
||||||
lift_row.add_child(sel_lower_btn)
|
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())
|
vbox.add_child(_separator())
|
||||||
|
|
||||||
# --- Map-Eigenschaften ---
|
# --- Map-Eigenschaften ---
|
||||||
|
|
@ -826,6 +880,14 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||||
if ctrl:
|
if ctrl:
|
||||||
_save()
|
_save()
|
||||||
get_viewport().set_input_as_handled()
|
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:
|
KEY_R:
|
||||||
if not ctrl:
|
if not ctrl:
|
||||||
var delta := -15.0 if event.shift_pressed else 15.0
|
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:
|
func _enter_objects_mode() -> void:
|
||||||
tool_mode = "objects"
|
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()
|
_base_tool_status()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1075,7 +1137,7 @@ func _recheck_terrain_mode() -> void:
|
||||||
tool_mode = "objects"
|
tool_mode = "objects"
|
||||||
terrain_panel.visible = false
|
terrain_panel.visible = false
|
||||||
_sync_terrain_button()
|
_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)
|
_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
|
## Rechte Panel-Sektion synchronisieren OHNE Statuszeilen-Text zu ändern
|
||||||
## (sonst überschreibt ein Stummsync die gerade gesetzte Hilfs-Meldung).
|
## (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:
|
func _sync_selection_panel() -> void:
|
||||||
var objects: Array = doc.get("objects", [])
|
var objects: Array = doc.get("objects", [])
|
||||||
var idx: int = interaction.selected_index
|
var sel: Array = interaction.selected_indices
|
||||||
var has_sel := idx >= 0 and idx < objects.size()
|
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,
|
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]:
|
sel_mirror_z_btn, sel_raise_btn, sel_lower_btn]:
|
||||||
btn.disabled = not has_sel
|
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:
|
if not has_sel:
|
||||||
sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an."
|
sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an."
|
||||||
return
|
return
|
||||||
var obj: Dictionary = objects[idx]
|
if valid.size() == 1:
|
||||||
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
var idx: int = valid[0]
|
||||||
var lift := ObjectOps.lift_offset(doc, idx)
|
var obj: Dictionary = objects[idx]
|
||||||
var lift_text := "am Boden" if absf(lift) < 0.02 else ("%+.2f m über dem Boden" % lift)
|
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
||||||
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" % [
|
var lift := ObjectOps.lift_offset(doc, idx)
|
||||||
object_label(str(obj.get("id", ""))), idx + 1, objects.size(),
|
var lift_text := "am Boden" if absf(lift) < 0.02 else ("%+.2f m über dem Boden" % lift)
|
||||||
float(pos[0]), float(pos[2]), lift_text, float(obj.get("rot_y_deg", 0.0))]
|
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).
|
## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt).
|
||||||
func _selection_status(idx: int) -> void:
|
func _selection_status(idx: int) -> void:
|
||||||
var objects: Array = doc.get("objects", [])
|
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()
|
_base_tool_status()
|
||||||
return
|
return
|
||||||
var obj: Dictionary = objects[idx]
|
|
||||||
var used := int(SvmapDocument.poly_usage(doc, manifest))
|
var used := int(SvmapDocument.poly_usage(doc, manifest))
|
||||||
var budget := int(SvmapIO.MAX_TOTAL_POLY)
|
var budget := int(SvmapIO.MAX_TOTAL_POLY)
|
||||||
var budget_warning := ""
|
var budget_warning := ""
|
||||||
if used > budget * 80 / 100:
|
if used > budget * 80 / 100:
|
||||||
budget_warning = " ⚠ Poly-Budget zu %.0f %% voll — bald passt kein Objekt mehr rein." % (100.0 * used / budget)
|
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" % [
|
if sel.size() > 1:
|
||||||
object_label(str(obj.get("id", ""))), budget_warning])
|
_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 -----
|
# ------------------------------------------------------- Eigenschaften -----
|
||||||
|
|
@ -1423,6 +1526,9 @@ func _maybe_start_demo_mode() -> void:
|
||||||
if args.has("--spawns-demo"):
|
if args.has("--spawns-demo"):
|
||||||
_start_spawns_demo(args)
|
_start_spawns_demo(args)
|
||||||
return
|
return
|
||||||
|
if args.has("--groups-demo"):
|
||||||
|
_start_groups_demo(args)
|
||||||
|
return
|
||||||
if not args.has("--ghost-demo"):
|
if not args.has("--ghost-demo"):
|
||||||
return
|
return
|
||||||
var oid := "crate_wood"
|
var oid := "crate_wood"
|
||||||
|
|
@ -1629,4 +1735,53 @@ func _start_spawns_demo(args: PackedStringArray) -> void:
|
||||||
await _demo_shot(out_dir + "/spawns_ready.png")
|
await _demo_shot(out_dir + "/spawns_ready.png")
|
||||||
print("SPAWNS_DEMO_OK: 4 Screenshots in %s" % out_dir)
|
print("SPAWNS_DEMO_OK: 4 Screenshots in %s" % out_dir)
|
||||||
if args.has("--spawns-demo-quit"):
|
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
|
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
|
||||||
|
|
@ -22,6 +22,7 @@ const WEATHER_LIGHT := {
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt)
|
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 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 spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius)
|
||||||
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
|
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
|
||||||
|
|
@ -188,7 +189,7 @@ func _build_objects(objects: Array, manifest: Dictionary) -> void:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
mi.position.y = MeshShapes.half_height(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)
|
marker.add_child(mi)
|
||||||
var half := (mesh.get_aabb().size * 0.5) as Vector3
|
var half := (mesh.get_aabb().size * 0.5) as Vector3
|
||||||
marker.set_meta("pick_radius", half.length() + maxf(0.6, half.y * 0.25))
|
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.
|
## 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:
|
func set_selected(idx: int) -> void:
|
||||||
selected_index = idx
|
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:
|
for marker in object_markers:
|
||||||
if marker == null or not is_instance_valid(marker):
|
if marker == null or not is_instance_valid(marker):
|
||||||
continue
|
continue
|
||||||
var mi: MeshInstance3D = marker.get_child(0)
|
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
|
## Spawn-Zonen (Karte t_c4162c5d): JEDE Zone ist ein flacher, teamfarbiger
|
||||||
|
|
|
||||||
183
tests/test_ux.gd
183
tests/test_ux.gd
|
|
@ -65,6 +65,10 @@ func _run_tests() -> void:
|
||||||
_test_terrain_height()
|
_test_terrain_height()
|
||||||
_test_mesh_shapes()
|
_test_mesh_shapes()
|
||||||
_test_object_ops()
|
_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
|
## 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]["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(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")
|
_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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue