scopedvr-map-core/ui/inspector_panel.gd
2026-11-01 19:31:33 +01:00

700 lines
25 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
## - SCALE IST FEST (Karte t_e166e656, Sly-Wunsch „Realismus“): die scale-Zeile
## ist bewusst ENTFERNT — neue Objekte bekommen immer scale 1.0, alte Karten
## laden mit ihrem gespeicherten scale (Format bleibt kompatibel). Hier gibt
## es nur den lesenden Hinweis „Größe fix“.
## - KIPPEN IN SÄMTLICHEN ACHSEN (Karte t_e166e656): das svmap-Feld `rot`
## (Euler x/y/z, OPTIONAL) trägt X/Z-Kippen; fehlt es, gilt rot_y_deg.
## rot/rot_y_deg bleiben über SvmapIO.rot_setzen synchron.
## - X/Z werden an der Map geklemmt, Y an den Match-Grenzen — Versenken UNTER
## die Terrain-Höhe ist ausdrücklich erlaubt (Steine im Boden), die
## Info-Zeile zeigt die Versenk-Tiefe.
const EPS := 0.005 # Toleranz „gleicher Wert“ (Anzeige-Konsens)
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 # core/ui/main.gd (ungewollt typisiert — dynamisch)
var _head: Label
var _sub: Label
var _grid: GridContainer
var _info: Label
var _hint: Label
var _ground_btn: Button
var _farben_grid: GridContainer # Farbfeld-Raster (Karte t_b0edb807)
var _farbe_reset_btn: Button
var _farbe_status: Label
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. UNTER die Terrain-Höhe absenken ist erlaubt (Versenken, Karte t_e166e656), Untergrenze = Match-Grenze.", 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 = -200.0 # Versenken erlaubt (Karte t_e166e656) — _apply klemmt exakt an bounds.min.y
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)
# --- Drehen & Kippen (Karte t_e166e656) ---
_grid.add_child(_field("Drehen & Kippen"))
_grid.add_child(_make_row("rot_y", "Drehen Y", "Blickrichtung in Grad (−180…180) — ziehen/tippen. Tasten: R (90°), Mausrad (15° fein).", 0.5, "%.1f", "°", -180.0, 180.0))
_grid.add_child(_make_row("rot_x", "Kippen X", "Kippen um die X-Achse in Grad (−180…180) — vor/zurück neigen. Tasten: Strg+R / Strg+Umschalt+R.", 0.5, "%.1f", "°", -180.0, 180.0))
_grid.add_child(_make_row("rot_z", "Kippen Z", "Kippen um die Z-Achse in Grad (−180…180) — links/rechts neigen. Tasten: Alt+R / Alt+Umschalt+R.", 0.5, "%.1f", "°", -180.0, 180.0))
# --- Größe (FEST, Karte t_e166e656) ---
_grid.add_child(_field("Größe"))
var gross_fix := Label.new()
gross_fix.text = "Größe fix (Original) — Assets bleiben in ihrer echten Größe."
gross_fix.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_grid.add_child(gross_fix)
var btn_row := HBoxContainer.new()
btn_row.add_theme_constant_override("separation", 6)
_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)
# --- Farbe / Material (Karte t_b0edb807) ---
# Feste Palette als Farbfeld-Raster; Klick = Farbe auf ALLE markierten
# Objekte (EIN Undo-Schritt). „Zurücksetzen“ entfernt das tint-Feld →
# wieder die Pack-Farbe (Rückwärtskompatibel, wie vor der Karte).
_grid.add_child(_field("Farbe / Material"))
_farben_grid = GridContainer.new()
_farben_grid.columns = 4
_farben_grid.add_theme_constant_override("h_separation", 6)
_farben_grid.add_theme_constant_override("v_separation", 6)
_grid.add_child(_farben_grid)
for eintrag in SvmapTint.PALETTE:
_farben_grid.add_child(_swatch(eintrag[1], str(eintrag[0])))
var farbe_row := HBoxContainer.new()
farbe_row.add_theme_constant_override("separation", 6)
_farbe_reset_btn = Button.new()
_farbe_reset_btn.text = "Zurücksetzen (Pack-Farbe)"
_farbe_reset_btn.custom_minimum_size = UIScale.v2(0, 40)
_farbe_reset_btn.tooltip_text = "Farbe der markierten Objekte entfernen — sie zeigen wieder die Pack-Farbe — EIN Rückgängig-Schritt"
_farbe_reset_btn.pressed.connect(_on_farbe_reset)
farbe_row.add_child(_farbe_reset_btn)
_grid.add_child(farbe_row)
_farbe_status = Label.new()
_farbe_status.text = ""
_grid.add_child(_farbe_status)
# --- 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
_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
## Ein Farbfeld-Knopf (Karte t_b0edb807): flacher Farb-Button mit Tooltip.
func _swatch(c: Color, name: String) -> Button:
var btn := Button.new()
btn.custom_minimum_size = UIScale.v2(34, 34)
btn.tooltip_text = "%s — Farbe auf alle markierten Objekte anwenden (EIN Rückgängig-Schritt)" % name
btn.focus_mode = Control.FOCUS_NONE
var sb := StyleBoxFlat.new()
sb.bg_color = c
sb.set_corner_radius_all(6)
btn.add_theme_stylebox_override("normal", sb)
var sb_hover: StyleBoxFlat = sb.duplicate()
sb_hover.bg_color = c.lightened(0.08) # U7: Aufhellung statt Manuell-Mathe
btn.add_theme_stylebox_override("hover", sb_hover)
btn.add_theme_stylebox_override("pressed", sb_hover)
btn.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
btn.pressed.connect(_on_farbe_gewaehlt.bind(c, name))
return btn
# ------------------------------------------------------------- Farbe/Material ---
## Palette-Farbe auf ALLE markierten Objekte — EIN Undo-Schritt.
func _on_farbe_gewaehlt(c: Color, name: String) -> void:
var indices := _selected()
if indices.is_empty():
return
var before: Dictionary = _main.history.snapshot(["objects"])
var objects: Array = _main.doc.get("objects", [])
var changed := false
for i in indices:
var obj: Dictionary = objects[i]
var alt := SvmapTint.von(obj)
SvmapTint.setze(obj, c)
if not SvmapTint.gleich(alt, c):
changed = true
if not changed:
return # gleiche Farbe erneut → kein Leer-Schritt
_commit_common("Inspektor: Farbe %s" % name, before)
_main.view_builder.call("tint_refresh", indices)
## tint-Feld entfernen → Pack-Farbe — EIN Undo-Schritt.
func _on_farbe_reset() -> void:
var indices := _selected()
if indices.is_empty():
return
var before: Dictionary = _main.history.snapshot(["objects"])
var objects: Array = _main.doc.get("objects", [])
var changed := false
for i in indices:
var obj: Dictionary = objects[i]
if SvmapTint.hat(obj):
SvmapTint.entferne(obj)
changed = true
if not changed:
return # nichts hatte eine Farbe → kein Leer-Schritt
_commit_common("Inspektor: Farbe zurückgesetzt (Pack-Farbe)", before)
_main.view_builder.call("tint_refresh", indices)
## Status-Zeile der Farbsektion: Pack-Farbe / Palettenname / eigene Farbe /
## gemischt (Mehrfachauswahl mit verschiedenen Farben).
func _farbe_text(objs: Array) -> String:
if objs.is_empty():
return ""
if objs.size() == 1:
var obj: Dictionary = objs[0]
if not SvmapTint.hat(obj):
return "Farbe: Pack-Farbe"
var c := SvmapTint.von(obj)
var pn := SvmapTint.palette_name(c)
return "Farbe: %s" % (pn if pn != "" else "eigene Farbe")
# Mehrfachauswahl: alle ohne Feld = Pack-Farbe, alle gleich = Name, sonst gemischt.
var first_hat := SvmapTint.hat(objs[0])
var first_c := SvmapTint.von(objs[0])
for obj in objs:
if SvmapTint.hat(obj) != first_hat:
return "Farbe: gemischt"
if first_hat and not SvmapTint.gleich(SvmapTint.von(obj), first_c):
return "Farbe: gemischt"
if not first_hat:
return "Farbe: Pack-Farbe"
var pn2 := SvmapTint.palette_name(first_c)
return "Farbe: %s" % (pn2 if pn2 != "" else "eigene Farbe")
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(SvmapIO.objekt_rotation(obj).y)
"rot_x":
return _clamp_norm_deg(SvmapIO.objekt_rotation(obj).x)
"rot_z":
return _clamp_norm_deg(SvmapIO.objekt_rotation(obj).z)
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
_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", "rot_x", "rot_z"]:
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))
# Farbsektion (Karte t_b0edb807): aktueller Farbstand der Auswahl.
_farbe_status.text = _farbe_text(objs)
# Info-Block (nur lesen).
var lift := ObjectOps.lift_offset(_main.doc, indices[0])
var lift_txt: String
if absf(lift) < ObjectOps.FLOAT_EPSILON:
lift_txt = "am Boden"
elif lift > 0.0:
lift_txt = "%+.2f m über dem Boden" % lift
else:
lift_txt = "%.2f m im Boden versenkt" % -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 "%sDrehen (Y) = %.1f°" % [mehr, v]
"rot_x":
return "%sKippen (X) = %.1f°" % [mehr, v]
"rot_z":
return "%sKippen (Z) = %.1f°" % [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 min_y := _min_y()
var changed := 0
# Position-Felder bei Mehrfachauswahl: der eingetippte Wert gilt für das
# ERSTE markierte Objekt (Anker), alle anderen wandern mit DEMSELBEN Delta
# mit → Abstände bleiben erhalten (Relativ-Move, wie beim Greifen).
# Bei 1 Objekt ist das Delta = absoluter Wert → Verhalten unverändert.
var rel_delta := 0.0
if field == "pos_x" or field == "pos_y" or field == "pos_z":
var anchor_pos: Array = (objects[indices[0]] as Dictionary).get("pos", [0.0, 0.0, 0.0])
if (anchor_pos as Array).size() == 3:
var comp := 0 if field == "pos_x" else (1 if field == "pos_y" else 2)
rel_delta = v - float(anchor_pos[comp])
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" or field == "rot_x" or field == "rot_z":
# Karte t_e166e656: rot/rot_y_deg bleiben über rot_setzen synchron
# (reines Y → kein rot-Feld in der Datei). Anzeige normalisiert
# auf −180…180, gespeichert wird der Wert wie getippt.
var euler := SvmapIO.objekt_rotation(obj)
var neu := Vector3(euler.x, euler.y, euler.z)
if field == "rot_y":
neu.y = _clamp_norm_deg(v)
elif field == "rot_x":
neu.x = _clamp_norm_deg(v)
else:
neu.z = _clamp_norm_deg(v)
SvmapIO.rot_setzen(obj, neu)
changed += 1
continue
if field == "pos_x":
x = clampf(float(pos[0]) + rel_delta, -half.x, half.x)
elif field == "pos_y":
# Karte t_e166e656: Versenken erlaubt — Untergrenze ist die
# Match-Grenze (bounds.min.y), NICHT mehr die Terrain-Höhe.
y = clampf(float(pos[1]) + rel_delta, min_y, max_y)
elif field == "pos_z":
z = clampf(float(pos[2]) + rel_delta, -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
## Untere Match-Grenze (Karte t_e166e656): Versenken im Boden ist erlaubt,
## klemmt an bounds.min.y statt an der Terrain-Höhe.
func _min_y() -> float:
var bounds: Dictionary = _main.doc.get("bounds", {})
if (bounds.get("min", null) is Array) and ((bounds["min"] as Array).size() == 3):
return float(bounds["min"][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_degrees = SvmapIO.objekt_rotation(objects[i] as Dictionary)
# ------------------------------------------------------------------- Knöpfe --
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"…"rot_z").
func widget(field: String) -> DragValue:
return _rows.get(field)