diff --git a/src/ui/main.gd b/src/ui/main.gd index 61a84dd..2896a41 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -34,6 +34,9 @@ var grid_snap := false # Raster-Snap (1 m) an/aus — Objekt-Werkzeug var history := ActionHistory.new() var interaction # src/editor/interaction.gd (script-erweitert → untypisiert) +var gizmo # src/viewport/gizmo.gd (Achsen-Pfeile, Kind von interaction) +var gizmo_step_m := 0.5 # Gizmo-Schrittweite in m (Panel-Feld; überlebt UI-Rebuilds) +var gizmo_step_box: SpinBox # SpinBox „Schrittweite“ im rechten Panel var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1) var spawn_tool # src/editor/spawn_tool.gd (Zonen, Karte t_c4162c5d) var build_tool # src/editor/build_tool.gd (Bauen-Modus, Karte t_97ea59c5) @@ -62,6 +65,7 @@ const LIBRARY_CFG_SECTION := "library" # Abschnitt in user://scopedvr_editor.c # 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") +const GizmoScript := preload("res://src/viewport/gizmo.gd") var undo_btn: Button var redo_btn: Button @@ -782,6 +786,12 @@ func _build_center_viewport() -> SubViewportContainer: interaction.set_script(load("res://src/editor/interaction.gd")) world.add_child(interaction) interaction.setup(self, camera, view_builder) + # Achsen-Gizmo (Karte t_e0120dc2) als KIND von interaction — Kinder + # bekommen _unhandled_input ZUERST, es fängt Pfeil-Klicks ab bevor + # interaction sie sieht. interaction.gd bleibt dadurch unverändert. + gizmo = GizmoScript.new() + gizmo.setup(self, camera, view_builder) + interaction.add_child(gizmo) camera.set("wheel_filter", Callable(interaction, "wheel_blocked")) # Freelook-Flug-Verdrahtung (Karte t_c46d31c2): Status-Meldungen der # Kamera laufen ins Statusfeld. Karte t_2470de89: die rechte Maustaste @@ -953,6 +963,29 @@ func _build_right_panel() -> PanelContainer: group_btns.add_child(sel_ungroup_btn) vbox.add_child(_muted_label("Mehrfachauswahl: Umschalt+Klick oder Rahmen aufziehen. Eine Gruppe wandert und dreht gemeinsam — auch nach Speichern/Laden.")) + # --- Gizmo-Einstellungen (Karte t_e0120dc2): Schrittweite der Achsen- + # Züge. Das Mausrad WÄHREND eines Gizmo-Zugs blättert durch die + # Voreinstellungen (fein/normal/grob); dieses Feld setzt sie exakt und + # überlebt UI-Rebuilds, weil der Wert in gizmo_step_m liegen bleibt. + vbox.add_child(_header_label("Gizmo-Einstellungen")) + var gizmo_row := HBoxContainer.new() + gizmo_row.add_theme_constant_override("separation", UIScale.px(8)) + vbox.add_child(gizmo_row) + gizmo_row.add_child(_field_label("Schrittweite")) + gizmo_step_box = SpinBox.new() + gizmo_step_box.min_value = 0.01 + gizmo_step_box.max_value = 10.0 + gizmo_step_box.step = 0.01 + gizmo_step_box.value = gizmo_step_m + gizmo_step_box.custom_minimum_size = UIScale.v2(110, 30) + gizmo_step_box.tooltip_text = "Wie weit bewegt sich das Objekt am Achsen-Gizmo pro Schritt (in Metern). Mausrad WÄHREND des Ziehens wechselt zwischen fein (0.1) / normal (0.5) / grob (2.0)." + gizmo_row.add_child(gizmo_step_box) + gizmo_step_box.value_changed.connect(func(v: float) -> void: + gizmo_step_m = v + if gizmo != null and is_instance_valid(gizmo): + gizmo.set_step_m(v) + ) + vbox.add_child(_separator()) # --- Map-Eigenschaften --- @@ -1973,6 +2006,12 @@ func _unhandled_key_input(event: InputEvent) -> void: _toggle_help_overlay() get_viewport().set_input_as_handled() return + # Karte t_e0120dc2: Läuft gerade ein Gizmo-Zug (Achsen-Pfeil), gehört + # Esc IHM — exakt zurückrollen auf die Zug-Startposition (kein + # History-Eintrag), danach NICHT weiter in der Esc-Kette eintauchen. + if gizmo != null and gizmo.cancel_drag_if_active(): + get_viewport().set_input_as_handled() + return # Esc-Priorität über pure Funktion (Karte t_c46d31c2, testbar): # Werkzeug verlassen → Greifen zurückrollen → Ghost-Preview # abbrechen → Auswahl aufheben (Greifen: Karte t_55257792). @@ -2615,6 +2654,12 @@ func _sync_selection_panel() -> void: # Objekt-Inspektor mit allen Auswahl-Wege synchron halten (Karte t_bb0d11c9). if inspector != null: inspector.call("refresh") + # Gizmo-Schrittweite-Feld mitziehen (Karte t_e0120dc2): Das Mausrad + # WÄHREND eines Zugs kann sie im Gizmo ändern (cycle_step) — das Panel- + # Feld folgt ohne eigene value_changed-Rückkopplung (set_value_no_signal). + if gizmo_step_box != null and is_instance_valid(gizmo_step_box) \ + and not gizmo_step_box.has_focus(): + gizmo_step_box.set_value_no_signal(gizmo_step_m) var objects: Array = doc.get("objects", []) var sel: Array = interaction.selected_indices var valid: Array = [] diff --git a/src/viewport/gizmo.gd b/src/viewport/gizmo.gd new file mode 100644 index 0000000..a176db7 --- /dev/null +++ b/src/viewport/gizmo.gd @@ -0,0 +1,433 @@ +extends Node3D +## Achsen-Gizmo (Karte t_e0120dc2): drei farbige Pfeile (X rot, Y grün, +## Z blau) am EINZELN markierten Objekt. Zieht man einen Pfeil, bewegt sich +## das Objekt GENAU entlang dieser Achse — die Zahlen im Objekt-Inspektor +## laufen LIVE mit. Mausrad WÄHREND des Ziehens wechselt die Schrittweite +## (fein/normal/grob); derselbe Wert steht als „Schrittweite“-Feld im +## rechten Panel (main.gd). +## +## Regeln der Karte: +## - NUR Einzelauswahl (Mehrfachauswahl bekommt kein Gizmo — Folgekarte). +## - JEDER Zug = EIN Undo-Schritt (Commit beim Loslassen). Esc bricht EXAKT +## auf die Startposition zurück (byte-identisch, wie grab_cancel). +## - Zieh-Werte rasten auf Vielfache der Schrittweite (Voreinstellungen +## 0,1 / 0,5 / 2 m; das Feld im Panel nimmt jeden Wert 0,01–10 m). +## - Klemmen wie der Inspektor: X/Z an der Map-Grenze, Y nie unter dem +## Terrain (X/Z-Zug folgt dem Boden wie der normale Objekt-Zug) und +## oben an der Höhen-Grenze. +## +## Eingabe-Verdrahtung: dieses Gizmo hängt als KIND an interaction.gd — +## Knoten bekommen _unhandled_input TIEFER IM BAUM ZUERST, so fängt es +## Pfeil-Klicks ab, BEVOR die Auswahl-/Zieh-Logik des Parents sie sieht. +## interaction.gd bleibt dadurch UNVERÄNDERT (Karten-Grenze) und der +## bestehende Klick-/Zieh-Pfad wird 1:1 wiederverwendet. + +const SvmapDocument := preload("res://src/svmap/map_document.gd") + +const AXIS_LEN := 2.2 # Pfeil-Länge in Metern (Weltmaß) +const SHAFT_R := 0.045 +const CONE_H := 0.45 +const CONE_R := 0.13 +const HIT_PX := 18.0 # Treffer-Toleranz am Bildschirm (Pixel) +const MIN_PX_PER_M := 10.0 # Zieh-Sensitivität deckeln (Achse fast im Blick) +const STEP_MIN := 0.01 +const STEP_MAX := 10.0 + +const STEP_PRESETS := [0.1, 0.5, 2.0] +const STEP_PRESET_NAMES := ["fein", "normal", "grob"] + +const AXES := { + "x": Vector3(1.0, 0.0, 0.0), + "y": Vector3(0.0, 1.0, 0.0), + "z": Vector3(0.0, 0.0, 1.0), +} +const AXIS_COLORS := { + "x": Color(1.0, 0.25, 0.25), + "y": Color(0.3, 0.95, 0.35), + "z": Color(0.35, 0.55, 1.0), +} + +var main # src/ui/main.gd (script-erweitert) +var camera: Camera3D +var view_builder # src/viewport/map_view_builder.gd + +var step_m := 0.5 # Schrittweite (m) — Panel-Feld + Mausrad-Presets + +# --- Zieh-Zustand --- +var _dragging := false +var _drag_idx := -1 +var _drag_axis := "" +var _drag_axis_vec := Vector3.ZERO +var _press_screen := Vector2.ZERO +var _screen_axis := Vector2.RIGHT # Achse am Bildschirm (normalisiert, px) +var _px_per_m := 1.0 +var _start_pos := Vector3.ZERO # doc-Position beim Zieh-Start +var _start_saved: Array = [] # exakte Start-Position für Esc-Rückabwicklung +var _drag_lift := 0.0 # Boden-Abstand (X/Z-Zug folgt dem Terrain) +var _drag_moved := false +var _last_delta := 0.0 # zuletzt angewendeter Delta (m) — 0 = kein Commit +var _drag_before: Dictionary = {} # History-Snapshot VOR dem Zug + +var _arrows := {} # "x"/"y"/"z" → Node3D (Pfeil-Root) + + +func setup(p_main, p_camera: Camera3D, p_builder) -> void: + main = p_main + camera = p_camera + view_builder = p_builder + _build_arrows() + visible = false + + +## Pfeile bauen: Schaft (Zylinder) + Spitze (Kegel), ungeschattet und OHNE +## Tiefentest — die Pfeile bleiben auch vor/über Objekten gut sichtbar. +func _build_arrows() -> void: + for a in AXES: + var root := Node3D.new() + # Alle Pfeile zeigen lokal +Y; der Root dreht sie auf die Welt-Achse. + if a == "x": + root.rotation.z = -PI / 2.0 # +Y → +X + elif a == "z": + root.rotation.x = PI / 2.0 # +Y → +Z + var mat := StandardMaterial3D.new() + mat.albedo_color = AXIS_COLORS[a] + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.no_depth_test = true + mat.render_priority = 20 + var shaft := MeshInstance3D.new() + var cyl := CylinderMesh.new() + cyl.top_radius = SHAFT_R + cyl.bottom_radius = SHAFT_R + cyl.height = AXIS_LEN + shaft.mesh = cyl + shaft.material_override = mat + shaft.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + shaft.position.y = AXIS_LEN * 0.5 + root.add_child(shaft) + var tip := MeshInstance3D.new() + var cone := CylinderMesh.new() + cone.top_radius = 0.0 + cone.bottom_radius = CONE_R + cone.height = CONE_H + tip.mesh = cone + tip.material_override = mat + tip.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + tip.position.y = AXIS_LEN + CONE_H * 0.5 + root.add_child(tip) + add_child(root) + _arrows[a] = root + + +func _axis_vec(a: String) -> Vector3: + return AXES.get(a, Vector3.ZERO) as Vector3 + + +## Sichtbarkeit + Position JEDES Frames aus der Auswahl ableiten (kein +## Verdrahten jedes Auswahl-Wegs nötig — Auswahl kommt aus interaction.gd, +## Baum, Undo/Redo, Inspektor … alles landet automatisch hier). +func _process(_delta: float) -> void: + if main == null or view_builder == null: + return + var inter = main.get("interaction") + if inter == null: + return + var sel: Array = inter.selected_indices + if sel.size() != 1: + visible = false + return + if bool(inter.get("placing")): + visible = false # Ghost-Platzieren: keine Pfeile im Weg + return + var idx: int = int(inter.selected_index) + var markers: Array = view_builder.object_markers + if idx < 0 or idx >= markers.size(): + visible = false + return + var marker = markers[idx] + if marker == null or not is_instance_valid(marker): + visible = false + return + visible = true + global_position = marker.global_position + + +func _unhandled_input(event: InputEvent) -> void: + if main == null or camera == null: + return + var doc: Dictionary = main.get("doc") + if doc == null or doc.is_empty(): + return + var mode: String = str(main.get("tool_mode")) + if mode == "terrain" or mode == "spawns": + return # Terrain-Pinsel / Spawn-Zonen haben das Zepter + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: + if _dragging: + return + var inter = main.get("interaction") + if inter == null or bool(inter.get("placing")): + return + var axis := pick(mb.position) + if axis != "": + _begin_drag(axis, mb.position) + get_viewport().set_input_as_handled() + elif mb.button_index == MOUSE_BUTTON_LEFT and not mb.pressed: + if _dragging: + _finish_drag(true) + get_viewport().set_input_as_handled() + elif (mb.button_index == MOUSE_BUTTON_WHEEL_UP \ + or mb.button_index == MOUSE_BUTTON_WHEEL_DOWN) and mb.pressed: + if _dragging: + var dir := 1 if mb.button_index == MOUSE_BUTTON_WHEEL_UP else -1 + cycle_step(dir) + get_viewport().set_input_as_handled() + elif event is InputEventMouseMotion and _dragging: + _drag_motion((event as InputEventMouseMotion).position) + get_viewport().set_input_as_handled() + elif event is InputEventKey and (event as InputEventKey).pressed \ + and (event as InputEventKey).keycode == KEY_ESCAPE: + if _dragging: + _finish_drag(false) + get_viewport().set_input_as_handled() + + +# ------------------------------------------------------------- Treffer ----- + +## Achse unter dem Cursor (Bildschirm-Segment-Distanz) — "" = kein Pfeil. +func pick(screen_pos: Vector2) -> String: + if not visible: + return "" + var o := _safe_unproject(global_position) + if o == Vector2.INF: + return "" + var best := "" + var best_d := HIT_PX + for a in AXES: + var t := _safe_unproject(global_position + _axis_vec(a) * AXIS_LEN) + if t == Vector2.INF: + continue + var d := _dist_point_segment(screen_pos, o, t) + if d < best_d: + best_d = d + best = a + return best + + +func _safe_unproject(p: Vector3) -> Vector2: + if camera == null: + return Vector2.INF + if camera.is_position_behind(p): + return Vector2.INF + return camera.unproject_position(p) + + +func _dist_point_segment(p: Vector2, a: Vector2, b: Vector2) -> float: + var ab := b - a + var len_sq := ab.length_squared() + if len_sq < 0.0001: + return p.distance_to(a) + var t := clampf((p - a).dot(ab) / len_sq, 0.0, 1.0) + return p.distance_to(a + ab * t) + + +# -------------------------------------------------------------- Ziehen ----- + +func _begin_drag(axis: String, mb_pos: Vector2) -> void: + if not _begin_drag_state(axis): + return + # Bildschirm-Mathe: Achse in Pixeln → Zieh-Sensitivität (Meter pro Pixel). + var markers: Array = view_builder.object_markers + var marker = markers[_drag_idx] + var origin: Vector3 = marker.global_position + var o := _safe_unproject(origin) + var t := _safe_unproject(origin + _drag_axis_vec * AXIS_LEN) + if o == Vector2.INF or t == Vector2.INF: + _dragging = false + return + var seg := t - o + var seg_len := seg.length() + if seg_len < 1.0: + _dragging = false + return + _screen_axis = seg.normalized() + _px_per_m = maxf(seg_len / AXIS_LEN, MIN_PX_PER_M) + _press_screen = mb_pos + main.call("_set_status", "Gizmo-Zug: entlang %s ziehen — Mausrad wechselt die Schrittweite (%.2f m), Esc bricht ab." % [ + _drag_axis.to_upper(), step_m]) + + +## Zieh-Zustand NUR aus dem Doc/Marke r aufsetzen (ohne Bildschirm-Mathe — +## auch für den Demo-/Test-Hook demo_drag_axis). +func _begin_drag_state(axis: String) -> bool: + var inter = main.get("interaction") + if inter == null or inter.selected_indices.size() != 1: + return false + var idx: int = int(inter.selected_index) + var markers: Array = view_builder.object_markers + if idx < 0 or idx >= markers.size(): + return false + var marker = markers[idx] + if marker == null or not is_instance_valid(marker): + return false + var objects: Array = main.doc.get("objects", []) + if idx >= objects.size(): + return false + var pos: Array = (objects[idx] as Dictionary).get("pos", [0.0, 0.0, 0.0]) + _drag_idx = idx + _drag_axis = axis + _drag_axis_vec = _axis_vec(axis) + _press_screen = Vector2.ZERO + _start_pos = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + _start_saved = (pos as Array).duplicate() + _drag_lift = marker.position.y - SvmapDocument.terrain_height_at( + main.doc, _start_pos.x, _start_pos.z) + _drag_before = main.history.snapshot(["objects"]) + _drag_moved = false + _last_delta = 0.0 + _dragging = true + return true + + +func _drag_motion(mb_pos: Vector2) -> void: + var d := mb_pos - _press_screen + var raw := d.dot(_screen_axis) / _px_per_m + _apply_axis_delta(snappedf(raw, step_m)) + + +## Delta entlang der Achse anwenden: doc + Marke + Gizmo, Inspektor LIVE. +func _apply_axis_delta(delta: float) -> void: + if not _dragging or absf(delta - _last_delta) < 0.0005: + return + var objects: Array = main.doc.get("objects", []) + if _drag_idx >= objects.size(): + _finish_drag(false) + return + var np := _start_pos + _drag_axis_vec * delta + var half := _terrain_half() + np.x = clampf(np.x, -half.x, half.x) + np.z = clampf(np.z, -half.y, half.y) + if _drag_axis == "y": + np.y = clampf(np.y, SvmapDocument.terrain_height_at(main.doc, np.x, np.z), _max_y()) + else: + # X/Z-Zug folgt dem Boden (gleiches Verhalten wie der normale Zug): + # der Boden-Abstand bleibt erhalten, nichts begräbt sich. + np.y = SvmapDocument.terrain_height_at(main.doc, np.x, np.z) + _drag_lift + var obj: Dictionary = objects[_drag_idx] + obj["pos"] = [np.x, np.y, np.z] + var markers: Array = view_builder.object_markers + if _drag_idx < markers.size(): + var marker = markers[_drag_idx] + if marker != null and is_instance_valid(marker): + marker.position = np + global_position = np + _last_delta = delta + _drag_moved = true + main.call("_sync_selection_panel") # Inspektor-Zahlen LIVE mitziehen + + +## Zug beenden: commit = true → EIN Undo-Schritt (wirklich bewegt), +## commit = false → exakte Rückabwicklung, KEIN History-Eintrag. +func _finish_drag(commit: bool) -> void: + if not _dragging: + return + _dragging = false + var objects: Array = main.doc.get("objects", []) + var idx := _drag_idx + if commit and absf(_last_delta) > 0.0005 and idx >= 0 and idx < objects.size(): + var desc: String + match _drag_axis: + "x": + desc = "Objekt entlang X verschoben (Gizmo)" + "y": + desc = "Objekt angehoben/abgesenkt (Gizmo, Y-Achse)" + _: + desc = "Objekt entlang Z verschoben (Gizmo)" + main.history.commit(desc, _drag_before, main.history.snapshot(["objects"])) + main.set("dirty", true) + main.call("_refresh_file_label") + main.call("_refresh_history_buttons") + main.call("_refresh_stats") + main.call("_sync_selection_panel") + main.call("_set_status", "%s — %.2f m. Strg+Z nimmt den Zug zurück; Mausrad WÄHREND des Ziehens wechselt fein/grob." % [ + desc, absf(_last_delta)]) + elif not commit and idx >= 0 and idx < objects.size(): + (objects[idx] as Dictionary)["pos"] = _start_saved.duplicate() + var markers: Array = view_builder.object_markers + if idx < markers.size(): + var marker = markers[idx] + if marker != null and is_instance_valid(marker): + marker.position = _start_pos + global_position = _start_pos + main.call("_sync_selection_panel") + main.call("_set_status", "Gizmo-Zug abgebrochen — Position wie vorher.") + _drag_before = {} + _drag_idx = -1 + _drag_axis = "" + _drag_moved = false + _last_delta = 0.0 + + +## Für die Esc-Kette in main.gd: Zug abbrechen, falls einer läuft. true = +## es lief einer und wurde abgebrochen (idempotent, weil _dragging prüft). +func cancel_drag_if_active() -> bool: + if not _dragging: + return false + _finish_drag(false) + return true + + +# ------------------------------------------------------ Schrittweite ------- + +## Mausrad WÄHREND des Ziehens: durch die Voreinstellungen blättern +## (fein 0,1 → normal 0,5 → grob 2,0 m, wrappt). Das Panel-Feld folgt. +func cycle_step(dir: int) -> void: + var idx := STEP_PRESETS.find(step_m) + if idx < 0: + idx = STEP_PRESETS.size() - 1 # eigener Feld-Wert: vom größten Preset starten + idx = wrapi(idx + dir, 0, STEP_PRESETS.size()) + set_step_m(STEP_PRESETS[idx]) + main.call("_set_status", "Gizmo-Schrittweite: %.2f m (%s) — Mausrad wechselt weiter, das Feld rechts setzt sie exakt." % [ + step_m, STEP_PRESET_NAMES[idx]]) + + +## Feld-Wert übernehmen (Panel-Feld ODER Mausrad-Preset). Haupt-Var hält den +## Wert über UI-Rebuilds (UIScale) hinweg. +func set_step_m(v: float) -> void: + step_m = clampf(v, STEP_MIN, STEP_MAX) + if main != null: + main.set("gizmo_step_m", step_m) + + +# ------------------------------------------------------- Demo-/Test-Hook --- + +## Probes/Tests: denselben Anwendungs- + Commit-Pfad wie ein echter Zug, +## ohne Bildschirm-Mathe. true = es wurde wirklich bewegt + committed. +func demo_drag_axis(axis: String, meters: float) -> bool: + if main == null: + return false + var applied := snappedf(meters, step_m) + if absf(applied) < 0.0005: + return false + if not _begin_drag_state(axis): + return false + _apply_axis_delta(applied) + _finish_drag(true) + return true + + +# ------------------------------------------------------------ Helfer ------- + +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", {}) + var mx = bounds.get("max", null) + if mx is Array and (mx as Array).size() == 3: + return float((mx as Array)[1]) + return 80.0 diff --git a/src/viewport/gizmo.gd.uid b/src/viewport/gizmo.gd.uid new file mode 100644 index 0000000..c114071 --- /dev/null +++ b/src/viewport/gizmo.gd.uid @@ -0,0 +1 @@ +uid://bchu6tv6aetfh