Objekt-Inspektor rechts (t_bb0d11c9): Position/Drehung/Größe tippbar+ziehbar (Godot-Konvention: am Label ziehen), Mehrfachauswahl mit „—“ bei ungleichen Werten, JEDE Änderung = 1 Undo-Schritt (Tippen beim Bestätigen, Ziehen beim Loslassen), live im 3D (Marker-Sync ohne Rebuild, Rescale inkl. Pick-Metas), Auf-Boden-Setzen über vorhandene Logik, Info-Block (Fußpunkt/Kollision/echtes Modell), Kopf mit N von M + Poly. Scale via UIScale (alle 3 Stufen layout-geprüft @2328x1310). Drive-by-Fixes (Vorbestand): Tree.ensure_visible → Tree.scroll_to_item (SCRIPT ERROR bei jeder Auswahl) + sel_label füllt jetzt bei select_object/select_many. tests/test_inspector.gd neu (35 Checks), Suiten grün.

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-19 20:02:11 +00:00
parent 366d482a1a
commit c8b9c59fda
8 changed files with 925 additions and 5 deletions

582
src/ui/inspector_panel.gd Normal file
View file

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

View file

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

View file

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

View file

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

264
tests/test_inspector.gd Normal file
View file

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

View file

@ -0,0 +1 @@
uid://882sv4iy0nva

View file

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

View file

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