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:
parent
366d482a1a
commit
c8b9c59fda
8 changed files with 925 additions and 5 deletions
582
src/ui/inspector_panel.gd
Normal file
582
src/ui/inspector_panel.gd
Normal 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)
|
||||||
1
src/ui/inspector_panel.gd.uid
Normal file
1
src/ui/inspector_panel.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dndmcn006xggw
|
||||||
|
|
@ -59,6 +59,10 @@ var _library_group_rows: Array = [] # {key, header, box, ids}
|
||||||
var _library_bold_font: Font = null
|
var _library_bold_font: Font = null
|
||||||
const LIBRARY_CFG_SECTION := "library" # Abschnitt in user://scopedvr_editor.cfg
|
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 undo_btn: Button
|
||||||
var redo_btn: Button
|
var redo_btn: Button
|
||||||
var grid_btn: Button # „⊞ Gitter“ (Karte t_7ac70014)
|
var grid_btn: Button # „⊞ Gitter“ (Karte t_7ac70014)
|
||||||
|
|
@ -81,6 +85,7 @@ var sel_raise_btn: Button
|
||||||
var sel_lower_btn: Button
|
var sel_lower_btn: Button
|
||||||
var sel_group_btn: Button # „Gruppieren“ (Karte t_f6f29d86)
|
var sel_group_btn: Button # „Gruppieren“ (Karte t_f6f29d86)
|
||||||
var sel_ungroup_btn: Button # „Gruppe lösen“ (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
|
# Rahmen-Ausziehen (Rubberband) — Overlay über dem 3D-Viewport
|
||||||
var rubber_overlay: Control
|
var rubber_overlay: Control
|
||||||
|
|
@ -865,6 +870,16 @@ func _build_right_panel() -> PanelContainer:
|
||||||
vbox.add_theme_constant_override("separation", UIScale.px(8))
|
vbox.add_theme_constant_override("separation", UIScale.px(8))
|
||||||
scroll.add_child(vbox)
|
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) ---
|
# --- Auswahl (Anfassen/Greifen) ---
|
||||||
vbox.add_child(_header_label("Auswahl"))
|
vbox.add_child(_header_label("Auswahl"))
|
||||||
sel_label = Label.new()
|
sel_label = Label.new()
|
||||||
|
|
@ -2582,7 +2597,11 @@ func _ensure_tree_visible(item: TreeItem, idx: int) -> void:
|
||||||
if item == null:
|
if item == null:
|
||||||
return
|
return
|
||||||
if item.has_meta("obj_index") and int(item.get_meta("obj_index")) == idx:
|
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
|
return
|
||||||
for child in item.get_children():
|
for child in item.get_children():
|
||||||
_ensure_tree_visible(child, idx)
|
_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
|
## Mehrfachauswahl/Gruppen-fähig (Karte t_f6f29d86): Panel listet ALLE
|
||||||
## gewählten Objekte auf + zeigt die Gruppen-Bindung an.
|
## gewählten Objekte auf + zeigt die Gruppen-Bindung an.
|
||||||
func _sync_selection_panel() -> void:
|
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 objects: Array = doc.get("objects", [])
|
||||||
var sel: Array = interaction.selected_indices
|
var sel: Array = interaction.selected_indices
|
||||||
var valid: Array = []
|
var valid: Array = []
|
||||||
|
|
@ -2641,6 +2663,15 @@ func _sync_selection_panel() -> void:
|
||||||
|
|
||||||
## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt).
|
## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt).
|
||||||
func _selection_status(idx: int) -> void:
|
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 objects: Array = doc.get("objects", [])
|
||||||
var sel: Array = interaction.selected_indices
|
var sel: Array = interaction.selected_indices
|
||||||
if (idx < 0 or idx >= objects.size()) and sel.is_empty():
|
if (idx < 0 or idx >= objects.size()) and sel.is_empty():
|
||||||
|
|
|
||||||
41
tests/probe_inspector_render.gd
Normal file
41
tests/probe_inspector_render.gd
Normal 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
264
tests/test_inspector.gd
Normal 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)
|
||||||
1
tests/test_inspector.gd.uid
Normal file
1
tests/test_inspector.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://882sv4iy0nva
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Parse-Check über alle geänderten/neuen Scripts (Karte t_3a998ae4)
|
# Parse-Check über alle geänderten/neuen Scripts (Karte t_3a998ae4)
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
GODOT=~/bin/godot4
|
GODOT=/opt/data/home/bin/godot4
|
||||||
fail=0
|
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
|
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)
|
out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true)
|
||||||
if [ -n "$out" ]; then
|
if [ -n "$out" ]; then
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Regression: alle Editor-Testsuiten (Karte t_3a998ae4)
|
# Regression: alle Editor-Testsuiten (Karte t_3a998ae4)
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
GODOT=~/bin/godot4
|
GODOT=/opt/data/home/bin/godot4
|
||||||
rc=0
|
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
|
"$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1
|
||||||
code=$?
|
code=$?
|
||||||
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)
|
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue