700 lines
25 KiB
GDScript
700 lines
25 KiB
GDScript
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)
|