extends RefCounted class_name ObjectOps ## Objekt-Operationen des Editors (Karte t_3a998ae4) — bewusst pure Funktionen ## (kein UI, kein Knoten): Snap-to-Ground und Raster-Snap. main.gd/interaction.gd ## rufen auf, tests/test_library.gd prüft headless. const GRID_M := 1.0 # Raster-Snap-Weite (1 m — handhabbar für Objekte) const FLOAT_EPSILON := 0.01 # ab dieser Abweichung gilt ein Objekt als "schwebend" const LIFT_STEP_M := 0.25 # Anheben/Absenken: grober Rasterschritt (Karte t_7567a06f) const DUPLICATE_OFFSET_M := Vector2(2.0, 0.0) # Kopie +2 m in X (Rasterversatz, # Karte t_c46d31c2) ## Auswahl per Pfeiltaste im 2-m-Raster verschieben (Karte t_55257792) — pure. ## Genau das gegebene Delta (±2 m pro Tastendruck, Richtung von MoveMath), ## X/Z an der Map geklemmt. Die HÖHE bleibt relativ zum Terrain erhalten ## (t_7567a06f-Semantik wie beim Ziehen): ein angehobenes Objekt schwebt ## weiter, ein bodennahes bleibt bodennah — ein Objekt auf dem 2-m-Raster ## landet wieder exakt auf dem Raster. Rückgabe: Anzahl geänderter Objekte. static func nudge_objects(doc: Dictionary, indices: Array, delta: Vector3) -> int: var objects: Array = doc.get("objects", []) var half := _terrain_half_size(doc) var changed := 0 for i in indices: if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary): continue var obj: Dictionary = objects[i] var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) if (pos as Array).size() != 3: continue var x := clampf(float(pos[0]) + delta.x, -half.x, half.x) var z := clampf(float(pos[2]) + delta.z, -half.y, half.y) var lift := float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) var y := SvmapDocument.terrain_height_at(doc, x, z) + lift if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001: changed += 1 obj["pos"] = [x, y, z] return changed ## Die AUSWAHL auf den Boden absetzen (Strg+Bild↑, Karte t_55257792 — das ## Godot-"Snap Object to Floor"-Äquivalent fürs Fußpunkt-Modell): pos.y wird ## exakt die Terrain-Höhe an (x, z). X/Z bleiben unangetastet. Rückgabe: ## Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden). static func snap_selection_to_floor(doc: Dictionary, indices: Array) -> int: var objects: Array = doc.get("objects", []) var changed := 0 for i in indices: if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary): continue var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0]) if (pos as Array).size() != 3: continue var h := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) if absf(float(pos[1]) - h) > FLOAT_EPSILON: (objects[i] as Dictionary)["pos"] = [float(pos[0]), h, float(pos[2])] changed += 1 return changed static func _terrain_half_size(doc: Dictionary) -> Vector2: var terrain: Dictionary = doc.get("terrain", {}) return Vector2(float(terrain.get("size_x_m", 1024.0)), float(terrain.get("size_z_m", 1024.0))) * 0.5 ## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen ## z. B. nach dem Terrain-Formen in der Luft — ein Klick holt sie runter. ## Rückgabe: Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden). static func snap_to_ground(doc: Dictionary) -> int: var changed := 0 var objects: Array = doc.get("objects", []) for obj in objects: if not (obj is Dictionary): continue var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) if (pos as Array).size() != 3: continue var h := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) if absf(float(pos[1]) - h) > FLOAT_EPSILON: obj["pos"] = [float(pos[0]), h, float(pos[2])] changed += 1 return changed ## Eine Koordinate aufs Raster legen (Raster-Snap). grid_m <= 0 oder aus = no-op. static func snap_coord(value: float, enabled: bool, grid_m := GRID_M) -> float: if not enabled or grid_m <= 0.0: return value return roundf(value / grid_m) * grid_m # ---------------------------------------------------- Objekt-Ops (t_7567a06f) -- # Duplizieren · Anheben/Absenken · Spiegeln. Bewusst pure Funktionen wie der # Rest der Datei: die UI (interaction.gd/main.gd) snapshotet, ruft auf, commitet. ## Duplizieren: Kopie von objects[index] ans ENDE der Liste hängen. Die Kopie ## ist um DUPLICATE_OFFSET_M versetzt (+2 m in X, Rasterversatz Karte ## t_c46d31c2; mit Raster-Snap eingerastet, wenn an) und behält Drehung/Größe/ ## Höhe — der Füßpunkt (pos.y) bleibt exakt. Rückgabe: neuer Index (-1 = ## ungültig). VOR dem Aufruf prüft die UI Budget/Objekt-Limit (hilfs-Meldung ## statt Fehler). static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool, offset_x := DUPLICATE_OFFSET_M.x) -> int: var objects: Array = doc.get("objects", []) if index < 0 or index >= objects.size(): return -1 var source = objects[index] if not (source is Dictionary): return -1 var copy: Dictionary = (source as Dictionary).duplicate(true) var pos: Array = copy.get("pos", [0.0, 0.0, 0.0]) if (pos as Array).size() == 3: var nx := float(pos[0]) + offset_x var nz := float(pos[2]) + DUPLICATE_OFFSET_M.y copy["pos"] = [snap_coord(nx, grid_enabled), float(pos[1]), snap_coord(nz, grid_enabled)] objects.append(copy) return objects.size() - 1 ## MEHRERE Objekte auf einmal duplizieren (Karte t_c46d31c2, Strg+D bei ## Mehrfachauswahl): jede Kopie über duplicate_object (+2 m in X), Reihenfolge ## der Quellen bleibt erhalten, alle Indizes sind Originale (nur angehängt). ## Rückgabe: Liste der NEUEN Indizes (in Quell-Reihenfolge). Die UI commitet ## EINEN Undo-Schritt für alle Kopien zusammen. static func duplicate_objects(doc: Dictionary, indices: Array, grid_enabled: bool, offset_x := DUPLICATE_OFFSET_M.x) -> Array: var new_indices: Array = [] for i in indices: var ni := duplicate_object(doc, int(i), grid_enabled, offset_x) if ni >= 0: new_indices.append(ni) regroup_copies(doc, new_indices) return new_indices ## Kopien gruppierter Objekte bilden eine NEUE Gruppe je Original-Gruppe — sonst ## würde die Kopie eines Gebäudes mit dem Original zu EINER Gruppe verschmelzen ## (Gruppen-ID wird von duplicate(true) mitkopiert). Name: " (Kopie)". static func regroup_copies(doc: Dictionary, new_indices: Array) -> void: var objects: Array = doc.get("objects", []) var mapping := {} for ni in new_indices: var obj: Variant = objects[int(ni)] var old_gid := GroupOps.group_id(obj) if old_gid == GroupOps.UNGROUPED: continue if not mapping.has(old_gid): var gid := GroupOps.next_group_id(doc) mapping[old_gid] = gid var old_name := GroupState.display_name(doc, old_gid) objects[int(ni)]["group"] = gid # sofort belegen, damit next_group_id weiterzählt GroupState.set_group_name(doc, gid, "%s (Kopie)" % old_name) (obj as Dictionary)["group"] = int(mapping[old_gid]) ## Anheben/Absenken in LIFT_STEP_M-Schritten (delta_m positiv = anheben). ## Objekte dürfen vom Terrain abweichen — die Höhe wohnt direkt in object.pos.y ## (das svmap-Format erlaubt freie y-Werte innerhalb der Match-Grenzen, V9 — ## bewusst KEIN Extra-Format-Feld → Loader-Kompatibilität unangetastet). ## Regeln (Karte t_e166e656, Platzierungsfreiheit — Sly: „Steine versenken!"): ## - Anheben: frei nach oben, Höchstgrenze = bounds.max.y (Match-Grenze, V9). ## - Absenken: FREI unter die Terrain-Höhe (Versenken — der Stein darf ## halb im Boden stecken). Untergrenze = bounds.min.y; fehlt bounds → ## −80 m als sinnvolle Default-Grenze. ## Rückgabe: tatsächlich angewendete Differenz in Metern (0.0 = nichts ## passiert — kein Commit; die UI gibt eine Erklärung statt). static func lift_object(doc: Dictionary, index: int, delta_m: float) -> float: var objects: Array = doc.get("objects", []) if index < 0 or index >= objects.size(): return 0.0 var obj = objects[index] if not (obj is Dictionary): return 0.0 var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) if (pos as Array).size() != 3: return 0.0 var y := float(pos[1]) var max_y := 80.0 var min_y := -80.0 var bounds: Dictionary = doc.get("bounds", {}) if (bounds.get("max", null) is Array) and ((bounds["max"] as Array).size() == 3): max_y = float(bounds["max"][1]) if (bounds.get("min", null) is Array) and ((bounds["min"] as Array).size() == 3): min_y = float(bounds["min"][1]) var target := clampf(y + delta_m, min_y, max_y) var applied := target - y if absf(applied) < 0.0001: return 0.0 obj["pos"] = [float(pos[0]), target, float(pos[2])] return applied ## Spiegeln des Objekts AN SEINER STELLE (nicht quer über die Map): die ## Position bleibt, die Blickrichtung wird gespiegelt: ## axis "x": Welt-X-Spiegelung („links↔rechts“) → rot_y_deg → −rot ## axis "z": Welt-Z-Spiegelung („vor↔zurück“) → rot_y_deg → 180° − rot ## Herleitung: Blickrichtungs-Vektor d(θ) = (−sin θ, −cos θ); Spiegelung ## negiert die jeweilige Komponente → −θ bzw. 180° − θ. Jede Spiegelung ist ## dadurch selbstinvers (zweimal spiegeln = Ausgangszustand). ## 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt — Blickachse X ## bleibt bei X-Spiegelung unverändert, Rollen (Z) dreht das Vorzeichen; ## bei Z-Spiegelung umgekehrt. rot/rot_y_deg bleiben via SvmapIO.rot_setzen ## synchron (reines Y → kein rot-Feld, Datei legacy-sauber). static func mirror_object(obj: Dictionary, axis: String) -> void: var euler := SvmapIO.objekt_rotation(obj) if axis == "x": SvmapIO.rot_setzen(obj, Vector3( fposmod(euler.x, 360.0), fposmod(-euler.y, 360.0), fposmod(-euler.z, 360.0))) else: SvmapIO.rot_setzen(obj, Vector3( fposmod(-euler.x, 360.0), fposmod(180.0 - euler.y, 360.0), fposmod(euler.z, 360.0))) ## Höhe ÜBER dem Terrain am Objektstandort (> 0 = schwebt, ~0 = steht auf ## dem Boden). Für die Auswahl-Anzeige. static func lift_offset(doc: Dictionary, index: int) -> float: var objects: Array = doc.get("objects", []) if index < 0 or index >= objects.size(): return 0.0 var obj = objects[index] if not (obj is Dictionary): return 0.0 var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) if (pos as Array).size() != 3: return 0.0 return float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) # ------------------------------------------------- Platzier-Fußpunkt (C) ---- # Karte t_4092e3a0 (Gerätebefund: „Ghost saß an der Unterlage, platziert saß # das Objekt HÖHER"): Ghost-Preview und Platzierung MÜSSEN denselben Pfad # nutzen. Diese eine Funktion ist der Pfad: # 1. Raster-Snap auf X/Z (wenn an), # 2. Y = Terrain-Höhe am GERASTETEN Punkt (SvmapDocument.terrain_height_at) # — das ist der Objekt-Fußpunkt (Konvention, README §Konventionen): # „pos.y = Terrainhöhe an (x, z)“. Der sichtbare Lift (halbe Mesh-Höhe, # aus der Registry-Kollision via MeshShapes.half_height) ist Aufgabe der # Darstellung (Viewport-Bauer UND Ghost-Kind-Mesh machen es identisch). # clamp_to_map: true = Ghost clampft still auf die Map (Vorschau bleibt # sichtbar am Rand); false = Platzierung bekommt den ungeclampten Punkt und # die UI meldet „außerhalb der Map“. static func placement_foot(doc: Dictionary, hit: Vector3, grid_enabled: bool, half: Vector2, clamp_to_map: bool) -> Vector3: var pos := hit pos.x = snap_coord(pos.x, grid_enabled) pos.z = snap_coord(pos.z, grid_enabled) if clamp_to_map: pos.x = clampf(pos.x, -half.x, half.x) pos.z = clampf(pos.z, -half.y, half.y) pos.y = SvmapDocument.terrain_height_at(doc, pos.x, pos.z) return pos # --------------------------------------------- Symmetrie — Objekte (M3) ----- # Karte t_34908425 (Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md): im # Symmetrie-Modus wirkt jede Objekt-Änderung auf beide Seiten. Die Transform- # Mathe und das sym-Feld kommen aus SymmetryCore (M1, t_f5935023) — hier nur # die Objekt-Anwendung, KEINE zweite Spiegel-Mathe. Alles static + pure # (Dokument rein, Mutation im Dokument), headless testbar via # tests/test_object_mirror.gd. Modus OFF = no-op (Verhalten wie bisher). # # Partner-Regel (invariant): der Zwilling ist IMMER der exakte Spiegel seiner # Quelle — Position via SymmetryCore.mirror_point (y bleibt 1:1, Spec-Tabelle), # Drehung via SymmetryCore.mirror_rot_y (Punkt: +180°, Achse: Vorzeichen). # # ACHTUNG Indizes: symmetry_place_twin hängt den Zwilling ans ENDE der Liste — # bestehende Indizes bleiben gültig (nur Anhängen, nie Verschieben). ## Zwilling beim PLATZIEREN/DUPLIZIEREN anlegen: objects[index] bekommt eine ## neue sym-Kennung (SymmetryCore.next_sym_key), der Zwilling mit exakt ## gespiegeltem Transform wandert ans ENDE der objects-Liste. Ein Objekt, das ## auf der Achse/im Zentrum liegt (SymmetryCore.maps_to_self), wird NICHT ## gedoppelt. Modus OFF oder ungültiger Index: es passiert nichts. ## Rückgabe: {"twin": int, "on_axis": bool} — twin = Index des Zwillings ## (-1 = keiner angelegt), on_axis = Objekt liegt auf Mitte/Achse. static func symmetry_place_twin(doc: Dictionary, index: int, mode: int) -> Dictionary: var out := {"twin": -1, "on_axis": false} if mode == SymmetryCore.Mode.OFF: return out var objects: Array = doc.get("objects", []) if index < 0 or index >= objects.size() or not (objects[index] is Dictionary): return out var src: Dictionary = objects[index] var pos: Array = src.get("pos", [0.0, 0.0, 0.0]) if (pos as Array).size() != 3: return out var center := SymmetryCore.map_center(doc) var p := Vector3(float(pos[0]), float(pos[1]), float(pos[2])) if SymmetryCore.maps_to_self(mode, p, center): out["on_axis"] = true return out var twin: Dictionary = src.duplicate(true) SymmetryCore.clear_sym(twin) var mp := SymmetryCore.mirror_point(mode, p, center) # y 1:1 übernommen (Spec vc27: Höhe ist ein Skalar) → der Zwilling ist der # EXAKTE Spiegel, inklusive Höhe (auf symmetrischem Terrain = auf dem Boden). twin["pos"] = [mp.x, mp.y, mp.z] # 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt; für reines # Y-Drehen exakt die alte mirror_rot_y-Regel (gleiche Zahlenwerte). SvmapIO.rot_setzen(twin, SymmetryCore.mirror_rot_euler( mode, SvmapIO.objekt_rotation(src))) var key := SymmetryCore.next_sym_key(doc) SymmetryCore.set_sym(src, key) SymmetryCore.set_sym(twin, key) objects.append(twin) out["twin"] = objects.size() - 1 return out ## Nach einer Positions-/Dreh-Änderung ALLE betroffenen Paare synchronisieren: ## der Zwilling wird auf den exakten Spiegel seiner Quelle gesetzt. members = ## Indizes der geänderten Objekte; gehört BEIDE Seiten eines Paares zur ## Änderung, ist die Quelle das primary_hint (das gezogene/gedrehte Primär- ## Objekt), sonst der niedrigere Index (deterministisch). Modus OFF: no-op. ## Rückgabe: Indizes der neu geschriebenen Zwillinge (für die Marker-Synchronisierung). static func symmetry_sync_pairs(doc: Dictionary, members: Array, mode: int, primary_hint: int = -1) -> Array: if mode == SymmetryCore.Mode.OFF: return [] var objects: Array = doc.get("objects", []) var center := SymmetryCore.map_center(doc) var changed: Array = [] var handled: Dictionary = {} for i in 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 members.has(t): # Beide Seiten in der Änderung (z. B. beide markiert und gezogen): # die Quelle entscheidet, der Zwilling wird neu gespiegelt — so # bleibt das Paar exakt spiegelbildlich statt auseinanderzulaufen. if primary_hint == t: src_i = t elif primary_hint != i and t < i: src_i = t var twin_i: int = t if src_i == i else i var src: Dictionary = objects[src_i] var spos: Array = src.get("pos", [0.0, 0.0, 0.0]) if (spos as Array).size() != 3: continue var mp := SymmetryCore.mirror_point(mode, Vector3(float(spos[0]), float(spos[1]), float(spos[2])), center) var twin: Dictionary = objects[twin_i] twin["pos"] = [mp.x, mp.y, mp.z] # 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt; für reines # Y-Drehen exakt die alte mirror_rot_y-Regel (gleiche Zahlenwerte). SvmapIO.rot_setzen(twin, SymmetryCore.mirror_rot_euler( mode, SvmapIO.objekt_rotation(src))) changed.append(twin_i) return changed ## Löschen erweitern: zu den gewählten Indizes kommen die sym-Partner dazu ## (jeder Partner genau einmal, auch wenn mehrere Quellen dasselbe Paar ## treffen). Modus OFF: Liste unverändert (Verhalten wie bisher). static func symmetry_expand_delete(doc: Dictionary, doomed: Array, mode: int) -> Array: if mode == SymmetryCore.Mode.OFF: return doomed var objects: Array = doc.get("objects", []) var out: Array = doomed.duplicate() for i in doomed: 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 and not out.has(t): out.append(t) return out