U4 t_69687afc: Assets-Bereich Unity-artig ausgebaut — Kategorien-Leiste (Alle + Gruppen aus LibraryBrowser, Summe = Gesamtzahl), Suche Name+ID, Zaehler "N von M sichtbar", Zuletzt benutzt (cfg [library] zuletzt_benutzt, Chip + Ansicht), Kacheln mit 96-px-Vorschau + ID + Name, Hover vergroessert die ID, fehlende GLB = abgedunkeltes Bild + orange Badge "Modell fehlt" (GlbMeshStore.fehlt), Klick = auswaehlen (Signal object_selected genau 1x je Wechsel), Doppelklick = Werkzeug starten wie bisher (nochmal = abbrechen), Spaltenzahl breitenabhaengig (180 px je Kachel, ueber ui_scale), Dock-Groesse bleibt in [fenster] gemerkt (U1). Klapp-Gruppen + Alle auf/zu + gepinnte Masse (Suchfeld 36, Kachel 126, Thumb 96) unveraendert. Suite tests/test_assets_panel.gd 75 checks ALL OK; test_library_browser_ui 30, test_library 101, test_ui_layout 63, test_gui_smoke 92, test_inspector 49, test_playtest 11, test_object_mirror 74, test_ui_scale OK — alle unveraendert gruen. Renderproben unter /opt/data/scopedvr/work/u4_assets_beweise/. test_playtest_spawn ist VOR dieser Karte rot (U1-Regression _test_start_reload, siehe Kommentar auf t_eaccbfed) — nicht von U4 beruehrt.

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 09:19:09 +00:00
parent 69ffe23a71
commit 8f0e011a07
3 changed files with 788 additions and 48 deletions

View file

@ -1,13 +1,30 @@
extends "res://src/ui/panels/editor_panel.gd"
## Objekt-Bibliothek / Assets-Bereich (aus main.gd ausgezogen, Karte t_eaccbfed
## U1 — Logik unverändert, Karten t_3a998ae4/t_59fc0f21): Suche, Klapp-Gruppen,
## Kacheln. Der Bereich wohnt jetzt UNTEN unter der 3D-Ansicht (Unity-
## Projekt-Fenster-Platz); Klick verhält sich noch wie vorher (U5 ändert das).
## Objekt-Bibliothek / Assets-Bereich (aus main.gd ausgezogen, Karte t_eaccbfed;
## Unity-artiger Ausbau Karte t_69687afc/U4): Kategorien-Leiste, Suche, Zuletzt
## benutzt, Vorschau-Kacheln mit Name + ID, Hover vergrößert die ID, fehlende
## Modelle werden markiert (nicht stillschweigend), Klick wählt aus,
## Doppelklick startet das Platzier-Werkzeug wie bisher.
## Der Bereich wohnt UNTEN unter der 3D-Ansicht (Unity-Projekt-Fenster-Platz).
##
## Kompatibilität (main.gd bindet diese Felder — NICHT umbenennen):
## library_search, library_buttons, _library_group_rows,
## library_open_all_btn, library_close_all_btn,
## _on_library_pressed(), _apply_library_filter(),
## _on_library_expand_all(), _on_library_collapse_all(), highlight_library().
## Die Klapp-Gruppen bleiben als Körper der „Alle"-Ansicht erhalten; die
## Kategorien-Leiste filtert dieselben Zeilen (Mechanik wie die Suche).
signal object_selected(oid: String) # Klick auf eine Kachel (nur bei Wechsel)
signal object_activated(oid: String) # Doppelklick — Werkzeug-Start (U5 koppelt)
const LIBRARY_CFG_SECTION := "library" # Abschnitt in user://scopedvr_editor.cfg
const RECENT_CFG_KEY := "zuletzt_benutzt" # selbiger Abschnitt, Liste von IDs
const RECENT_MAX := 12 # gemerkte Objekte (neueste zuerst)
const CAT_ALLE := "alle" # Pseudo-Kategorie: alles
const CAT_ZULETZT := "zuletzt" # Pseudo-Kategorie: zuletzt benutzt
var library_box: VBoxContainer
var library_buttons: Dictionary = {} # id → Button
var library_buttons: Dictionary = {} # id → Button (Haupt-Grid, kanonisch)
## Bibliothek-Gruppen (t_59fc0f21): Klapp-Zustand + Zeilen für Suche/Alle auf/zu.
var library_search: LineEdit
var library_open_all_btn: Button
@ -16,6 +33,18 @@ var _library_group_open: Dictionary = {} # Gruppen-Key → auf/zu
var _library_group_rows: Array = [] # {key, header, box, ids}
var _library_bold_font: Font = null
# --- U4 neu: Kategorien, Zähler, Zuletzt benutzt, Auswahl -------------------
var library_counter: Label # „N von M sichtbar"
var _cat_chips: HFlowContainer # Kategorien-Leiste
var _cat_buttons: Dictionary = {} # Key → Chip-Button
var _cat_active: String = CAT_ALLE # aktive Kategorie (Chip)
var _recent_box: VBoxContainer # Zuletzt-benutzt-Ansicht (im Scroll)
var _recent_grid: GridContainer
var _recent_tiles: Dictionary = {} # id → Button (Zuletzt-Ansicht, frisch gebaut)
var _recent_ids: Array = [] # Reihenfolge: neueste zuerst
var selected_oid: String = "" # per Klick gewähltes Objekt
var _id_font_normal: int = 0 # ID-Schriftgröße (skaliert), Normalzustand
static func spec() -> Dictionary:
return {"titel": "Objekt-Bibliothek", "gruppe": "unten", "order": 10}
@ -25,16 +54,15 @@ func panel_title() -> String:
return "Objekt-Bibliothek"
## Bau (aus der alten main.gd/_build_left_panel übernommen, Logik identisch):
## Suche → „Alle auf/zu“ → Kachel-Liste (library_box) → _fill_library().
## Der Bereich wohnt jetzt UNTEN (Registry-Gruppe "unten"); Klick verhält sich
## noch wie vorher (U5 ändert das Verhalten).
## Bau: Überschrift → Suche → Kategorien-Leiste → Alle auf/zu → Zähler →
## Klapp-Gruppen-Körper („Alle") + Zuletzt-benutzt-Ansicht.
func build() -> void:
add_child(_header_label("Objekt-Bibliothek"))
add_child(_muted_label("Objekt anklicken, dann in die Karte klicken — der Modus bleibt an bis ESC (Rechte Maustaste = Flug)."))
add_child(_muted_label("Klick = auswählen, Doppelklick = Werkzeug starten. Ohne GLB wird der Platzhalter gesetzt und die Kachel markiert (Rechte Maustaste = Flug)."))
# Suche (t_59fc0f21): tippen filtert live nach Name/ID — Gruppen mit
# Treffern klappen auf, leere Gruppen verschwinden.
# Treffern klappen auf, leere Gruppen verschwinden. Größe ist von
# test_ui_scale gepinnt (36 → 45 bei „Mittel") — nicht ändern.
library_search = LineEdit.new()
library_search.placeholder_text = "Suchen (Name oder ID)…"
library_search.clear_button_enabled = true
@ -43,6 +71,8 @@ func build() -> void:
library_search.text_submitted.connect(func(_t: String) -> void: library_search.release_focus())
add_child(library_search)
_build_category_chips()
# „Alle auf" / „Alle zu" (t_59fc0f21): Klapp-Zustand aller Gruppen auf einmal.
var group_btns := HBoxContainer.new()
group_btns.add_theme_constant_override("separation", UIScale.px(6))
@ -60,6 +90,14 @@ func build() -> void:
group_btns.add_child(library_close_all_btn)
add_child(group_btns)
# Zähler „N von M sichtbar" (U4): M = Objekte im aktiven Bereich, N = davon
# durch Suche/Kategorie sichtbar.
library_counter = Label.new()
library_counter.add_theme_font_size_override("font_size", UIScale.f(11))
library_counter.modulate = Color(1, 1, 1, 0.7)
library_counter.tooltip_text = "Sichtbare Kacheln im aktiven Bereich"
add_child(library_counter)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.custom_minimum_size = UIScale.v2(0, 280)
@ -69,9 +107,101 @@ func build() -> void:
library_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
library_box.add_theme_constant_override("separation", UIScale.px(4))
scroll.add_child(library_box)
_recent_box = VBoxContainer.new()
_recent_box.visible = false
_recent_box.add_theme_constant_override("separation", UIScale.px(4))
library_box.add_child(_recent_box)
_fill_library()
_recent_ids = _recent_load()
_update_recent_chip()
_apply_library_filter(library_search.text)
# ------------------------------------------------------------ Kategorien ----
## Kategorien-Leiste (U4): „Alle" + eine Chip je Bibliotheks-Gruppe
## (LibraryBrowser.groups_in_order — KEINE eigene Kategorieliste) +
## „Zuletzt benutzt". Chips zeigen Name + Anzahl; Summe der Gruppen-Chips =
## Gesamtzahl (test_assets_panel rechnet nach).
func _build_category_chips() -> void:
_cat_chips = HFlowContainer.new()
_cat_chips.add_theme_constant_override("h_separation", UIScale.px(4))
_cat_chips.add_theme_constant_override("v_separation", UIScale.px(4))
add_child(_cat_chips)
var bg := ButtonGroup.new()
var groups := LibraryBrowser.groups_in_order(main.manifest)
_cat_add_chip(CAT_ALLE, "Alle", _manifest_count(), bg)
for group in groups:
var g_key: String = group["key"]
var g_ids: Array = group["ids"]
if g_ids.is_empty():
continue
_cat_add_chip(g_key, str(group["label"]), g_ids.size(), bg)
_cat_add_chip(CAT_ZULETZT, "Zuletzt benutzt", 0, bg)
(_cat_buttons[CAT_ZULETZT] as Button).visible = false # taucht auf, sobald benutzt
func _manifest_count() -> int:
return (main.manifest.get("objects", {}) as Dictionary).size()
func _cat_add_chip(key: String, label: String, count: int, bg: ButtonGroup) -> void:
var chip := Button.new()
chip.toggle_mode = true
chip.button_group = bg
chip.button_pressed = key == _cat_active
chip.text = "%s (%d)" % [label, count] if key != CAT_ZULETZT else label
chip.custom_minimum_size = UIScale.v2(0, 28)
chip.tooltip_text = "Zeigt nur die Gruppe „%s“" % label if key != CAT_ALLE and key != CAT_ZULETZT else "Zeigt alle Objekte" if key == CAT_ALLE else "Die zuletzt ausgewählten/benutzten Objekte"
chip.set_meta("cat_key", key)
chip.set_meta("cat_count", count)
chip.toggled.connect(_on_cat_chip_toggled.bind(key))
_cat_chips.add_child(chip)
_cat_buttons[key] = chip
func _on_cat_chip_toggled(on: bool, key: String) -> void:
if not on:
return # Radio-Gruppe: der neue Chip drückt sich selbst an
_select_category(key)
## Kategorie wechseln: Zustand merken (kein Config-Zwang — Chips sind
## Ansichtsfilter, der Klapp-Zustand je Gruppe bleibt in der cfg).
func _select_category(key: String) -> void:
if key != CAT_ZULETZT and not _cat_buttons.has(key):
return
_cat_active = key
for chip_key in _cat_buttons:
var chip := _cat_buttons[chip_key] as Button
chip.set_pressed_no_signal(str(chip_key) == _cat_active)
_apply_library_filter(library_search.text)
func _update_recent_chip() -> void:
var chip := _cat_buttons.get(CAT_ZULETZT) as Button
if chip == null:
return
chip.visible = not _recent_ids.is_empty()
chip.text = "Zuletzt benutzt (%d)" % _recent_ids.size()
## Objekte des aktiven Bereichs (M im Zähler): Alle → Gesamtkatalog,
## Kategorie → deren IDs, Zuletzt → gemerkte Liste.
func _scope_ids() -> Array:
var lib: Dictionary = main.manifest.get("objects", {})
if _cat_active == CAT_ALLE:
return lib.keys()
if _cat_active == CAT_ZULETZT:
return _recent_ids.duplicate()
for row in _library_group_rows:
if str(row["key"]) == _cat_active:
return (row["ids"] as Array).duplicate()
return []
# -------------------------------------------------------- Klapp-Gruppen ----
func _fill_library() -> void:
library_buttons.clear()
_library_group_rows.clear()
@ -81,6 +211,7 @@ func _fill_library() -> void:
# - STANDARD EINGEKLAPPT — nur die erste Gruppe ist offen (Übersicht!),
# - Klapp-Zustand je Gruppe wird in user://scopedvr_editor.cfg gemerkt,
# - Objekte als Kacheln mit 96-px-Vorschaubild (2 Spalten je Gruppe).
# U4: der Körper bleibt („Alle“-Ansicht), die Kategorien-Leiste filtert ihn.
var first_open: String = ""
for group in LibraryBrowser.groups_in_order(main.manifest):
var g_key: String = group["key"]
@ -115,7 +246,41 @@ func _fill_library() -> void:
header.toggled.connect(_on_library_group_toggled.bind(g_key, g_label, g_ids.size(), header, group_box))
for oid in g_ids:
_add_library_button(str(oid), grid)
_library_group_rows.append({"key": g_key, "header": header, "box": group_box, "ids": g_ids, "label": g_label, "count": g_ids.size()})
_library_group_rows.append({"key": g_key, "header": header, "box": group_box, "ids": g_ids, "label": g_label, "count": g_ids.size(), "grid": grid})
if not resized.is_connected(_on_panel_resized):
resized.connect(_on_panel_resized)
_on_panel_resized()
## Unity-Projekt-Fenster-Feeling (U4): die Spaltenzahl passt sich der BREITE
## des unteren Bereichs an (schmaler Dock = wenige Spalten, breiter = viele).
## Kachel-Zielbreite ≈ 180 px (skaliert über ui_scale).
func _on_panel_resized() -> void:
_apply_grid_columns(size.x)
func _apply_grid_columns(width: float) -> void:
var cols := clampi(int(width / float(UIScale.px(180))), 1, 12)
for row in _library_group_rows:
((row["grid"] as GridContainer)).columns = cols
if _recent_grid != null:
_recent_grid.columns = cols
## Kompletter Neubau des Bibliotheks-Körpers (Probe/Umbau): alte Zeilen frei
## geben, dann frisch füllen und den Filter anwenden. Die Zuletzt-Ansicht
## (_recent_box) überlebt den Neubau — sie wird nur aus dem Baum genommen und
## wieder eingehängt, damit die Referenz NICHT auf ein freigegebenes Objekt
## zeigt (sonst „previously freed" im nächsten Filterlauf).
func _rebuild_library() -> void:
if _recent_box != null and _recent_box.get_parent() == library_box:
library_box.remove_child(_recent_box)
for c in library_box.get_children():
library_box.remove_child(c)
(c as Node).free()
if _recent_box != null and _recent_box.get_parent() == null:
library_box.add_child(_recent_box)
_fill_library()
_apply_library_filter(library_search.text)
@ -132,12 +297,18 @@ func _on_library_group_toggled(on: bool, g_key: String, g_label: String, count:
func _on_library_expand_all() -> void:
for row in _library_group_rows:
(row["header"] as Button).button_pressed = true
var header := row["header"] as Button
header.button_pressed = true
# toggled feuert → box sichtbar; Filter danach neu anwenden, damit
# ein aktiver Kategorie-Filter/Suche nicht weggeblasen wird.
_apply_library_filter(library_search.text)
func _on_library_collapse_all() -> void:
for row in _library_group_rows:
(row["header"] as Button).button_pressed = false
var header := row["header"] as Button
header.button_pressed = false
_apply_library_filter(library_search.text)
func _library_header_font() -> Font:
@ -155,6 +326,8 @@ func _library_load_open_states() -> Dictionary:
var out := {}
if cfg.has_section(LIBRARY_CFG_SECTION):
for key in cfg.get_section_keys(LIBRARY_CFG_SECTION):
if str(key) == RECENT_CFG_KEY:
continue
out[str(key)] = bool(cfg.get_value(LIBRARY_CFG_SECTION, str(key), true))
return out
@ -166,12 +339,20 @@ func _library_save_open_state(g_key: String, on: bool) -> void:
cfg.save("user://scopedvr_editor.cfg")
## Live-Suche (t_59fc0f21): leer = alles normal sichtbar (Klapp-Zustand wie
## gemerkt); Text = nur Treffer (Name de/en, ID — case-insensitive), Gruppen
## mit Treffern bleiben offen, leere verschwinden.
# ----------------------------------------------------------------- Suche ----
## Live-Filter (t_59fc0f21, U4 erweitert): Suche (Name de/en, ID — case-
## insensitive) geht VOR der Kategorie (sucht im ganzen Katalog); ohne Suche
## zeigt die aktive Kategorie nur ihre Gruppe, „Alle" den Klapp-Zustand wie
## gemerkt, „Zuletzt" die Gemerkten. Danach Zähler „N von M sichtbar".
func _apply_library_filter(query: String) -> void:
var q := query.strip_edges().to_lower()
var searching := not q.is_empty()
var visible_count := 0
# Suche gilt immer für den GANZEN Katalog; ohne Suche zählt der aktive Bereich.
var scope: int = _manifest_count() if not q.is_empty() else _scope_ids().size()
_recent_box.visible = false
if searching:
for row in _library_group_rows:
var ids: Array = row["ids"]
var any := false
@ -179,24 +360,62 @@ func _apply_library_filter(query: String) -> void:
var btn := library_buttons.get(str(oid)) as Button
if btn == null:
continue
var vis := true
if searching:
vis = _library_oid_matches(str(oid), q)
var vis := _library_oid_matches(str(oid), q)
btn.visible = vis
any = any or vis
if vis:
visible_count += 1
var header := row["header"] as Button
var box := row["box"] as VBoxContainer
header.visible = any
if searching:
# Treffer-Gruppen bleiben auf — auch wenn sie zugeklappt waren
# (Pfeil zeigt dann offen, kosmetisch konsistent).
box.visible = any
if any:
header.text = _library_header_text(true, str(row["label"]), int(row["count"]))
elif _cat_active == CAT_ZULETZT:
for row in _library_group_rows:
(row["header"] as Button).visible = false
(row["box"] as VBoxContainer).visible = false
for oid in row["ids"]:
var btn := library_buttons.get(str(oid)) as Button
if btn != null:
btn.visible = false
_fill_recent_box()
_recent_box.visible = true
for oid in _recent_ids:
if _recent_tiles.has(str(oid)):
visible_count += 1
else:
for row in _library_group_rows:
var in_cat: bool = _cat_active == CAT_ALLE or str(row["key"]) == _cat_active
var ids: Array = row["ids"]
var header := row["header"] as Button
var box := row["box"] as VBoxContainer
header.visible = in_cat
if not in_cat:
box.visible = false
for oid in ids:
var btn := library_buttons.get(str(oid)) as Button
if btn != null:
btn.visible = false
continue
var is_open := bool(_library_group_open.get(str(row["key"]), false))
if _cat_active != CAT_ALLE:
# Kategorie-Ansicht zeigt DIESE Gruppe geöffnet (wie die
# Suche ihre Treffer-Gruppen öffnet) — ohne den gemerkten
# Klapp-Zustand zu überschreiben.
is_open = true
box.visible = is_open
header.text = _library_header_text(is_open, str(row["label"]), int(row["count"]))
for oid in ids:
var btn := library_buttons.get(str(oid)) as Button
if btn != null:
btn.visible = is_open
if is_open:
visible_count += 1
if library_counter != null:
library_counter.text = "%d von %d sichtbar" % [visible_count, scope]
func _library_oid_matches(oid: String, q: String) -> bool:
@ -210,18 +429,35 @@ func _library_oid_matches(oid: String, q: String) -> bool:
return false
# ---------------------------------------------------------------- Kacheln ----
## Kachel in ein Grid hängen. canonical = Haupt-Grid (landet in
## library_buttons, wird von interaction/_highlight_library gefunden);
## Zuletzt-benutzt-Kacheln sind eigene frische Buttons (kein Dict-Konflikt).
func _add_library_button(oid: String, parent: Container) -> void:
var btn := _build_tile(oid)
parent.add_child(btn)
library_buttons[oid] = btn
func _build_tile(oid: String) -> Button:
var lib: Dictionary = main.manifest.get("objects", {})
var entry: Dictionary = lib.get(oid, {})
var poly := int(entry.get("poly", 0))
var name_txt := LibraryBrowser.entry_label(oid, entry)
var missing := _tile_is_missing(oid, entry)
var btn := Button.new()
btn.toggle_mode = true
btn.button_pressed = false
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
btn.custom_minimum_size = UIScale.v2(0, 126)
btn.tooltip_text = "%s · %d Polys · %s" % [name_txt, poly, oid]
btn.pressed.connect(_on_library_pressed.bind(oid))
if missing:
btn.tooltip_text += " · MODELL FEHLT (Platzhalter-Kiste)"
btn.pressed.connect(_on_tile_pressed.bind(oid))
btn.gui_input.connect(_on_tile_gui_input.bind(oid))
btn.mouse_entered.connect(_on_tile_hover.bind(oid, true))
btn.mouse_exited.connect(_on_tile_hover.bind(oid, false))
var tex := LibraryBrowser.load_thumb(oid)
if tex == null:
# Kein Thumbnail → Text-Zeile wie früher (Fehlertoleranz, t_3a998ae4).
@ -229,13 +465,21 @@ func _add_library_button(oid: String, parent: Container) -> void:
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
btn.custom_minimum_size = UIScale.v2(0, 44)
else:
# Kachel (t_59fc0f21): 96-px-Vorschaubild oben, Name klein darunter.
# Kachel (t_59fc0f21 + U4): 96-px-Vorschaubild oben, ID + Name darunter.
# Kinder ignorieren die Maus, damit die Klicks am Button ankommen.
# Reihenfolge Bild → ID → Name: der Name bleibt das LETZTE Label
# (test_library_browser_ui liest das letzte Label als Namen).
var tile := VBoxContainer.new()
tile.mouse_filter = Control.MOUSE_FILTER_IGNORE
tile.set_anchors_preset(Control.PRESET_FULL_RECT)
tile.add_theme_constant_override("separation", UIScale.px(2))
btn.add_child(tile)
# Vorschaubild: bleibt DIREKTES Kind der Kachel-VBox (test_library_
# browser_ui sucht es dort) mit gepinnter Min-Größe (test_ui_scale
# sucht THUMB_DISPLAY_PX × Stufenfaktor). Die „Modell fehlt“-Marke
# hängt als Badge-KIND am TextureRect — der Button ist kein Container,
# eine Extra-Zeile unter dem Bild würde unter die Kachelkante rutschen
# und von der nächsten Reihe überdeckt.
var tr := TextureRect.new()
tr.texture = tex
tr.custom_minimum_size = UIScale.v2(LibraryBrowser.THUMB_DISPLAY_PX, LibraryBrowser.THUMB_DISPLAY_PX)
@ -244,6 +488,17 @@ func _add_library_button(oid: String, parent: Container) -> void:
tr.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
tile.add_child(tr)
var id_lbl := Label.new()
id_lbl.text = oid
_id_font_normal = UIScale.f(9)
id_lbl.add_theme_font_size_override("font_size", _id_font_normal)
id_lbl.modulate = Color(1, 1, 1, 0.6)
id_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
id_lbl.clip_text = true
id_lbl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
id_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
id_lbl.set_meta("tile_id_label", true)
tile.add_child(id_lbl)
var lbl := Label.new()
lbl.text = name_txt
lbl.add_theme_font_size_override("font_size", UIScale.f(11))
@ -252,27 +507,189 @@ func _add_library_button(oid: String, parent: Container) -> void:
lbl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
tile.add_child(lbl)
parent.add_child(btn)
library_buttons[oid] = btn
if missing:
# Klar sichtbar markieren (U4): Bild abgedunkelt + Warn-Badge
# als Overlay am unteren Bildrand (immer innerhalb der Kachel).
tr.modulate = Color(1, 1, 1, 0.45)
tr.clip_contents = true
var warn := Label.new()
warn.text = "Modell fehlt"
warn.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
warn.offset_top = -UIScale.px(13)
warn.offset_bottom = -UIScale.px(1)
warn.add_theme_font_size_override("font_size", UIScale.f(9))
warn.add_theme_color_override("font_color", Color(1, 0.62, 0.3))
warn.add_theme_color_override("font_outline_color", Color(0, 0, 0))
warn.add_theme_constant_override("outline_size", UIScale.px(3))
warn.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
warn.mouse_filter = Control.MOUSE_FILTER_IGNORE
tr.add_child(warn)
return btn
func _on_library_pressed(oid: String) -> void:
## Fehlendes Modell (U4): nur Katalog-Objekte MIT glb-Eintrag können fehlen;
## kuratierte Primitive (ohne glb) bauen ihren Platzhalter lokal — nie „fehlt".
func _tile_is_missing(oid: String, entry: Dictionary) -> bool:
if str(entry.get("glb", "")).is_empty():
return false
return GlbMeshStore.fehlt(oid)
## Hover (U4): die ID wird GRÖSSER, beim Verlassen wieder normal.
func _on_tile_hover(oid: String, entered: bool) -> void:
_set_tile_hover(oid, entered)
func _set_tile_hover(oid: String, on: bool) -> void:
var btn := _tile_button(oid)
if btn == null:
return
var id_lbl := _find_id_label(btn)
if id_lbl == null:
return
id_lbl.add_theme_font_size_override("font_size", UIScale.f(14) if on else _id_font_normal)
func _tile_button(oid: String) -> Button:
if library_buttons.has(oid):
return library_buttons[oid] as Button
if _recent_tiles.has(oid):
return _recent_tiles[oid] as Button
return null
func _find_id_label(btn: Button) -> Label:
var stack: Array = [btn]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node is Label and (node as Label).has_meta("tile_id_label"):
return node as Label
for c in node.get_children():
stack.push_back(c)
return null
# --------------------------------------------------- Klick / Doppelklick ----
## Klick = auswählen (U4). Das Platzieren selbst startet NICHT mehr hier —
## das übernimmt der Doppelklick (bzw. U5 das Einmal-Platzieren).
func _on_tile_pressed(oid: String) -> void:
_select_tile(oid)
## Doppelklick = „gesetzt" haken: Werkzeug startet wie bisher (U4-Vertrag;
## das Einmal-Platzieren kommt in U5).
func _on_tile_gui_input(event: InputEvent, oid: String) -> void:
if event is InputEventMouseButton and event.pressed \
and event.button_index == MOUSE_BUTTON_LEFT and event.double_click:
_activate_tile(oid)
func _select_tile(oid: String) -> void:
var changed := selected_oid != oid
selected_oid = oid
_apply_tile_selection()
_record_recent(oid)
if changed:
object_selected.emit(oid)
## Doppelklick-Aktion: Werkzeug-Start 1:1 wie der alte Bibliotheks-Klick
## (interaction.start_placement, Auto-Rückwechsel auf Objekt-Werkzeug).
func _activate_tile(oid: String) -> void:
_select_tile(oid)
if str(main.get("tool_mode")) != "objects":
main.call("_set_tool_mode", "objects") # Bibliothek-Klick wechselt automatisch zurück
if main.interaction.placing and main.interaction.place_oid == oid:
# Nochmal geklickt → Modus beenden (An/Aus ohne Umwege).
# Nochmal (Doppel-)geklickt → Modus beenden (An/Aus ohne Umwege).
main.interaction.cancel_placement()
main.call("_set_status", "Platzieren abgebrochen.")
return
for other_oid in library_buttons:
(library_buttons[other_oid] as Button).button_pressed = false
library_buttons[oid].button_pressed = true
main.interaction.start_placement(oid)
object_activated.emit(oid)
## Der Bibliothek-Button des aktiven Platzier-Objekts leuchtet auf.
func highlight_library(oid: String) -> void:
## Auswahll-Marke je Kachel: ausgewählt = voll hell + Rahmen-Akzent.
func _apply_tile_selection() -> void:
for other_oid in library_buttons:
var btn := library_buttons[other_oid] as Button
btn.button_pressed = other_oid == oid
btn.modulate = Color(1, 1, 1) if oid == other_oid else Color(1, 1, 1, 0.85)
btn.button_pressed = str(other_oid) == selected_oid
btn.modulate = Color(1, 1, 1) if str(other_oid) == selected_oid else Color(1, 1, 1, 0.85)
for recent_oid in _recent_tiles:
var rbtn := _recent_tiles[recent_oid] as Button
rbtn.button_pressed = str(recent_oid) == selected_oid
rbtn.modulate = Color(1, 1, 1) if str(recent_oid) == selected_oid else Color(1, 1, 1, 0.85)
## Der Bibliotheks-Button des aktiven Platzier-Objekts leuchtet auf
## (Weitersenden von interaction.gd — nur Anzeige, KEIN Signal).
func highlight_library(oid: String) -> void:
selected_oid = oid
_apply_tile_selection()
if not oid.is_empty():
_record_recent(oid)
# -------------------------------------------------------- Zuletzt benutzt ----
func _recent_load() -> Array:
var cfg := ConfigFile.new()
cfg.load("user://scopedvr_editor.cfg")
var raw: Variant = cfg.get_value(LIBRARY_CFG_SECTION, RECENT_CFG_KEY, [])
var lib: Dictionary = main.manifest.get("objects", {})
var out: Array = []
for v in (raw as Array):
var oid := str(v)
if lib.has(oid): # nur IDs, die es wirklich gibt
out.append(oid)
return out
func _recent_save() -> void:
var cfg := ConfigFile.new()
cfg.load("user://scopedvr_editor.cfg")
cfg.set_value(LIBRARY_CFG_SECTION, RECENT_CFG_KEY, _recent_ids)
cfg.save("user://scopedvr_editor.cfg")
## Auswahl/Benutzung merken (neueste zuerst, Ring mit RECENT_MAX Einträgen).
func _record_recent(oid: String) -> void:
if oid.is_empty():
return
_recent_ids.erase(oid)
_recent_ids.insert(0, oid)
while _recent_ids.size() > RECENT_MAX:
_recent_ids.pop_back()
_recent_save()
_update_recent_chip()
if _cat_active == CAT_ZULETZT:
_fill_recent_box()
func _fill_recent_box() -> void:
if _recent_box == null:
return
for c in _recent_box.get_children():
(c as Node).queue_free()
_recent_tiles.clear()
var head := Label.new()
head.text = "Zuletzt benutzt"
head.add_theme_font_size_override("font_size", UIScale.f(18))
head.add_theme_font_override("font", _library_header_font())
_recent_box.add_child(head)
_recent_grid = GridContainer.new()
_recent_grid.columns = 2
_recent_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_recent_grid.add_theme_constant_override("h_separation", UIScale.px(6))
_recent_grid.add_theme_constant_override("v_separation", UIScale.px(6))
_recent_box.add_child(_recent_grid)
var lib: Dictionary = main.manifest.get("objects", {})
for oid in _recent_ids:
if not lib.has(str(oid)):
continue
var btn := _build_tile(str(oid))
_recent_grid.add_child(btn)
_recent_tiles[str(oid)] = btn
if str(oid) == selected_oid:
btn.button_pressed = true
btn.modulate = Color(1, 1, 1)

322
tests/test_assets_panel.gd Normal file
View file

@ -0,0 +1,322 @@
extends SceneTree
## Editor-Test (headless): Assets-Bereich Unity-artig — Karte t_69687afc (U4).
## Ausführen: godot --headless --path . --script res://tests/test_assets_panel.gd
## Exit 0 = alles grün.
##
## Instanziert die ECHTE Main-UI (Muster tests/test_library_browser_ui.gd) und prüft:
## 1. Kategorien-Leiste: ein Chip je Bibliotheks-Gruppe (keine eigene Liste),
## Summe der Gruppen-Anzahlen = Gesamtzahl des Manifests, „Alle" = Gesamtzahl.
## 2. Suche findet NAME und ID (case-insensitive), Zähler „N von M sichtbar".
## 3. Kategorie-Chip filtert auf genau diese Gruppe (Zähler M = Gruppengröße).
## 4. Fehlendes Modell (glb-Eintrag, keine Quelle) → Kachel trägt die
## Markierung „Modell fehlt" + Tooltip-Hinweis; Primitive ohne glb nie.
## 5. Auswahl-Signal: Klick (Selection) löst object_selected GENAU EINMAL je
## NEUER Auswahl aus (Wiederholung derselben Kachel kein zweites Signal).
## 6. Hover vergrößert die ID-Schrift auf der Kachel.
## 7. Doppelklick-Aktion startet das Platzier-Werkzeug wie bisher und nochmal
## auf derselben Kachel bricht ab (alter An/Aus-Vertrag).
## 8. „Zuletzt benutzt" wird je Auswahl in user://scopedvr_editor.cfg
## gemerkt, überlebt Neu-Instanz und taucht als Chip + Ansicht auf.
const GLB_QUELLEN_SETTING := "scopedvr/editor/glb_quellen"
var failures := 0
var checks := 0
var main: Control
var assets # src/ui/panels/assets_panel.gd
var selected_count := 0
var activated_count := 0
func _initialize() -> void:
_run_all.call_deferred()
func _run_all() -> void:
_clear_library_cfg_section()
var scene: PackedScene = load("res://scenes/main.tscn")
_check(scene != null, "scenes/main.tscn laedt")
if scene == null:
_finish()
return
main = scene.instantiate()
root.add_child(main)
await process_frame
await process_frame
assets = main.assets_mod
_check(assets != null, "Assets-Panel über Registry angemeldet")
if assets == null:
_finish()
return
assets.object_selected.connect(_on_selected)
assets.object_activated.connect(_on_activated)
_test_kategorien()
_test_suche_und_zaehler()
_test_kategorie_filter()
_test_fehlendes_modell()
_test_auswahl_signal()
_test_hover()
_test_doppelklick()
await _test_zuletzt_persistenz()
_finish()
func _on_selected(_oid: String) -> void:
selected_count += 1
func _on_activated(_oid: String) -> void:
activated_count += 1
func _finish() -> void:
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _clear_library_cfg_section() -> void:
# Nur UNSEREN Abschnitt leeren — [test]/[update]/[fenster] bleiben.
var cfg := ConfigFile.new()
cfg.load("user://scopedvr_editor.cfg")
if cfg.has_section("library"):
cfg.erase_section("library")
cfg.save("user://scopedvr_editor.cfg")
# ------------------------------------------------------------- 1 Kategorien ----
func _test_kategorien() -> void:
print("Testgruppe: Kategorien-Leiste (aus der Bibliothek, Summe = Gesamtzahl)")
var lib: Dictionary = main.manifest.get("objects", {})
var groups := LibraryBrowser.groups_in_order(main.manifest)
var chip_keys: Array = assets._cat_buttons.keys()
_check(chip_keys.has("alle"), "Chip „Alle“ existiert")
_check(chip_keys.has("zuletzt"), "Chip „Zuletzt benutzt“ angelegt (noch versteckt)")
var alle_chip: Button = assets._cat_buttons.get("alle")
_check(int(alle_chip.get_meta("cat_count")) == lib.size(),
"„Alle“ zeigt Gesamtzahl (%d)" % int(alle_chip.get_meta("cat_count")))
var sum := 0
var group_keys := {}
for group in groups:
if (group["ids"] as Array).is_empty():
continue
group_keys[str(group["key"])] = true
var chip: Button = assets._cat_buttons.get(str(group["key"]))
_check(chip != null, "Chip für Gruppe „%s“ existiert" % str(group["label"]))
if chip == null:
continue
sum += int(chip.get_meta("cat_count"))
_check(chip.text.contains(str(group["label"])), "Chip-Text trägt Gruppenname (%s)" % chip.text)
_check(chip_keys.size() == group_keys.size() + 2, "keine erfundenen Kategorien (%d Chips für %d Gruppen + Alle/Zuletzt)" % [chip_keys.size(), group_keys.size()])
_check(sum == lib.size(), "Summe der Gruppen-Anzahlen = Gesamtzahl (%d = %d)" % [sum, lib.size()])
_check(alle_chip.button_pressed, "„Alle“ ist Start-Kategorie")
_check(not (assets._cat_buttons.get("zuletzt") as Button).visible, "Zuletzt-Chip startet versteckt (noch nichts benutzt)")
# ---------------------------------------------------- 2 Suche + Zähler ----
func _zaehler_n() -> int:
var text: String = assets.library_counter.text
var vor: String = text.split(" ")[0]
return int(vor)
func _zaehler_m() -> int:
var text: String = assets.library_counter.text
var teile: PackedStringArray = text.split(" ")
return int(teile[2])
func _test_suche_und_zaehler() -> void:
print("Testgruppe: Suche (Name + ID) und Zähler „N von M sichtbar“")
var lib_size := (main.manifest.get("objects", {}) as Dictionary).size()
# Namens-Suche (deutsch, case egal):
main.library_search.text = "PILZ"
assets._apply_library_filter("PILZ")
var pilze := 0
for oid in (main.manifest.get("objects", {}) as Dictionary).keys():
var btn := main.library_buttons.get(str(oid)) as Button
if btn != null and btn.visible:
pilze += 1
_check(pilze == 6, "„PILZ“ (Name, groß) findet 6 Kacheln (ist %d)" % pilze)
_check(assets.library_counter.text == "%d von %d sichtbar" % [6, lib_size],
"Zähler bei Suche: „%s“" % assets.library_counter.text)
# ID-Suche (Kurz-ID):
main.library_search.text = ""
assets._apply_library_filter("watchtower")
var wt := main.library_buttons.get("watchtower_wood") as Button
_check(wt != null and wt.visible, "ID-Suche „watchtower“ findet Wachturm")
main.library_search.text = ""
assets._apply_library_filter("")
_check(assets.library_counter.text == "%d von %d sichtbar" % [assets._library_group_rows[0]["count"], lib_size],
"Zähler bei „Alle“ (1 Gruppe offen): „%s“" % assets.library_counter.text)
# -------------------------------------------------- 3 Kategorie-Filter ----
func _test_kategorie_filter() -> void:
print("Testgruppe: Kategorie-Chip filtert auf die Gruppe")
assets._select_category("pilze")
var sichtbar := 0
for oid in (main.manifest.get("objects", {}) as Dictionary).keys():
var btn := main.library_buttons.get(str(oid)) as Button
if btn != null and btn.visible:
sichtbar += 1
_check(sichtbar == 6, "Kategorie „Pilze“: 6 Kacheln sichtbar (ist %d)" % sichtbar)
_check(assets.library_counter.text == "6 von 6 sichtbar", "Zähler in Kategorie: „%s“" % assets.library_counter.text)
var pilze_chip: Button = assets._cat_buttons.get("pilze")
_check(pilze_chip.button_pressed, "Pilze-Chip gedrückt")
assets._select_category("alle")
_check((assets._cat_buttons.get("alle") as Button).button_pressed, "Zurück auf „Alle“")
# -------------------------------------------------- 4 Fehlendes Modell ----
func _test_fehlendes_modell() -> void:
print("Testgruppe: Fehlendes Modell wird markiert (nicht stillschweigend)")
# Hermetisch: GLB-Quellen auf ein garantiert leeres Lager umlenken →
# JEDES Katalog-Objekt mit glb-Eintrag gilt als fehlend, egal wie die
# Umgebung aussieht. (Leere Liste greift nicht — quellen() fällt auf
# DEFAULT_QUELLEN zurück.)
var alter_cache: Dictionary = GlbMeshStore._cache.duplicate()
GlbMeshStore._cache.clear()
ProjectSettings.set_setting(GLB_QUELLEN_SETTING, ["/nonexistent_glb_lager_u4/assets/objekte"])
var fehlt_oid := ""
for oid in (main.manifest.get("objects", {}) as Dictionary).keys():
var entry: Dictionary = (main.manifest.get("objects", {}) as Dictionary).get(oid, {})
if not str(entry.get("glb", "")).is_empty():
fehlt_oid = str(oid)
break
_check(not fehlt_oid.is_empty(), "Katalog hat glb-Objekte (Probe: %s)" % fehlt_oid)
if fehlt_oid.is_empty():
ProjectSettings.set_setting(GLB_QUELLEN_SETTING, GlbMeshStore.DEFAULT_QUELLEN)
return
_check(GlbMeshStore.fehlt(fehlt_oid), "GlbMeshStore.fehlt ohne Lager = true (%s)" % fehlt_oid)
_check(assets._tile_is_missing(fehlt_oid, main.manifest.get("objects", {}).get(fehlt_oid, {})),
"_tile_is_missing(%s) = true ohne Quellen" % fehlt_oid)
_check(not assets._tile_is_missing("crate_wood", main.manifest.get("objects", {}).get("crate_wood", {})),
"Primitive ohne glb-Eintrag gelten NIE als fehlend (crate_wood)")
var probe_grid := GridContainer.new()
root.add_child(probe_grid)
var tile: Button = assets._build_tile(fehlt_oid)
probe_grid.add_child(tile)
var warn: Label = null
var dim_thumb := false
var stack: Array = [tile]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node is Label and (node as Label).text == "Modell fehlt":
warn = node
if node is TextureRect and (node as TextureRect).modulate.a < 0.9:
dim_thumb = true
for c in node.get_children():
stack.push_back(c)
_check(warn != null, "Kachel trägt Warn-Label „Modell fehlt“")
_check(dim_thumb, "Vorschaubild bei fehlendem Modell abgedunkelt")
_check(tile.tooltip_text.contains("MODELL FEHLT"), "Tooltip nennt das fehlende Modell")
probe_grid.queue_free()
ProjectSettings.set_setting(GLB_QUELLEN_SETTING, GlbMeshStore.DEFAULT_QUELLEN)
GlbMeshStore._cache = alter_cache
# ------------------------------------------------------- 5 Auswahl-Signal ----
func _test_auswahl_signal() -> void:
print("Testgruppe: Auswahl-Signal genau einmal je neuer Auswahl")
selected_count = 0
assets._select_tile("crate_wood")
_check(assets.selected_oid == "crate_wood", "selected_oid gesetzt")
_check(selected_count == 1, "1. Auswahl → genau 1 Signal (ist %d)" % selected_count)
assets._select_tile("crate_wood")
_check(selected_count == 1, "gleiche Kachel nochmal → KEIN zweites Signal (ist %d)" % selected_count)
assets._select_tile("crate_metal")
_check(selected_count == 2, "Wechsel → 2. Signal (ist %d)" % selected_count)
var sel_btn := main.library_buttons.get("crate_metal") as Button
_check(sel_btn != null and sel_btn.modulate.a > 0.95, "gewählte Kachel voll sichtbar")
var unsel_btn := main.library_buttons.get("crate_wood") as Button
_check(unsel_btn != null and unsel_btn.modulate.a < 0.95, "andere Kachel abgedimmt")
# --------------------------------------------------------------- 6 Hover ----
func _test_hover() -> void:
print("Testgruppe: Hover zeigt die ID größer")
var btn := main.library_buttons.get("crate_metal") as Button
var id_lbl_normal: int = assets._id_font_normal
assets._set_tile_hover("crate_metal", true)
var id_lbl: Label = assets._find_id_label(btn)
_check(id_lbl != null, "Kachel hat ID-Label")
if id_lbl != null:
var gross: int = id_lbl.get_theme_font_size("font_size")
assets._set_tile_hover("crate_metal", false)
var klein: int = id_lbl.get_theme_font_size("font_size")
_check(gross > klein, "Hover vergrößert die ID (%d > %d)" % [gross, klein])
_check(klein == id_lbl_normal, "ohne Hover wieder Normalgröße (%d)" % klein)
# --------------------------------------------------------- 7 Doppelklick ----
func _test_doppelklick() -> void:
print("Testgruppe: Doppelklick startet Werkzeug wie bisher, nochmal = aus")
activated_count = 0
assets._activate_tile("crate_wood")
_check(main.interaction.placing and main.interaction.place_oid == "crate_wood",
"Doppelklick: Platzier-Werkzeug läuft für crate_wood")
_check(activated_count == 1, "object_activated genau 1× (ist %d)" % activated_count)
assets._activate_tile("crate_wood")
_check(not main.interaction.placing, "nochmal dieselbe Kachel: Platzieren abgebrochen")
assets._activate_tile("crate_metal")
_check(main.interaction.placing and main.interaction.place_oid == "crate_metal",
"Wechsel per Doppelklick auf crate_metal")
main.interaction.cancel_placement()
_check(not main.interaction.placing, "aufgeräumt: kein Platzieren mehr aktiv")
# --------------------------------------------------- 8 Zuletzt benutzt ----
func _test_zuletzt_persistenz() -> void:
print("Testgruppe: „Zuletzt benutzt“ — cfg + Neu-Instanz")
var cfg := ConfigFile.new()
cfg.load("user://scopedvr_editor.cfg")
var lst: Array = cfg.get_value("library", "zuletzt_benutzt", [])
_check(lst.size() >= 2, "cfg [library] zuletzt_benutzt gefüllt (%d)" % lst.size())
_check(str(lst[0]) == "crate_metal", "neueste Benutzung zuerst (ist %s)" % str(lst[0]))
var chip := assets._cat_buttons.get("zuletzt") as Button
_check(chip != null and chip.visible, "Zuletzt-Chip jetzt sichtbar")
_check(chip.text.contains("2"), "Zuletzt-Chip zählt Einträge (%s)" % chip.text)
# Ansicht: Kategorie „zuletzt" zeigt nur die Gemerkten.
assets._select_category("zuletzt")
_check(assets._recent_box.visible, "Zuletzt-Ansicht sichtbar")
var tiles := 0
for oid in assets._recent_ids:
if assets._recent_tiles.has(str(oid)):
tiles += 1
_check(tiles == 2, "Zuletzt-Ansicht zeigt 2 Kacheln (ist %d)" % tiles)
# Neu-Instanz = „Neustart": Chip + Liste kommen zurück.
main.queue_free()
await process_frame
await process_frame
main = (load("res://scenes/main.tscn") as PackedScene).instantiate()
root.add_child(main)
await process_frame
await process_frame
assets = main.assets_mod
var chip2 := assets._cat_buttons.get("zuletzt") as Button
_check(chip2 != null and chip2.visible, "nach Neu-Instanz: Zuletzt-Chip sichtbar")
_check(assets._recent_ids.size() == 2, "nach Neu-Instanz: 2 gemerkte IDs (ist %d)" % assets._recent_ids.size())
_check(str(assets._recent_ids[0]) == "crate_metal", "Reihenfolge hält den Neustart")

View file

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