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:
Hermes godot-vr 2026-10-15 15:31:19 +00:00
parent eca11bbf83
commit 849129d67f
9 changed files with 1035 additions and 73 deletions

View file

@ -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
View 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

View file

@ -0,0 +1 @@
uid://d0luucpxtrabo

View file

@ -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

View 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)))

View file

@ -0,0 +1 @@
uid://00t31s6wj67c

View file

@ -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

View file

@ -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

View file

@ -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)