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 # 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 = false # U5: jeder frische Start startet im Einmal-Modus _ghost_active = false refresh_terrain_cache() # Cursor trifft das GELÄNDE — Cache muss frisch sein _ensure_ghost() _ensure_ghost_visible() var label: String = main.object_label(oid) 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 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 _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: 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: 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)) # --------------------------------------------------------------- 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"]) 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) main.history.commit(label, before, main.history.snapshot(["objects"])) 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"]) var new_indices: Array = ObjectOps.duplicate_objects(main.doc, sources, bool(main.get("grid_snap"))) 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"])) 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 InputEventMouseMotion: _ctrl_held = (event as InputEventMouseMotion).ctrl_pressed elif event is InputEventMouseButton: _ctrl_held = (event as InputEventMouseButton).ctrl_pressed elif event is InputEventKey: _ctrl_held = (event as InputEventKey).ctrl_pressed var mode: String = main.get("tool_mode") if mode == "terrain" or mode == "spawns": return # Terrain-Pinsel / Spawn-Zonen haben das Zepter (eigene Werkzeuge) if event is InputEventMouseMotion: var mm := event as InputEventMouseMotion if placing: _ghost_update(mm.position) if _grabbing: var gh: Variant = _ground_point(mm.position) if gh != null: grab_move(gh as Vector3) return if _rubber_pending or _rubber_active: var dist := Vector2(mm.position.x - _press_pos.x, mm.position.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, mm.position) return if _dragging and not placing and not _drag_members.is_empty(): _drag_members_move(mm.position) elif _dragging and not placing and selected_index >= 0: # Einzel-Greifen (alter Pfad, ohne Gruppe/Mehrfachauswahl) var p: Variant = _ground_point(mm.position) 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(mm.position.x - _press_pos.x, mm.position.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 return if event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: if _grabbing: grab_confirm() get_viewport().set_input_as_handled() return _on_left_press(mb) get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_LEFT and not mb.pressed: _on_left_release(mb) # Rechte Maustaste: absichtlich NICHT behandelt (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. elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: if _camera_flying(): return # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen if placing: place_rot_deg = fmod(place_rot_deg + ROT_STEP_DEG + 3600.0, 360.0) _ghost_rotation_anwenden() get_viewport().set_input_as_handled() elif selected_index >= 0 and not _grabbing: rotate_selected(ROT_STEP_DEG) get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: if _camera_flying(): return # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen if placing: place_rot_deg = fmod(place_rot_deg - ROT_STEP_DEG + 3600.0, 360.0) _ghost_rotation_anwenden() get_viewport().set_input_as_handled() elif selected_index >= 0 and not _grabbing: rotate_selected(-ROT_STEP_DEG) get_viewport().set_input_as_handled() func _on_left_press(mb: InputEventMouseButton) -> void: if placing: _place_object(mb.position, mb.shift_pressed) # U5: Shift = Serienmodus return _press_pos = mb.position var hit := _pick_object(mb.position) if hit >= 0: if mb.shift_pressed or mb.is_command_or_control_pressed(): # 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, mb.position) else: # Leere Stelle: Rahmen-Ausziehen vorbereiten; loser Klick = Auswahl weg. _rubber_pending = true _rubber_active = false _rubber_start = mb.position _rubber_shift = mb.shift_pressed ## 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(mb: InputEventMouseButton) -> 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(mb.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