U2 t_e3c0310a: Szenen-Baum links mit Kategorie-Ebene + Zwei-Wege-Auswahl — scene_tree_panel: Objekte unter Kategorie-Knoten (LibraryBrowser.group_of + Label-Mapping), Spawn-Zonen Team A/B, Bau-Teile, Gruppen-Ebene (GroupOps; Klick auf Gruppe waehlt alle Mitglieder), Kopf mit Zaehlern (Objekte (N)), Auge-Praefix je Eintrag (nur Anzeige, keine Doc-Aenderung), leerer Zustand mit Klartext-Hinweis, Zwei-Wege-Kopplung ueber _obj_index_map (O(1) Markieren + scroll_to_item). Neue Suite tests/test_scene_tree.gd (24 checks: Baum fuellt sich, Klick->3D, 3D->Baum, Mehrfach, Gruppen-Klick, leerer Hinweis, UIScale). Renderprobe probe_u2_scene_tree_render.gd. main.gd NICHT angefasst.

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 10:03:34 +00:00
parent 3d7ba993d5
commit 60b09bfb74
5 changed files with 371 additions and 36 deletions

View file

@ -6,6 +6,7 @@ extends "res://src/ui/panels/editor_panel.gd"
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)
static func spec() -> Dictionary:
@ -38,6 +39,20 @@ func _on_tree_selected() -> void:
if item.has_meta("gebaeude_index"):
_on_tree_selected_gebaeude(int(item.get_meta("gebaeude_index")))
return
# Gruppe geklickt (Karte t_e3c0310a U2): alle Mitglieder auswählen.
if item.has_meta("group_id"):
var g_indices: Array = []
var walk_g := item.get_first_child()
while walk_g != null:
if walk_g.has_meta("obj_index"):
g_indices.append(int(walk_g.get_meta("obj_index")))
walk_g = walk_g.get_next()
if not g_indices.is_empty():
_tree_select_lock = true
main.interaction.select_many(g_indices)
_tree_select_lock = false
main.call("_set_status", "Gruppe %d ausgewählt (%d Objekte) — gemeinsam verschieben/drehen/duplizieren." % [int(item.get_meta("group_id")), g_indices.size()])
return
var indices: Array = []
var walk := content_tree.get_next_selected(null)
while walk != null:
@ -77,26 +92,24 @@ func mark_tree_selection_multi(indices: Array) -> void:
var wanted := {}
for i in indices:
wanted[int(i)] = true
_mark_tree_selection_walk(content_tree.get_root(), wanted)
# Zwei-Wege-Kopplung (Karte t_e3c0310a U2): die Auswahl in 3D spiegelt
# sich im Baum — über die Index-Karte O(1) statt Voll-Suche.
for idx in wanted.keys():
if _obj_index_map.has(idx):
var item: TreeItem = _obj_index_map[idx]
item.select(0)
# Abwählen: alle übrigen markierten Objekt-Zeilen deselektieren.
var walk := content_tree.get_next_selected(null)
while walk != null:
if walk.has_meta("obj_index") and not wanted.has(int(walk.get_meta("obj_index"))):
walk.deselect(0)
walk = content_tree.get_next_selected(walk)
var primary: Variant = main.interaction.get("selected_index") if main.interaction != null else -1
if primary is int and primary >= 0:
if primary is int and primary >= 0 and _obj_index_map.has(primary):
_ensure_tree_visible(content_tree.get_root(), primary)
_tree_select_lock = false
func _mark_tree_selection_walk(item: TreeItem, wanted: Dictionary) -> void:
if item == null:
return
if item.has_meta("obj_index"):
var idx := int(item.get_meta("obj_index"))
if wanted.has(idx):
item.select(0)
elif item.is_selected(0):
item.deselect(0)
for child in item.get_children():
_mark_tree_selection_walk(child, wanted)
func _ensure_tree_visible(item: TreeItem, idx: int) -> void:
if item == null:
return
@ -113,21 +126,47 @@ func _ensure_tree_visible(item: TreeItem, idx: int) -> void:
func refresh_tree() -> void:
content_tree.clear()
_obj_index_map.clear()
var root := content_tree.create_item()
var objects: Array = main.doc.get("objects", [])
var zones: Dictionary = main.doc.get("spawn_zones", {})
var geb_list: Array = BuildOps.pieces(main.doc)
var group_ids: Array = GroupOps.active_group_ids(main.doc)
# Leerer Zustand (Karte t_e3c0310a U2): Klartext-Hinweis statt leere Gruppen.
if objects.is_empty() and zones.is_empty() and geb_list.is_empty() and group_ids.is_empty():
var empty_item := content_tree.create_item(root)
empty_item.set_text(0, "Noch keine Objekte — links in der Objekt-Bibliothek wählen")
empty_item.set_selectable(0, false)
return
# 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())
var kat_map: Dictionary = {}
for i in objects.size():
var obj = objects[i]
var item := content_tree.create_item(obj_item)
if obj is Dictionary:
item.set_text(0, "%s #%d" % [main.object_label(str(obj.get("id", "?"))), i + 1])
item.set_meta("obj_index", i)
else:
item.set_text(0, "ungültig #%d" % (i + 1))
var oid := str(obj.get("id", "?"))
var kat := LibraryBrowser.group_of(oid)
if not kat_map.has(kat):
kat_map[kat] = []
kat_map[kat].append({"idx": i, "obj": obj})
for kat in kat_map.keys():
var kat_item := content_tree.create_item(obj_item)
kat_item.set_text(0, "%s (%d)" % [_kat_label(kat), (kat_map[kat] as Array).size()])
for data in kat_map[kat]:
var idx: int = int(data["idx"])
var obj: Dictionary = data["obj"]
var is_visible: bool = bool(obj.get("visible", true))
var item := content_tree.create_item(kat_item)
item.set_text(0, ("👁 " if is_visible else "🚫 ") + "%s #%d" % [main.object_label(str(obj.get("id", "?"))), idx + 1])
item.set_meta("obj_index", idx)
item.set_meta("visible", is_visible)
_obj_index_map[idx] = item
var zones: Dictionary = main.doc.get("spawn_zones", {})
# Spawn-Zonen: Team A/B (Karte t_e3c0310a U2 — aus der alten Struktur).
if not zones.is_empty():
var spawn_item := content_tree.create_item(root)
spawn_item.set_text(0, "Spawn-Zonen")
for team_key in ["0", "1"]:
@ -144,7 +183,6 @@ func refresh_tree() -> void:
str(p.get("id", "?")), float(p.get("pos", [0, 0, 0])[0]), float(p.get("pos", [0, 0, 0])[2])])
# Gebäude (Karte t_97ea59c5): Bauteile im Baum — Klick wählt das Teil an.
var geb_list: Array = BuildOps.pieces(main.doc)
if not geb_list.is_empty():
var geb_item := content_tree.create_item(root)
geb_item.set_text(0, "Gebäude (%d Bauteile)" % geb_list.size())
@ -153,3 +191,29 @@ func refresh_tree() -> void:
var g_child := content_tree.create_item(geb_item)
g_child.set_text(0, "%s #%d" % [BuildOps.kind_label(str(teil.get("id", "?"))), i + 1])
g_child.set_meta("gebaeude_index", i)
# Gruppen (Karte t_e3c0310a U2): aktive Gruppen als eigene Ebene im Baum.
# Klick auf eine Gruppe wählt ALLE Mitglieder in der 3D-Szene (Zwei-Wege).
if not group_ids.is_empty():
var grp_item := content_tree.create_item(root)
grp_item.set_text(0, "Gruppen (%d)" % group_ids.size())
for gid in group_ids:
var member_indices: Array = GroupOps.members_of(main.doc, gid)
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_meta("group_id", int(gid))
for mi in member_indices:
var obj: Dictionary = main.doc["objects"][int(mi)]
var m_item := content_tree.create_item(g_node)
m_item.set_text(0, " %s" % main.object_label(str(obj.get("id", "?"))))
m_item.set_meta("obj_index", int(mi))
## Kategorie-Schlüssel (z. B. "mauern") in Anzeige-Label (z. B. "Mauern") —
## Karte t_e3c0310a U2. Nutzt KAT_GROUPS aus LibraryBrowser.
static func _kat_label(key: String) -> String:
for kat_name in LibraryBrowser.KAT_GROUPS:
var g: Dictionary = LibraryBrowser.KAT_GROUPS[kat_name]
if str(g["key"]) == key:
return str(g["label"])
return key.capitalize()

View file

@ -0,0 +1,70 @@
extends SceneTree
## Render-Probe U2 (Karte t_e3c0310a): Szenen-Baum links mit Zwei-Wege-Auswahl.
## Legt 3 Objekte an, wählt Objekt 1 in 3D (interaction.select_object) und
## rendert die echte Main-UI: der Baum links muss die Kategorie-Ebene, das
## Auge-Präfix und die markierte Zeile zeigen; in 3D muss das Objekt
## hervorgehoben sein.
## Start (BRAUCHT Rendering — NICHT --headless):
## xvfb-run godot --path . --rendering-driver opengl3 -s res://tests/probe_u2_scene_tree_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
# Drei Objekte nah an der Kartenmitte, kräftig skaliert (sichtbar aus
# der Startansicht 80 m über der Mitte).
(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": "fremd_objekt_xyz", "pos": [-9.0, 0.0, -5.0], "scale": 3.0, "rot_y_deg": 0.0})
main.call("_after_structure_change")
await process_frame
await process_frame
# Objekt 1 in 3D auswählen → Baum-Zeile wird mitmarkiert (Zwei-Wege).
main.interaction.call("select_object", 1)
main.call("_focus_selection") # Kamera auf die Auswahl (Objekt sichtbar machen)
# Hinweis-Dialoge (z. B. „Modelle fehlen" im Klon ohne GLB-Quellen) schließen,
# damit der Beweis die 3D-Ansicht frei gibt.
for child in main.get_children():
if child is Window or child is AcceptDialog:
child.hide()
await process_frame
await process_frame
await process_frame
await RenderingServer.frame_post_draw
var img := root.get_texture().get_image()
var out_path := out_dir + "/u2_baum_links_beweis.png"
img.save_png(out_path)
print("PROBE_PNG: ", out_path)
print("PROBE_FERTIG: Auswahl=", main.interaction.get("selected_index"),
" Baumzeile=", tree_mark_ok(main))
quit(0)
func tree_mark_ok(main) -> bool:
var tm = main.get("tree_mod")
if tm == null:
return false
var imap: Dictionary = tm.get("_obj_index_map")
if not imap.has(1):
return false
return (imap[1] as TreeItem).is_selected(0)

View file

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

199
tests/test_scene_tree.gd Normal file
View file

@ -0,0 +1,199 @@
extends SceneTree
## test_scene_tree (Karte t_e3c0310a, U2 Szenen-Baum links): prüft den
## hierarchischen Map-Inhalt-Baum mit Zwei-Wege-Auswahl.
##
## Geprüft wird:
## T1 Baum füllt sich aus dem Dokument (Objekte mit Kategorie-Ebene,
## Index-Meta, Auge-Präfix, Kopf „Objekte (N)“).
## T2 Klick im Baum setzt die Auswahl in 3D (Index stimmt).
## T3 Auswahl in 3D setzt die Baum-Markierung (beide Richtungen).
## T4 Mehrfachauswahl gespiegelt (2 Indizes → 2 markierte Zeilen).
## T5 Gruppen-Ebene: doc-Gruppe erscheint als Knoten mit Mitgliedern.
## T6 Leeres Dokument = Klartext-Hinweis (nicht klickbar).
## T7 Skalierung folgt ui_scale.gd (UIScale.px/v2 — eine Skalierungsquelle).
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: Szenen-Baum links (U2 Zwei-Wege-Auswahl)")
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")
# Drei Objekte ins Doc: 2x props (barrel/crate), 1x sonstiges (fremde ID).
(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_baum_fuellt_sich()
await _t2_klick_setzt_auswahl()
await _t3_auswahl_3d_markiert_baum()
await _t4_mehrfach_gespiegelt()
await _t5_gruppen_ebene()
await _t6_leerer_zustand()
await _t7_skalierung()
_finish()
func _obj_items_under(item: TreeItem) -> Array:
## Alle Kind-Zeilen mit obj_index-Meta (nur DIREKTE Kinder).
var out: Array = []
for child in item.get_children():
if child.has_meta("obj_index"):
out.append(child)
return out
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 _t1_baum_fuellt_sich() -> void:
var tree: Tree = tree_mod.get("content_tree")
var root: TreeItem = tree.get_root()
var obj_head := _find_child(root, "Objekte (3)")
_check(obj_head != null, "T1 Kopf „Objekte (3)“ aus dem Dokument")
if obj_head == null:
return
# Kategorie-Ebene (Karte t_e3c0310a): Objekte hängen unter Kategorie-Knoten.
var kat_nodes: int = obj_head.get_child_count()
_check(kat_nodes >= 1, "T1 Kategorie-Ebene vorhanden (%d Knoten)" % kat_nodes)
var found_idx: Dictionary = {}
var all_ok := true
for kat in obj_head.get_children():
for oi in _obj_items_under(kat):
var idx := int(oi.get_meta("obj_index"))
if found_idx.has(idx):
all_ok = false
found_idx[idx] = true
all_ok = all_ok and str(oi.get_text(0)).begins_with("👁 ")
_check(found_idx.size() == 3, "T1 alle 3 Objekt-Indizes genau einmal im Baum")
_check(all_ok, "T1 Auge-Präfix (nur Anzeige) je Objekt-Zeile")
_check(found_idx.has(0) and found_idx.has(1) and found_idx.has(2), "T1 Indizes 0/1/2 stimmen")
func _t2_klick_setzt_auswahl() -> void:
var tree: Tree = tree_mod.get("content_tree")
var root: TreeItem = tree.get_root()
var obj_head := _find_child(root, "Objekte (3)")
var item1: TreeItem = null
for kat in obj_head.get_children():
for oi in _obj_items_under(kat):
if int(oi.get_meta("obj_index")) == 1:
item1 = oi
_check(item1 != null, "T2 Zeile für Objekt-Index 1 gefunden")
if item1 == null:
return
item1.select(0)
tree.item_selected.emit() # simuliert den Klick-Pfad (item_selected → _on_tree_selected)
await process_frame
var sel: Variant = main.interaction.get("selected_index")
_check(int(sel) == 1, "T2 Klick im Baum setzt Auswahl in 3D (Index 1 — hat %s)" % str(sel))
func _t3_auswahl_3d_markiert_baum() -> void:
var tree: Tree = tree_mod.get("content_tree")
var imap: Dictionary = tree_mod.get("_obj_index_map")
_check(imap.has(0) and imap.has(2), "T3 Index-Karte deckt 0 und 2 ab")
main.call("_mark_tree_selection", 0)
await process_frame
var item0: TreeItem = imap[0]
var item1: TreeItem = imap[1]
_check(item0.is_selected(0), "T3 Auswahl in 3D (0) markiert die Baum-Zeile")
_check(not item1.is_selected(0), "T3 alte Markierung (1) beim Einzel-Klick abgewählt")
# Scroll-zu-Auswahl (Karte t_e3c0310a): im selben Pfad ruft mark_tree_selection_multi
# Tree.scroll_to_item auf der Primär-Zeile auf — hier durch die Markierung mitgeprüft.
func _t4_mehrfach_gespiegelt() -> void:
var imap: Dictionary = tree_mod.get("_obj_index_map")
main.call("_mark_tree_selection_multi", [0, 2])
await process_frame
_check(imap[0].is_selected(0), "T4 Mehrfachauswahl: Zeile 0 markiert")
_check(imap[2].is_selected(0), "T4 Mehrfachauswahl: Zeile 2 markiert")
_check(not imap[1].is_selected(0), "T4 Zeile 1 bleibt abgewählt")
func _t5_gruppen_ebene() -> void:
var gid: int = GroupOps.group(main.doc, [0, 2])
_check(gid >= 0, "T5 Gruppe im Doc angelegt (id %d)" % gid)
main.call("_after_structure_change")
await process_frame
var tree: Tree = tree_mod.get("content_tree")
var root: TreeItem = tree.get_root()
var grp_head := _find_child(root, "Gruppen (1)")
_check(grp_head != null, "T5 Gruppen-Ebene im Baum")
if grp_head == null:
return
var g_node: TreeItem = grp_head.get_first_child()
_check(g_node != null and g_node.has_meta("group_id"), "T5 Gruppen-Knoten trägt group_id")
var members := _obj_items_under(g_node)
_check(members.size() == 2, "T5 Gruppe zeigt 2 Mitglieder im Baum")
# Klick auf den Gruppen-Knoten wählt ALLE Mitglieder in 3D (Zwei-Wege).
g_node.select(0)
tree.item_selected.emit()
await process_frame
var sel_idx: int = int(main.interaction.get("selected_index"))
var sel_many: Array = main.interaction.get("selected_indices")
var many_ok: bool = 0 in sel_many and 2 in sel_many
_check(many_ok and (sel_idx == 0 or sel_idx == 2),
"T5 Gruppen-Klick spiegelt beide Mitglieder in 3D (Anker %d)" % sel_idx)
func _t6_leerer_zustand() -> void:
(main.doc["objects"] as Array).clear()
main.doc["spawn_zones"] = {}
main.call("_after_structure_change")
await process_frame
var tree: Tree = tree_mod.get("content_tree")
var root: TreeItem = tree.get_root()
var hint := _find_child(root, "Noch keine Objekte")
_check(hint != null, "T6 leeres Dokument zeigt Klartext-Hinweis")
if hint != null:
_check(not hint.is_selectable(0), "T6 Hinweis-Zeile ist nicht anwählbar")
func _t7_skalierung() -> void:
var tree: Tree = tree_mod.get("content_tree")
var want: Vector2 = UIScale.v2(0, 160)
_check(tree.custom_minimum_size == want,
"T7 Baum-Mindestgröße folgt UIScale.v2 (%s)" % str(want))
_check(int(tree.custom_minimum_size.y) == UIScale.px(160),
"T7 Höhe = UIScale.px(160) — eine Skalierungsquelle")
func _finish() -> void:
print("CHECKS: %d FAILURES: %d" % [_oks + _fails, _fails])
if _fails > 0:
print("SCENE_TREE_TEST_FAIL")
quit(1)
else:
print("ALL OK: %d checks" % (_oks + _fails))
quit(0)

View file

@ -0,0 +1 @@
uid://16mq6q3b053r