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://core/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 # core/ui/main.gd (script-erweitert) var camera: Camera3D var view_builder # core/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) # --- Dreh-/Kipp-Modus (25.10.): Ringe um alle drei Achsen ------------------- const RING_R := 1.5 # Ring-Radius in Metern const RING_SEGMENTS := 48 const ROT_STEP_DEG := 15.0 var mode := "move" # "move" = Pfeile, "rotate" = Ringe var rot_step_deg := ROT_STEP_DEG var _rings := {} # "x"/"y"/"z" → MeshInstance3D var _rot_dragging := false var _rot_axis := "" var _rot_center := Vector2.ZERO var _rot_start_angle := 0.0 var _rot_applied := 0.0 # bisher angewendete (gerastete) Grad var _rot_suffix := "" # merge-Kennung: EIN Undo-Schritt pro Zug var _rot_serial := 0 func setup(p_main, p_camera: Camera3D, p_builder) -> void: main = p_main camera = p_camera view_builder = p_builder _build_arrows() _build_rings() _apply_mode_visibility() 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 ## Drehringe bauen: je Achse ein dünner Torus, ungeschattet, ohne Tiefentest. func _build_rings() -> void: for a in AXES: 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 ring := MeshInstance3D.new() var torus := TorusMesh.new() torus.inner_radius = RING_R - 0.06 torus.outer_radius = RING_R + 0.06 torus.rings = 48 torus.ring_segments = 8 ring.mesh = torus ring.material_override = mat ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF # Torus liegt lokal in der XZ-Ebene (Achse = Y). if a == "x": ring.rotation.z = PI / 2.0 elif a == "z": ring.rotation.x = PI / 2.0 ring.visible = false add_child(ring) _rings[a] = ring ## Modus wechseln ("move" = Verschieben, "rotate" = Drehen/Kippen). func set_mode(m: String) -> void: if m != "move" and m != "rotate": return if _dragging: _finish_drag(false) if _rot_dragging: _finish_rotate(false) mode = m var inter = main.get("interaction") if main != null else null if inter != null: inter.no_drag_move = (m == "rotate") _apply_mode_visibility() func _apply_mode_visibility() -> void: for a in _arrows: (_arrows[a] as Node3D).visible = mode == "move" for a in _rings: (_rings[a] as Node3D).visible = mode == "rotate" 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: var p := MousePointer.from_input(event) if p != null and handle_pointer(p): get_viewport().set_input_as_handled() func handle_pointer(p: PointerEvent) -> bool: if main == null or camera == null: return false var doc: Dictionary = main.get("doc") if doc == null or doc.is_empty(): return false var tool_mode: String = str(main.get("tool_mode")) if tool_mode == "terrain" or tool_mode == "spawns": return false # Terrain-Pinsel / Spawn-Zonen haben das Zepter match p.kind: PointerEvent.Kind.PRESS: if _dragging: return false var inter = main.get("interaction") if inter == null or bool(inter.get("placing")): return false if mode == "rotate": var ring_axis := pick_ring(p.position) if ring_axis != "": _begin_rotate(ring_axis, p.position) return true return false var axis := pick(p.position) if axis != "": _begin_drag(axis, p.position) return true PointerEvent.Kind.RELEASE: if _rot_dragging: _finish_rotate(true) return true if _dragging: _finish_drag(true) return true PointerEvent.Kind.SCROLL: if _rot_dragging: return true # Mausrad zoomt nicht mitten im Dreh-Zug if _dragging: cycle_step(p.scroll) return true PointerEvent.Kind.MOVE: if _rot_dragging: _rotate_motion(p.position) return true if _dragging: _drag_motion(p.position) return true PointerEvent.Kind.CANCEL: if _rot_dragging: _finish_rotate(false) return true if _dragging: _finish_drag(false) return true return false # ------------------------------------------------------------- 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 ## Ring unter dem Cursor (Abstand zum projizierten Kreis) — "" = keiner. func pick_ring(screen_pos: Vector2) -> String: if not visible or mode != "rotate": return "" var best := "" var best_d := HIT_PX * 1.6 for a in AXES: var pts := _ring_screen_points(a) for i in pts.size() - 1: if pts[i] == Vector2.INF or pts[i + 1] == Vector2.INF: continue var d := _dist_point_segment(screen_pos, pts[i], pts[i + 1]) if d < best_d: best_d = d best = a return best func _ring_screen_points(a: String) -> Array: var axis := _axis_vec(a) var u := axis.cross(Vector3.UP if absf(axis.dot(Vector3.UP)) < 0.9 else Vector3.RIGHT).normalized() var v := axis.cross(u).normalized() var out: Array = [] for i in RING_SEGMENTS + 1: var t := TAU * float(i) / float(RING_SEGMENTS) out.append(_safe_unproject(global_position + (u * cos(t) + v * sin(t)) * RING_R)) return out 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 _rot_dragging: _finish_rotate(false) return true if not _dragging: return false _finish_drag(false) return true # --------------------------------------------------- Drehen / Kippen ------- func _ring_angle(mouse: Vector2) -> float: var d := mouse - _rot_center return atan2(d.y, d.x) func _begin_rotate(axis: String, mouse: Vector2) -> void: var inter = main.get("interaction") if inter == null or inter.selected_indices.is_empty(): return var c := _safe_unproject(global_position) if c == Vector2.INF: return _rot_axis = axis _rot_center = c _rot_start_angle = _ring_angle(mouse) _rot_applied = 0.0 _rot_serial += 1 _rot_suffix = "#g%d" % _rot_serial _rot_dragging = true var name := "Drehen (Y)" if axis == "y" else ("Kippen um X" if axis == "x" else "Kippen um Z") main.call("_set_status", "%s — Ring ziehen, rastet in %.0f°-Schritten; Esc bricht ab, Strg+Z nimmt den Zug zurück." % [name, rot_step_deg]) func _rotate_motion(mouse: Vector2) -> void: # Bildschirmwinkel → Drehwinkel um die Achse (Vorzeichen je nach Blickrichtung). var delta_screen := wrapf(_ring_angle(mouse) - _rot_start_angle, -PI, PI) var facing := _axis_vec(_rot_axis).dot(camera.global_transform.basis.z) var deg := -rad_to_deg(delta_screen) * (1.0 if facing >= 0.0 else -1.0) var snapped_deg := snappedf(deg, rot_step_deg) _rotate_to(snapped_deg) ## Auf einen Gesamtwinkel (gerastet) nachführen — in Schritten, EIN Undo-Eintrag. func _rotate_to(total_deg: float) -> void: var diff := total_deg - _rot_applied if absf(diff) < 0.001: return var inter = main.get("interaction") if inter == null: return if _rot_axis == "y": inter.rotate_selected(diff, _rot_suffix) else: inter.tilt_selected(_rot_axis == "x", diff, _rot_suffix) _rot_applied = total_deg func _finish_rotate(commit: bool) -> void: if not _rot_dragging: return _rot_dragging = false if not commit and absf(_rot_applied) > 0.001: main.call("_do_undo") # der ganze Zug ist EIN Eintrag → komplett zurück main.call("_set_status", "Dreh-Zug abgebrochen — Objekt wie vorher.") _rot_axis = "" _rot_applied = 0.0 ## Probes/Tests: derselbe Pfad wie ein echter Ring-Zug (ohne Maus-Mathe). func demo_rotate_axis(axis: String, deg: float) -> bool: var inter = main.get("interaction") if main != null else null if inter == null or inter.selected_indices.is_empty() or absf(deg) < 0.001: return false _rot_axis = axis _rot_serial += 1 _rot_suffix = "#g%d" % _rot_serial _rot_applied = 0.0 _rotate_to(snappedf(deg, rot_step_deg)) _rot_axis = "" _rot_applied = 0.0 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