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