From c8b9c59fdae43dabfdb87d99801fcfb4bb34db0c Mon Sep 17 00:00:00 2001 From: "Hermes (scopedvr-dev)" Date: Mon, 19 Oct 2026 20:02:11 +0000 Subject: [PATCH] =?UTF-8?q?Objekt-Inspektor=20rechts=20(t=5Fbb0d11c9):=20P?= =?UTF-8?q?osition/Drehung/Gr=C3=B6=C3=9Fe=20tippbar+ziehbar=20(Godot-Konv?= =?UTF-8?q?ention:=20am=20Label=20ziehen),=20Mehrfachauswahl=20mit=20?= =?UTF-8?q?=E2=80=9E=E2=80=94=E2=80=9C=20bei=20ungleichen=20Werten,=20JEDE?= =?UTF-8?q?=20=C3=84nderung=20=3D=201=20Undo-Schritt=20(Tippen=20beim=20Be?= =?UTF-8?q?st=C3=A4tigen,=20Ziehen=20beim=20Loslassen),=20live=20im=203D?= =?UTF-8?q?=20(Marker-Sync=20ohne=20Rebuild,=20Rescale=20inkl.=20Pick-Meta?= =?UTF-8?q?s),=20Auf-Boden-Setzen=20=C3=BCber=20vorhandene=20Logik,=20Info?= =?UTF-8?q?-Block=20(Fu=C3=9Fpunkt/Kollision/echtes=20Modell),=20Kopf=20mi?= =?UTF-8?q?t=20N=20von=20M=20+=20Poly.=20Scale=20via=20UIScale=20(alle=203?= =?UTF-8?q?=20Stufen=20layout-gepr=C3=BCft=20@2328x1310).=20Drive-by-Fixes?= =?UTF-8?q?=20(Vorbestand):=20Tree.ensure=5Fvisible=20=E2=86=92=20Tree.scr?= =?UTF-8?q?oll=5Fto=5Fitem=20(SCRIPT=20ERROR=20bei=20jeder=20Auswahl)=20+?= =?UTF-8?q?=20sel=5Flabel=20f=C3=BCllt=20jetzt=20bei=20select=5Fobject/sel?= =?UTF-8?q?ect=5Fmany.=20tests/test=5Finspector.gd=20neu=20(35=20Checks),?= =?UTF-8?q?=20Suiten=20gr=C3=BCn.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/inspector_panel.gd | 582 ++++++++++++++++++++++++++++++++ src/ui/inspector_panel.gd.uid | 1 + src/ui/main.gd | 33 +- tests/probe_inspector_render.gd | 41 +++ tests/test_inspector.gd | 264 +++++++++++++++ tests/test_inspector.gd.uid | 1 + tools/parse_check.sh | 4 +- tools/run_tests.sh | 4 +- 8 files changed, 925 insertions(+), 5 deletions(-) create mode 100644 src/ui/inspector_panel.gd create mode 100644 src/ui/inspector_panel.gd.uid create mode 100644 tests/probe_inspector_render.gd create mode 100644 tests/test_inspector.gd create mode 100644 tests/test_inspector.gd.uid diff --git a/src/ui/inspector_panel.gd b/src/ui/inspector_panel.gd new file mode 100644 index 0000000..579a4af --- /dev/null +++ b/src/ui/inspector_panel.gd @@ -0,0 +1,582 @@ +extends VBoxContainer +## Objekt-Inspektor (Karte t_bb0d11c9) — Godot/Unity-Style Eigenschaften-Panel +## oben im rechten Panel: Position/Drehung/Größe der markierten Objekte +## TIPPBAR (LineEdit) und ZIEHBAR (am Label ziehen, Godot-Konvention). +## +## Verdrahtung (main.gd): der Inspektor wird in _build_right_panel() als +## ERSTER Abschnitt eingehängt; refresh() läuft in _selection_status und +## _sync_selection_panel — damit füllen ALLE Auswahl-Wege das Panel +## (3D-Klick, Rahmen-Ausziehen, Map-Inhalt-Baum). +## +## Regeln der Karte: +## - Änderungen wirken LIVE im 3D-Fenster (Marker direkt umgesetzt, kein Rebuild). +## - JEDE Änderung ist EIN Strg+Z-Schritt: Tippen = ein Schritt beim Bestätigen +## (Enter/Klick woanders), Ziehen = ein Schritt beim Loslassen. +## - Mehrfachauswahl: Änderungen wirken auf ALLE markierten; ungleiche Werte +## zeigen „—“ und werden erst beim Tippen/Ziehen angeglichen. +## - Doc-Format ist bewusst UNVERÄNDERT: pos [x,y,z], rot_y_deg (nur Y), +## scale (einheitlicher Faktor) — das svmap-Format kennt keine X/Z-Drehung +## und keine Achsen-einzelne Größe, deshalb gibt es die Felder hier nicht. +## - X/Z werden an der Map geklemmt, Y am Terrain (nichts im Boden begraben) +## und an der Höhen-Obergrenze, Größe auf 0,05–20. + +const EPS := 0.005 # Toleranz „gleicher Wert“ (Anzeige-Konsens) +const SCALE_MIN := 0.05 +const SCALE_MAX := 20.0 +const MIXED := "—" # Anzeige für ungleiche Werte in der Auswahl + + +func _clamp_norm_deg(f: float) -> float: + return fposmod(f + 180.0, 360.0) - 180.0 + + +class DragValue extends HBoxContainer: + ## Eine Zeile „Label + Wert“: am Label ziehen ändert den Wert live + ## (Godot-Inspector-Konvention), ins LineEdit tippen setzt ihn exakt. + ## Die Logik (Wert lesen/anwenden/Undo) liegt beim Inspektor — dieses + ## Widget ist nur die Eingabe-Maschine und ruft die Callables. + var step := 0.05 # Wert-Änderung pro Pixel beim Ziehen + var fmt := "%.2f" # Anzeige-Format + var suffix := "" + var min_v := -1000000.0 # Zieh-Grenzen (Anzeige + Doc werden geklemmt) + var max_v := 1000000.0 + var get_value: Callable # -> float (Wert des ersten markierten Objekts) + var is_mixed: Callable # -> bool (true = Werte in der Auswahl ungleich) + var begin_live: Callable # Undo-Basis merken (Zieh-Start) + var live_apply: Callable # (float) — live anwenden OHNE Undo-Commit + var end_live: Callable # (final: float, original: float) — Commit beim Loslassen + var commit: Callable # (float) — Tippen: Snapshot + anwenden + EIN Commit + + var label: Label + var edit: LineEdit + var _dragging := false + var _drag_base := 0.0 + var _drag_accum := 0.0 + + func _init() -> void: + add_theme_constant_override("separation", 6) + + func build(p_label: String, tip: String, edit_width: int) -> void: + label = Label.new() + label.text = p_label + # KEINE feste Mindestbreite — die kompakte X/Y/Z-Zeile (3 Widgetpaare + # nebeneinander) darf das Panel NICHT breiter drücken als das Fenster + # (Layout-Bruch bei UI-Größe Groß, Beweis-Probe 19.10.). Natürliche + # Breite von „X“/„Grad“/„Faktor“ reicht als Zieh-Fläche. + label.mouse_filter = Control.MOUSE_FILTER_STOP + label.tooltip_text = tip + label.gui_input.connect(_on_label_input) + add_child(label) + edit = LineEdit.new() + edit.custom_minimum_size = Vector2(edit_width, 0) + edit.select_all_on_focus = true + edit.text_submitted.connect(_on_text_submitted) + edit.focus_exited.connect(_on_focus_exited) + add_child(edit) + + func set_shown(v: float) -> void: + edit.text = (fmt % v) + suffix + + func set_mixed() -> void: + edit.text = "—" + + ## Probes/Tests: Ziehen programmgesteuert ausführen (gleicher Pfad wie Maus). + func probe_drag(pixels: float) -> void: + _start_drag() + _drag_accum += pixels + _apply_drag() + _finish_drag() + + func _start_drag() -> void: + _dragging = true + _drag_accum = 0.0 + _drag_base = float(get_value.call()) + begin_live.call() + + func _apply_drag() -> void: + var v := _clamp(_drag_base + _drag_accum * step) + live_apply.call(v) + set_shown(v) + + func _finish_drag() -> void: + if not _dragging: + return + _dragging = false + end_live.call(_clamp(_drag_base + _drag_accum * step), _drag_base) + + func _clamp(f: float) -> float: + return clampf(f, min_v, max_v) + + func _on_label_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + if event.pressed: + _start_drag() + else: + _finish_drag() + elif event is InputEventMouseMotion and _dragging: + _drag_accum += event.relative.x + _apply_drag() + + func _parse(text: String) -> float: + var t := text.strip_edges().replace(",", ".") + if t.is_empty() or not t.is_valid_float(): + return NAN + return t.to_float() + + func _apply_text(text: String) -> void: + var v := _parse(text) + if is_nan(v): + set_shown(float(get_value.call())) # „—“/Müll → Anzeige wiederherstellen + return + commit.call(_clamp(v)) + + func _on_text_submitted(text: String) -> void: + _apply_text(text) + + func _on_focus_exited() -> void: + # Nur nachziehen, wenn der Text NICHT frisch bestätigt wurde (sonst + # würde Enter + Klick woanders DOPPELT committen). + if edit.text.is_empty(): + return + if edit.text.begins_with("—"): + return + # Bestätigte Werte wurden beim submitted bereits gesetzt und neu + # formatiert; weicht der Text noch ab (Klick weg ohne Enter), gilt das + # als bestätigt — EIN Undo-Schritt. + _apply_text(edit.text) + + +var _main # src/ui/main.gd (ungewollt typisiert — dynamisch) +var _head: Label +var _sub: Label +var _grid: GridContainer +var _info: Label +var _hint: Label +var _reset_btn: Button +var _ground_btn: Button +var _rows := {} # field -> DragValue +var _built := false + + +func setup(p_main) -> void: + _main = p_main + add_theme_constant_override("separation", 6) + + _head = Label.new() + _head.text = "Objekt-Inspektor" + _head.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(_head) + + _sub = Label.new() + _sub.text = "" + _sub.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(_sub) + + _hint = Label.new() + _hint.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an." + _hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(_hint) + + _grid = GridContainer.new() + _grid.columns = 1 + _grid.add_theme_constant_override("h_separation", 6) + _grid.add_theme_constant_override("v_separation", 6) + add_child(_grid) + + # --- Position X/Y/Z --- + _grid.add_child(_field("Position")) + var px := _make_row("pos_x", "X", "Position X in Meter — ZIEHEN am Label ändert live, TIPPEN setzt exakt (Enter bestätigt).", 0.05, "%.2f", " m") + var py := _make_row("pos_y", "Y", "Höhe (Fußpunkt) in Meter — ziehen/tippen; nie unter den Boden.", 0.05, "%.2f", " m") + var pz := _make_row("pos_z", "Z", "Position Z in Meter — ziehen/tippen.", 0.05, "%.2f", " m") + # Zieh-Grenzen grob an der Map orientieren (doc-Apply klemmt exakt). + var half_span: float = 1024.0 + px.min_v = -half_span + px.max_v = half_span + py.min_v = 0.0 + py.max_v = 80.0 + pz.min_v = -half_span + pz.max_v = half_span + var pos_row := HBoxContainer.new() + pos_row.add_theme_constant_override("separation", 6) + pos_row.add_child(px) + pos_row.add_child(py) + pos_row.add_child(pz) + _grid.add_child(pos_row) + + # --- Drehung (Y) --- + _grid.add_child(_field("Drehung (Y)")) + _grid.add_child(_make_row("rot_y", "Grad", "Blickrichtung in Grad (−180…180) — ziehen/tippen. Mehr (X/Z) kennt das Karten-Format nicht.", 0.5, "%.1f", "°", -180.0, 180.0)) + + # --- Größe --- + _grid.add_child(_field("Größe")) + _grid.add_child(_make_row("scale", "Faktor", "Größen-Faktor (1,0 = Original) — ziehen/tippen, Bereich 0,05–20.", 0.01, "%.2f", "×", SCALE_MIN, SCALE_MAX)) + + var btn_row := HBoxContainer.new() + btn_row.add_theme_constant_override("separation", 6) + _reset_btn = Button.new() + _reset_btn.text = "Größe zurücksetzen" + _reset_btn.custom_minimum_size = UIScale.v2(0, 40) + _reset_btn.tooltip_text = "Größe aller markierten Objekte auf 1,0 (Original) zurücksetzen — EIN Rückgängig-Schritt" + _reset_btn.pressed.connect(_on_reset_scale) + btn_row.add_child(_reset_btn) + _ground_btn = Button.new() + _ground_btn.text = "Auf Boden setzen" + _ground_btn.custom_minimum_size = UIScale.v2(0, 40) + _ground_btn.tooltip_text = "Alle markierten Objekte exakt auf die Terrain-Höhe setzen (Strg+Bild↑ geht auch) — EIN Rückgängig-Schritt" + _ground_btn.pressed.connect(_on_ground) + btn_row.add_child(_ground_btn) + _grid.add_child(btn_row) + + # --- Info (nur lesen) --- + _info = Label.new() + _info.text = "" + _info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(_info) + + # Startzustand: keine Auswahl → nur Hinweis sichtbar (refresh() schaltet um). + _grid.visible = false + _info.visible = false + _reset_btn.visible = false + _ground_btn.visible = false + _built = true + + +func _field(text: String) -> Label: + var l := Label.new() + l.text = text + l.tooltip_text = "Werte gelten für ALLE markierten Objekte. „—“ = Werte sind verschieden und werden erst beim Tippen/Ziehen angeglichen." + return l + + +func _make_row(field: String, lbl: String, tip: String, step: float, fmt: String, suffix: String, + min_v := -1000000.0, max_v := 1000000.0) -> DragValue: + var w := DragValue.new() + w.step = step + w.fmt = fmt + w.suffix = suffix + w.min_v = min_v + w.max_v = max_v + w.get_value = _get_value.bind(field) + w.is_mixed = _is_mixed.bind(field) + w.begin_live = _begin_live + w.live_apply = _live_apply.bind(field) + w.end_live = _end_live.bind(field) + w.commit = _commit.bind(field) + w.build(lbl, tip, UIScale.px(64)) + _rows[field] = w + return w + + +# ------------------------------------------------------------- Datenzugriff -- + +func _selected() -> Array: + if _main == null or _main.get("interaction") == null: + return [] + var objects: Array = _main.doc.get("objects", []) + var out: Array = [] + for i in _main.interaction.selected_indices: + if i is int and i >= 0 and i < objects.size(): + out.append(i) + return out + + +func _sel_objects() -> Array: + var objects: Array = _main.doc.get("objects", []) + var out: Array = [] + for i in _selected(): + var obj = objects[i] + if obj is Dictionary: + out.append(obj) + return out + + +func _entry(obj: Dictionary) -> Dictionary: + var manifest: Dictionary = _main.get("manifest") + if manifest == null or manifest.is_empty(): + return {} + var lib: Dictionary = manifest.get("objects", {}) + return lib.get(str(obj.get("id", "")), {}) + + +func _field_value(obj: Dictionary, field: String) -> float: + match field: + "pos_x": + var p: Array = obj.get("pos", [0.0, 0.0, 0.0]) + return float(p[0]) if (p as Array).size() == 3 else 0.0 + "pos_y": + var p: Array = obj.get("pos", [0.0, 0.0, 0.0]) + return float(p[1]) if (p as Array).size() == 3 else 0.0 + "pos_z": + var p: Array = obj.get("pos", [0.0, 0.0, 0.0]) + return float(p[2]) if (p as Array).size() == 3 else 0.0 + "rot_y": + return _clamp_norm_deg(float(obj.get("rot_y_deg", 0.0))) + "scale": + return float(obj.get("scale", 1.0)) + return 0.0 + + +func _get_value(field: String) -> float: + var objs := _sel_objects() + if objs.is_empty(): + return 0.0 + return _field_value(objs[0], field) + + +func _is_mixed(field: String) -> bool: + var objs := _sel_objects() + if objs.size() < 2: + return false + var first := _field_value(objs[0], field) + for obj in objs: + if absf(_field_value(obj, field) - first) > EPS: + return true + return false + + +# ------------------------------------------------------------ Anzeige (refresh) -- + +func refresh() -> void: + if not _built or _main == null or _main.doc.is_empty(): + return + var indices := _selected() + var objects: Array = _main.doc.get("objects", []) + var has := not indices.is_empty() + _grid.visible = has + _reset_btn.visible = has + _ground_btn.visible = has + _info.visible = has + _hint.visible = not has + if not has: + _head.text = "Objekt-Inspektor" + _sub.text = "" + return + + var objs := _sel_objects() + var m := objs.size() + # Kopf: Name(n) + ID + Kategorie + „N von M markiert“ + Poly. + var names := PackedStringArray() + for obj in objs: + if names.size() < 3: + names.append(str(LibraryBrowser.object_label(str(obj.get("id", "?"))))) + var more := " … +%d" % (m - 3) if m > 3 else "" + var name_txt := (" · ".join(names) + more) if m > 1 else str(LibraryBrowser.object_label(str(objs[0].get("id", "?")))) + _head.text = name_txt + var kat := "?" + var oid := str((objs[0] as Dictionary).get("id", "")) + var entry: Dictionary = _entry(objs[0]) + if not entry.is_empty(): + kat = str(entry.get("kategorie", "?")) + var poly := 0.0 + for obj in objs: + var e: Dictionary = _entry(obj) + poly += float(e.get("poly", 0)) * float(obj.get("scale", 1.0)) * float(obj.get("scale", 1.0)) + _sub.text = "ID: %s · Kategorie: %s · %d von %d Objekten markiert · Poly: %d" % [ + oid if m == 1 else "%s …" % oid, kat, m, objects.size(), int(poly)] + + for field in ["pos_x", "pos_y", "pos_z", "rot_y", "scale"]: + var w: DragValue = _rows[field] + if w.edit.has_focus(): + continue # nicht unter dem tippenden Nutzer wegziehen + if _is_mixed(field): + w.set_mixed() + else: + w.set_shown(_get_value(field)) + + # Info-Block (nur lesen). + var lift := ObjectOps.lift_offset(_main.doc, indices[0]) + var lift_txt := "am Boden" if absf(lift) < ObjectOps.FLOAT_EPSILON else ("%+.2f m über dem Boden" % lift) + var e0: Dictionary = _entry(objs[0]) + var col: Dictionary = e0.get("collision", {}) + var col_size: Array = col.get("size", []) + var col_txt := "—" + if (col_size as Array).size() == 3: + var s := float(objs[0].get("scale", 1.0)) + col_txt = "%s %s × %s × %s m" % [ + ("Box" if str(col.get("shape", "box")) == "box" else str(col.get("shape", "?"))), + ("%.1f" % (float(col_size[0]) * s)), ("%.1f" % (float(col_size[1]) * s)), ("%.1f" % (float(col_size[2]) * s))] + var echt := _echt_modell(indices[0]) + _info.text = "Fußpunkt: y = %.2f m (%s)\nKollision: %s\nEchtes Modell: %s" % [ + float((objs[0] as Dictionary).get("pos", [0.0, 0.0, 0.0])[1]) if ((objs[0] as Dictionary).get("pos", [0.0, 0.0, 0.0]) as Array).size() == 3 else 0.0, + lift_txt, col_txt, "ja" if echt else "nein (Platzhalter)"] + + +func _echt_modell(idx: int) -> bool: + var markers: Array = _main.view_builder.object_markers + if idx < markers.size(): + var marker = markers[idx] + if marker != null and is_instance_valid(marker) and marker.has_meta("glb"): + return bool(marker.get_meta("glb")) + return not str(_entry(_main.doc["objects"][idx]).get("glb", "")).is_empty() + + +# ------------------------------------------------------------ Anwenden + Undo -- + +var _live_before: Dictionary = {} # Undo-Basis während des Ziehens +var _live_changed := false # hat das Ziehen das Doc wirklich geändert? + +func _begin_live() -> void: + _live_before = _main.history.snapshot(["objects"]) + _live_changed = false + +## Achtung Callable.bind hängt die gebundenen Argumente HINTER die +## Aufruf-Argumente: commit.call(v) → _commit(v, field). +func _live_apply(v: float, field: String) -> void: + if _apply(field, v): + _live_changed = true + +func _end_live(final: float, original: float, field: String) -> void: + if not _live_changed or absf(final - original) < 0.0001: + return # Ziehen ohne Änderung → kein Leer-Undo-Schritt + _commit_common("Inspektor: %s" % _desc_for(field, final), _live_before) + +func _commit(v: float, field: String) -> void: + var before: Dictionary = _main.history.snapshot(["objects"]) + if not _apply(field, v): + return # nichts geändert (alles geklemmt/identisch) → kein Leer-Schritt + _commit_common("Inspektor: %s" % _desc_for(field, v), before) + + +func _commit_common(desc: String, before: Dictionary) -> void: + _main.history.commit(desc, before, _main.history.snapshot(["objects"])) + _main.dirty = true + _main.call("_refresh_file_label") + _main.call("_refresh_stats") + _main.call("_refresh_history_buttons") + _main.call("_refresh_spawn_status") + _main.call("_set_status", "%s — Strg+Z macht es in EINEM Schritt rückgängig." % desc) + refresh() + + +func _desc_for(field: String, v: float) -> String: + var n := _sel_objects().size() + var mehr := "%d Objekte: " % n if n > 1 else "" + match field: + "pos_x": + return "%sPosition X = %.2f m" % [mehr, v] + "pos_y": + return "%sHöhe (Y) = %.2f m" % [mehr, v] + "pos_z": + return "%sPosition Z = %.2f m" % [mehr, v] + "rot_y": + return "%sDrehung (Y) = %.1f°" % [mehr, v] + "scale": + return "%sGröße = %.2f×" % [mehr, v] + return "Inspektor-Änderung" + + +## Wert auf ALLE markierten Objekte anwenden + 3D-Ansicht live aktualisieren. +## false = nichts geändert (keine Auswahl / alles geklemmt). +func _apply(field: String, v: float) -> bool: + var indices := _selected() + if indices.is_empty(): + return false + var objects: Array = _main.doc.get("objects", []) + var half: Vector2 = _terrain_half() + var max_y := _max_y() + var changed := 0 + for i in indices: + var obj: Dictionary = objects[i] + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() != 3: + continue + var x := float(pos[0]) + var y := float(pos[1]) + var z := float(pos[2]) + if field == "rot_y": + obj["rot_y_deg"] = _clamp_norm_deg(v) + changed += 1 + continue + if field == "scale": + var old := clampf(float(obj.get("scale", 1.0)), SCALE_MIN, SCALE_MAX) + var new := clampf(v, SCALE_MIN, SCALE_MAX) + if absf(new - old) < 0.0001: + continue + obj["scale"] = new + _rescale_marker(i, old, new) + changed += 1 + continue + if field == "pos_x": + x = clampf(v, -half.x, half.x) + elif field == "pos_y": + y = clampf(v, SvmapDocument.terrain_height_at(_main.doc, x, z), max_y) + elif field == "pos_z": + z = clampf(v, -half.y, half.y) + if absf(x - float(pos[0])) > 0.0001 or absf(y - float(pos[1])) > 0.0001 or absf(z - float(pos[2])) > 0.0001: + obj["pos"] = [x, y, z] + changed += 1 + _sync_markers(indices) + return changed > 0 + + +func _terrain_half() -> Vector2: + var terrain: Dictionary = _main.doc.get("terrain", {}) + return Vector2(float(terrain.get("size_x_m", 1024.0)), float(terrain.get("size_z_m", 1024.0))) * 0.5 + + +func _max_y() -> float: + var bounds: Dictionary = _main.doc.get("bounds", {}) + if (bounds.get("max", null) is Array) and ((bounds["max"] as Array).size() == 3): + return float(bounds["max"][1]) + return 80.0 + + +func _sync_markers(indices: Array) -> void: + var markers: Array = _main.view_builder.object_markers + var objects: Array = _main.doc.get("objects", []) + for i in indices: + if i >= markers.size(): + continue + var marker = markers[i] + if marker == null or not is_instance_valid(marker): + continue + var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() == 3: + marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + marker.rotation.y = deg_to_rad(float((objects[i] as Dictionary).get("rot_y_deg", 0.0))) + + +## Größe live am Marker umsetzen (ohne Rebuild): Mesh-Skalierung + Pick-Metas +## genauso wie map_view_builder._build_objects sie rechnet. +func _rescale_marker(i: int, old_scale: float, new_scale: float) -> void: + var markers: Array = _main.view_builder.object_markers + if i >= markers.size(): + return + var marker = markers[i] + if marker == null or not is_instance_valid(marker): + return + var mi := marker.get_child(0) as MeshInstance3D + if mi == null or mi.mesh == null: + return + var echt: bool = bool(marker.get_meta("glb", false)) + var aabb := mi.mesh.get_aabb() + if echt: + mi.scale = Vector3.ONE * new_scale + var half: Vector3 = (aabb.size * new_scale * 0.5) as Vector3 + marker.set_meta("pick_radius", half.length() + maxf(0.6, half.y * 0.25)) + marker.set_meta("half_height", MeshShapes.center_height(mi.mesh) * new_scale) + else: + # Platzhalter-Mesh ist mit der ALTEN Größe gebacken → relativer Faktor. + var factor := new_scale / maxf(old_scale, 0.0001) + mi.scale = Vector3.ONE * factor + var half: Vector3 = (aabb.size * factor * 0.5) as Vector3 + marker.set_meta("pick_radius", half.length() + maxf(0.6, half.y * 0.25)) + marker.set_meta("half_height", MeshShapes.center_height(mi.mesh) * factor) + + +# ------------------------------------------------------------------- Knöpfe -- + +func _on_reset_scale() -> void: + var w: DragValue = _rows["scale"] + w.commit.call(1.0) + +func _on_ground() -> void: + # Bewährte Boden-Logik (interaction.snap_selection_to_floor): snapshotet, + # committet selbst EINEN Schritt und meldet in der Statuszeile. + _main.interaction.call("snap_selection_to_floor") + refresh() + + +## Für Tests/Probes: Widget zu einem Feld holen ("pos_x"…"scale"). +func widget(field: String) -> DragValue: + return _rows.get(field) diff --git a/src/ui/inspector_panel.gd.uid b/src/ui/inspector_panel.gd.uid new file mode 100644 index 0000000..3076705 --- /dev/null +++ b/src/ui/inspector_panel.gd.uid @@ -0,0 +1 @@ +uid://dndmcn006xggw diff --git a/src/ui/main.gd b/src/ui/main.gd index 7274043..61a84dd 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -59,6 +59,10 @@ var _library_group_rows: Array = [] # {key, header, box, ids} var _library_bold_font: Font = null const LIBRARY_CFG_SECTION := "library" # Abschnitt in user://scopedvr_editor.cfg +# Objekt-Inspektor (Karte t_bb0d11c9) — preload statt class_name, damit die +# Referenz auch ohne frischen Import-Lauf auflöst (Belt-and-braces). +const INSPECTOR_PANEL := preload("res://src/ui/inspector_panel.gd") + var undo_btn: Button var redo_btn: Button var grid_btn: Button # „⊞ Gitter“ (Karte t_7ac70014) @@ -81,6 +85,7 @@ var sel_raise_btn: Button var sel_lower_btn: Button var sel_group_btn: Button # „Gruppieren“ (Karte t_f6f29d86) var sel_ungroup_btn: Button # „Gruppe lösen“ (Karte t_f6f29d86) +var inspector: Control # Objekt-Inspektor (Karte t_bb0d11c9) — oben rechts # Rahmen-Ausziehen (Rubberband) — Overlay über dem 3D-Viewport var rubber_overlay: Control @@ -865,6 +870,16 @@ func _build_right_panel() -> PanelContainer: vbox.add_theme_constant_override("separation", UIScale.px(8)) scroll.add_child(vbox) + # --- Objekt-Inspektor (Karte t_bb0d11c9): ERSTER Abschnitt, so wie der + # Inspector in Godot/Unity — erscheint beim Anklicken und zeigt + # Position/Drehung/Größe tippbar UND ziehbar (Widget-Logik in + # inspector_panel.gd). Verdrahtung über _selection_status + + # _sync_selection_panel (alle Auswahl-Wege laufen dort zusammen). + inspector = INSPECTOR_PANEL.new() + inspector.setup(self) + vbox.add_child(inspector) + vbox.add_child(_separator()) + # --- Auswahl (Anfassen/Greifen) --- vbox.add_child(_header_label("Auswahl")) sel_label = Label.new() @@ -2582,7 +2597,11 @@ func _ensure_tree_visible(item: TreeItem, idx: int) -> void: if item == null: return if item.has_meta("obj_index") and int(item.get_meta("obj_index")) == idx: - content_tree.ensure_visible(item) + # Fix (beim Inspektor-Einbau gefunden, Vorbestand t_f6f29d86): Tree + # und TreeItem haben KEIN ensure_visible — die Godot-4-API heißt + # Tree.scroll_to_item. Der alte Ruf warf bei JEDER Auswahl einen + # SCRIPT ERROR und scrollte den Baum nie zur Auswahl. + content_tree.scroll_to_item(item) return for child in item.get_children(): _ensure_tree_visible(child, idx) @@ -2593,6 +2612,9 @@ func _ensure_tree_visible(item: TreeItem, idx: int) -> void: ## Mehrfachauswahl/Gruppen-fähig (Karte t_f6f29d86): Panel listet ALLE ## gewählten Objekte auf + zeigt die Gruppen-Bindung an. func _sync_selection_panel() -> void: + # Objekt-Inspektor mit allen Auswahl-Wege synchron halten (Karte t_bb0d11c9). + if inspector != null: + inspector.call("refresh") var objects: Array = doc.get("objects", []) var sel: Array = interaction.selected_indices var valid: Array = [] @@ -2641,6 +2663,15 @@ func _sync_selection_panel() -> void: ## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt). func _selection_status(idx: int) -> void: + # Objekt-Inspektor füllen (Karte t_bb0d11c9) — dieser Weg läuft bei + # 3D-Klick, Rahmen-Ausziehen und Map-Inhalt-Baum. + if inspector != null: + inspector.call("refresh") + # Fix (Vorbestand, beim Inspektor-Einbau gefunden): select_object/ + # select_many füllten das alte „Auswahl“-Feld NIE — sel_label blieb auf + # „Nichts ausgewählt“ stehen, während die Statuszeile das Objekt nannte. + # _sync_selection_panel ändert bewusst KEINEN Statuszeilen-Text (Stummsync). + _sync_selection_panel() var objects: Array = doc.get("objects", []) var sel: Array = interaction.selected_indices if (idx < 0 or idx >= objects.size()) and sel.is_empty(): diff --git a/tests/probe_inspector_render.gd b/tests/probe_inspector_render.gd new file mode 100644 index 0000000..b164131 --- /dev/null +++ b/tests/probe_inspector_render.gd @@ -0,0 +1,41 @@ +extends SceneTree +## Beweis-Probe (Karte t_bb0d11c9, WEGWERF — nicht Teil der Suite): +## rendert die echte Main-UI mit markiertem Objekt + gefülltem Inspektor +## und speichert einen Screenshot (PROBE_PNG-Umgebungsvariable). +## Aufruf: xvfb-run -a godot4 --path . --rendering-driver opengl3 \ +## -s res://tests/probe_inspector_render.gd + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + var scene: PackedScene = load("res://scenes/main.tscn") + var main = scene.instantiate() + root.add_child(main) + await process_frame + await process_frame + # Erst NACH _ready setzen — die Main-UI setzt ihren Fensterzustand selbst + # (maximiert/Lage-Restore) und überschreibt frühere Größen (xvfb ohne WM). + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + DisplayServer.window_set_size(Vector2i(2328, 1310)) + (main.doc["objects"] as Array).append({ + "id": "barrel_001", "pos": [8.0, 0.0, 8.0], "scale": 1.5, "rot_y_deg": 30.0}) + (main.doc["objects"] as Array).append({ + "id": "crate_wood", "pos": [14.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 0.0}) + main.call("_after_structure_change") + main.interaction.call("select_object", 0) + var cam = main.get("camera") + if cam != null: + cam.call("focus_on", Vector3(8.0, 0.0, 8.0)) + cam.set("distance", 30.0) + for i in 6: + await process_frame + await RenderingServer.frame_post_draw + var img := root.get_texture().get_image() + var out := OS.get_environment("PROBE_PNG") + if out.is_empty(): + out = "/tmp/probe_inspector.png" + img.save_png(out) + print("PNG_OK ", out) + quit(0) diff --git a/tests/test_inspector.gd b/tests/test_inspector.gd new file mode 100644 index 0000000..bd7b332 --- /dev/null +++ b/tests/test_inspector.gd @@ -0,0 +1,264 @@ +extends SceneTree +## Editor-Test (headless): Objekt-Inspektor — Karte t_bb0d11c9. +## Ausführen: godot --headless --path . --script res://tests/test_inspector.gd +## Exit 0 = alles grün. +## +## Instanziert die ECHTE Main-UI (wie test_gui_smoke) und prüft: +## T1 Kopf/Sub/Info füllen bei Einzel-Auswahl (Name, ID, Kategorie, +## „N von M“, Poly, Kollision, echtes Modell ja/nein). +## T2 Position TIPPEN → doc geändert + 3D-Marker live; EIN Undo-Schritt. +## T3 Position ZIEHEN (Probe-Drag wie Maus) → doc geändert; EIN Undo-Schritt. +## T4 Mehrfachauswahl: Drehung tippen → beide Objekte gleich; EIN Undo-Schritt. +## T5 Uneinheitliche Werte → „—“; Tippen gleicht ALLE an. +## T6 Größe: Klemmung 0,05–20 + Marker-Rescale (echtes Modell) + Zurücksetzen. +## T7 „Auf Boden setzen“ nutzt die Boden-Logik; EIN Undo-Schritt. +## T8 Keine Auswahl → Felder aus, Hinweis an. + +var failures := 0 +var checks := 0 +var main +var insp +var glb_ok := false # GLB-Quelle verfügbar? (sonst wie test_glb_view umgebungsabhängig) + + +func _initialize() -> void: + _run_all.call_deferred() + + +func _check(cond: bool, label: String) -> void: + checks += 1 + print("%s: %s" % ["ok " if cond else "FAIL", label]) + if not cond: + failures += 1 + + +func _undo_steps_to_clear() -> int: + ## Wie viele Undo-Schritte nötig, bis die History leer ist (Baseline clear). + var n := 0 + while main.history.can_undo() and n < 50: + main.call("_do_undo") + n += 1 + return n + + +func _run_all() -> void: + 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 + + insp = main.get("inspector") + _check(insp != null, "Inspektor ist in main eingebaut") + if insp == null: + _finish() + return + + # Zwei Objekte ins Doc: barrel_001 (echtes GLB) + crate_wood (Platzhalter). + (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.call("_after_structure_change") + main.history.clear() + await process_frame + glb_ok = load("res://src/viewport/glb_mesh_store.gd").mesh_fuer("barrel_001") != null + + _t1_einzelauswahl() + _t2_tippen() + _t3_ziehen() + _t4_mehrfach_drehung() + _t5_mixed_angleichen() + _t6_groesse() + _t7_auf_boden() + _t8_leer() + _finish() + + +func _select(idx: int) -> void: + # select_object ist synchron; das Panel füllt im selben Aufruf (refresh). + main.interaction.call("select_object", idx) + + +func _sel_obj(idx: int) -> Dictionary: + return (main.doc["objects"] as Array)[idx] + + +func _t1_einzelauswahl() -> void: + _select(0) + var head: String = str(insp.get("_head").text) + var sub: String = str(insp.get("_sub").text) + var info: String = str(insp.get("_info").text) + _check(not head.is_empty() and head != "Objekt-Inspektor", "T1 Kopf zeigt Objekt-Namen") + _check(sub.contains("ID: barrel_001"), "T1 Sub zeigt ID") + _check(sub.contains("Kategorie: props"), "T1 Sub zeigt Kategorie") + _check(sub.contains("1 von 2 Objekten markiert"), "T1 Sub zeigt 'N von M markiert'") + _check(sub.contains("Poly: 184"), "T1 Sub zeigt Poly-Zahl") + _check(info.contains("Kollision:"), "T1 Info zeigt Kollisionsform") + # „Echtes Modell ja“ braucht die GLB-Quellen (Geschwister-Ordner) — fehlen + # sie im Workspace-Klon, zeigt das Panel korrekt „nein“ (wie test_glb_view + # umgebungsabhängig; der „nein“-Pfad wird über crate_wood geprüft). + if glb_ok: + _check(info.contains("Echtes Modell: ja"), "T1 echtes Modell ja (barrel_001 mit GLB)") + else: + print("SKIP: T1 echtes-Modell-ja (keine GLB-Quelle im Workspace — Platzhalter-„nein“ ist geprüft)") + _check(str(insp.widget("pos_x").edit.text).contains("4.00"), "T1 pos_x zeigt 4.00") + _check(str(insp.widget("rot_y").edit.text).contains("0.0"), "T1 rot_y zeigt 0.0") + # Auswahl-Beweis per Klick im Viewport-Kontext: Panel ist sichtbar. + _check(insp.get("_grid").visible, "T1 Felder sichtbar bei Auswahl") + # Fix Vorbestand: das alte „Auswahl“-Feld füllt sich jetzt auch bei + # select_object (Vorher stand dort dauerhaft „Nichts ausgewählt.“). + _check(str(main.get("sel_label").text).contains("Barrel"), "T1 altes Auswahl-Feld füllt bei 3D-Klick-Weg") + + +func _t2_tippen() -> void: + _select(0) + var w = insp.widget("pos_x") + w.edit.text = "12.5" + w.edit.text_submitted.emit("12.5") + var px: float = float(_sel_obj(0).get("pos", [0, 0, 0])[0]) + _check(absf(px - 12.5) < 0.0001, "T2 Tippen ändert doc-Position auf 12.5") + var marker = main.view_builder.object_markers[0] + _check(marker != null and absf(marker.position.x - 12.5) < 0.0001, + "T2 3D-Marker folgt live (position.x = 12.5)") + _check(_undo_steps_to_clear() == 1, "T2 Tippen = GENAU EIN Undo-Schritt") + _check(absf(float(_sel_obj(0).get("pos", [0, 0, 0])[0]) - 4.0) < 0.0001, + "T2 Undo stellt 4.0 wieder her") + + +func _t3_ziehen() -> void: + _select(0) + var base: float = float(_sel_obj(0).get("pos", [0, 0, 0])[2]) + var w = insp.widget("pos_z") + w.probe_drag(40.0) # 40 px × 0,05 m/px = +2,0 m + var now: float = float(_sel_obj(0).get("pos", [0, 0, 0])[2]) + _check(absf(now - (base + 2.0)) < 0.0001, "T3 Ziehen am Label ändert Z um +2,0 m") + var marker = main.view_builder.object_markers[0] + _check(marker != null and absf(marker.position.z - now) < 0.0001, + "T3 3D-Marker folgt live beim Ziehen") + _check(_undo_steps_to_clear() == 1, "T3 Ziehen (Loslassen) = GENAU EIN Undo-Schritt") + + +func _t4_mehrfach_drehung() -> void: + main.interaction.call("select_many", [0, 1]) + await process_frame + var w = insp.widget("rot_y") + w.edit.text = "45" + w.edit.text_submitted.emit("45") + var r0: float = float(_sel_obj(0).get("rot_y_deg", 0.0)) + var r1: float = float(_sel_obj(1).get("rot_y_deg", 0.0)) + _check(absf(r0 - 45.0) < 0.0001 and absf(r1 - 45.0) < 0.0001, + "T4 Mehrfachauswahl: beide Drehungen = 45°") + _check(_undo_steps_to_clear() == 1, "T4 Mehrfach-Änderung = GENAU EIN Undo-Schritt") + _check(absf(float(_sel_obj(0).get("rot_y_deg", 0.0))) < 0.0001 + and absf(float(_sel_obj(1).get("rot_y_deg", 0.0)) - 10.0) < 0.0001, + "T4 Undo stellt 0°/10° wieder her") + + +func _t5_mixed_angleichen() -> void: + main.interaction.call("select_many", [0, 1]) + await process_frame + var w = insp.widget("pos_x") + _check(str(w.edit.text) == "—", "T5 ungleiche X-Werte zeigen „—“") + w.edit.text = "3" + w.edit.text_submitted.emit("3") + var x0: float = float(_sel_obj(0).get("pos", [0, 0, 0])[0]) + var x1: float = float(_sel_obj(1).get("pos", [0, 0, 0])[0]) + _check(absf(x0 - 3.0) < 0.0001 and absf(x1 - 3.0) < 0.0001, + "T5 Tippen gleicht beide X auf 3.0 an") + _check(_undo_steps_to_clear() == 1, "T5 Angleichen = GENAU EIN Undo-Schritt") + + +func _t6_groesse() -> void: + _select(0) + var w = insp.widget("scale") + w.edit.text = "100" + w.edit.text_submitted.emit("100") + _check(absf(float(_sel_obj(0).get("scale", 1.0)) - 20.0) < 0.0001, + "T6 Größe auf 100 geklemmt auf 20") + var marker = main.view_builder.object_markers[0] + var mi: MeshInstance3D = marker.get_child(0) as MeshInstance3D + if glb_ok: + _check(mi != null and mi.scale.length() > 19.9, "T6 Marker-Mesh skaliert live (echtes Modell)") + _check(float(marker.get_meta("half_height", 0.0)) > 0.0 + and absf(float(marker.get_meta("pick_radius", 0.0))) > 0.0, + "T6 Pick-Metas nach Rescale gesetzt") + _check(_undo_steps_to_clear() == 1, "T6 Größen-Tippen = GENAU EIN Undo-Schritt") + # Zurücksetzen-Knopf (Undo hat die Auswahl geleert → neu anwählen). + _select(0) + w = insp.widget("scale") + w.edit.text = "3" + w.edit.text_submitted.emit("3") + _check(absf(float(_sel_obj(0).get("scale", 1.0)) - 3.0) < 0.0001, "T6 Größe 3.0 gesetzt") + insp.get("_reset_btn").pressed.emit() + _check(absf(float(_sel_obj(0).get("scale", 1.0)) - 1.0) < 0.0001, + "T6 Zurücksetzen stellt 1.0 her") + # (Vorher: Tippen 3.0 = 1 Schritt, Zurücksetzen = 1 Schritt → EIN Undo + # holt das Zurücksetzen zurück.) + _check(main.history.can_undo(), "T6 Zurücksetzen erzeugt Undo-Eintrag") + main.call("_do_undo") + _check(absf(float(_sel_obj(0).get("scale", 1.0)) - 3.0) < 0.0001, + "T6 EIN Undo-Schritt holt 3.0 zurück (kein Frame-/Tasten-Splitting)") + _undo_steps_to_clear() + # Platzhalter-Pfad (crate_wood, scale 2.0 → 1.0) + main.interaction.call("select_object", 1) + await process_frame + var w2 = insp.widget("scale") + w2.edit.text = "1" + w2.edit.text_submitted.emit("1") + _check(absf(float(_sel_obj(1).get("scale", 1.0)) - 1.0) < 0.0001, + "T6 Platzhalter-Größe geändert") + var marker2 = main.view_builder.object_markers[1] + var mi2: MeshInstance3D = marker2.get_child(0) as MeshInstance3D + _check(mi2 != null and absf(mi2.scale.x - 0.5) < 0.01, + "T6 Platzhalter-Mesh relativ reskaliert (2.0 → 1.0 = Faktor 0.5)") + _undo_steps_to_clear() + + +func _t7_auf_boden() -> void: + _select(0) + var w = insp.widget("pos_y") + w.edit.text = "5" + w.edit.text_submitted.emit("5") + _check(absf(float(_sel_obj(0).get("pos", [0, 0, 0])[1]) - 5.0) < 0.0001, + "T7 Höhe getippt: y = 5") + insp.get("_ground_btn").pressed.emit() + var floor_y: float = float(_sel_obj(0).get("pos", [0, 0, 0])[1]) + var expected: float = SvmapDocument.terrain_height_at(main.doc, 4.0, 4.0) + _check(absf(floor_y - expected) < 0.01, "T7 'Auf Boden setzen' setzt auf Terrain-Höhe") + # Zwei Schritte in der History: Tippen (0→5) + Boden (5→Boden). Jedes EIN Schritt. + _check(main.history.can_undo(), "T7 Boden-Setzen hat Undo-Eintrag") + main.call("_do_undo") + _check(absf(float(_sel_obj(0).get("pos", [0, 0, 0])[1]) - 5.0) < 0.0001, + "T7 1. Undo holt das Boden-Setzen zurück (y = 5)") + main.call("_do_undo") + _check(absf(float(_sel_obj(0).get("pos", [0, 0, 0])[1]) - 0.0) < 0.0001, + "T7 2. Undo holt das Tippen zurück (y = 0, Ausgangswert)") + _check(not main.history.can_undo(), "T7 genau 2 Undo-Schritte, dann History leer") + # Y unter Boden tippen wird geklemmt (nichts begraben). + w.edit.text = "-3" + w.edit.text_submitted.emit("-3") + _check(absf(float(_sel_obj(0).get("pos", [0, 0, 0])[1]) - expected) < 0.01, + "T7 y unter dem Boden wird auf Terrain-Höhe geklemmt") + _undo_steps_to_clear() + + +func _t8_leer() -> void: + main.interaction.call("clear_selection") + await process_frame + _check(insp.get("_hint").visible, "T8 ohne Auswahl: Hinweis sichtbar") + _check(not insp.get("_grid").visible, "T8 ohne Auswahl: Felder aus") + _check(not insp.get("_ground_btn").visible, "T8 ohne Auswahl: Knöpfe aus") + + +func _finish() -> void: + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) diff --git a/tests/test_inspector.gd.uid b/tests/test_inspector.gd.uid new file mode 100644 index 0000000..93bc7a5 --- /dev/null +++ b/tests/test_inspector.gd.uid @@ -0,0 +1 @@ +uid://882sv4iy0nva diff --git a/tools/parse_check.sh b/tools/parse_check.sh index 84f574e..d9425b3 100755 --- a/tools/parse_check.sh +++ b/tools/parse_check.sh @@ -1,9 +1,9 @@ #!/bin/bash # Parse-Check über alle geänderten/neuen Scripts (Karte t_3a998ae4) cd "$(dirname "$0")/.." -GODOT=~/bin/godot4 +GODOT=/opt/data/home/bin/godot4 fail=0 -for f in src/ui/main.gd src/editor/test_run.gd src/editor/library_browser.gd src/editor/object_ops.gd \ +for f in src/ui/main.gd src/ui/inspector_panel.gd tests/test_inspector.gd src/editor/test_run.gd src/editor/library_browser.gd src/editor/object_ops.gd \ src/editor/interaction.gd tools/gen_thumbs.gd tests/test_library.gd; do out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true) if [ -n "$out" ]; then diff --git a/tools/run_tests.sh b/tools/run_tests.sh index 8b0ce8a..41a5cf8 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -1,9 +1,9 @@ #!/bin/bash # Regression: alle Editor-Testsuiten (Karte t_3a998ae4) cd "$(dirname "$0")/.." -GODOT=~/bin/godot4 +GODOT=/opt/data/home/bin/godot4 rc=0 -for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang; do +for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang test_inspector; do "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1 code=$? line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)