U3 t_a4bf9b8c: Objekt-Gruppen — GroupState (group_state.gd, fuell-/datei-agnostisch ueber get_meta/set_meta), scene_tree_panel: Gruppen-Ebene im Baum (Name + (N Objekte), eingerueckte Mitglieder, Klick waehlt alle Mitglieder, Gruppen-Knoten markiert wenn alle Mitglieder gewaehlt), zwei-Wege mit Auswahl, Rendering bleibt Gruppen-unabhaengig. Neue Suite tests/test_scene_groups.gd + Renderprobe probe_u3_groups_render.gd.
This commit is contained in:
parent
5dca5513da
commit
835a7f7363
7 changed files with 904 additions and 1 deletions
111
src/svmap/group_state.gd
Normal file
111
src/svmap/group_state.gd
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
extends RefCounted
|
||||
class_name GroupState
|
||||
## Gruppen-ZUSTAND über das Objekt-Feld "group" hinaus (Karte t_a4bf9b8c, U3 —
|
||||
## Gruppen im Szenen-Baum). GroupOps (src/editor/group_ops.gd) besitzt die
|
||||
## Zugehörigkeit (Optionales Objekt-Feld "group", int ≥ 0) — diese Datei ergänzt
|
||||
## NUR ADDITIV, was der Baum additionally braucht:
|
||||
##
|
||||
## 1. GRUPPENNAMEN: doc["group_names"] = { "<gid>": "Lager", ... } — optionaler
|
||||
## Ober-Block. Alte Maps ohne diesen Key laden unverändert (V4 prüft nur
|
||||
## Pflichtblöcke, unbekannte Ober-Keys wandern 1:1 durch _canonicalize in
|
||||
## SvmapIO und in die sha256-Prüfsumme — gleiche Begründung wie beim
|
||||
## "group"-Feld in GroupOps). Eintrag fehlt ⇒ Anzeige-Fallback "Gruppe N".
|
||||
## 2. DRAG-MITGLIDSCHAFT: Objekt in Gruppe ziehen (assign) / heraus ziehen
|
||||
## (clear für EINZELNE Indizes — GroupOps.ungroup löst dagegen die GANZE
|
||||
## Gruppe, was beim Drag-out nur eines Objekts nicht gewollt ist).
|
||||
##
|
||||
## Bewusst KEINE eigene Undo-Logik: der Aufrufer macht snapshot/commit über
|
||||
## ActionHistory (["objects"] bzw. ["group_names"]) — jede Aktion bleibt EIN
|
||||
## Undo-Schritt.
|
||||
|
||||
const NAMES_KEY := "group_names"
|
||||
|
||||
|
||||
## Rohe Namen-Tabelle (leer, wenn der Key fehlt — alte Maps).
|
||||
static func names_dict(doc: Dictionary) -> Dictionary:
|
||||
var raw: Variant = doc.get(NAMES_KEY, {})
|
||||
return raw if raw is Dictionary else {}
|
||||
|
||||
|
||||
## Gespeicherter Name einer Gruppe ("" = keiner gesetzt).
|
||||
static func group_name(doc: Dictionary, gid: int) -> String:
|
||||
var names := names_dict(doc)
|
||||
var value: Variant = names.get(str(gid), "")
|
||||
return str(value) if value is String else ""
|
||||
|
||||
|
||||
## Anzeige-Name im Baum: gespeicherter Name oder Fallback "Gruppe N"
|
||||
## (N = gid + 1 — gleiche Zählung wie in interaction.gd/selection_panel.gd).
|
||||
static func display_name(doc: Dictionary, gid: int) -> String:
|
||||
var name := group_name(doc, gid)
|
||||
return name if not name.is_empty() else ("Gruppe %d" % (gid + 1))
|
||||
|
||||
|
||||
## Namen setzen ("" oder nur Leerzeichen = Eintrag entfernen → Fallback).
|
||||
## Rückgabe: true, wenn sich am Dokument etwas geändert hat.
|
||||
static func set_group_name(doc: Dictionary, gid: int, new_name: String) -> bool:
|
||||
var clean := new_name.strip_edges()
|
||||
var names := names_dict(doc)
|
||||
if clean.is_empty():
|
||||
if not names.has(str(gid)):
|
||||
return false
|
||||
(doc[NAMES_KEY] as Dictionary).erase(str(gid))
|
||||
return true
|
||||
if names.get(str(gid), "") == clean:
|
||||
return false
|
||||
if not doc.has(NAMES_KEY) or not (doc[NAMES_KEY] is Dictionary):
|
||||
doc[NAMES_KEY] = {}
|
||||
(doc[NAMES_KEY] as Dictionary)[str(gid)] = clean
|
||||
return true
|
||||
|
||||
|
||||
## Namen-Einträge ohne Mitglieder entfernen (Aufräumen nach Löschen/Lösen —
|
||||
## vom Aufrufer IM SELBEN Undo-Schritt mit-committet).
|
||||
static func prune_orphans(doc: Dictionary) -> int:
|
||||
var active := {}
|
||||
for obj in (doc.get("objects", []) as Array):
|
||||
var g := GroupOps.group_id(obj)
|
||||
if g != GroupOps.UNGROUPED:
|
||||
active[g] = true
|
||||
var names := names_dict(doc)
|
||||
var removed := 0
|
||||
for key in names.keys():
|
||||
if not active.has(int(key)):
|
||||
names.erase(key)
|
||||
removed += 1
|
||||
if removed > 0:
|
||||
if (names as Dictionary).is_empty():
|
||||
doc.erase(NAMES_KEY) # sauberes Format: leerer Block fliegt raus
|
||||
elif doc.has(NAMES_KEY):
|
||||
doc[NAMES_KEY] = names
|
||||
return removed
|
||||
|
||||
|
||||
## Drag IN eine Gruppe: Objekt-Feld "group" auf die Ziel-ID setzen (Objekte,
|
||||
## die schon diese ID tragen, zählen nicht als Änderung). Nur die GEBENEN
|
||||
## Indizes — die restliche Gruppe bleibt unberührt. Rückgabe: geänderte Anzahl.
|
||||
static func assign_group(doc: Dictionary, indices: Array, gid: int) -> int:
|
||||
if gid < 0:
|
||||
return 0
|
||||
var objects: Array = doc.get("objects", [])
|
||||
var changed := 0
|
||||
for i in indices:
|
||||
if i is int and i >= 0 and i < objects.size() and (objects[i] is Dictionary):
|
||||
if GroupOps.group_id(objects[i]) != gid:
|
||||
objects[i]["group"] = gid
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
## Drag AUS der Gruppe: "group"-Feld NUR der gegebenen Indizes entfernen
|
||||
## (die Gruppe selbst bleibt mit ihren übrigen Mitgliedern bestehen).
|
||||
## Rückgabe: geänderte Anzahl.
|
||||
static func clear_group_fields(doc: Dictionary, indices: Array) -> int:
|
||||
var objects: Array = doc.get("objects", [])
|
||||
var changed := 0
|
||||
for i in indices:
|
||||
if i is int and i >= 0 and i < objects.size() and (objects[i] is Dictionary):
|
||||
if GroupOps.group_id(objects[i]) != GroupOps.UNGROUPED:
|
||||
(objects[i] as Dictionary).erase("group")
|
||||
changed += 1
|
||||
return changed
|
||||
1
src/svmap/group_state.gd.uid
Normal file
1
src/svmap/group_state.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b13m4gw07qdsf
|
||||
|
|
@ -8,6 +8,14 @@ var content_tree: Tree
|
|||
var _tree_select_lock := false # verhindert Rückkopplung Auswahl ↔ Baum
|
||||
var _obj_index_map: Dictionary = {} # obj_index -> TreeItem (schnelle Zwei-Wege-Kopplung)
|
||||
|
||||
# U3 (Karte t_a4bf9b8c): Kontextmenü, Umbenennen, Lösch-Rückfrage, Drag&Drop.
|
||||
var _context_menu: PopupMenu
|
||||
var _rename_dialog: AcceptDialog
|
||||
var _rename_field: LineEdit
|
||||
var _rename_gid := -1 # Gruppe, die das Umbenennen-Dialogfeld betrifft
|
||||
var _confirm_delete: ConfirmationDialog
|
||||
var _confirm_gid := -1 # Gruppe, die das Lösch-Rückfragen betrifft
|
||||
|
||||
|
||||
static func spec() -> Dictionary:
|
||||
return {"titel": "Map-Inhalt", "gruppe": "links", "order": 10}
|
||||
|
|
@ -25,6 +33,34 @@ func build() -> void:
|
|||
content_tree.item_selected.connect(_on_tree_selected)
|
||||
add_child(content_tree)
|
||||
|
||||
# U3 (Karte t_a4bf9b8c): Rechtsklick-Kontextmenü + Drag&Drop für Gruppen.
|
||||
content_tree.gui_input.connect(_on_tree_gui_input)
|
||||
# Tree.set_drag_forwarding: die drei Virtuals (_get_drag_data/_can_drop_data/
|
||||
# _drop_data) wohnen am Tree selbst — hier per Callables weitergeleitet.
|
||||
content_tree.set_drag_forwarding(_tree_drag_data, _tree_can_drop, _tree_drop)
|
||||
_context_menu = PopupMenu.new()
|
||||
_context_menu.hide_on_item_selection = true
|
||||
_context_menu.id_pressed.connect(_on_menu_action)
|
||||
add_child(_context_menu)
|
||||
_rename_dialog = AcceptDialog.new()
|
||||
_rename_dialog.title = "Gruppe umbenennen"
|
||||
_rename_dialog.ok_button_text = "Übernehmen"
|
||||
# Hinweis: AcceptDialog kennt in Godot 4.7 weder cancel_button_text noch
|
||||
# get_cancel_button() — der Default-Abbrechen-Button bleibt, wie er ist.
|
||||
_rename_field = LineEdit.new()
|
||||
_rename_field.placeholder_text = "Gruppenname"
|
||||
_rename_dialog.add_child(_rename_field)
|
||||
_rename_dialog.confirmed.connect(_on_rename_confirmed)
|
||||
add_child(_rename_dialog)
|
||||
_confirm_delete = ConfirmationDialog.new()
|
||||
_confirm_delete.title = "Gruppe löschen"
|
||||
_confirm_delete.ok_button_text = "Löschen"
|
||||
_confirm_delete.cancel_button_text = "Abbrechen"
|
||||
_confirm_delete.confirmed.connect(_on_delete_confirmed)
|
||||
add_child(_confirm_delete)
|
||||
add_child(_muted_label("Rechtsklick: Gruppieren / Auflösen / Umbenennen / Löschen. Objektzeile in eine Gruppe ziehen = aufnehmen, auf „Objekte“ zurückziehen = herauslösen."))
|
||||
add_child(_separator())
|
||||
|
||||
|
||||
## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt.
|
||||
## SELECT_MULTI (Karte t_c46d31c2): einfacher Klick = Ersatz-Auswahl,
|
||||
|
|
@ -143,6 +179,7 @@ func refresh_tree() -> void:
|
|||
# Objekte: Kategorie → Objekte (Karte t_e3c0310a U2 — z. B. Mauern, Barrieren).
|
||||
var obj_item := content_tree.create_item(root)
|
||||
obj_item.set_text(0, "Objekte (%d)" % objects.size())
|
||||
obj_item.set_meta("objekte_kopf", true) # Drag-Ziel „aus Gruppe herauslösen“ (U3)
|
||||
var kat_map: Dictionary = {}
|
||||
for i in objects.size():
|
||||
var obj = objects[i]
|
||||
|
|
@ -197,10 +234,12 @@ func refresh_tree() -> void:
|
|||
if not group_ids.is_empty():
|
||||
var grp_item := content_tree.create_item(root)
|
||||
grp_item.set_text(0, "Gruppen (%d)" % group_ids.size())
|
||||
grp_item.set_meta("objekte_kopf", true) # Drag-Ziel „herauslösen“ (U3)
|
||||
for gid in group_ids:
|
||||
var member_indices: Array = GroupOps.members_of(main.doc, gid)
|
||||
# U3 (Karte t_a4bf9b8c): gespeicherter Name voran, Fallback "Gruppe N".
|
||||
var g_node := content_tree.create_item(grp_item)
|
||||
g_node.set_text(0, "Gruppe %d (%d Objekte)" % [int(gid), member_indices.size()])
|
||||
g_node.set_text(0, "%s (%d Objekte)" % [GroupState.display_name(main.doc, int(gid)), member_indices.size()])
|
||||
g_node.set_meta("group_id", int(gid))
|
||||
for mi in member_indices:
|
||||
var obj: Dictionary = main.doc["objects"][int(mi)]
|
||||
|
|
@ -217,3 +256,314 @@ static func _kat_label(key: String) -> String:
|
|||
if str(g["key"]) == key:
|
||||
return str(g["label"])
|
||||
return key.capitalize()
|
||||
|
||||
|
||||
# ================= U3: Gruppen im Baum (Karte t_a4bf9b8c) ====================
|
||||
# Gruppen direkt im Szenen-Baum bilden: Rechtsklick-Menü (Gruppieren/Auflösen/
|
||||
# Umbenennen/Löschen), Drag&Drop für die Mitgliedschaft, Gruppennamen im
|
||||
# Dokument (GroupState, additiv). Jede Aktion = EIN Undo-Schritt über
|
||||
# main.history — die Mehrfachauswahl-Logik (interaction.gd) wird benutzt,
|
||||
# nicht kopiert; object_ops.gd nur LESEND.
|
||||
|
||||
|
||||
## Rechtsklick im Baum: Zeile unter der Maus finden → Kontextmenü aufmachen.
|
||||
func _on_tree_gui_input(event: InputEvent) -> void:
|
||||
if not (event is InputEventMouseButton):
|
||||
return
|
||||
var mb := event as InputEventMouseButton
|
||||
if not mb.pressed or mb.button_index != MOUSE_BUTTON_RIGHT:
|
||||
return
|
||||
var item: TreeItem = content_tree.get_item_at_position(mb.position)
|
||||
if item == null:
|
||||
return
|
||||
# Rechtsklick auf eine (noch nicht markierte) Zeile wählt sie an — wie in
|
||||
# Unity/Godot: das Menü gehört zur angeklickten Zeile. Direkter Ruf von
|
||||
# _on_tree_selected (item_selected feuert bei .select() nicht selbst).
|
||||
if item.has_meta("obj_index") and not item.is_selected(0):
|
||||
content_tree.deselect_all()
|
||||
item.select(0)
|
||||
_on_tree_selected()
|
||||
_open_context_menu(item, Vector2i(content_tree.get_screen_transform() * mb.position))
|
||||
|
||||
|
||||
## Kontextmenü je Zeilen-Typ aufbauen (Klartext-Einträge, je Aktion eine ID).
|
||||
func _open_context_menu(item: TreeItem, at_screen: Vector2i) -> void:
|
||||
_context_menu.clear()
|
||||
var has_entries := false
|
||||
# Gruppen-Knoten: Auflösen / Umbenennen / Löschen (Rückfrage bei > 1 Objekt).
|
||||
if item.has_meta("group_id"):
|
||||
var gid := int(item.get_meta("group_id"))
|
||||
_context_menu.add_item("⛓ Gruppe auflösen", 1)
|
||||
_context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "loesen_gruppe", "gid": gid})
|
||||
_context_menu.add_item("✂ Umbenennen…", 2)
|
||||
_context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "umbenennen", "gid": gid})
|
||||
_context_menu.add_item("🗑 Gruppe löschen…", 3)
|
||||
_context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "loeschen_gruppe", "gid": gid})
|
||||
has_entries = true
|
||||
# Objekt-Zeile: Gruppieren (wenn ≥ 2 markiert), Aus Gruppe lösen, Löschen.
|
||||
if item.has_meta("obj_index"):
|
||||
var sel: Array = main.interaction.selected_indices
|
||||
if sel.size() >= 2:
|
||||
_context_menu.add_item("🔗 Gruppieren (%d Objekte)" % sel.size(), 10)
|
||||
_context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "gruppieren"})
|
||||
has_entries = true
|
||||
var objects: Array = main.doc.get("objects", [])
|
||||
var idx := int(item.get_meta("obj_index"))
|
||||
if idx >= 0 and idx < objects.size() and GroupOps.group_id(objects[idx]) != GroupOps.UNGROUPED:
|
||||
_context_menu.add_item("⛓ Aus Gruppe lösen", 1)
|
||||
_context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "loesen_einzeln", "idx": idx})
|
||||
has_entries = true
|
||||
_context_menu.add_item("🗑 Löschen", 3)
|
||||
_context_menu.set_item_metadata(_context_menu.item_count - 1, {"aktion": "loeschen_einzeln", "idx": idx})
|
||||
has_entries = true
|
||||
if not has_entries:
|
||||
return
|
||||
_context_menu.position = at_screen
|
||||
_context_menu.popup()
|
||||
|
||||
|
||||
## Menü-Auswahl → Aktion (Metadaten tragen Ziel; ID → Zeile über get_item_index,
|
||||
## robust auch nachdem das Menü sich bei hide_on_item_selection schon versteckt).
|
||||
func _on_menu_action(id: int) -> void:
|
||||
var menu_index := _context_menu.get_item_index(id)
|
||||
if menu_index < 0:
|
||||
return
|
||||
var meta: Variant = _context_menu.get_item_metadata(menu_index)
|
||||
if not (meta is Dictionary):
|
||||
return
|
||||
var m := meta as Dictionary
|
||||
match str(m.get("aktion", "")):
|
||||
"gruppieren":
|
||||
group_selected()
|
||||
"loesen_gruppe":
|
||||
ungroup_group(int(m.get("gid", -1)))
|
||||
"loesen_einzeln":
|
||||
ungroup_indices([int(m.get("idx", -1))])
|
||||
"umbenennen":
|
||||
open_rename_dialog(int(m.get("gid", -1)))
|
||||
"loeschen_gruppe":
|
||||
delete_group(int(m.get("gid", -1)))
|
||||
"loeschen_einzeln":
|
||||
_select_for_action([int(m.get("idx", -1))])
|
||||
main.interaction.delete_selected()
|
||||
|
||||
|
||||
## Auswahl für eine Aktion setzen (Baum-Markierung + 3D synchron).
|
||||
func _select_for_action(indices: Array) -> void:
|
||||
_tree_select_lock = true
|
||||
main.interaction.select_many(indices)
|
||||
_tree_select_lock = false
|
||||
|
||||
|
||||
## Mehrfachauswahl → Gruppe (nutzt die bestehende interaction-Logik, EIN Undo).
|
||||
func group_selected() -> void:
|
||||
var sel: Array = main.interaction.selected_indices
|
||||
if sel.size() < 2:
|
||||
main.call("_set_status", "Gruppieren braucht mindestens 2 markierte Objekte — Strg+Klick im Baum oder im Viewport.", true)
|
||||
return
|
||||
main.interaction.group_selection()
|
||||
_refresh_after_group_change()
|
||||
_select_for_action(sel) # Gruppe bleibt markiert (Mitglieder hängen jetzt unter dem Gruppen-Knoten)
|
||||
|
||||
|
||||
## GANZE Gruppe auflösen: Mitglieder anwählen → bestehende Löse-Logik.
|
||||
func ungroup_group(gid: int) -> void:
|
||||
var members: Array = GroupOps.members_of(main.doc, gid)
|
||||
if members.is_empty():
|
||||
main.call("_set_status", "Gruppe %d existiert nicht mehr." % (gid + 1), true)
|
||||
return
|
||||
_select_for_action(members)
|
||||
main.interaction.ungroup_selection()
|
||||
_refresh_after_group_change()
|
||||
|
||||
|
||||
## Objekte aus ihrer Gruppe herauslösen (NUR diese Indizes — Drag-out-Pfad).
|
||||
## EIN Undo-Schritt: snapshot ["objects", "group_names"] vor/nach (verwaiste
|
||||
## Namen-Einträge werden im selben Schritt mitgeräumt).
|
||||
func ungroup_indices(indices: Array) -> void:
|
||||
var before := _snapshot_group_blocks()
|
||||
var freed := GroupState.clear_group_fields(main.doc, indices)
|
||||
if freed == 0:
|
||||
main.call("_set_status", "Die markierten Objekte stecken in keiner Gruppe.")
|
||||
return
|
||||
GroupState.prune_orphans(main.doc)
|
||||
main.history.commit("Objekt aus Gruppe gelöst (%d)" % freed,
|
||||
before, _snapshot_group_blocks())
|
||||
main.call("_refresh_history_buttons")
|
||||
_refresh_after_group_change()
|
||||
|
||||
|
||||
## Snapshot für Gruppen-Undo-Schritte: ["objects", "group_names"]. Fehlt der
|
||||
## Namen-Block im Dokument (alte Maps), wird er als null SEEDED — ActionHistory
|
||||
## wendet nur Schlüssel an, die im Snapshot stehen; ohne Seed bliebe der Name
|
||||
## nach Undo als Leiche stehen (Restore von null löscht den Block sauber).
|
||||
func _snapshot_group_blocks() -> Dictionary:
|
||||
var out: Dictionary = main.history.snapshot(["objects", "group_names"])
|
||||
if not out.has(GroupState.NAMES_KEY):
|
||||
out[GroupState.NAMES_KEY] = null
|
||||
return out
|
||||
|
||||
|
||||
## Umbenennen-Kern (testbar ohne Dialog): Name im Dokument setzen, EIN Undo.
|
||||
func rename_group(gid: int, new_name: String) -> bool:
|
||||
if gid < 0:
|
||||
return false
|
||||
var clean := new_name.strip_edges()
|
||||
if GroupState.group_name(main.doc, gid) == clean:
|
||||
return false # Name unverändert — kein leerer Undo-Eintrag
|
||||
var before := _snapshot_group_blocks()
|
||||
GroupState.set_group_name(main.doc, gid, new_name)
|
||||
GroupState.prune_orphans(main.doc)
|
||||
var after := _snapshot_group_blocks()
|
||||
var label := ("Gruppe umbenannt in „%s“" % clean) if not clean.is_empty() \
|
||||
else ("Gruppenname entfernt (Gruppe %d)" % (gid + 1))
|
||||
main.history.commit(label, before, after)
|
||||
main.call("_refresh_history_buttons")
|
||||
_refresh_after_group_change()
|
||||
return true
|
||||
|
||||
|
||||
## Umbenennen-Dialog (Rechtsklick am Gruppen-Knoten).
|
||||
func open_rename_dialog(gid: int) -> void:
|
||||
var members: Array = GroupOps.members_of(main.doc, gid)
|
||||
if members.is_empty():
|
||||
main.call("_set_status", "Gruppe %d existiert nicht mehr." % (gid + 1), true)
|
||||
return
|
||||
_rename_gid = gid
|
||||
_rename_field.text = GroupState.group_name(main.doc, gid)
|
||||
_rename_dialog.dialog_text = "Neuer Name für die Gruppe (leer = wieder „Gruppe %d“):" % (gid + 1)
|
||||
_rename_field.grab_focus()
|
||||
_rename_dialog.popup_centered(Vector2i(360, 100))
|
||||
|
||||
|
||||
func _on_rename_confirmed() -> void:
|
||||
if _rename_gid >= 0:
|
||||
rename_group(_rename_gid, _rename_field.text)
|
||||
_rename_gid = -1
|
||||
|
||||
|
||||
## Gruppe löschen: Rückfrage bei > 1 Objekt, dann bestehende Lösch-Logik
|
||||
## (interaction.delete_selected — EIN Undo-Schritt, Marker/3D räumen mit auf).
|
||||
func delete_group(gid: int) -> void:
|
||||
var members: Array = GroupOps.members_of(main.doc, gid)
|
||||
if members.is_empty():
|
||||
main.call("_set_status", "Gruppe %d existiert nicht mehr." % (gid + 1), true)
|
||||
return
|
||||
if members.size() > 1:
|
||||
_confirm_gid = gid
|
||||
_confirm_delete.dialog_text = "Gruppe „%s“ löschen? Die %d Objekte darin werden ALLE gelöscht — Strg+Z holt sie zurück." % [GroupState.display_name(main.doc, gid), members.size()]
|
||||
_confirm_delete.popup_centered(Vector2i(480, 140))
|
||||
return
|
||||
delete_group_confirmed(gid)
|
||||
|
||||
|
||||
## Lösch-Kern NACH der Rückfrage (auch direkt aufrufbar — Tests).
|
||||
func delete_group_confirmed(gid: int) -> void:
|
||||
var members: Array = GroupOps.members_of(main.doc, gid)
|
||||
if members.is_empty():
|
||||
return
|
||||
_select_for_action(members)
|
||||
main.interaction.delete_selected() # ruft selbst _after_structure_change → Baum neu
|
||||
|
||||
|
||||
func _on_delete_confirmed() -> void:
|
||||
if _confirm_gid >= 0:
|
||||
delete_group_confirmed(_confirm_gid)
|
||||
_confirm_gid = -1
|
||||
|
||||
|
||||
# --- Drag & Drop (Mitgliedschaft ändern) -------------------------------------
|
||||
|
||||
## Zieh-Menge bestimmen: gezogene Zeile ist Teil der Markierung → die GANZE
|
||||
## Markierung wandert mit; sonst nur die gezogene Zeile. (Testbar ohne echten
|
||||
## Drag — _tree_drag_data ruft nur dies + set_drag_preview.)
|
||||
func _collect_drag_indices(item: TreeItem) -> Array:
|
||||
if item == null or not item.has_meta("obj_index"):
|
||||
return []
|
||||
var idx := int(item.get_meta("obj_index"))
|
||||
var sel: Array = main.interaction.selected_indices
|
||||
if idx in sel:
|
||||
var out: Array = []
|
||||
for i in sel:
|
||||
if i is int:
|
||||
out.append(int(i))
|
||||
return out
|
||||
return [idx]
|
||||
|
||||
|
||||
## Tree-Drag-Start (set_drag_forwarding-Callable): Datenpaket für Drop-Ziele.
|
||||
## Vorschau-Control wird NICHT in den Baum gehängt (set_drag_preview verlangt
|
||||
## ein Control außerhalb des Baums).
|
||||
func _tree_drag_data(from_position: Vector2) -> Variant:
|
||||
var item: TreeItem = content_tree.get_item_at_position(from_position)
|
||||
var indices := _collect_drag_indices(item)
|
||||
if indices.is_empty():
|
||||
return null
|
||||
var label := Label.new()
|
||||
label.text = "%d Objekt(e) ziehen" % indices.size()
|
||||
content_tree.set_drag_preview(label)
|
||||
return {"svmap_tree_objekte": indices}
|
||||
|
||||
|
||||
## Drop erlaubt: AUF einen Gruppen-Knoten (aufnehmen) oder AUF den Objekte-Kopf/
|
||||
## eine Kategorie-Zeile (herauslösen — nur wenn mindestens ein gezogenes Objekt
|
||||
## überhaupt gruppiert ist).
|
||||
func _tree_can_drop(at_position: Vector2, data: Variant) -> bool:
|
||||
if not (data is Dictionary) or not (data as Dictionary).has("svmap_tree_objekte"):
|
||||
return false
|
||||
var item: TreeItem = content_tree.get_item_at_position(at_position)
|
||||
if item == null:
|
||||
return false
|
||||
if item.has_meta("group_id"):
|
||||
return true
|
||||
if item.has_meta("objekte_kopf"):
|
||||
var objects: Array = main.doc.get("objects", [])
|
||||
for i in (data["svmap_tree_objekte"] as Array):
|
||||
if i is int and i >= 0 and i < objects.size() \
|
||||
and GroupOps.group_id(objects[i]) != GroupOps.UNGROUPED:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Drop ausführen → Mitgliedschaft ändern (EIN Undo-Schritt), Baum/Auswahl neu.
|
||||
func _tree_drop(at_position: Vector2, data: Variant) -> void:
|
||||
var item: TreeItem = content_tree.get_item_at_position(at_position)
|
||||
if item == null:
|
||||
return
|
||||
var indices: Array = (data as Dictionary).get("svmap_tree_objekte", [])
|
||||
if item.has_meta("group_id"):
|
||||
_apply_membership_drop(indices, int(item.get_meta("group_id")))
|
||||
elif item.has_meta("objekte_kopf"):
|
||||
_apply_membership_drop(indices, -1)
|
||||
|
||||
|
||||
## Mitgliedschafts-Kern (testbar): gid ≥ 0 = aufnehmen, gid < 0 = herauslösen.
|
||||
func _apply_membership_drop(indices: Array, gid: int) -> void:
|
||||
var before := _snapshot_group_blocks()
|
||||
var changed := 0
|
||||
var label := ""
|
||||
if gid >= 0:
|
||||
changed = GroupState.assign_group(main.doc, indices, gid)
|
||||
label = "%d Objekt(e) in „%s“ gezogen" % [changed, GroupState.display_name(main.doc, gid)]
|
||||
else:
|
||||
changed = GroupState.clear_group_fields(main.doc, indices)
|
||||
label = "%d Objekt(e) aus der Gruppe herausgelöst" % changed
|
||||
if changed == 0:
|
||||
main.call("_set_status", "Nichts zu ändern — die Objekte gehören schon genau so zur Gruppe.")
|
||||
return
|
||||
GroupState.prune_orphans(main.doc)
|
||||
main.history.commit(label, before, _snapshot_group_blocks())
|
||||
main.call("_refresh_history_buttons")
|
||||
_refresh_after_group_change()
|
||||
_select_for_action(indices) # gezogene Objekte bleiben sichtbar markiert
|
||||
|
||||
|
||||
## Nach jeder Gruppen-Änderung: Baum neu + Panels synchron (kein 3D-Rebuild —
|
||||
## Positionen ändern sich beim Gruppieren/Umbenennen/Zuordnen nicht).
|
||||
func _refresh_after_group_change() -> void:
|
||||
main.call("_refresh_tree")
|
||||
var sel: Array = main.interaction.selected_indices
|
||||
_tree_select_lock = true
|
||||
mark_tree_selection_multi(sel)
|
||||
_tree_select_lock = false
|
||||
main.call("_sync_selection_panel")
|
||||
|
|
|
|||
97
tests/probe_u3_groups_render.gd
Normal file
97
tests/probe_u3_groups_render.gd
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
extends SceneTree
|
||||
## Render-Probe U3 (Karte t_a4bf9b8c): Gruppen im Szenen-Baum.
|
||||
## Legt 4 Objekte an, gruppiert 2 davon über den BAUM-WEG (tree_mod.group_selected),
|
||||
## benennt die Gruppe „Lager“, hängt Objekt 3 per Drag-Kern in die Gruppe und
|
||||
## rendert die echte Main-UI: links muss die Gruppe mit Namen + 3 Mitgliedern im
|
||||
## Baum stehen und in 3D markiert sein.
|
||||
## Start (BRAUCHT Rendering — NICHT --headless):
|
||||
## xvfb-run godot --path . --rendering-driver opengl3 -s res://tests/probe_u3_groups_render.gd
|
||||
|
||||
var out_dir := "/opt/data/profiles/scopedvr-dev/cache/scratch"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
if OS.get_cmdline_user_args().size() > 0:
|
||||
out_dir = OS.get_cmdline_user_args()[0]
|
||||
# Feste Fenstergröße (sonst fallen die Docks im xvfb-Default zusammen).
|
||||
root.size = Vector2i(1440, 900)
|
||||
root.mode = Window.MODE_WINDOWED
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
var scene: PackedScene = load("res://scenes/main.tscn")
|
||||
var main = scene.instantiate()
|
||||
root.add_child(main)
|
||||
await process_frame
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
# Vier Objekte nah an der Kartenmitte (sichtbar aus der Startansicht).
|
||||
(main.doc["objects"] as Array).append({
|
||||
"id": "barrel_001", "pos": [0.0, 0.0, 0.0], "scale": 3.0, "rot_y_deg": 0.0})
|
||||
(main.doc["objects"] as Array).append({
|
||||
"id": "crate_wood", "pos": [9.0, 0.0, 5.0], "scale": 20.0, "rot_y_deg": 25.0})
|
||||
(main.doc["objects"] as Array).append({
|
||||
"id": "barrel_001", "pos": [-9.0, 0.0, -5.0], "scale": 3.0, "rot_y_deg": 0.0})
|
||||
(main.doc["objects"] as Array).append({
|
||||
"id": "crate_wood", "pos": [-6.0, 0.0, 9.0], "scale": 8.0, "rot_y_deg": 0.0})
|
||||
main.call("_after_structure_change")
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
# U3-Pfade benutzen — nicht GroupOps direkt:
|
||||
# 1) Mehrfachauswahl im Baum-Weg → group_selected() (nutzt interaction).
|
||||
var tree_mod = main.get("tree_mod")
|
||||
main.interaction.call("select_many", [0, 1])
|
||||
tree_mod.call("group_selected")
|
||||
await process_frame
|
||||
# 2) Gruppe umbenennen (Kern-Funktion, wie der Dialog es ruft).
|
||||
tree_mod.call("rename_group", 0, "Lager")
|
||||
# 3) Objekt 2 per Drag-Kern in die Gruppe ziehen.
|
||||
tree_mod.call("_apply_membership_drop", [2], 0)
|
||||
# 4) Gruppe auswählen (Klick auf den Gruppen-Knoten simulieren).
|
||||
var g_node: TreeItem = _find_group_node(tree_mod, "Lager")
|
||||
if g_node != null:
|
||||
g_node.select(0)
|
||||
(tree_mod.get("content_tree") as Tree).item_selected.emit()
|
||||
main.call("_focus_selection")
|
||||
await process_frame
|
||||
await process_frame
|
||||
await process_frame
|
||||
# Hinweis-Dialoge (z. B. „Modelle fehlen“ im Klon ohne GLB-Quellen) schließen —
|
||||
# REKURSIV über den ganzen Fensterbaum und direkt vor dem Screenshot nochmal
|
||||
# (das Banner erscheint lazy nach dem View-Rebuild, nicht beim Setup).
|
||||
_windows_zu(root)
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
|
||||
var img := root.get_texture().get_image()
|
||||
var out_path := out_dir + "/u3_gruppen_im_baum_beweis.png"
|
||||
img.save_png(out_path)
|
||||
var members: Array = GroupOps.members_of(main.doc, 0)
|
||||
print("PROBE_PNG: ", out_path)
|
||||
print("PROBE_FERTIG: Gruppe=", GroupState.display_name(main.doc, 0),
|
||||
" Mitglieder=", members,
|
||||
" Baumzeile=", str(g_node.get_text(0)) if g_node != null else "FEHLT",
|
||||
" Auswahl=", main.interaction.get("selected_indices"))
|
||||
quit(0)
|
||||
|
||||
|
||||
func _find_group_node(tree_mod, name_prefix: String) -> TreeItem:
|
||||
var tree: Tree = tree_mod.get("content_tree")
|
||||
var root_item: TreeItem = tree.get_root()
|
||||
for child in root_item.get_children():
|
||||
if str(child.get_text(0)).begins_with("Gruppen ("):
|
||||
for g in child.get_children():
|
||||
if str(g.get_text(0)).begins_with(name_prefix):
|
||||
return g
|
||||
return null
|
||||
|
||||
|
||||
## Alle Fenster/Dialoge im Unterbaum zu-klappen (Beweis-Bild frei räumen).
|
||||
func _windows_zu(node: Node) -> void:
|
||||
if node is Window:
|
||||
(node as Window).hide()
|
||||
return # Fenster-Inhalte brauchen nicht weiter durchlaufen zu werden
|
||||
for child in node.get_children():
|
||||
_windows_zu(child)
|
||||
1
tests/probe_u3_groups_render.gd.uid
Normal file
1
tests/probe_u3_groups_render.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://xlxxr8guu4o8
|
||||
342
tests/test_scene_groups.gd
Normal file
342
tests/test_scene_groups.gd
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
extends SceneTree
|
||||
## test_scene_groups (Karte t_a4bf9b8c, U3 Gruppen im Szenen-Baum): prüft
|
||||
## Gruppieren/Auflösen/Umbenennen per Baum, Drag-Mitgliedschaft, Gruppenbewegung,
|
||||
## Ein-Undo-Schritt je Aktion, Alte-Map-Kompatibilität und ID-Eindeutigkeit.
|
||||
##
|
||||
## Geprüft wird:
|
||||
## T1 Mehrfachauswahl → „Gruppieren“ (Baum-Weg): Doc-Feld, EIN Undo-Schritt.
|
||||
## T2 Umbenennen: Name im Doc (additiv), Baum-Zeile zeigt ihn, EIN Undo-Schritt
|
||||
## (Undo entfernt den Namen auch von NIE-GESPEICHERTEM Zustand, Redo holt ihn).
|
||||
## T3 Drag IN Gruppe: Mitgliedschaft ändert sich, EIN Undo-Schritt, can_drop
|
||||
## nur an erlaubten Zielen.
|
||||
## T4 Drag OUT (auf „Objekte“): nur die gezogenen Indizes lösen sich.
|
||||
## T5 Gruppenbewegung: alle Mitglieder gleichmäßig (Abstände erhalten), EIN Undo.
|
||||
## T6 Gruppe auflösen + Gruppe löschen (Rückfrage-Pfad) — je EIN Undo-Schritt,
|
||||
## Undo holt gelöschte Objekte samt Gruppenbindung zurück.
|
||||
## T7 Alte Karte ohne Gruppenzustand: Roundtrip unverändert (kein group-Feld,
|
||||
## kein group_names-Block); Map MIT Gruppen: Roundtrip erhält alles.
|
||||
## T8 Gruppen-IDs bleiben eindeutig.
|
||||
|
||||
var _fails := 0
|
||||
var _oks := 0
|
||||
var main
|
||||
var tree_mod
|
||||
|
||||
|
||||
func _check(cond: bool, what: String) -> void:
|
||||
if cond:
|
||||
_oks += 1
|
||||
print(" ok: ", what)
|
||||
else:
|
||||
_fails += 1
|
||||
print(" FAIL: ", what)
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
print("Testgruppe: Gruppen im Szenen-Baum (U3)")
|
||||
var scene: PackedScene = load("res://scenes/main.tscn")
|
||||
main = scene.instantiate()
|
||||
root.add_child(main)
|
||||
await process_frame
|
||||
await process_frame
|
||||
await process_frame
|
||||
tree_mod = main.get("tree_mod")
|
||||
_check(tree_mod != null, "tree_mod aus der Registry geladen")
|
||||
_check(tree_mod.get("content_tree") != null, "content_tree im Modul vorhanden")
|
||||
_check(tree_mod.get("_context_menu") != null, "Kontextmenü im Baum angelegt (U3)")
|
||||
|
||||
# Drei Objekte ins Doc (gleiche Basis wie test_scene_tree).
|
||||
(main.doc["objects"] as Array).append({
|
||||
"id": "barrel_001", "pos": [4.0, 0.0, 4.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||||
(main.doc["objects"] as Array).append({
|
||||
"id": "crate_wood", "pos": [-6.0, 0.0, 2.0], "scale": 2.0, "rot_y_deg": 10.0})
|
||||
(main.doc["objects"] as Array).append({
|
||||
"id": "fremd_objekt_xyz", "pos": [0.0, 0.0, 8.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||||
main.call("_after_structure_change")
|
||||
await process_frame
|
||||
|
||||
await _t1_gruppieren()
|
||||
await _t2_umbenennen()
|
||||
await _t3_drag_rein()
|
||||
await _t4_drag_raus()
|
||||
await _t5_gruppenbewegung()
|
||||
await _t6_loesen_und_loeschen()
|
||||
await _t7_alte_karte_roundtrip()
|
||||
await _t8_ids_eindeutig()
|
||||
_finish()
|
||||
|
||||
|
||||
func _history_count() -> int:
|
||||
return (main.history.get("entries") as Array).size()
|
||||
|
||||
|
||||
## History-Index (Position des zuletzt angewandten Eintrags). Nach einem Undo
|
||||
## ersetzt der nächste Commit den verworfenen Redo-Zweig — der Eintrags-Zähler
|
||||
## wächst dann NICHT, aber der Index springt +1. „EIN Undo-Schritt“ prüfen wir
|
||||
## deshalb über das Index-Delta (präzise über beide Fälle).
|
||||
func _history_index() -> int:
|
||||
return int(main.history.get("index"))
|
||||
|
||||
|
||||
func _find_child(item: TreeItem, prefix: String) -> TreeItem:
|
||||
for child in item.get_children():
|
||||
if str(child.get_text(0)).begins_with(prefix):
|
||||
return child
|
||||
return null
|
||||
|
||||
|
||||
func _group_node(gid: int) -> TreeItem:
|
||||
var tree: Tree = tree_mod.get("content_tree")
|
||||
var grp_head := _find_child(tree.get_root(), "Gruppen (")
|
||||
if grp_head == null:
|
||||
return null
|
||||
for child in grp_head.get_children():
|
||||
if child.has_meta("group_id") and int(child.get_meta("group_id")) == gid:
|
||||
return child
|
||||
return null
|
||||
|
||||
|
||||
func _t1_gruppieren() -> void:
|
||||
print("== T1 Gruppieren (Mehrfachauswahl → Baum) ==")
|
||||
var before_steps := _history_count()
|
||||
main.interaction.call("select_many", [0, 2])
|
||||
tree_mod.call("group_selected")
|
||||
await process_frame
|
||||
var ids: Array = GroupOps.active_group_ids(main.doc)
|
||||
_check(ids.size() == 1 and int(ids[0]) == 0, "T1 Gruppe 0 im Doc angelegt (ids=%s)" % str(ids))
|
||||
_check(_history_count() == before_steps + 1,
|
||||
"T1 GENAU EIN Undo-Schritt (%d → %d)" % [before_steps, _history_count()])
|
||||
var node := _group_node(0)
|
||||
_check(node != null and str(node.get_text(0)).contains("(2 Objekte)"),
|
||||
"T1 Baum zeigt Gruppen-Knoten mit 2 Mitgliedern")
|
||||
# Ein Objekt allein kann nicht gruppieren (Klartext-Meldung, kein Crash).
|
||||
var steps_now := _history_count()
|
||||
main.interaction.call("clear_selection", true)
|
||||
main.interaction.call("select_object", 0, true)
|
||||
tree_mod.call("group_selected")
|
||||
_check(_history_count() == steps_now, "T1 Gruppieren mit < 2 Objekten = kein Undo-Schritt")
|
||||
|
||||
|
||||
func _t2_umbenennen() -> void:
|
||||
print("== T2 Umbenennen ==")
|
||||
var before_steps := _history_count()
|
||||
var changed: bool = tree_mod.call("rename_group", 0, "Lager")
|
||||
_check(changed, "T2 rename_group meldet Änderung")
|
||||
_check(GroupState.group_name(main.doc, 0) == "Lager", "T2 Name im Dokument (group_names-Block)")
|
||||
var node := _group_node(0)
|
||||
_check(node != null and str(node.get_text(0)).begins_with("Lager "),
|
||||
"T2 Baum-Zeile zeigt den Namen („%s“)" % (str(node.get_text(0)) if node != null else "?"))
|
||||
_check(_history_count() == before_steps + 1,
|
||||
"T2 GENAU EIN Undo-Schritt (%d → %d)" % [before_steps, _history_count()])
|
||||
# Undo muss den NIE-GESPEICHERTEN Namen-Block entfernen (null-Seed).
|
||||
main.call("_do_undo")
|
||||
await process_frame
|
||||
_check(not main.doc.has("group_names"), "T2 Undo entfernt group_names-Block komplett (alte-Map-Semantik)")
|
||||
_check(GroupOps.active_group_ids(main.doc).size() == 1, "T2 Undo lässt die GRUPPE selbst bestehen")
|
||||
main.call("_do_redo")
|
||||
await process_frame
|
||||
_check(GroupState.group_name(main.doc, 0) == "Lager", "T2 Redo stellt den Namen wieder her")
|
||||
# Umbenennen auf denselben Namen = kein neuer Undo-Eintrag.
|
||||
var steps_now := _history_count()
|
||||
tree_mod.call("rename_group", 0, "Lager")
|
||||
_check(_history_count() == steps_now, "T2 Rename auf denselben Namen = kein Undo-Schritt")
|
||||
|
||||
|
||||
func _t3_drag_rein() -> void:
|
||||
print("== T3 Drag in Gruppe ==")
|
||||
var data: Dictionary = {"svmap_tree_objekte": [1]}
|
||||
# can_drop: Gruppen-Knoten ja, Objekte-Kopf mit ungruppiertem Objekt nein.
|
||||
var g_node := _group_node(0)
|
||||
_check(g_node != null and tree_mod.call("_tree_can_drop", Vector2.ZERO, data) == false,
|
||||
"T3 can_drop braucht die ECHTE Position (Vector2.ZERO = keine Zeile) — kein Blind-True")
|
||||
_check(tree_mod.call("_tree_can_drop", Vector2(0, 0), {"falsch": 1}) == false,
|
||||
"T3 fremdes Datenpaket wird abgelehnt")
|
||||
var before_steps := _history_count()
|
||||
tree_mod.call("_apply_membership_drop", [1], 0)
|
||||
await process_frame
|
||||
var members: Array = GroupOps.members_of(main.doc, 0)
|
||||
_check(members == ([0, 1, 2] as Array), "T3 Objekt 1 ist jetzt Mitglied (members=%s)" % str(members))
|
||||
_check(_history_count() == before_steps + 1,
|
||||
"T3 GENAU EIN Undo-Schritt (%d → %d)" % [before_steps, _history_count()])
|
||||
var node := _group_node(0)
|
||||
_check(node != null and str(node.get_text(0)).contains("(3 Objekte)"),
|
||||
"T3 Baum zeigt 3 Mitglieder")
|
||||
# Drag-Start (Zeile finden + Menge): Ziehmenge = ganze Markierung, wenn die
|
||||
# Zeile Teil der Auswahl ist — sonst nur die Zeile.
|
||||
main.interaction.call("select_many", [1, 2])
|
||||
var drag_item: TreeItem = null
|
||||
for child in node.get_children():
|
||||
if child.has_meta("obj_index") and int(child.get_meta("obj_index")) == 1:
|
||||
drag_item = child
|
||||
_check(drag_item != null, "T3 gezogene Zeile (Objekt 1) unter dem Gruppen-Knoten gefunden")
|
||||
if drag_item != null:
|
||||
var picked: Array = tree_mod.call("_collect_drag_indices", drag_item)
|
||||
_check(picked == ([1, 2] as Array), "T3 Ziehmenge = ganze Markierung [1, 2] (hat %s)" % str(picked))
|
||||
|
||||
|
||||
func _t4_drag_raus() -> void:
|
||||
print("== T4 Drag aus Gruppe (auf „Objekte“) ==")
|
||||
# can_drop auf Objekte-Kopf: nur erlaubt, wenn EIN gezogenes Objekt gruppiert ist.
|
||||
var objects: Array = main.doc.get("objects", [])
|
||||
_check(GroupOps.group_id(objects[0]) == 0, "T4 Vorbedingung: Objekt 0 ist in Gruppe 0")
|
||||
var before_steps := _history_count()
|
||||
tree_mod.call("_apply_membership_drop", [0], -1)
|
||||
await process_frame
|
||||
_check(GroupOps.group_id(objects[0]) == GroupOps.UNGROUPED, "T4 Objekt 0 ist ungruppiert")
|
||||
_check(GroupOps.members_of(main.doc, 0) == ([1, 2] as Array), "T4 Gruppe 0 bleibt mit 1+2 bestehen")
|
||||
_check(_history_count() == before_steps + 1,
|
||||
"T4 GENAU EIN Undo-Schritt (%d → %d)" % [before_steps, _history_count()])
|
||||
# Undo holt Objekt 0 in die Gruppe zurück.
|
||||
main.call("_do_undo")
|
||||
await process_frame
|
||||
_check(GroupOps.members_of(main.doc, 0) == ([0, 1, 2] as Array), "T4 Undo stellt die Mitgliedschaft wieder her")
|
||||
|
||||
|
||||
func _t5_gruppenbewegung() -> void:
|
||||
print("== T5 Gruppenbewegung (Abstände erhalten, EIN Undo) ==")
|
||||
var members: Array = GroupOps.members_of(main.doc, 0)
|
||||
var objects: Array = main.doc.get("objects", [])
|
||||
var pos_before: Dictionary = {}
|
||||
for i in members:
|
||||
pos_before[int(i)] = Vector3(
|
||||
float((objects[i] as Dictionary)["pos"][0]),
|
||||
float((objects[i] as Dictionary)["pos"][1]),
|
||||
float((objects[i] as Dictionary)["pos"][2]))
|
||||
var dist_before: float = pos_before[1].distance_to(pos_before[2])
|
||||
main.interaction.call("select_many", members)
|
||||
var before_steps := _history_count()
|
||||
var before_index := _history_index()
|
||||
main.interaction.call("nudge_selection", Vector3(4.0, 0.0, -2.0))
|
||||
await process_frame
|
||||
var moved: Dictionary = {}
|
||||
for i in members:
|
||||
var p: Array = (main.doc["objects"][int(i)] as Dictionary)["pos"]
|
||||
moved[int(i)] = Vector3(float(p[0]), float(p[1]), float(p[2]))
|
||||
var delta: Vector3 = moved[1] - pos_before[1]
|
||||
var dist_after: float = moved[1].distance_to(moved[2])
|
||||
_check(delta.length() > 0.5, "T5 Mitglieder sind tatsächlich gewandert (%.2f m)" % delta.length())
|
||||
_check(absf((moved[2] - pos_before[2]).length() - delta.length()) < 0.001,
|
||||
"T5 ALLE Mitglieder um denselben Betrag verschoben")
|
||||
_check(absf(dist_after - dist_before) < 0.001,
|
||||
"T5 Abstand erhalten (%.3f m → %.3f m)" % [dist_before, dist_after])
|
||||
_check(_history_index() == before_index + 1,
|
||||
"T5 GENAU EIN Undo-Schritt (Index %d → %d, Einträge %d)" % [before_index, _history_index(), _history_count()])
|
||||
main.call("_do_undo")
|
||||
await process_frame
|
||||
var objects2: Array = main.doc.get("objects", [])
|
||||
var restored: bool = true
|
||||
for i in members:
|
||||
var p: Array = (objects2[int(i)] as Dictionary)["pos"]
|
||||
if Vector3(float(p[0]), float(p[1]), float(p[2])).distance_to(pos_before[int(i)]) > 0.001:
|
||||
restored = false
|
||||
_check(restored, "T5 EIN Undo stellt alle Positionen exakt wieder her")
|
||||
|
||||
|
||||
func _t6_loesen_und_loeschen() -> void:
|
||||
print("== T6 Auflösen + Löschen (Rückfrage-Pfad) ==")
|
||||
var before_steps := _history_count()
|
||||
var before_index := _history_index()
|
||||
tree_mod.call("ungroup_group", 0)
|
||||
await process_frame
|
||||
_check(GroupOps.active_group_ids(main.doc).is_empty(), "T6 Gruppe 0 komplett aufgelöst")
|
||||
var field_free := true
|
||||
for obj in (main.doc["objects"] as Array):
|
||||
if (obj as Dictionary).has("group"):
|
||||
field_free = false
|
||||
_check(field_free, "T6 kein Objekt trägt noch ein group-Feld")
|
||||
_check(_history_index() == before_index + 1,
|
||||
"T6 Auflösen = GENAU EIN Undo-Schritt (Index %d → %d, Einträge %d)" % [before_index, _history_index(), _history_count()])
|
||||
# Neu gruppieren, dann löschen (Rückfrage bei > 1 Objekt → Dialog; Kern direkt).
|
||||
main.interaction.call("select_many", [0, 1])
|
||||
tree_mod.call("group_selected")
|
||||
await process_frame
|
||||
var ids: Array = GroupOps.active_group_ids(main.doc)
|
||||
_check(ids.size() == 1, "T6 Gruppe neu angelegt (ids=%s)" % str(ids))
|
||||
var gid := int(ids[0])
|
||||
var before_del := _history_count()
|
||||
tree_mod.call("delete_group_confirmed", gid)
|
||||
await process_frame
|
||||
_check((main.doc["objects"] as Array).size() == 1, "T6 beide Gruppen-Mitglieder gelöscht (1 Objekt übrig)")
|
||||
_check(_history_count() == before_del + 1,
|
||||
"T6 Löschen = GENAU EIN Undo-Schritt (%d → %d)" % [before_del, _history_count()])
|
||||
main.call("_do_undo")
|
||||
await process_frame
|
||||
_check((main.doc["objects"] as Array).size() == 3, "T6 EIN Undo holt beide Objekte zurück")
|
||||
var back_ids: Array = GroupOps.active_group_ids(main.doc)
|
||||
_check(back_ids.size() == 1 and GroupOps.members_of(main.doc, int(back_ids[0])).size() == 2,
|
||||
"T6 Undo stellt auch die Gruppen-Bindung wieder her")
|
||||
# Aufräumen für T7/T8: Gruppe lösen, alle Objekte einzeln.
|
||||
tree_mod.call("ungroup_group", int(back_ids[0]))
|
||||
|
||||
|
||||
func _t7_alte_karte_roundtrip() -> void:
|
||||
print("== T7 Alte Karte ohne Gruppenzustand lädt unverändert ==")
|
||||
var doc := SvmapDocument.create_new("U3-Alte-Karte", 256.0, "mittag")
|
||||
var save1 := SvmapIO.save_to_file("user://test_scene_groups_alt.svmap", doc)
|
||||
_check(save1["ok"], "T7 alte Karte speichert ok: %s" % str(save1["errors"]))
|
||||
var load1 := SvmapIO.load_from_file("user://test_scene_groups_alt.svmap")
|
||||
_check(load1["ok"], "T7 alte Karte lädt ok: %s" % str(load1["errors"]))
|
||||
var loaded: Dictionary = load1["data"]
|
||||
_check(not loaded.has("group_names"), "T7 geladene Map hat KEINEN group_names-Block")
|
||||
var no_group_field := true
|
||||
for obj in (loaded["objects"] as Array):
|
||||
if (obj as Dictionary).has("group"):
|
||||
no_group_field = false
|
||||
_check(no_group_field, "T7 kein Objekt trägt ein group-Feld")
|
||||
# Anzeige-Fallback ohne Namen: "Gruppe 1" statt Crash.
|
||||
_check(GroupState.display_name(loaded, 0) == "Gruppe 1", "T7 Anzeige-Fallback „Gruppe 1“ ohne Namen")
|
||||
# Map MIT Gruppen: Roundtrip erhält group-Feld UND Namen.
|
||||
var doc2: Dictionary = SvmapDocument.create_new("U3-Mit-Gruppen", 256.0, "mittag")
|
||||
(doc2["objects"] as Array).append({
|
||||
"id": "barrel_001", "pos": [4.0, 0.0, 4.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||||
(doc2["objects"] as Array).append({
|
||||
"id": "crate_wood", "pos": [-6.0, 0.0, 2.0], "scale": 2.0, "rot_y_deg": 10.0})
|
||||
GroupOps.group(doc2, [0, 1])
|
||||
GroupState.set_group_name(doc2, 0, "Lager")
|
||||
var save2 := SvmapIO.save_to_file("user://test_scene_groups_neu.svmap", doc2)
|
||||
_check(save2["ok"], "T7 Map mit Gruppen speichert ok: %s" % str(save2["errors"]))
|
||||
var load2 := SvmapIO.load_from_file("user://test_scene_groups_neu.svmap")
|
||||
_check(load2["ok"], "T7 Map mit Gruppen lädt ok: %s" % str(load2["errors"]))
|
||||
var loaded2: Dictionary = load2["data"]
|
||||
_check(GroupState.group_name(loaded2, 0) == "Lager", "T7 Gruppenname überlebt Roundtrip")
|
||||
_check(GroupOps.group_id(loaded2["objects"][0]) == 0
|
||||
and GroupOps.group_id(loaded2["objects"][1]) == 0,
|
||||
"T7 group-Felder überleben Roundtrip")
|
||||
|
||||
|
||||
func _t8_ids_eindeutig() -> void:
|
||||
print("== T8 Gruppen-IDs bleiben eindeutig ==")
|
||||
var objects: Array = main.doc.get("objects", [])
|
||||
_check(objects.size() == 3, "T8 Vorbedingung: 3 Objekte im Doc")
|
||||
var seen: Dictionary = {}
|
||||
# Mehrere Gruppen nacheinander anlegen — jede ID muss neu sein.
|
||||
var g1: int = GroupOps.group(main.doc, [0, 1])
|
||||
seen[g1] = true
|
||||
_check(g1 >= 0, "T8 Gruppe 1 angelegt (id %d)" % g1)
|
||||
var g2: int = GroupOps.group(main.doc, [2])
|
||||
_check(g2 < 0, "T8 Gruppe braucht ≥ 2 Objekte (id %d)" % g2)
|
||||
# Objekt 2 ist nicht gruppiert → mit Objekt 0 gruppieren → verlässt Gruppe 1.
|
||||
var g2b: int = GroupOps.group(main.doc, [2, 0])
|
||||
_check(g2b != g1, "T8 zweite Gruppe bekommt eine NEUE ID (%d ≠ %d)" % [g2b, g1])
|
||||
seen[g2b] = true
|
||||
var active: Array = GroupOps.active_group_ids(main.doc)
|
||||
var unique_ids: Dictionary = {}
|
||||
for g in active:
|
||||
unique_ids[int(g)] = true
|
||||
_check((active as Array).size() == unique_ids.size(),
|
||||
"T8 aktive IDs sind eindeutig (ids=%s)" % str(active))
|
||||
_check(GroupOps.next_group_id(main.doc) > int(active.max()), "T8 nächste ID liegt über allen aktiven")
|
||||
# Aufräumen: alles ungruppiert für den restlichen Lauf.
|
||||
GroupState.clear_group_fields(main.doc, [0, 1, 2])
|
||||
GroupState.prune_orphans(main.doc)
|
||||
_check(GroupOps.active_group_ids(main.doc).is_empty(), "T8 Aufräumen: keine Gruppen mehr aktiv")
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("CHECKS: %d FAILURES: %d" % [_oks + _fails, _fails])
|
||||
if _fails > 0:
|
||||
print("SCENE_GROUPS_TEST_FAIL")
|
||||
quit(1)
|
||||
else:
|
||||
print("ALL OK: %d checks" % (_oks + _fails))
|
||||
quit(0)
|
||||
1
tests/test_scene_groups.gd.uid
Normal file
1
tests/test_scene_groups.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cb3hbjkkpslcl
|
||||
Loading…
Reference in a new issue