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

1729 lines
73 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 Node3D
## Maus-Interaktion im Editor-Viewport (M2 UX-Fundament, Karte t_40d855d0;
## Auswahl-System Erweiterung: Karte t_f6f29d86):
## - Ghost-Preview: beim Platzieren schwebt eine transparente Vorschau am
## Cursor, Klick setzt das Objekt (kein "überraschtes" Ergebnis).
## - Auswahl: angeklickte Objekte färben sich orange, Ziehen verschiebt,
## R-Taste ODER Mausrad dreht (15°-Raster), Entf löscht.
## - Mehrfachauswahl (Karte t_f6f29d86, erweitert t_c46d31c2): Umschalt+Klick
## ODER Strg+Klick fügt hinzu/entfernt, Rahmen-Aufziehen über leeren Boden
## wählt alles im Rahmen. Löschen/Duplizieren wirken auf die GANZE Auswahl.
## - Gruppen: Objekte mit gleicher "group"-ID ziehen/drehen gemeinsam
## (GroupOps), Gruppieren/Lösen über main-Buttons (Strg+G / Strg+Umschalt+G).
## - Undo: Jede Aktion = EINE ActionHistory-Stufe (auch Gruppen-Ziehen).
## Alle Meldungen laufen über main._set_status — Klartext + nächsten Schritt
## vorschlagen (Chef-Grundsatz, map-editor-README).
##
## Lebt im selben SubViewport-Welt-Knoten wie Kamera + Viewport-Bauer;
## main.gd verdrahtet Referenzen per setup().
const ROT_STEP_DEG := 15.0
const TILT_STEP_DEG := 15.0 # Kipp-Schritt (Karte t_e166e656: Strg+R / Alt+R)
const DRAG_THRESHOLD_PX := 4.0
const GRID_M := 1.0 # Raster-Snap-Weite (Objekt-Werkzeuge links, Karte t_3a998ae4)
# Höhe beim Ziehen merken (Karte t_7567a06f): das Objekt behält seinen
# Abstand zum Terrain — ein angehobenes Objekt bleibt beim Verschieben oben.
var main # Hauptfenster (script-erweitert, Variant — Referenzen per setup())
var camera: Camera3D
var view_builder # script-erweitert, Variant
# Platzier-Modus
var placing := false
var place_oid := ""
var place_rot_deg := 0.0
var place_tilt_x := 0.0 # Kippen X beim Platzieren (Karte t_e166e656, Strg+R)
var place_tilt_z := 0.0 # Kippen Z beim Platzieren (Karte t_e166e656, Alt+R)
# U5 (t_5e3e97c9): Shift beim Setz-Klick = Serienmodus — weiter setzen wie
# bisher (Ghost bleibt an, jeder Klick setzt). Ohne Shift wird EIN Objekt
# gesetzt und das Werkzeug beendet sich selbst; das neue Objekt ist ausgewählt.
var _place_series := false
var _ghost: Node3D # Ghost-ROOT: Position = Objekt-Fußpunkt —
# identisch zur platzierten Marker-Position
# (Karte t_4092e3a0: Ghost == Platzierung).
var _ghost_mi: MeshInstance3D # Kind-Mesh, gehoben um half_height —
# exakt wie der Viewport-Bauer (map_view_builder).
var _ghost_active := false # Ghost sichtbar weil Cursor über der Map ist
# Bauen-Modus (Bauteile bau_*): der Ghost rastet über BuildSnap (Kante/Zelle/
# Ebene), jeder Klick setzt ein Teil in das laufende Gebäude (= Gruppe).
var build_group_id := -1 # laufendes Gebäude; -1 = nächstes Teil beginnt ein neues
var _build_last: Dictionary = {} # letztes BuildSnap-Ergebnis (pos/rot/ersetze/ok/grund)
var _build_hit: Dictionary = {} # letzter Treffer {punkt, obj, dir}
var _build_hint := "" # letzte Statuszeile des Ghosts (kein Spam)
var build_geschoss := -1 # -1 = Höhe automatisch, sonst festes Geschoss (0 = Boden, 1 = erste Decke …)
const BAU_GESCHOSS_MAX := 8
# Terrain-Höhen-Cache (Karte t_4092e3a0): der Objekt-Cursor trifft das GELÄNDE
# (TerrainField.raycast, wie Terrain-/Bauen-/Spawn-Werkzeug), nicht mehr nur
# die flache Basis-Ebene. Dekodiert einmal pro Map-Änderung, nicht pro Mausbewegung.
var _heights := PackedFloat32Array()
var _res := 0
var _sx := 0.0
var _sz := 0.0
# Auswahl + Greifen
var selected_index := -1 # PRIMÄR-Objekt (letzter Klick) — Grab/Status-Anker
var selected_indices: Array = [] # komplette Auswahl in Klick-Reihenfolge (int-Indizes)
var _dragging := false
var _drag_moved := false
var _press_pos := Vector2.ZERO
var _grab_offset := Vector3.ZERO # Einzel-Greifen (Rückwärtskompatibilität)
var _drag_lift := 0.0 # Terrain-Abstand des Objekts beim Anfassen (Höhe hebt sich mit)
# Gruppen-/Mehrfach-Ziehen (Karte t_f6f29d86)
var _drag_members: Array = [] # Indizes, die GEMEINSAM ziehen
var _grab_offsets: Dictionary = {} # index (int) → Vector3 (y = 0)
var _drag_lifts: Dictionary = {} # index (int) → float (Terrain-Abstand beim Anfassen, t_7567a06f)
var _drag_primary := -1 # Anker-Objekt (Raster-Snap & Delta-Herkunft)
var _drag_is_group := false # Zieh-Menge = komplette svmap-Gruppe
# Rahmen-Ausziehen (Rubberband) über leeren Boden
var _rubber_pending := false # Press auf leerer Fläche, noch nicht geschwenkt
var _rubber_active := false # Rahmen wird gerade aufgezogen
var _rubber_start := Vector2.ZERO
var _rubber_shift := false # Umschalt: Rahmen zur Auswahl HINZUFÜGEN
# Strg halten = temporärer Snap-Zwang (Karte t_55257792): egal was der
# Raster-Snap-Schalter sagt — solange Strg unten ist, rastet Bewegen/Platzieren.
# Aus JEDEM durchgelassenen Input-Event aktualisiert (Maus- UND Taste).
var _ctrl_held := false
# Greifen (Karte t_55257792, G — Blender-Semantik): die Auswahl hängt an der
# Maus, Linksklick setzt die neue Position (EIN Undo-Schritt), Esc bricht ab
# und stellt die EXAKTEN alten Positionen wieder her.
var _grabbing := false
var _grab_members: Array = [] # Indizes der Greif-Menge (inkl. Gruppe)
var _grab_primary := -1
var _grab_originals: Dictionary = {} # idx (int) → [x, y, z] exakt (Rückabwicklung)
var _grab_lifts: Dictionary = {} # idx (int) → Terrain-Abstand beim Anfassen
var _grab_before: Dictionary = {} # History-Snapshot VOR dem Greifen
# Symmetrie-Zwillinge beim Greifen (M3, t_34908425): Mitglied → Partner-Index;
# Originale der Partner, die NICHT selbst greifen (für die Esc-Rückabwicklung).
var _grab_twin_map: Dictionary = {} # idx (int) → partner index (int)
var _grab_twin_originals: Dictionary = {} # partner idx (int) → [x, y, z] exakt
func setup(p_main, p_camera: Camera3D, p_builder) -> void:
main = p_main
camera = p_camera
view_builder = p_builder
refresh_terrain_cache() # t_4092e3a0: Cursor trifft das Gelände ab der ersten Mausbewegung
## Symmetrie-Modus aus dem Hauptfenster lesen (M3, t_34908425). Fehlt das Feld
## (ältere Test-Doppel, Setup-Reihenfolgen), gilt Aus — nie ein harter Fehler.
func _sym_mode() -> int:
var v: Variant = main.get("sym_mode") if main != null else null
return int(v) if v is int else SymmetryCore.Mode.OFF
## Kamera fragt: dreht das Mausrad gerade ein Objekt/eine Vorschau
## (dann zoomt das Rad NICHT)? Im Freelook-Flug (t_c46d31c2) blockt das Rad
## zusätzlich ALLE Objekt-Reaktionen — dort ändert es die Grundgeschwindigkeit.
func wheel_blocked() -> bool:
if _camera_flying():
return true
return placing or selected_index >= 0
func _camera_flying() -> bool:
return camera != null and camera.has_method("fly_active") \
and bool(camera.call("fly_active"))
## Höhen-Cache auffrischen (Karte t_4092e3a0): NACH Terrain-Edits, Undo/Redo,
## Laden/Neu — main ruft das in _after_structure_change. Ein Decode pro
## Änderung statt pro Mausbewegung (129×129-Quantisierung bleibt billig).
func refresh_terrain_cache() -> void:
var terrain: Dictionary = main.doc.get("terrain", {})
_res = int(terrain.get("resolution", 0))
_sx = float(terrain.get("size_x_m", 0.0))
_sz = float(terrain.get("size_z_m", 0.0))
if _res >= 2 and _sx > 0.0 and _sz > 0.0:
_heights = SvmapIO.decode_heights(terrain)
else:
_heights = PackedFloat32Array()
func _ground_point(viewport_pos: Vector2) -> Variant:
# Cursor-Strahl → erster Treffer AUF DEM GELÄNDE (Karte t_4092e3a0): die
# Höhenform zählt, nicht die flache Basis-Ebene — sonst landet ein Objekt
# auf einem Hügel versetzt hinter dem Cursor. Gleicher Pfad wie Terrain-,
# Bauen- und Spawn-Werkzeug (TerrainField.raycast). Rückgabe null = kein
# Treffer (Strahl ins Leere / außerhalb der Map).
var terrain: Dictionary = main.doc.get("terrain", {})
if terrain.is_empty():
return null
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
if _res >= 2 and _heights.size() >= _res * _res:
return TerrainField.raycast(_heights, _res, _sx, _sz, origin, dir)
# Fallback (Map ohne brauchbares Höhenfeld): Basis-Ebene wie bisher.
var plane_y := float(terrain.get("min_height_m", 0.0))
if absf(dir.y) < 0.0001:
return null
var t := (plane_y - origin.y) / dir.y
if t <= 0.0:
return null
return origin + dir * t
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
## Cursor-Strahl → getroffenes Objekt. Rückgabe: Index in doc.objects oder -1.
## Zwei Stufen (Mauer-Klick-Fix 24.10.): Die Kugel-Näherung der Marker ist nur
## noch Vorfilter — entschieden wird über den EXAKTEN Treffer auf den echten
## Mesh-Dreiecken (nächstes getroffenes Objekt gewinnt). Nur wenn der Strahl
## kein Mesh trifft, zählt wie bisher die nächste Kugel (kleine Objekte und
## Platzhalter bleiben leicht anklickbar).
func _pick_object(viewport_pos: Vector2) -> int:
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
var ground: Variant = _ground_point(viewport_pos)
var ground_t := INF
if ground != null:
ground_t = (ground as Vector3).distance_to(origin)
var exact := -1
var exact_t := INF
var best := -1
var best_t := INF
for marker in view_builder.object_markers:
if marker == null or not is_instance_valid(marker):
continue
var center: Vector3 = marker.global_position + Vector3.UP * float(marker.get_meta("half_height", 1.0))
var to_center := center - origin
var tca := to_center.dot(dir)
if tca <= 0.0:
continue
var d2 := to_center.length_squared() - tca * tca
var radius := float(marker.get_meta("pick_radius", 1.0))
if d2 > radius * radius:
continue
var t := tca - sqrt(radius * radius - d2)
if t < best_t and t < ground_t + 0.01:
best = int(marker.get_meta("obj_index", -1))
best_t = t
var mi := SelectionMath.marker_mesh(marker)
if mi != null and mi.mesh != null:
var mt := SelectionMath.ray_mesh_distance(mi.global_transform,
SelectionMath.mesh_faces(mi.mesh), origin, dir)
if mt >= 0.0 and mt < exact_t and mt < ground_t + 0.01:
exact = int(marker.get_meta("obj_index", -1))
exact_t = mt
return exact if exact >= 0 else best
# ------------------------------------------------------------ Platzieren ----
func start_placement(oid: String) -> void:
place_oid = oid
placing = true
place_rot_deg = 0.0
place_tilt_x = 0.0
place_tilt_z = 0.0
_place_series = BauKatalog.ist_bau(oid) # U5: Einmal-Modus; Bauteile = Serie (Esc beendet)
_ghost_active = false
_build_last = {}
_build_hit = {}
_build_hint = ""
refresh_terrain_cache() # Cursor trifft das GELÄNDE — Cache muss frisch sein
_ensure_ghost()
_ensure_ghost_visible()
var label: String = main.object_label(oid)
if BauKatalog.ist_bau(oid):
main.call("_set_status", "%s: Maus über die Karte — die Vorschau rastet auf Zelle/Kante ein. Klick setzt, R dreht, Esc beendet. Klick auf die Wandseite ersetzt das Teil (z. B. Tür), Klick auf die Oberkante stapelt." % label)
main.call("_highlight_library", oid)
return
var hinweis := "" if not GlbMeshStore.fehlt(oid) \
else " (Platzhalter — echte GLB fehlt für %s)" % oid
main.call("_set_status", "%s%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, ESC bricht ab (Rechte Maustaste = Flug)." % [label, hinweis])
main.call("_highlight_library", oid)
func cancel_placement() -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
placing = false
place_oid = ""
_place_series = false
_ghost_active = false
_build_last = {}
_build_hit = {}
build_geschoss = -1
main.call("_highlight_library", "")
main.call("_base_tool_status")
## U5 (t_5e3e97c9): Ein Setz-Klick beendet das Platzier-Werkzeug und wählt
## GENAU das neue Objekt aus (Baum-Markierung + Inspektor folgen über
## select_object). Ghost aus, Kachel-Hervorhebung im Assets-Panel aus
## (_highlight_library("")), das Objekt-Werkzeug bleibt das aktive Werkzeug.
## from_tree=true: der Baum wird bereits von _apply_selection_view markiert —
## kein zweites Auswahl-Ereignis (Auswahl-Stabilität, Karten-Test).
## Aufruf VOR der Klartext-Statusmeldung: die letzte Meldung gewinnt.
func finish_placement_and_select(new_index: int) -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
placing = false
place_oid = ""
_place_series = false
_ghost_active = false
main.call("_highlight_library", "")
select_object(new_index, true)
func _ensure_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
return
# Struktur WIE der Viewport-Bauer (map_view_builder._build_objects):
# Root auf dem Fußpunkt, Kind-Mesh um half_height gehoben. So kann Ghost
# und platziertes Objekt nie auseinanderfallen (Karte t_4092e3a0).
_ghost = Node3D.new()
_ghost_mi = MeshInstance3D.new()
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.6, 0.9, 1.0, 0.45)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.emission_enabled = true
mat.emission = Color(0.2, 0.5, 0.8) * 0.4
_ghost_mi.material_override = mat
_ghost.add_child(_ghost_mi)
add_child(_ghost)
_ghost.visible = false
func _ghost_refresh_mesh() -> void:
var lib: Dictionary = main.manifest.get("objects", {})
var entry: Dictionary = lib.get(place_oid, {})
var col: Dictionary = entry.get("collision", {})
# Echtes GLB vorziehen (Karte t_9b50b231) — die Vorschau zeigt das
# Modell, das landet, nicht nur die Kollisionsform. Ohne GLB: Platzhalter.
var mesh := GlbMeshStore.mesh_fuer(place_oid)
if mesh == null:
mesh = MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0)
_ghost_mi.mesh = mesh
# Gleicher Lift wie beim platzierten Objekt: Mesh-Unterseite = Fußpunkt.
_ghost_mi.position.y = MeshShapes.half_height(mesh)
func _ensure_ghost_visible() -> void:
_ghost_refresh_mesh()
_ghost_rotation_anwenden()
_ghost.visible = true
## Ghost-Rotation inkl. Kippen (Karte t_e166e656): Euler (X-Kippen, Y-Drehung,
## Z-Kippen) — derselbe Weg wie beim platzierten Objekt (rot_setzen schreibt
## genau diese drei Werte).
func _ghost_rotation_anwenden() -> void:
if _ghost == null or not is_instance_valid(_ghost):
return
if BauKatalog.ist_bau(place_oid):
_bau_ghost_neu() # Drehung ergibt sich aus Kante/Zelle + R
return
_ghost.rotation_degrees = Vector3(place_tilt_x, place_rot_deg, place_tilt_z)
## Raster-Snap (wenn in main angeschaltet ODER Strg gehalten wird — temporärer
## Snap-Zwang, Karte t_55257792): X/Z auf ganze Meter rasten — Objekte passen
## dann nebeneinander wie Bauklötze. Höhe bleibt Terrain.
func _apply_grid(pos: Vector3) -> Vector3:
if not (bool(main.get("grid_snap")) or _ctrl_held):
return pos
pos.x = ObjectOps.snap_coord(pos.x, true, GRID_M)
pos.z = ObjectOps.snap_coord(pos.z, true, GRID_M)
return pos
## Raster-Snap-Erzwingung fürs Platzieren: Schalter an ODER Strg gehalten.
func _placement_grid_enabled() -> bool:
return bool(main.get("grid_snap")) or _ctrl_held
func _ghost_update(viewport_pos: Vector2) -> void:
if BauKatalog.ist_bau(place_oid):
_bau_ghost_update(viewport_pos)
return
var p: Variant = _ground_point(viewport_pos)
if p == null:
_ghost_active = false
_ghost.visible = false
return
_ghost_active = true
# EIN Pfad für Ghost UND Platzierung (Karte t_4092e3a0): Fußpunkt =
# Terrain-Höhe am gerasteten Punkt. Der sichtbare Lift hängt am Kind-Mesh.
_ghost.position = ObjectOps.placement_foot(
main.doc, p as Vector3, _placement_grid_enabled(), _terrain_half(), true)
_ghost.visible = true
func _place_object(viewport_pos: Vector2, mb_shift_held := false) -> void:
if BauKatalog.ist_bau(place_oid):
_place_bau(viewport_pos)
return
var p: Variant = _ground_point(viewport_pos)
if p == null:
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
return
var half := _terrain_half()
var foot: Vector3 = ObjectOps.placement_foot(
main.doc, p as Vector3, _placement_grid_enabled(), half, false)
if absf(foot.x) > half.x or absf(foot.z) > half.y:
main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true)
return
# Poly-Budget prüfen, BEVOR etwas passiert (hilfreiche Meldung statt Fehler).
# Symmetrie-Modus (M3, t_34908425): der Zwilling kostet Dasselbe nochmal —
# außer das Objekt liegt auf der Mitte/Achse (wird nicht gedoppelt).
var sym_mode: int = _sym_mode()
var sym_center := SymmetryCore.map_center(main.doc)
var sym_factor := 1.0
if sym_mode != SymmetryCore.Mode.OFF \
and not SymmetryCore.maps_to_self(sym_mode, foot, sym_center):
sym_factor = 2.0
var lib: Dictionary = main.manifest.get("objects", {})
var entry: Dictionary = lib.get(place_oid, {})
var poly := float(entry.get("poly", 0))
var used := SvmapDocument.poly_usage(main.doc, main.manifest)
var budget := float(SvmapIO.MAX_TOTAL_POLY)
if used + poly * sym_factor > budget:
main.call("_set_status",
"Kein Platz mehr im Poly-Budget (%s von %s belegt) — lass ein Objekt weg oder drück Strg+Z, um etwas zurückzunehmen."
% [main._fmt_int(int(used)), main._fmt_int(int(budget))], true)
return
var before: Dictionary = main.history.snapshot(["objects"])
# EIN Pfad für Ghost UND Platzierung (Karte t_4092e3a0): derselbe Fußpunkt
# wie die Vorschau — Raster-Snap + Terrain-Höhe am gerasteten Punkt.
var pos_f := [foot.x, foot.y, foot.z]
var neues_obj := {
"id": place_oid,
"pos": pos_f,
"scale": 1.0, # Karte t_e166e656: Scale ist FIX — immer Originalgröße
"rot_y_deg": float(place_rot_deg),
}
# Karte t_e166e656: gekippt platziert → rot-Feld (Euler xyz) in die Datei.
if absf(place_tilt_x) > 0.0001 or absf(place_tilt_z) > 0.0001:
SvmapIO.rot_setzen(neues_obj, Vector3(place_tilt_x, float(place_rot_deg), place_tilt_z))
(main.doc["objects"] as Array).append(neues_obj)
var new_index: int = (main.doc["objects"] as Array).size() - 1
# Symmetrie (M3, t_34908425): der Zwilling wandert in denselben Undo-Schritt
# (ein Commit für Quelle + Zwilling) und bekommt dieselbe sym-Kennung.
var twin_index := -1
var on_axis := false
if sym_mode != SymmetryCore.Mode.OFF:
var symres: Dictionary = ObjectOps.symmetry_place_twin(main.doc, new_index, sym_mode)
twin_index = int(symres.get("twin", -1))
on_axis = bool(symres.get("on_axis", false))
main.history.commit("%s platzieren" % main.object_label(place_oid),
before, main.history.snapshot(["objects"]))
main.call("_after_structure_change")
# U5 (t_5e3e97c9): Shift gehalten = Serienmodus (weiter setzen wie bisher,
# Ghost bleibt an) — sonst beendet das Werkzeug sich selbst und DAS NEUE
# Objekt ist ausgewählt (Baum markiert, Inspektor gefüllt).
if mb_shift_held:
_place_series = true
select_object(new_index, true)
if twin_index >= 0:
main.call("_set_status", "Serienmodus (Shift) — ESC beendet. %s platziert, Zwilling jeweils mitgesetzt; Strg+Z nimmt BEIDE je Schritt zurück. Weitere Klicks setzen erneut." % main.object_label(place_oid))
else:
main.call("_set_status", "Serienmodus (Shift) — ESC beendet. Weitere Klicks setzen erneut; Strg+Z nimmt je Schritt zurück.")
else:
var placed_oid := place_oid # U5: finish leert place_oid — Label vorher sichern
finish_placement_and_select(new_index)
if twin_index >= 0:
main.call("_set_status", "%s gesetzt und ausgewählt — der Spiegel-Zwilling wurde mitgesetzt; Strg+Z nimmt BEIDE zurück. Ziehen verschiebt, R oder Mausrad dreht, Entf löscht." % main.object_label(placed_oid))
elif on_axis:
main.call("_set_status", "%s gesetzt und ausgewählt — liegt auf der Symmetrie-Mitte und wird NICHT gedoppelt. Ziehen verschiebt, R oder Mausrad dreht, Entf löscht." % main.object_label(placed_oid))
else:
main.call("_set_status", "%s gesetzt und ausgewählt. Ziehen verschiebt, R oder Mausrad dreht, Entf löscht, Strg+Z nimmt es zurück." % main.object_label(placed_oid))
# ----------------------------------------------------- Bauen (bau_*) -----
## Strahl → Treffer auf Gelände ODER einem gesetzten Objekt (Bauteile müssen
## sich auf Wänden/Böden stapeln lassen). Rückgabe {punkt, obj (-1 = Gelände), dir}
## oder {} bei Leerlauf.
func _build_ray_hit(viewport_pos: Vector2) -> Dictionary:
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
var ground: Variant = _ground_point(viewport_pos)
if build_geschoss >= 0:
return _build_ray_geschoss(origin, dir, ground)
var best_t := INF
var out := {}
if ground != null:
best_t = (ground as Vector3).distance_to(origin)
out = {"punkt": ground, "obj": -1, "dir": dir}
for marker in view_builder.object_markers:
if marker == null or not is_instance_valid(marker):
continue
var center: Vector3 = marker.global_position + Vector3.UP * float(marker.get_meta("half_height", 1.0))
var to_center := center - origin
var tca := to_center.dot(dir)
if tca <= 0.0:
continue
var radius := float(marker.get_meta("pick_radius", 1.0))
if to_center.length_squared() - tca * tca > radius * radius:
continue
var mi := SelectionMath.marker_mesh(marker)
if mi == null or mi.mesh == null:
continue
var mt := SelectionMath.ray_mesh_distance(mi.global_transform,
SelectionMath.mesh_faces(mi.mesh), origin, dir)
if mt >= 0.0 and mt < best_t:
best_t = mt
out = {"punkt": origin + dir * mt, "obj": int(marker.get_meta("obj_index", -1)), "dir": dir}
return out
## Feste Geschosshöhe: der Strahl trifft die waagerechte Ebene dieses Geschosses,
## egal ob darunter Wand, Boden oder nur Gelände ist (Decke mitten im Raum).
func _build_ray_geschoss(origin: Vector3, dir: Vector3, ground: Variant) -> Dictionary:
var xz := Vector2(origin.x, origin.z)
if ground != null:
xz = Vector2((ground as Vector3).x, (ground as Vector3).z)
var y := _bau_geschoss_hoehe(xz)
if absf(dir.y) < 0.001:
return {}
var t := (y - origin.y) / dir.y
if t <= 0.0:
return {}
return {"punkt": origin + dir * t, "obj": -1, "dir": dir, "ebene": y}
func _bau_gelaende_fn() -> Callable:
return func(x: float, z: float) -> float:
if _res >= 2 and _heights.size() >= _res * _res:
return TerrainField.height_at(_heights, _res, _sx, _sz, x, z)
return float(main.doc.get("terrain", {}).get("min_height_m", 0.0))
func _bau_geschoss_hoehe(xz: Vector2) -> float:
var basis := BuildSnap.geschoss_basis(BuildSnap.sammle_teile(main.doc), xz, _bau_gelaende_fn())
return basis + float(build_geschoss) * BauKatalog.WAND_H
## Bild↑/Bild↓ bzw. Knöpfe im Bauen-Tab: Geschoss wechseln (0 → zurück auf Automatik).
func bau_geschoss_aendern(delta: int) -> void:
var neu := build_geschoss
if delta > 0:
neu = 1 if build_geschoss < 0 else mini(build_geschoss + 1, BAU_GESCHOSS_MAX)
elif build_geschoss >= 0:
neu = build_geschoss - 1 # 0 → -1 (Automatik)
build_geschoss = neu
_build_hint = ""
if placing and not _build_hit.is_empty():
_bau_ghost_neu()
main.call("_set_status", bau_geschoss_text())
main.call("_refresh_bau_info")
func bau_geschoss_text() -> String:
if build_geschoss < 0:
return "Höhe: automatisch (Gelände bzw. Oberkante des Teils darunter). Bild↑ wählt ein festes Geschoss — dann sitzen Böden/Decken überall im Raum."
return "Höhe: festes Geschoss %d (%s). Bild↑/Bild↓ wechselt, bis „automatisch“ erreicht ist." % [
build_geschoss, "Erdgeschoss-Boden" if build_geschoss == 0 else "%d × %d m über dem Boden" % [build_geschoss, int(BauKatalog.WAND_H)]]
func _bau_ghost_update(viewport_pos: Vector2) -> void:
_build_hit = _build_ray_hit(viewport_pos)
if _build_hit.is_empty():
_ghost_active = false
_ghost.visible = false
_build_last = {}
return
_bau_ghost_neu()
## Snap neu berechnen (Mausbewegung ODER R-Taste) und den Ghost setzen.
func _bau_snap() -> Dictionary:
if _build_hit.is_empty():
return {}
return BuildSnap.berechne(place_oid, _build_hit["punkt"] as Vector3, place_rot_deg,
BuildSnap.sammle_teile(main.doc), _bau_gelaende_fn(),
int(_build_hit.get("obj", -1)) >= 0, _build_hit.get("dir", Vector3.ZERO) as Vector3,
float(_build_hit.get("ebene", NAN)))
func _bau_ghost_neu() -> void:
_build_last = _bau_snap()
if _build_last.is_empty():
return
var pos: Vector3 = _build_last["pos"]
var half := _terrain_half()
var innerhalb := absf(pos.x) <= half.x and absf(pos.z) <= half.y
_ghost.position = pos
_ghost.rotation_degrees = Vector3(0.0, float(_build_last["rot"]), 0.0)
_ghost_active = true
_ghost.visible = true
var mat := _ghost_mi.material_override as StandardMaterial3D
var hinweis := ""
if not bool(_build_last["ok"]) or not innerhalb:
mat.albedo_color = Color(1.0, 0.25, 0.2, 0.5)
hinweis = str(_build_last["grund"]) if not bool(_build_last["ok"]) else "Außerhalb der Map."
elif int(_build_last["ersetze"]) >= 0:
mat.albedo_color = Color(1.0, 0.7, 0.2, 0.55)
hinweis = "Klick ersetzt das vorhandene Teil durch %s (Strg+Z holt es zurück)." % main.object_label(place_oid)
else:
mat.albedo_color = Color(0.6, 0.9, 1.0, 0.45)
if hinweis != _build_hint:
_build_hint = hinweis
if not hinweis.is_empty():
main.call("_set_status", hinweis)
## Klick im Bauen-Modus: Teil setzen/ersetzen — ein Undo-Schritt, Teil landet im
## laufenden Gebäude (Gruppe), Serie bleibt aktiv (Esc beendet).
func _place_bau(viewport_pos: Vector2) -> void:
_build_hit = _build_ray_hit(viewport_pos)
if _build_hit.is_empty():
main.call("_set_status", "Da war nichts unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
return
var snap := _bau_snap()
if snap.is_empty():
return
var pos: Vector3 = snap["pos"]
var half := _terrain_half()
if absf(pos.x) > half.x or absf(pos.z) > half.y:
main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true)
return
if not bool(snap["ok"]):
main.call("_set_status", str(snap["grund"]), true)
return
var objects: Array = main.doc["objects"]
var ersetzt := int(snap["ersetze"]) >= 0
var lib: Dictionary = main.manifest.get("objects", {})
var poly := float((lib.get(place_oid, {}) as Dictionary).get("poly", 0))
var sym_mode: int = _sym_mode()
var sym_factor := 1.0
if sym_mode != SymmetryCore.Mode.OFF and not SymmetryCore.maps_to_self(
sym_mode, pos, SymmetryCore.map_center(main.doc)):
sym_factor = 2.0
if not ersetzt and objects.size() + int(sym_factor) > SvmapIO.MAX_OBJECTS:
main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — das Löschen von Objekten macht wieder Platz." % SvmapIO.MAX_OBJECTS, true)
return
var used := SvmapDocument.poly_usage(main.doc, main.manifest)
var budget := float(SvmapIO.MAX_TOTAL_POLY)
if used + poly * sym_factor > budget:
main.call("_set_status",
"Kein Platz mehr im Poly-Budget (%s von %s belegt) — lass ein Teil weg oder drück Strg+Z."
% [main._fmt_int(int(used)), main._fmt_int(int(budget))], true)
return
var keys := ["objects", "group_names"]
var before: Dictionary = main.history.snapshot(keys)
if not before.has("group_names"):
before["group_names"] = null # Undo entfernt die neu angelegten Gebäude-Namen wieder
var label: String = main.object_label(place_oid)
var gid := _bau_gruppe()
var spiegel := -1
if sym_mode != SymmetryCore.Mode.OFF and sym_factor > 1.0:
spiegel = _bau_spiegel_gruppe(gid)
BauOps.setze_teil(main.doc, place_oid, snap, gid, sym_mode, spiegel)
main.history.commit("%s bauen" % label if not ersetzt else "%s einsetzen" % label,
before, main.history.snapshot(keys))
main.call("_after_structure_change")
_build_hit = _build_ray_hit(viewport_pos)
_bau_ghost_neu()
main.call("_set_status", "%s %s (%s, %d Teile). Weiter klicken — R dreht, Esc beendet, Strg+Z nimmt das letzte Teil zurück." % [
label, "eingesetzt" if ersetzt else "gesetzt", GroupState.display_name(main.doc, gid),
GroupOps.members_of(main.doc, gid).size()])
main.call("_refresh_bau_info")
## Laufendes Gebäude (Gruppe): bestehende Gruppe weiterführen oder neue anlegen
## und benennen. Rückgabe: Gruppen-ID.
func _bau_gruppe() -> int:
if build_group_id >= 0 and not GroupOps.members_of(main.doc, build_group_id).is_empty():
return build_group_id
build_group_id = BauOps.gruppe_neu(main.doc)
return build_group_id
## Gruppe für den Spiegel-Zwilling des laufenden Gebäudes (1 Gebäude ↔ 1 Spiegel).
var _bau_spiegel: Dictionary = {} # laufende ID → Spiegel-ID
func _bau_spiegel_gruppe(gid: int) -> int:
var sp := int(_bau_spiegel.get(gid, -1))
if sp >= 0 and not GroupOps.members_of(main.doc, sp).is_empty():
return sp
sp = maxi(GroupOps.next_group_id(main.doc), gid + 1)
GroupState.set_group_name(main.doc, sp, "%s (Spiegel)" % GroupState.display_name(main.doc, gid))
_bau_spiegel[gid] = sp
return sp
## Nächstes Teil beginnt ein NEUES Gebäude (Button „Neues Gebäude“).
func neues_gebaeude() -> void:
build_group_id = -1
_bau_spiegel.clear()
## Demo-Hook: Ghost an einem Punkt parken (Screenshots ohne Maus).
func demo_park_bau(punkt: Vector3) -> void:
_build_hit = {"punkt": punkt, "obj": -1, "dir": Vector3.ZERO}
_bau_ghost_neu()
# --------------------------------------------------------------- Auswahl ----
## Einzel-Auswahl (Klick ins Objekt / Klick im Baum): ersetzt die Auswahl.
func select_object(idx: int, from_tree := false) -> void:
selected_indices = [idx] if idx >= 0 else []
selected_index = idx
_apply_selection_view()
if not from_tree:
main.call("_mark_tree_selection", idx)
main.call("_selection_status", idx)
## Mehrere auf einmal (Rahmen-Ausziehen, Demo) — Reihenfolge bleibt erhalten.
func select_many(indices: Array, silent := false) -> void:
var objects: Array = main.doc.get("objects", [])
selected_indices = []
for i in indices:
if i is int and i >= 0 and i < objects.size():
selected_indices.append(i)
selected_index = selected_indices.back() if not selected_indices.is_empty() else -1
_apply_selection_view()
if not silent:
main.call("_selection_status", selected_index)
## Umschalt+Klick: Objekt zur Auswahl HINZUFÜGEN (oder wieder rausnehmen).
func select_toggle(idx: int) -> void:
var objects: Array = main.doc.get("objects", [])
if idx < 0 or idx >= objects.size():
return
var label: String = main.object_label(str(objects[idx].get("id", "")))
if selected_indices.has(idx):
selected_indices.erase(idx)
if selected_index == idx:
selected_index = selected_indices.back() if not selected_indices.is_empty() else -1
main.call("_set_status", "%s aus der Auswahl genommen — noch %d Objekte markiert. Umschalt-/Strg+Klick fügt wieder hinzu." % [label, selected_indices.size()])
else:
selected_indices.append(idx)
selected_index = idx
main.call("_set_status", "%s zur Auswahl dazugeholt — jetzt %d Objekte. Mehr umschalten (Umschalt-/Strg+Klick), Rahmen aufziehen oder Strg+G zum Gruppieren." % [label, selected_indices.size()])
_apply_selection_view()
main.call("_sync_selection_panel")
func clear_selection(silent := false) -> void:
if selected_index != -1 or not selected_indices.is_empty():
selected_indices = []
selected_index = -1
view_builder.call("set_selected_multi", [])
if not silent:
main.call("_set_status", "Auswahl aufgehoben.")
main.call("_sync_selection_panel")
## Auswahl-Anzeige (Viewport-Färbung + Baum + Panel) aus dem aktuellen Stand.
func _apply_selection_view() -> void:
view_builder.call("set_selected_multi", selected_indices)
# Baum GANZ synchron (Karte t_c46d31c2): auch Abwählen per Strg+Klick im
# Viewport entfernt die Baum-Markierung wieder.
main.call("_mark_tree_selection_multi", selected_indices)
if selected_index >= 0:
main.call("_mark_tree_selection", selected_index)
## Ausgewählte Objekte zu EINER Gruppe machen (main-Button/Strg+G).
func group_selection() -> void:
var objects: Array = main.doc.get("objects", [])
var valid: Array = []
for i in selected_indices:
if i is int and i >= 0 and i < objects.size():
valid.append(i)
if valid.size() < 2:
main.call("_set_status", "Eine Gruppe braucht mindestens 2 Objekte — markiere zuerst mehrere (Umschalt+Klick oder Rahmen aufziehen).", true)
return
var before: Dictionary = main.history.snapshot(["objects"])
var gid := GroupOps.group(main.doc, valid)
main.history.commit("Auswahl gruppieren (%d Objekte → Gruppe %d)" % [valid.size(), gid + 1],
before, main.history.snapshot(["objects"]))
_mark_dirty()
main.call("_refresh_history_buttons")
_apply_selection_view()
main.call("_sync_selection_panel")
main.call("_set_status", "Gruppe %d gebaut: %d Objekte ziehen und drehen jetzt GEMEINSAM. „Gruppe lösen“ (Strg+Umschalt+G) macht es rückgängig — die Objekte bleiben, nur die Bindung löst sich." % [gid + 1, valid.size()])
## Gruppe(n) der Auswahl komplett lösen (main-Button/Strg+Umschalt+G).
func ungroup_selection() -> void:
var groups: Array = GroupOps.groups_of(main.doc, selected_indices)
if groups.is_empty():
main.call("_set_status", "In der Auswahl steckt keine Gruppe — markiere zuerst ein Objekt aus einer Gruppe.", true)
return
var before: Dictionary = main.history.snapshot(["objects"])
var freed := GroupOps.ungroup(main.doc, selected_indices)
main.history.commit("Gruppe gelöst (%d Objekte)" % freed,
before, main.history.snapshot(["objects"]))
_mark_dirty()
main.call("_refresh_history_buttons")
_apply_selection_view()
main.call("_sync_selection_panel")
main.call("_set_status", "Gruppe gelöst: %d Objekte sind wieder einzeln verschiebbar — Strg+Z baut die Gruppe wieder zusammen." % freed)
# ---------------------------------------------------- Drehen / Löschen ------
## Auswahl drehen (R / Mausrad / Button): bei mehreren Objekten oder einer
## Gruppe rotiert ALLES gemeinsam um den Gruppen-Mittelpunkt. Mausrad-Ticks
## derselben Auswahl laufen zu EINEM Undo-Schritt zusammen (merge_tag).
func rotate_selected(delta_deg: float, merge_suffix: String = "") -> void:
if selected_index < 0 or selected_indices.is_empty():
return
var objects: Array = main.doc.get("objects", [])
if selected_index >= objects.size():
return
var members := selected_indices.duplicate()
# Einzelnes Objekt, das in einer Gruppe steckt → die GANZE Gruppe drehen
# (Sly-Erwartung: die Gruppe dreht gemeinsam) und sichtbar markieren.
if members.size() == 1:
var gid := GroupOps.group_id(objects[members[0]])
if gid != GroupOps.UNGROUPED:
members = GroupOps.members_of(main.doc, gid)
select_many(members, true)
main.call("_sync_selection_panel")
var before: Dictionary = main.history.snapshot(["objects"])
GroupOps.rotate_members(main.doc, members, delta_deg)
_sync_members_to_markers(members)
# Symmetrie (M3, t_34908425): Zwillinge der gedrehten Auswahl auf den exakten
# Spiegel setzen (Punkt: +180°-Basis, Achse: Vorzeichen) — IM SELBEN Commit.
var twins: Array = ObjectOps.symmetry_sync_pairs(
main.doc, members, _sym_mode(), selected_index)
_sync_members_to_markers(twins)
var tag := "rotmulti:%s%s" % [str(members), merge_suffix]
main.history.commit("Auswahl gedreht (%d Objekte)" % members.size(),
before, main.history.snapshot(["objects"]), tag)
_mark_dirty()
main.call("_refresh_history_buttons")
main.call("_refresh_stats")
main.call("_sync_selection_panel")
## Auswahl KIPPEN (Karte t_e166e656 — Sly: „nicht nur rotieren, sondern auch
## kippen in sämtliche Achsen“). axis_x = Kippen um die X-Achse (vor/zurück
## neigen), sonst um die Z-Achse (links/rechts neigen). Ein Einzelobjekt in
## einer Gruppe kippt die GANZE Gruppe (wie beim Drehen), Mehrfachauswahl
## kippt jedes Objekt um denselben Betrag, Symmetrie-Zwillinge laufen im
## SELBEN Commit mit. rot/rot_y_deg bleiben über SvmapIO.rot_setzen synchron.
func tilt_selected(axis_x: bool, delta_deg: float, merge_suffix: String = "") -> void:
if selected_index < 0 or selected_indices.is_empty():
return
var objects: Array = main.doc.get("objects", [])
if selected_index >= objects.size():
return
var members := selected_indices.duplicate()
if members.size() == 1:
var gid := GroupOps.group_id(objects[members[0]])
if gid != GroupOps.UNGROUPED:
members = GroupOps.members_of(main.doc, gid)
select_many(members, true)
main.call("_sync_selection_panel")
var before: Dictionary = main.history.snapshot(["objects"])
var axis := "X" if axis_x else "Z"
for i in members:
var obj: Dictionary = objects[i]
var euler := SvmapIO.objekt_rotation(obj)
var neu := Vector3(euler.x, euler.y, euler.z)
if axis_x:
neu.x = fposmod(euler.x + delta_deg + 3600.0, 360.0)
else:
neu.z = fposmod(euler.z + delta_deg + 3600.0, 360.0)
SvmapIO.rot_setzen(obj, neu)
_sync_members_to_markers(members)
var twins: Array = ObjectOps.symmetry_sync_pairs(
main.doc, members, _sym_mode(), selected_index)
_sync_members_to_markers(twins)
var tag := ("tilt:%d" % selected_index if members.size() == 1 \
else "tiltmulti:%s" % str(members)) + merge_suffix
main.history.commit("Auswahl gekippt (%s, %d Objekte)" % [axis, members.size()],
before, main.history.snapshot(["objects"]), tag)
_mark_dirty()
main.call("_refresh_history_buttons")
main.call("_refresh_stats")
main.call("_sync_selection_panel")
main.call("_set_status", "Auswahl um %s%s gekippt — Inspektor zeigt die Werte; Strg+Z nimmt den Schritt zurück." % [
("+%.0f°" % delta_deg) if delta_deg > 0.0 else ("%.0f°" % delta_deg), axis])
## Nach Doc-Änderung die betroffenen Marker positionieren/drehen (kein Rebuild).
func _sync_members_to_markers(members: Array) -> void:
var objects: Array = main.doc.get("objects", [])
for i in members:
if i >= view_builder.object_markers.size():
continue
var marker: Node3D = view_builder.object_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])
marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
# Karte t_e166e656: volle 3-Achsen-Rotation (rot-Feld oder Fallback rot_y_deg).
marker.rotation_degrees = SvmapIO.objekt_rotation(objects[i] as Dictionary)
func delete_selected() -> void:
if selected_indices.is_empty() or selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das gelöscht werden soll.", true)
return
var objects: Array = main.doc.get("objects", [])
var doomed: Array = []
for i in selected_indices:
if i is int and i >= 0 and i < objects.size():
doomed.append(i)
if doomed.is_empty():
clear_selection(true)
return
# Symmetrie (M3, t_34908425): die Partner der Auswahl löschen sich MIT —
# ein Paar lebt oder stirbt zusammen, IM SELBEN Undo-Schritt.
doomed = ObjectOps.symmetry_expand_delete(main.doc, doomed, _sym_mode())
var before: Dictionary = main.history.snapshot(["objects", "group_names"])
if not before.has("group_names"):
before["group_names"] = null
var label: String
if doomed.size() == 1:
label = "%s löschen" % main.object_label(str(objects[doomed[0]].get("id", "")))
else:
label = "%d Objekte löschen" % doomed.size()
# Rückwärts löschen, damit die Indizes währenddessen stimmen.
doomed.sort()
doomed.reverse()
for i in doomed:
objects.remove_at(i)
GroupState.prune_orphans(main.doc) # Gebäude ohne Teile verlieren ihren Namen
main.history.commit(label, before, main.history.snapshot(["objects", "group_names"]))
clear_selection(true)
main.call("_after_structure_change")
main.call("_set_status", "%s ist erledigt — Strg+Z (Rückgängig) holt %s zurück." % [
label, "alles" if doomed.size() > 1 else "es"])
# ------------------------------------------ Duplizieren/Höhe/Spiegeln ------
## (Karte t_7567a06f — alle drei auch bei Sly bedienbar: Button rechts +
## Tastenkürzel, alles über ActionHistory, Klartext-Status-Hilfe.)
## Strg+D / Button "Duplizieren" (Karte t_c46d31c2): JEDE Kopie rastet +2 m in
## X versetzt ein (Füßpunkt-Höhe unverändert, mit Raster-Snap eingerastet, wenn
## an) — bei Mehrfachauswahl werden ALLE Objekte kopiert, EIN Undo-Schritt für
## alle Kopien, die Kopien sind direkt als neue Auswahl angewählt. Poly-Budget
## und Objekt-Limit werden VOR dem Kopieren gesammelt geprüft.
func duplicate_selected() -> void:
var objects: Array = main.doc.get("objects", [])
var sources: Array = []
for i in selected_indices:
if i is int and i >= 0 and i < objects.size():
sources.append(i)
if sources.is_empty():
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du kopieren willst.", true)
return
if objects.size() + sources.size() > SvmapIO.MAX_OBJECTS \
and _sym_mode() == SymmetryCore.Mode.OFF:
main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — das Löschen von Objekten macht wieder Platz." % SvmapIO.MAX_OBJECTS, true)
return
var lib: Dictionary = main.manifest.get("objects", {})
var sym_mode: int = _sym_mode()
var sym_center := SymmetryCore.map_center(main.doc)
var twins_planned := 0
var poly_sum := 0.0
for i in sources:
var src_obj: Dictionary = objects[i]
var scl := float(src_obj.get("scale", 1.0))
var entry: Dictionary = lib.get(str(src_obj.get("id", "")), {})
poly_sum += float(entry.get("poly", 0)) * scl * scl
# Symmetrie (M3, t_34908425): JEDE Kopie bekommt einen Zwilling (außer
# auf der Mitte/Achse) — Budget und Objekt-Limit VORAB mitrechnen.
if sym_mode != SymmetryCore.Mode.OFF:
var spos: Array = src_obj.get("pos", [0.0, 0.0, 0.0])
if (spos as Array).size() == 3 and not SymmetryCore.maps_to_self(
sym_mode, Vector3(float(spos[0]), float(spos[1]), float(spos[2])), sym_center):
twins_planned += 1
poly_sum += float(entry.get("poly", 0)) * scl * scl
if twins_planned > 0 and objects.size() + sources.size() + twins_planned > SvmapIO.MAX_OBJECTS:
main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — mit Symmetrie-Zwilllingen bräuchte es %d. Das Löschen von Objekten macht wieder Platz." % [SvmapIO.MAX_OBJECTS, objects.size() + sources.size() + twins_planned], true)
return
var used := SvmapDocument.poly_usage(main.doc, main.manifest)
var budget := float(SvmapIO.MAX_TOTAL_POLY)
if used + poly_sum > budget:
main.call("_set_status", "Für %s fehlt das Poly-Budget (%s von %s belegt) — Strg+Z nimmt etwas zurück, oder lösche ein anderes Objekt." % [
"die Kopien" if sources.size() > 1 else "eine Kopie",
main._fmt_int(int(used)), main._fmt_int(int(budget))], true)
return
var before: Dictionary = main.history.snapshot(["objects", "group_names"])
if not before.has("group_names"):
before["group_names"] = null # Undo entfernt die Namen der Kopie-Gruppen wieder
# Gebäude (Bauteile): Kopie rückt um die Gebäudebreite weg statt 2 m — sonst
# läge sie halb im Original.
var offset_x := ObjectOps.DUPLICATE_OFFSET_M.x
var bau_min := INF
var bau_max := -INF
for i in sources:
var so: Dictionary = objects[i]
if BauKatalog.ist_bau(str(so.get("id", ""))):
var spx := float((so.get("pos", [0.0, 0.0, 0.0]) as Array)[0])
bau_min = minf(bau_min, spx)
bau_max = maxf(bau_max, spx)
if bau_min < INF:
offset_x = maxf(offset_x, ceilf((bau_max - bau_min + 4.0) / 2.0) * 2.0)
var new_indices: Array = ObjectOps.duplicate_objects(main.doc, sources, bool(main.get("grid_snap")), offset_x)
if new_indices.is_empty():
clear_selection(true)
return
# Symmetrie (M3, t_34908425): jede Kopie bekommt ihren Zwilling (exakter
# Spiegel der KOPIE, neue sym-Kennung) — im selben Commit wie die Kopien.
var sym_twins: Array = []
if sym_mode != SymmetryCore.Mode.OFF:
for c in new_indices:
if c is int:
var sres: Dictionary = ObjectOps.symmetry_place_twin(main.doc, c, sym_mode)
if int(sres.get("twin", -1)) >= 0:
sym_twins.append(int(sres["twin"]))
var label: String
if new_indices.size() == 1:
label = "%s duplizieren" % main.object_label(str((objects[sources[0]] as Dictionary).get("id", "")))
else:
label = "%d Objekte duplizieren" % new_indices.size()
main.history.commit(label, before, main.history.snapshot(["objects", "group_names"]))
main.call("_after_structure_change")
# Kopien direkt anwählen — Sly kann sie gleich weiterziehen. Die frischen
# Zwillinge wandern mit in die Auswahl (beide Seiten sichtbar markiert).
var selected_new: Array = new_indices.duplicate()
selected_new.append_array(sym_twins)
select_many(selected_new)
if sym_twins.is_empty():
main.call("_set_status", "%s: die Kopie(n) liegen +2 m versetzt und sind direkt angewählt — Ziehen verschiebt, Entf löscht, Strg+Z nimmt ALLE Kopien auf einmal zurück." % label)
else:
main.call("_set_status", "%s: %d Kopie(n) + %d Zwilling(e) sind direkt angewählt — jede Kopie bildet mit ihrem Zwilling ein neues Paar. Strg+Z nimmt ALLE auf einmal zurück." % [label, new_indices.size(), sym_twins.size()])
## ▲/▼ / Strg+Bild↑/↓: AUSWAHL in 0,25-m-Schritten anheben/absenken
## (Karte t_55257792: die GANZE Auswahl, wie Entf/Strg+D auch).
## Mehrere Klicks in Serie = EIN Undo-Schritt (Merge-Tag, wie beim Drehen).
## Absenken stoppt am Terrain (nichts wird begraben), Anheben an der
## Match-Grenze (bounds.max) — Regeln in ObjectOps.lift_object.
func lift_selected(delta_m: float) -> void:
if selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du anheben oder absenken willst.", true)
return
if placing:
return
var objects: Array = main.doc.get("objects", [])
if selected_index >= objects.size():
clear_selection(true)
return
var members := _lift_members()
var before: Dictionary = main.history.snapshot(["objects"])
var applied_sum := 0.0
for i in members:
applied_sum += ObjectOps.lift_object(main.doc, i, delta_m)
if absf(applied_sum) < 0.0001:
# Nichts passiert (Snapshot verwerfen): hilfsbereit erklären, WO die Grenze ist.
var ground: String
if delta_m > 0.0:
ground = "Die Match-Höhe ist erreicht — höher passt der Server nicht mehr."
else:
ground = "Die untere Match-Grenze ist erreicht — tiefer passt der Server nicht mehr (Versenken im Boden ist erlaubt, Karte t_e166e656)."
main.call("_set_status", ground)
return
var after: Dictionary = main.history.snapshot(["objects"])
var tag := "lift:%d" % selected_index if members.size() == 1 \
else "liftmulti:%s" % str(members)
main.history.commit(_lift_label(members, delta_m, applied_sum),
before, after, tag)
for i in members:
if i >= view_builder.object_markers.size():
continue
var marker: Node3D = view_builder.object_markers[i]
if marker != null and is_instance_valid(marker):
marker.position.y = float((objects[i]["pos"] as Array)[1])
main.dirty = true
main.call("_refresh_file_label")
main.call("_refresh_stats")
main.call("_refresh_history_buttons")
main.call("_sync_selection_panel")
# Karte t_e166e656: Versenk-Tiefe sichtbar machen (Stein halb im Boden).
var sel_pos: Array = (objects[selected_index]["pos"] as Array)
var boden := SvmapDocument.terrain_height_at(main.doc, float(sel_pos[0]), float(sel_pos[2]))
var tiefe := boden - float(sel_pos[1])
var versenk := ""
if tiefe > 0.01:
versenk = " (%.2f m versenkt)" % tiefe
main.call("_set_status", "%s auf %.2f m%s — mehrmals drücken stapelt weiter; Strg+Z nimmt alle Stufen auf einmal zurück." % [
_lift_label(members, delta_m, applied_sum), float(sel_pos[1]), versenk])
## Heben/Absenken wirkt auf die GANZE Auswahl (Karte t_55257792) — ein einzelnes
## Objekt, das in einer Gruppe steckt, zieht die ganze Gruppe mit (wie Drehen).
func _lift_members() -> Array:
var members := selected_indices.duplicate()
if members.size() == 1:
var objects: Array = main.doc.get("objects", [])
var gid := GroupOps.group_id(objects[members[0]])
if gid != GroupOps.UNGROUPED:
members = GroupOps.members_of(main.doc, gid)
select_many(members, true)
main.call("_sync_selection_panel")
return members
func _lift_label(members: Array, delta_m: float, applied_sum: float) -> String:
var dir := "angehoben" if delta_m > 0.0 else "abgesenkt"
if members.size() == 1:
return "Objekt %s (%s)" % [dir, "%.2f m" % applied_sum]
return "%d Objekte %s (%.2f m)" % [members.size(), dir, applied_sum]
## Strg+Bild↑ (Karte t_55257792): die AUSWAHL direkt auf das Terrain/Boden
## absetzen (Godot "Snap Object to Floor"-Äquivalent fürs Fußpunkt-Modell).
## EIN Undo-Schritt; steht alles schon am Boden → Meldung statt leerem Commit.
func snap_selection_to_floor() -> void:
if selected_indices.is_empty() or selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — Strg+Bild↑ setzt die Auswahl auf den Boden. Klicke zuerst ein Objekt an.", true)
return
if placing:
return
var members := _lift_members()
var before: Dictionary = main.history.snapshot(["objects"])
var changed := ObjectOps.snap_selection_to_floor(main.doc, members)
if changed == 0:
main.call("_set_status", "Steht schon alles auf dem Boden — nichts zu setzen.")
return
main.history.commit("Auswahl auf Boden gesetzt (%d Objekte)" % changed,
before, main.history.snapshot(["objects"]))
for i in members:
if i >= view_builder.object_markers.size():
continue
var marker: Node3D = view_builder.object_markers[i]
if marker != null and is_instance_valid(marker):
var pos: Array = ((main.doc.get("objects", []) as Array)[i] as Dictionary).get("pos", [0.0, 0.0, 0.0])
marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
_mark_dirty()
main.call("_refresh_stats")
main.call("_refresh_history_buttons")
main.call("_sync_selection_panel")
main.call("_set_status", "%d Objekt(e) auf den Boden gesetzt — Strg+Z nimmt es zurück." % changed)
## Pfeiltasten (Karte t_55257792): Auswahl im 2-m-Raster verschieben — EINE
## Zelle pro Tastendruck, Richtung = Kamera-Ausrichtung nächste Weltachse
## (MoveMath). Fußpunkt bleibt am Raster, Höhe wandert mit (t_7567a06f).
## EIN Undo-Schritt pro Tastendruck.
func nudge_selection(delta: Vector3) -> void:
if selected_indices.is_empty() or selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — Pfeiltasten verschieben die Auswahl im 2-m-Raster. Klicke zuerst ein Objekt an.")
return
if placing:
return
var members := selected_indices.duplicate()
var objects: Array = main.doc.get("objects", [])
if members.size() == 1:
var gid := GroupOps.group_id(objects[members[0]])
if gid != GroupOps.UNGROUPED:
members = GroupOps.members_of(main.doc, gid)
var before: Dictionary = main.history.snapshot(["objects"])
var changed := ObjectOps.nudge_objects(main.doc, members, delta)
if changed == 0:
return # am Map-Rand geklemmt — Snapshot verwerfen, kein Commit
_sync_members_to_markers(members)
# Symmetrie (M3, t_34908425): Zwillinge mitführen — exakter Spiegel der
# verschobenen Quelle, im selben Commit (Pfeiltasten = 1 Undo-Schritt).
var twins: Array = ObjectOps.symmetry_sync_pairs(
main.doc, members, _sym_mode())
_sync_members_to_markers(twins)
var desc := "Objekt verschoben (Pfeiltaste)" if members.size() == 1 \
else "%d Objekte verschoben (Pfeiltasten)" % members.size()
main.history.commit(desc, before, main.history.snapshot(["objects"]))
_mark_dirty()
main.call("_refresh_history_buttons")
main.call("_refresh_stats")
main.call("_sync_selection_panel")
main.call("_set_status", "%s — 2 m pro Tastendruck. Greifen (G) setzt frei, Strg+Bild↑ setzt auf den Boden." % desc)
# ------------------------------------------------------------ Greifen (G) ----
## Blender-Semantik (Karte t_55257792): G hängt die Auswahl an die Maus,
## Linksklick setzt die neue Position (EIN Undo-Schritt, Raster-Snap wie
## eingestellt bzw. Strg-Zwang), Esc bricht ab und setzt die EXAKTEN alten
## Positionen zurück. Die Auswahl wandert GEMEINSAM (Gruppe/Mehrfachauswahl),
## relative Anordnung bleibt exakt erhalten, Terrain-Abstand je Objekt bleibt.
## Greifen starten. false = nichts ausgewählt (Meldung steht dann).
func grab_begin() -> bool:
if _grabbing:
return true
if placing:
return false
var objects: Array = main.doc.get("objects", [])
var members := selected_indices.duplicate()
if members.size() == 1:
var gid := GroupOps.group_id(objects[members[0]])
if gid != GroupOps.UNGROUPED:
members = GroupOps.members_of(main.doc, gid)
select_many(members, true)
main.call("_sync_selection_panel")
if members.is_empty() or selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — G greift die Auswahl, damit du sie mit der Maus frei setzen kannst. Klicke zuerst ein Objekt an.")
return false
_grab_members = members
_grab_primary = selected_index
_grab_originals = {}
_grab_lifts = {}
for i in members:
var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0])
_grab_originals[i] = [float(pos[0]), float(pos[1]), float(pos[2])]
_grab_lifts[i] = float(pos[1]) - SvmapDocument.terrain_height_at(
main.doc, float(pos[0]), float(pos[2]))
# Symmetrie (M3, t_34908425): Zwillinge der Greif-Menge merken — sie ziehen
# live mit (exakter Spiegel) und Esc setzt sie EXAKT zurück.
_grab_twin_map = {}
_grab_twin_originals = {}
if _sym_mode() != SymmetryCore.Mode.OFF:
for i in members:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var sym_key := SymmetryCore.sym_of(objects[i] as Dictionary)
if sym_key.is_empty():
continue
var t := SymmetryCore.find_partner_index(objects, sym_key, i)
if t < 0:
continue
_grab_twin_map[i] = t
if not members.has(t) and not _grab_twin_originals.has(t):
var tpos: Array = (objects[t] as Dictionary).get("pos", [0.0, 0.0, 0.0])
if (tpos as Array).size() == 3:
_grab_twin_originals[t] = [float(tpos[0]), float(tpos[1]), float(tpos[2])]
_grabbing = true
_grab_before = main.history.snapshot(["objects"])
main.call("_set_status", "GREIFEN an: die Auswahl hängt an der Maus — Linksklick setzt sie neu (mit Raster-Snap), Esc bricht ab und setzt zurück.")
return true
## Auswahl live zur Cursor-Bodenposition ziehen (Maus-Bewegung ODER Demo-Hook
## mit Weltpunkt). Der Primär-Anker geht EXAKT zum Punkt (rastet, wenn Snap
## an bzw. Strg), die Mitglieder wandern mit demselben Delta mit.
func grab_move(hit: Vector3) -> void:
if not _grabbing:
return
var target := _apply_grid(hit)
var half := _terrain_half()
var primary_pos: Array = _grab_originals[_grab_primary]
var delta := Vector3(target.x - float(primary_pos[0]), 0.0,
target.z - float(primary_pos[2]))
var objects: Array = main.doc.get("objects", [])
for i in _grab_members:
var orig: Array = _grab_originals[i]
var nx := clampf(float(orig[0]) + delta.x, -half.x, half.x)
var nz := clampf(float(orig[2]) + delta.z, -half.y, half.y)
var ny := SvmapDocument.terrain_height_at(main.doc, nx, nz) + float(_grab_lifts[i])
(objects[i] as Dictionary)["pos"] = [nx, ny, nz]
if i < view_builder.object_markers.size():
var marker: Node3D = view_builder.object_markers[i]
if marker != null and is_instance_valid(marker):
marker.position = Vector3(nx, ny, nz)
# Symmetrie (M3, t_34908425): Zwillinge live mitziehen — doc + Marker,
# exakter Spiegel der gegriffenen Quelle.
_sym_grab_twin_move()
## Linksklick im Greifen: Positionen festschreiben — EIN Undo-Schritt.
func grab_confirm() -> void:
if not _grabbing:
return
_grabbing = false
var desc := "Objekt verschoben (greifen)" if _grab_members.size() == 1 \
else "%d Objekte verschoben (greifen)" % _grab_members.size()
main.history.commit(desc, _grab_before, main.history.snapshot(["objects"]))
_grab_members = []
_grab_originals = {}
_grab_lifts = {}
_grab_before = {}
_grab_twin_map = {}
_grab_twin_originals = {}
_mark_dirty()
main.call("_refresh_history_buttons")
main.call("_refresh_stats")
main.call("_sync_selection_panel")
main.call("_set_status", "%s — Strg+Z nimmt den ganzen Zug zurück. G greift erneut, Esc hebt immer ab." % desc)
## Esc im Greifen: EXAKTE Rückabwicklung auf die Positionen von grab_begin —
## byte-identisch (keine Float-Re-Rechnung), KEIN History-Eintrag.
func grab_cancel() -> void:
if not _grabbing:
return
_grabbing = false
var objects: Array = main.doc.get("objects", [])
for i in _grab_members:
var orig: Array = _grab_originals[i]
(objects[i] as Dictionary)["pos"] = [float(orig[0]), float(orig[1]), float(orig[2])]
if i < view_builder.object_markers.size():
var marker: Node3D = view_builder.object_markers[i]
if marker != null and is_instance_valid(marker):
marker.position = Vector3(float(orig[0]), float(orig[1]), float(orig[2]))
# Symmetrie (M3, t_34908425): die MITgezogenen Zwillinge exakt zurücksetzen.
for t in _grab_twin_originals:
var torig: Array = _grab_twin_originals[t]
(objects[t] as Dictionary)["pos"] = [float(torig[0]), float(torig[1]), float(torig[2])]
if t < view_builder.object_markers.size():
var tmarker: Node3D = view_builder.object_markers[t]
if tmarker != null and is_instance_valid(tmarker):
tmarker.position = Vector3(float(torig[0]), float(torig[1]), float(torig[2]))
_grab_members = []
_grab_originals = {}
_grab_lifts = {}
_grab_before = {}
_grab_twin_map = {}
_grab_twin_originals = {}
main.call("_set_status", "Greifen abgebrochen — alles ist wieder, wie es war.")
## Symmetrie (M3): Zwillinge beim GREIFEN live mitziehen — doc + Marker,
## exakter Spiegel der Quelle. Gehören BEIDE Seiten eines Paares zur Greif-
## Menge, ist die Quelle das Primär-Objekt (sonst der niedrigere Index).
func _sym_grab_twin_move() -> void:
var sym_mode: int = _sym_mode()
if sym_mode == SymmetryCore.Mode.OFF or _grab_twin_map.is_empty():
return
var objects: Array = main.doc.get("objects", [])
var center := SymmetryCore.map_center(main.doc)
var handled: Dictionary = {}
for i in _grab_members:
if not _grab_twin_map.has(i):
continue
var t: int = int(_grab_twin_map[i])
var pair_key := "%d|%d" % [mini(i, t), maxi(i, t)]
if handled.has(pair_key):
continue
handled[pair_key] = true
var src_i: int = i
if _grab_members.has(t):
if _grab_primary == t:
src_i = t
elif _grab_primary != i and t < i:
src_i = t
var twin_i: int = t if src_i == i else i
if src_i >= objects.size() or not (objects[src_i] is Dictionary):
continue
var spos: Array = (objects[src_i] as Dictionary).get("pos", [0.0, 0.0, 0.0])
if (spos as Array).size() != 3:
continue
var mp := SymmetryCore.mirror_point(sym_mode,
Vector3(float(spos[0]), float(spos[1]), float(spos[2])), center)
(objects[twin_i] as Dictionary)["pos"] = [mp.x, mp.y, mp.z]
if twin_i < view_builder.object_markers.size():
var tm: Node3D = view_builder.object_markers[twin_i]
if tm != null and is_instance_valid(tm):
tm.position = Vector3(mp.x, mp.y, mp.z)
## Symmetrie (M3): Zwillinge beim ZIEHEN live mitführen (nur Marker — das doc
## schreibt _commit_drag über symmetry_sync_pairs). Quelle = Primär-Objekt,
## wenn beide Seiten eines Paares in der Zieh-Menge sind, sonst niedrigerer Index.
func _sym_drag_sync_live() -> void:
var sym_mode: int = _sym_mode()
if sym_mode == SymmetryCore.Mode.OFF or view_builder == null:
return
var objects: Array = main.doc.get("objects", [])
var center := SymmetryCore.map_center(main.doc)
var handled: Dictionary = {}
for i in _drag_members:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var key := SymmetryCore.sym_of(objects[i] as Dictionary)
if key.is_empty():
continue
var t := SymmetryCore.find_partner_index(objects, key, i)
if t < 0:
continue
var pair_key := "%d|%d" % [mini(i, t), maxi(i, t)]
if handled.has(pair_key):
continue
handled[pair_key] = true
var src_i: int = i
if _drag_members.has(t):
if _drag_primary == t:
src_i = t
elif _drag_primary != i and t < i:
src_i = t
var twin_i: int = t if src_i == i else i
if src_i >= view_builder.object_markers.size() or twin_i >= view_builder.object_markers.size():
continue
var sm: Node3D = view_builder.object_markers[src_i]
var tm: Node3D = view_builder.object_markers[twin_i]
if sm == null or tm == null or not is_instance_valid(sm) or not is_instance_valid(tm):
continue
tm.position = SymmetryCore.mirror_point(sym_mode, sm.position, center)
## ⇄ Spiegeln (X: links↔rechts, Z: vor↔zurück) an der Stelle des Objekts —
## die Blickrichtung wird gespiegelt (sicher selbstinvers: doppelt = zurück).
func mirror_selected(axis: String) -> void:
if selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du spiegeln willst.", true)
return
var objects: Array = main.doc.get("objects", [])
if selected_index >= objects.size():
clear_selection(true)
return
var before: Dictionary = main.history.snapshot(["objects"])
var obj: Dictionary = objects[selected_index]
var old_rot := float(obj.get("rot_y_deg", 0.0))
ObjectOps.mirror_object(obj, axis)
var new_rot := float(obj.get("rot_y_deg", 0.0))
var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null
if marker != null and is_instance_valid(marker):
marker.rotation.y = deg_to_rad(new_rot)
# Symmetrie (M3, t_34908425): der Zwilling dreht MIT (exakter Spiegel der
# neuen Blickrichtung) — im selben Commit wie die Quelle.
var twins: Array = ObjectOps.symmetry_sync_pairs(
main.doc, [selected_index], _sym_mode(), selected_index)
_sync_members_to_markers(twins)
var desc := "Objekt gespiegelt (links↔rechts)" if axis == "x" else "Objekt gespiegelt (vor↔zurück)"
main.history.commit(desc, before, main.history.snapshot(["objects"]))
main.dirty = true
main.call("_refresh_file_label")
main.call("_refresh_history_buttons")
main.call("_sync_selection_panel")
main.call("_set_status", "%s — Blickrichtung %.0f° → %.0f°. Strg+Z stellt die alte Richtung wieder her." % [
desc, old_rot, new_rot])
# ------------------------------------------------------------ Eingaben ------
func _unhandled_input(event: InputEvent) -> void:
# Strg-Zwang (Karte t_55257792) aus JEDEM durchgelassenen Event verfolgen —
# Maus-Events tragen den Modifier, Tasten-Events ebenso (nicht konsumieren!).
if event is InputEventKey:
_ctrl_held = (event as InputEventKey).ctrl_pressed
var p := MousePointer.from_input(event)
if p != null and handle_pointer(p):
get_viewport().set_input_as_handled()
## Rechte Maustaste kommt hier nie an (Karte t_2470de89): sie gehört mit
## absolutem Vorrang dem Freelook-Flug (orbit_camera.gd). Beim Platzieren
## bleibt das Objekt IN der Hand, Abwurf nur über Esc.
func handle_pointer(p: PointerEvent) -> bool:
if p.kind != PointerEvent.Kind.CANCEL:
_ctrl_held = p.ctrl
var mode: String = main.get("tool_mode")
if mode == "terrain" or mode == "spawns":
return false # Terrain-Pinsel / Spawn-Zonen haben das Zepter (eigene Werkzeuge)
match p.kind:
PointerEvent.Kind.MOVE:
_on_pointer_move(p.position)
PointerEvent.Kind.PRESS:
if _grabbing:
grab_confirm()
return true
_on_left_press(p)
return true
PointerEvent.Kind.RELEASE:
_on_left_release(p)
PointerEvent.Kind.SCROLL:
if _camera_flying():
return false # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen
if placing:
place_rot_deg = fmod(place_rot_deg + p.scroll * (90.0 if BauKatalog.ist_bau(place_oid) else ROT_STEP_DEG) + 3600.0, 360.0)
_ghost_rotation_anwenden()
return true
if selected_index >= 0 and not _grabbing:
rotate_selected(p.scroll * ROT_STEP_DEG)
return true
return false
func _on_pointer_move(pos: Vector2) -> void:
if placing:
_ghost_update(pos)
if _grabbing:
var gh: Variant = _ground_point(pos)
if gh != null:
grab_move(gh as Vector3)
return
if _rubber_pending or _rubber_active:
var dist := Vector2(pos.x - _press_pos.x, pos.y - _press_pos.y).length()
if dist > DRAG_THRESHOLD_PX:
_rubber_active = true
_rubber_pending = false
if _rubber_active:
main.call("_rubberband_update", _rubber_start, pos)
return
if _dragging and not placing and not _drag_members.is_empty():
_drag_members_move(pos)
elif _dragging and not placing and selected_index >= 0:
# Einzel-Greifen (alter Pfad, ohne Gruppe/Mehrfachauswahl)
var p: Variant = _ground_point(pos)
if p == null:
return
var new_pos: Vector3 = _apply_grid((p as Vector3) + _grab_offset)
var half := _terrain_half()
new_pos.x = clampf(new_pos.x, -half.x, half.x)
new_pos.z = clampf(new_pos.z, -half.y, half.y)
# Höhe: der Terrain-Abstand bleibt beim Ziehen erhalten (angehoben
# schwebt weiter oben) — kein harter Terrain-Clamp mehr, t_7567a06f.
new_pos.y = SvmapDocument.terrain_height_at(main.doc, new_pos.x, new_pos.z) + _drag_lift
var dist := Vector2(pos.x - _press_pos.x, pos.y - _press_pos.y).length()
if dist > DRAG_THRESHOLD_PX:
_drag_moved = true
if _drag_moved:
var marker: Node3D = view_builder.object_markers[selected_index]
if marker != null and is_instance_valid(marker):
marker.position = new_pos
func _on_left_press(p: PointerEvent) -> void:
if placing:
_place_object(p.position, p.shift) # U5: Shift = Serienmodus
return
_press_pos = p.position
var hit := _pick_object(p.position)
if hit >= 0:
if p.shift or p.command:
# Umschalt+Klick / Strg+Klick (Karte t_c46d31c2): zur Auswahl
# hinzufügen/entfernen — kein Ziehen.
select_toggle(hit)
return
if not selected_indices.has(hit):
select_object(hit)
_begin_drag(hit, p.position)
else:
# Leere Stelle: Rahmen-Ausziehen vorbereiten; loser Klick = Auswahl weg.
_rubber_pending = true
_rubber_active = false
_rubber_start = p.position
_rubber_shift = p.shift
## Zieh-Menge festlegen: Mehrfachauswahl oder komplette Gruppe ziehen GEMEINSAM.
## Vom Gizmo im Drehen-Modus gesetzt: ein Klick auf das gewählte Objekt zieht
## es NICHT mehr weg (nur die Ringe drehen).
var no_drag_move := false
func _begin_drag(hit: int, viewport_pos: Vector2) -> void:
if no_drag_move and hit == selected_index:
return
var objects: Array = main.doc.get("objects", [])
_drag_members = []
_grab_offsets = {}
_drag_lifts = {}
_drag_is_group = false
if selected_indices.size() > 1:
_drag_members = selected_indices.duplicate()
else:
var gid := GroupOps.group_id(objects[hit])
if gid != GroupOps.UNGROUPED:
var members := GroupOps.members_of(main.doc, gid)
if members.size() > 1:
_drag_members = members
_drag_is_group = true
select_many(members, true) # ganze Gruppe sichtbar markieren
if _drag_members.is_empty():
_drag_members = [selected_index]
_drag_primary = selected_index if selected_index >= 0 else hit
_dragging = true
_drag_moved = false
var p: Variant = _ground_point(viewport_pos)
if p != null:
for i in _drag_members:
if i >= view_builder.object_markers.size():
continue
var marker: Node3D = view_builder.object_markers[i]
if marker != null and is_instance_valid(marker):
var off: Vector3 = marker.position - (p as Vector3)
off.y = 0.0
_grab_offsets[i] = off
# Terrain-Abstand einfrieren (t_7567a06f, für die GANZE Zieh-
# Menge): ein Objekt mit Höhe behält beim Ziehen diesen Abstand
# zum Boden — bodennah bleibt bodennah, angehoben schwebt weiter.
var lift := marker.position.y \
- SvmapDocument.terrain_height_at(main.doc, marker.position.x, marker.position.z)
_drag_lifts[i] = lift
if i == _drag_primary:
_grab_offset = off
_drag_lift = lift
else:
for i in _drag_members:
_grab_offsets[i] = Vector3.ZERO
_drag_lifts[i] = 0.0
_grab_offset = Vector3.ZERO
_drag_lift = 0.0
## Gemeinsames Ziehen: Delta vom Primär-Objekt auf ALLE Mitglieder — relative
## Anordnung bleibt exakt erhalten, Raster-Snap rastet die GANZE Gruppe.
func _drag_members_move(viewport_pos: Vector2) -> void:
var p: Variant = _ground_point(viewport_pos)
if p == null or not _grab_offsets.has(_drag_primary):
return
var half := _terrain_half()
var new_primary: Vector3 = _apply_grid((p as Vector3) + (_grab_offsets[_drag_primary] as Vector3))
new_primary.x = clampf(new_primary.x, -half.x, half.x)
new_primary.z = clampf(new_primary.z, -half.y, half.y)
new_primary.y = SvmapDocument.terrain_height_at(main.doc, new_primary.x, new_primary.z)
var dist := Vector2(viewport_pos.x - _press_pos.x, viewport_pos.y - _press_pos.y).length()
if dist > DRAG_THRESHOLD_PX:
_drag_moved = true
if not _drag_moved:
return
var primary_marker: Node3D = view_builder.object_markers[_drag_primary]
if primary_marker == null or not is_instance_valid(primary_marker):
return
var delta: Vector3 = new_primary - primary_marker.position
delta.y = 0.0
if delta.length_squared() < 0.0000001:
return
for i in _drag_members:
if i >= view_builder.object_markers.size():
continue
var marker: Node3D = view_builder.object_markers[i]
if marker == null or not is_instance_valid(marker):
continue
var np: Vector3 = marker.position + delta
np.x = clampf(np.x, -half.x, half.x)
np.z = clampf(np.z, -half.y, half.y)
# Höhe: Terrain-Abstand bleibt erhalten (t_7567a06f, pro Objekt —
# angehoben schwebt weiter, bodennah bleibt bodennah).
np.y = SvmapDocument.terrain_height_at(main.doc, np.x, np.z) \
+ float(_drag_lifts.get(i, 0.0))
marker.position = np
# Symmetrie (M3, t_34908425): Zwillinge LIVE mitziehen (nur Marker —
# das doc schreibt _commit_drag), exakter Spiegel der gezogenen Quelle.
_sym_drag_sync_live()
func _on_left_release(p: PointerEvent) -> void:
if _rubber_pending or _rubber_active:
var was_active := _rubber_active
_rubber_pending = false
_rubber_active = false
main.call("_rubberband_clear")
if was_active:
_finish_rubberband(p.position)
elif not _rubber_shift:
# Loser Klick auf leere Fläche: Auswahl aufheben (Klick statt Auswendiglernen).
if not selected_indices.is_empty():
clear_selection()
return
if _dragging:
_dragging = false
if _drag_moved and not _drag_members.is_empty():
_commit_drag()
_drag_moved = false
_drag_members = []
_grab_offsets = {}
_drag_is_group = false
## Rahmen-Ausziehen abgeschlossen: alle Marker im Rahmen auswählen.
func _finish_rubberband(end_pos: Vector2) -> void:
var rect := SelectionMath.rect_from(_rubber_start, end_pos)
var positions: Array = []
for marker in view_builder.object_markers:
if marker != null and is_instance_valid(marker):
positions.append(marker.global_position)
else:
positions.append(Vector3(INF, INF, INF)) # kaputter Marker: nie im Rahmen
var picked := SelectionMath.pick_in_rect(camera, positions, rect)
if _rubber_shift and not picked.is_empty():
for i in picked:
if not selected_indices.has(i):
selected_indices.append(i)
selected_index = picked[picked.size() - 1]
elif not _rubber_shift:
selected_indices = picked
selected_index = picked.back() if not picked.is_empty() else -1
_apply_selection_view()
main.call("_sync_selection_panel")
if selected_indices.is_empty():
main.call("_set_status", "Der Rahmen hat kein Objekt erwischt — zieh ihn über die Objekte, die du zusammen bearbeiten willst.")
else:
main.call("_set_status", "Rahmen-Auswahl: %d Objekte markiert. Strg+G gruppier sie, oder ziehe sie gemeinsam weiter." % selected_indices.size())
## Bewegte Marker → Doc schreiben, EIN Undo-Schritt für den ganzen Zug.
func _commit_drag() -> void:
var objects: Array = main.doc.get("objects", [])
var moved: Array = []
for i in _drag_members:
if i >= objects.size() or i >= view_builder.object_markers.size():
continue
var marker: Node3D = view_builder.object_markers[i]
if marker == null or not is_instance_valid(marker):
continue
moved.append(i)
if moved.is_empty():
return
var before: Dictionary = main.history.snapshot(["objects"])
for i in moved:
var pos: Vector3 = (view_builder.object_markers[i] as Node3D).position
objects[i]["pos"] = [float(pos.x), float(pos.y), float(pos.z)]
# Symmetrie (M3, t_34908425): Zwillinge der gezogenen Auswahl auf den exakten
# Spiegel setzen — IM SELBEN Commit (ein Zug = EIN Undo-Schritt für beide).
var twins: Array = ObjectOps.symmetry_sync_pairs(
main.doc, moved, _sym_mode(), _drag_primary)
_sync_members_to_markers(twins)
var desc: String
if _drag_is_group:
desc = "Gruppe verschoben (%d Objekte)" % moved.size()
elif moved.size() > 1:
desc = "%d Objekte verschoben" % moved.size()
else:
desc = "Objekt verschoben"
main.history.commit(desc, before, main.history.snapshot(["objects"]))
_mark_dirty()
main.call("_refresh_history_buttons")
main.call("_refresh_stats")
## Dirty-Markierung ohne Full-Rebuild (Auswahl/Marker bleiben bestehen).
func _mark_dirty() -> void:
main.set("dirty", true)
main.call("_refresh_file_label")
# ---------------------------------------------------- Screenshot-Hilfe ------
## Demo-Hook (Desktop-Smoke): Ghost außerhalb echter Maus-Ereignisse parken,
## damit ein Screenshot die Vorschau zeigen kann.
func demo_park_ghost(world_pos: Vector3, oid: String) -> void:
start_placement(oid)
placing = true
place_oid = oid
_ghost_refresh_mesh()
_ghost.position = world_pos
_ghost_rotation_anwenden()
_ghost.visible = true
_ghost_active = true
main.call("_highlight_library", oid)
main.call("_set_status", "%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, ESC bricht ab (Rechte Maustaste = Flug)." % main.object_label(oid))
## Demo-Hook (Gruppen-Smoke, Karte t_f6f29d86): die Auswahl EXAKT so ziehen
## wie ein echter Drag-Release (Marker bewegen + EIN Commit).
func demo_drag_selection(delta: Vector3) -> void:
if selected_indices.is_empty():
return
_drag_members = selected_indices.duplicate()
_drag_is_group = false
_drag_primary = selected_index
_dragging = true
_drag_moved = true
for i in _drag_members:
if i < view_builder.object_markers.size():
var marker: Node3D = view_builder.object_markers[i]
if marker != null and is_instance_valid(marker):
marker.position += delta
_commit_drag()
_dragging = false
_drag_members = []
_drag_moved = false