1729 lines
73 KiB
GDScript
1729 lines
73 KiB
GDScript
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
|