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),
|
||||
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.
|
||||
|
|
|
|||
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
|
||||
## 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))
|
||||
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_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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
183
tests/test_ux.gd
183
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue