extends RefCounted class_name SymmetryOps ## M5 Ein-Klick „Karte symmetrisch machen“ + Symmetrie-Check (Karte t_60014669, ## Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md). Baut auf M1–M4 auf: ## Spiegel-Mathe und Partner-Links kommen aus SymmetryCore, das Anwenden auf ## Objekten aus ObjectOps, auf Bauteilen (normale Objekte) und auf Spawn-Zonen aus ## dem Spawn-Spiegel-Mechanismus (spawn_tool.gd — dort NUR BENUTZT, nie ## geändert). Diese Datei orchestriert den GANZEN Vorgang und den Check: ## ## analyze(doc, mode) → ehrliche Bestandsaufnahme (nur lesen): Paare, die ## schon passen, Paare, die widersprechen, Elemente ## ohne Zwilling, abweichende Boden-Zellen. ## apply(doc, mode, quelle_hoch) → gleicht die Karte an: alles aus der ## Quell-Hälfte wird ERGÄNZT (Objekte, Bau-Teile, ## Spawn-Punkte, Boden zellgenau); widersprüchliche ## Paare bekommen die Quelle zugesprochen (nichts wird ## gelöscht, die Zahl wurde vorher genannt). ## check(doc, mode) → Klartext-Bericht: „Alles symmetrisch ✓“ oder ## „N Stellen weichen ab: Objekt #12, Boden (31,17) …“ ## ## Seiten-Logik ohne Mode-Wirrwarr: Jedes Element wird mit seinem Spiegelbild ## verglichen. Die „linke“/„vordere“ Seite eines Paares ist die, deren ## Spiegelbild weiter rechts/hinten liegt (lex-Antwort: erst x, dann z). ## Die Quell-Hälfte-Wahl (quelle_hoch=false = links/vorn, true = rechts/hinten) ## entscheidet NUR, welche Seite eines Paares bei Widerspruch gewinnt und in ## welche Richtung einzelstehende Elemente ergänzt werden. ## ## Ein-Klick-Anwendung (Panel) macht den GANZEN Vorgang zu EINEM Undo-Schritt: ## ActionHistory snapshotet das ganze Dokument — hier wird nur mutiert, das ## Committen bleibt beim Aufrufer (Panel/main, Muster wie beim Resize R3). ## ## Alles static + reine Funktionen (mutieren nur das übergebene doc) → ## headless testbar wie SymmetryCore/ObjectOps. const SpawnToolScript := preload("res://core/editor/spawn_tool.gd") ## Wie nah ein Spiegelbild an einem Element liegen darf, damit sie als Paar ## gelten (Meter, XZ-Ebene). Handgebaute Karten sitzen nie auf den Millimeter — ## aber exakt gespiegelte Paare aus M2–M4 liegen auf 0. const PAIR_TOLERANCE_M := 1.0 ## Ab wann Höhen als verschieden gelten (Terrain-R16-Schritt ist viel kleiner). const HEIGHT_EPS_M := 0.02 ## Kleine Vergleichstoleranzen für Position/Drehung/Maßstab/Farbe. const POS_EPS_M := 0.01 const ROT_EPS_DEG := 0.5 const VALUE_EPS := 0.01 ## Wie viele Beispiele der Klartext-Bericht maximal einzeln nennt. const MAX_EXAMPLES := 8 # ------------------------------------------------------------ Seiten-Logik -- ## Klartext-Namen der beiden Hälften für den Modus ([niedrige Seite, hohe]). static func side_labels(mode: int) -> Array: if mode == SymmetryCore.Mode.AXIS_Z: return ["vorn", "hinten"] return ["links", "rechts"] static func _center_of(doc: Dictionary) -> Vector2: return SymmetryCore.map_center(doc) static func _mirror(mode: int, doc: Dictionary, p: Vector3) -> Vector3: return SymmetryCore.mirror_point(mode, p, _center_of(doc)) static func _pos_of(arr: Array) -> Vector3: if (arr as Array).size() < 3: return Vector3.ZERO return Vector3(float(arr[0]), float(arr[1]), float(arr[2])) ## „self“ = liegt auf Mitte/Achse (wird nie gedoppelt, nie geprüft). ## „low“ = lex-erste Seite (links bzw. vorn), „high“ = die andere. ## Bei Punkt-Spiegelung entscheidet auf der Mittel-Linie z statt x. static func _side_of(mode: int, p: Vector3, doc: Dictionary) -> String: if SymmetryCore.maps_to_self(mode, p, _center_of(doc)): return "self" var m := _mirror(mode, doc, p) if p.x < m.x - 0.0001: return "low" if p.x > m.x + 0.0001: return "high" return "low" if p.z < m.z - 0.0001 else "high" ## Templat-loses Sammeln: items = [{"idx": int, "pos": Vector3}]. Paarung ## IMMERS vom low-Spiegel aus (deterministisch, Richtung der Quell-Wahl ## ändert die Paare nicht). Rückgabe: ## pairs: [{"low": idx, "high": idx, "mirror": Vector3}] ## singles_low / singles_high: idx-Listen der Zwillinglosen static func _pair_items(mode: int, doc: Dictionary, items: Array) -> Dictionary: var out := {"pairs": [], "singles_low": [], "singles_high": []} var low_pool: Array = [] var high_pool: Array = [] for it in items: if not (it is Dictionary): continue var side: String = _side_of(mode, it["pos"], doc) if side == "self": continue if side == "low": low_pool.append(it) else: high_pool.append(it) var used: Dictionary = {} for d in low_pool: var m := _mirror(mode, doc, d["pos"]) var best: int = -1 var best_d: float = PAIR_TOLERANCE_M for k in high_pool.size(): if used.has(k): continue var t: Dictionary = high_pool[k] var dxy := Vector2(m.x - t["pos"].x, m.z - t["pos"].z).length() if dxy <= best_d: best = k best_d = dxy if best >= 0: used[best] = true out["pairs"].append({ "low": int(d["idx"]), "high": int(high_pool[best]["idx"]), "mirror": m, }) for k in high_pool.size(): if not used.has(k): out["singles_high"].append(int(high_pool[k]["idx"])) for d in low_pool: var paired := false for p in out["pairs"]: if int(p["low"]) == int(d["idx"]): paired = true break if not paired: out["singles_low"].append(int(d["idx"])) return out # -------------------------------------------------------------- analyze() --- ## Ehrliche Bestandsaufnahme (liest NUR). Siehe Klassenkopf für die Struktur. static func analyze(doc: Dictionary, mode: int) -> Dictionary: var an := { "mode": mode, "objects": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0}, "spawns": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0}, "terrain_cells": [], "material_cells": [], # Material-Raster (fein, Karte 24.10.) — eigenes Gitter "deviations": [], "links": 0, # positionierende Paare ohne sym-Kopplung (werden verknüpft) "conflicts": 0, # widersprüchliche Paare + Boden-Zellen "adds": 0, # Zwillinge, die ergänzt werden "_dev_total": 0, "total_changes": 0, "total_deviations": 0, } if mode == SymmetryCore.Mode.OFF: return an _analyze_objects(doc, mode, an) _analyze_spawns(doc, mode, an) _analyze_terrain(doc, mode, an) an["conflicts"] = int(an["objects"]["diff"]) \ + int(an["spawns"]["diff"]) + (an["terrain_cells"] as Array).size() \ + (an["material_cells"] as Array).size() an["adds"] = (an["objects"]["singles_low"] as Array).size() \ + (an["objects"]["singles_high"] as Array).size() \ + (an["spawns"]["singles_low"] as Array).size() \ + (an["spawns"]["singles_high"] as Array).size() an["total_deviations"] = int(an["_dev_total"]) an["total_changes"] = int(an["conflicts"]) + int(an["adds"]) + int(an["links"]) return an ## Meldet eine Abweichung: Zähler ZÄHLT IMMER, die Beispielliste ist gekappt. static func _push_dev(an: Dictionary, label: String) -> void: an["_dev_total"] = int(an["_dev_total"]) + 1 var devs: Array = an["deviations"] if devs.size() < MAX_EXAMPLES: devs.append(label) static func _valid_link(list: Array, idx: int) -> int: ## sym-Partner-Index NUR wenn der Partner wirklich existiert — sonst gilt ## das Element als verkoppelt-lost und läuft über die Paarung mit. var item: Dictionary = list[idx] var key := SymmetryCore.sym_of(item) if key.is_empty(): return -1 return SymmetryCore.find_partner_index(list, key, idx) ## Frische gemeinsame sym-Kennung für ein Paar. Hingen die Elemente vorher an ## ANDEREN Partnern, verlieren diese ihre Kennung mit — sonst bliebe ein ## V16-Waise übrig (eine Kennung = GENAU 2 Elemente). Merkwurzeln einer ## beschädigten Karte können so pro Lauf kleiner werden; ein zweiter Klick ## erledigt den Rest. static func _fresh_pair_link(doc: Dictionary, list: Array, donor_i: int, twin_i: int) -> void: for idx in [donor_i, twin_i]: var key := SymmetryCore.sym_of(list[idx]) if key.is_empty(): continue var old_partner := SymmetryCore.find_partner_index(list, key, idx) if old_partner >= 0 and old_partner != donor_i and old_partner != twin_i: SymmetryCore.clear_sym(list[old_partner]) SymmetryCore.clear_sym(list[idx]) var nk := SymmetryCore.next_sym_key(doc) SymmetryCore.set_sym(list[donor_i], nk) SymmetryCore.set_sym(list[twin_i], nk) ## Spawn-Gegenstück: Kopplung design_notes.spawn_mirror sauber neu setzen ## (beide Richtungen überschreiben, alte Partner beidseitig auskoppeln). static func _fresh_spawn_link(doc: Dictionary, donor_id: String, twin_id: String) -> void: for pid in [donor_id, twin_id]: var old := _spawn_linked(doc, pid) if not old.is_empty() and old != donor_id and old != twin_id: SpawnToolScript._mirror_map_clear(doc, pid, old) SpawnToolScript._mirror_map_set(doc, donor_id, twin_id) static func _obj_pair_differs(src: Dictionary, twin: Dictionary, mp: Vector3, mode: int) -> bool: var tpos := _pos_of(twin.get("pos", [])) if Vector2(mp.x - tpos.x, mp.z - tpos.z).length() > POS_EPS_M: return true if absf(tpos.y - mp.y) > POS_EPS_M: return true # 3 Achsen (Karte t_e166e656): der Vergleich läuft über die gespiegelte # Euler-Orientierung (für reines Y-Drehen identisch zur alten rot_y-Regel). var gespiegelt := SymmetryCore.mirror_rot_euler(mode, SvmapIO.objekt_rotation(src)) var twin_euler := SvmapIO.objekt_rotation(twin) var trot := fposmod(twin_euler.y - gespiegelt.y, 360.0) var rotd := minf(trot, 360.0 - trot) if rotd > ROT_EPS_DEG: return true var kipp := fposmod(twin_euler.x - gespiegelt.x + 180.0, 360.0) - 180.0 if absf(kipp) > ROT_EPS_DEG: return true kipp = fposmod(twin_euler.z - gespiegelt.z + 180.0, 360.0) - 180.0 if absf(kipp) > ROT_EPS_DEG: return true if absf(float(twin.get("scale", 1.0)) - float(src.get("scale", 1.0))) > VALUE_EPS: return true if str(twin.get("id", "")) != str(src.get("id", "")): return true var s_tint: Variant = src.get("tint", null) var t_tint: Variant = twin.get("tint", null) if (s_tint is Array) != (t_tint is Array): return true if s_tint is Array and t_tint is Array: var sa: Array = s_tint var ta: Array = t_tint if (sa as Array).size() != (ta as Array).size(): return true for k in sa.size(): if absf(float(sa[k]) - float(ta[k])) > VALUE_EPS: return true return false static func _analyze_objects(doc: Dictionary, mode: int, an: Dictionary) -> void: var objects: Array = doc.get("objects", []) var items: Array = [] for i in objects.size(): var obj = objects[i] if not (obj is Dictionary): continue var pos: Array = obj.get("pos", []) if (pos as Array).size() < 3: continue items.append({"idx": i, "pos": _pos_of(pos)}) var paired := _pair_items(mode, doc, items) var pairs: Array = paired["pairs"] var diff_n := 0 for p in pairs: var low_i: int = p["low"] var high_i: int = p["high"] var low_ok: bool = _valid_link(objects, low_i) == high_i var high_ok: bool = _valid_link(objects, high_i) == low_i if not (low_ok and high_ok): an["links"] = int(an["links"]) + 1 var donor: Dictionary = objects[low_i] var twin: Dictionary = objects[high_i] var differs := _obj_pair_differs(donor, twin, p["mirror"], mode) if differs: diff_n += 1 _push_dev(an, "Objekt #%d" % low_i) an["objects"]["pairs"] = pairs an["objects"]["singles_low"] = paired["singles_low"] an["objects"]["singles_high"] = paired["singles_high"] an["objects"]["diff"] = diff_n for idx in (paired["singles_low"] as Array) + (paired["singles_high"] as Array): _push_dev(an, "Objekt #%d hat keinen Zwilling" % int(idx)) # ------------------------------------------------------------------ Spawns -- ## Nur Teams 0/1 (TDM-Konvention wie M4): der Spiegel-Zwilling eines Spawn- ## Punkts liegt im GEGNER-Team. Kopplung läuft über design_notes.spawn_mirror. static func _spawn_items(doc: Dictionary) -> Array: var items: Array = [] for team in [0, 1]: for p in SpawnOps.points(doc, team): if not (p is Dictionary): continue var pos: Array = p.get("pos", []) if (pos as Array).size() < 3: continue items.append({ "team": team, "id": str(p.get("id", "")), "pos": _pos_of(pos), }) return items static func _spawn_linked(doc: Dictionary, pid: String) -> String: return SpawnToolScript.spawn_mirror_partner(doc, pid) static func _analyze_spawns(doc: Dictionary, mode: int, an: Dictionary) -> void: var items := _spawn_items(doc) var paired := _pair_items_spawn(mode, doc, items) var pairs: Array = paired["pairs"] var diff_n := 0 for p in pairs: var low_ok: bool = _spawn_linked(doc, str(p["low_id"])) == str(p["high_id"]) var high_ok: bool = _spawn_linked(doc, str(p["high_id"])) == str(p["low_id"]) if not (low_ok and high_ok): an["links"] = int(an["links"]) + 1 var donor: Dictionary = p["low_point"] var mp := SymmetryCore.mirror_point(mode, donor["pos"], _center_of(doc)) var twin: Dictionary = p["high_point"] if Vector2(mp.x - twin["pos"].x, mp.z - twin["pos"].z).length() > POS_EPS_M: diff_n += 1 _push_dev(an, "Spawn-Punkt %s" % str(p["low_id"])) an["spawns"]["pairs"] = pairs an["spawns"]["singles_low"] = paired["singles_low"] an["spawns"]["singles_high"] = paired["singles_high"] an["spawns"]["diff"] = diff_n for s in (paired["singles_low"] as Array) + (paired["singles_high"] as Array): _push_dev(an, "Spawn-Punkt %s hat keinen Zwilling" % str(s["id"])) ## Spawn-Variante der Paarung: items tragen team/id; Singles bleiben Objekte ## ({team, id, pos}) statt idx (Spawn-Punkte hängen an Teams, nicht am Array). static func _pair_items_spawn(mode: int, doc: Dictionary, items: Array) -> Dictionary: var out := {"pairs": [], "singles_low": [], "singles_high": []} var low_pool: Array = [] var high_pool: Array = [] for it in items: var side: String = _side_of(mode, it["pos"], doc) if side == "self": continue if side == "low": low_pool.append(it) else: high_pool.append(it) var used: Dictionary = {} for d in low_pool: var m := _mirror(mode, doc, d["pos"]) var best: int = -1 var best_d: float = PAIR_TOLERANCE_M for k in high_pool.size(): if used.has(k): continue var t: Dictionary = high_pool[k] var dxy := Vector2(m.x - t["pos"].x, m.z - t["pos"].z).length() if dxy <= best_d: best = k best_d = dxy if best >= 0: used[best] = true out["pairs"].append({ "low_team": int(d["team"]), "low_id": str(d["id"]), "high_team": int(high_pool[best]["team"]), "high_id": str(high_pool[best]["id"]), "low_point": d, "high_point": high_pool[best], }) for k in high_pool.size(): if not used.has(k): out["singles_high"].append(high_pool[k]) for d in low_pool: var paired := false for p in out["pairs"]: if str(p["low_id"]) == str(d["id"]): paired = true break if not paired: out["singles_low"].append(d) return out # ---------------------------------------------------------------- Terrain --- ## Abweichende Zell-PAARE (je einmal, kanonisch = lex-erste Zelle des Paares). static func _analyze_terrain(doc: Dictionary, mode: int, an: Dictionary) -> void: var terrain: Dictionary = doc.get("terrain", {}) var res := int(terrain.get("resolution", 0)) if res < 2: return var heights := SvmapIO.decode_heights(terrain) if heights.size() < res * res: return var cells: Array = an["terrain_cells"] var eps := HEIGHT_EPS_M # absolut in Metern for j in res: for i in res: var mc := SymmetryCore.mirror_cell(mode, i, j, res) if mc.x == i and mc.y == j: continue # Nur das lex-erste Paarmitglied behandeln (keine Doppel-Meldung). var is_low := i < mc.x or (i == mc.x and j < mc.y) if not is_low: continue var ia := j * res + i var ib := mc.y * res + mc.x var dh := absf(heights[ia] - heights[ib]) if dh > eps: cells.append(Vector2i(i, j)) if cells.size() <= MAX_EXAMPLES: _push_dev(an, "Boden (%d,%d)" % [i, j]) # Material auf seinem eigenen Raster (Byte exakt vergleichen). var mres := SvmapIO.material_resolution(terrain) var mats := SvmapIO.decode_materials(terrain) if mres < 2 or mats.size() < mres * mres: return var mix := SvmapIO.decode_mix(terrain) # weiche Ränder: b + Anteil mitvergleichen var mb: PackedByteArray = mix[0] var mw: PackedByteArray = mix[1] var mcells: Array = an["material_cells"] for j in mres: for i in mres: var mc := SymmetryCore.mirror_cell(mode, i, j, mres) if not (i < mc.x or (i == mc.x and j < mc.y)): continue var ia := j * mres + i var ib := mc.y * mres + mc.x if mats[ia] != mats[ib] or mb[ia] != mb[ib] or mw[ia] != mw[ib]: mcells.append(Vector2i(i, j)) if mcells.size() <= MAX_EXAMPLES: _push_dev(an, "Boden-Material (%d,%d)" % [i, j]) # ----------------------------------------------------------------- check() -- ## Klartext-Bericht (nur lesen). report: {"ok": bool, "text": String, ## "deviations": Array}. „Alles symmetrisch ✓“ / „N Stellen weichen ab: …“. static func check(doc: Dictionary, mode: int) -> Dictionary: if mode == SymmetryCore.Mode.OFF: return {"ok": false, "text": "Der Symmetrie-Modus ist Aus — erst einen Modus wählen (Punkt / Achse), dann prüfen.", "deviations": [], "analyze": analyze(doc, mode)} var an := analyze(doc, mode) var devs: Array = an["deviations"] var total := int(an["total_deviations"]) var text := "" if total == 0: text = "Alles symmetrisch ✓" else: var shown: Array = [] for d in devs: shown.append(str(d)) var rest := total - shown.size() var listing := ", ".join(PackedStringArray(shown)) if rest > 0: listing += " … und %d weitere" % rest text = "%d Stellen weichen ab: %s" % [total, listing] return {"ok": total == 0, "text": text, "deviations": devs, "analyze": an} # ----------------------------------------------------------------- apply() -- ## Karte angleichen. quelle_hoch=false → links/vorn ist die Quelle (Gewinner ## bei Widersprüchen), true → rechts/hinten. Mutiert doc IN PLACE (Pattern ## wie die übrigen Tools: der Aufrufer snapshotet drum herum für EIN Undo). ## Rückgabe: {"ok": bool, "report": Klartext (mehrzeilig), "counts": {...}}. static func apply(doc: Dictionary, mode: int, quelle_hoch: bool) -> Dictionary: if mode == SymmetryCore.Mode.OFF: return {"ok": false, "report": "Der Symmetrie-Modus ist Aus — ohne Modus weiß ich nicht, wie gespiegelt werden soll.", "counts": {}} var an := analyze(doc, mode) var c := { "objects_added": 0, "objects_fixed": 0, "objects_linked": 0, "spawns_added": 0, "spawns_fixed": 0, "spawns_linked": 0, "terrain_cells": 0, "skipped": 0, } _apply_objects(doc, mode, an, quelle_hoch, c) _apply_terrain(doc, mode, an, quelle_hoch, c) # Spawns ZULETZT: die Zwillinge sitzen auf der Terrain-Höhe — die stimmt # erst nach dem Boden-Ausgleich (M2/M4-Konvention: y = Terrainhöhe). _apply_spawns(doc, mode, an, quelle_hoch, c) return {"ok": true, "counts": c, "report": _apply_report(c, quelle_hoch, mode)} static func _apply_report(c: Dictionary, quelle_hoch: bool, mode: int) -> String: var erg: Array = [] for entry in [["objects_added", "Objekte ergänzt"], ["spawns_added", "Spawn-Punkte ergänzt"], ["terrain_cells", "Bodenstellen angeglichen"], ["objects_fixed", "Objekt-Widersprüche"], ["spawns_fixed", "Spawn-Widersprüche"], ["objects_linked", "Objekt-Paare verknüpft"], ["spawns_linked", "Spawn-Paare verknüpft"]]: var n := int(c[entry[0]]) if n > 0: erg.append("%d %s" % [n, entry[1]]) if erg.is_empty(): return "Nichts zu tun — die Karte ist schon symmetrisch ✓" var sides := side_labels(mode) var conflict_sum := int(c["objects_fixed"]) + int(c["spawns_fixed"]) + int(c["terrain_cells"]) var text := "Fertig ✓ — " + ", ".join(PackedStringArray(erg)) if conflict_sum > 0: text += ". Bei %d Widersprüchen hat %s gewonnen" % [conflict_sum, str(sides[1] if quelle_hoch else sides[0])] return text + ". Ein Strg+Z macht alles rückgängig." static func _write_object_from(twin: Dictionary, donor: Dictionary, mp: Vector3, mode: int) -> void: 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(donor))) twin["scale"] = donor.get("scale", 1.0) if donor.has("tint"): twin["tint"] = (donor["tint"] as Array).duplicate() else: twin.erase("tint") twin["id"] = donor.get("id", "") static func _apply_objects(doc: Dictionary, mode: int, an: Dictionary, quelle_hoch: bool, c: Dictionary) -> void: var objects: Array = doc.get("objects", []) if (objects as Array).is_empty(): return # a) Paare: verknüpfen + Widersprüche zugunsten der Quelle entscheiden. for p in (an["objects"]["pairs"] as Array): var low_i: int = p["low"] var high_i: int = p["high"] var donor_i: int = high_i if quelle_hoch else low_i var twin_i: int = low_i if quelle_hoch else high_i var donor: Dictionary = objects[donor_i] var twin: Dictionary = objects[twin_i] var key_d := SymmetryCore.sym_of(donor) var key_t := SymmetryCore.sym_of(twin) if key_d != key_t or key_d.is_empty(): # Ohne/widerstreitende Kopplung: frische gemeinsame Kennung. _fresh_pair_link(doc, objects, donor_i, twin_i) c["objects_linked"] = int(c["objects_linked"]) + 1 var mp := SymmetryCore.mirror_point(mode, _pos_of(donor.get("pos", [])), _center_of(doc)) if _obj_pair_differs(donor, twin, mp, mode): _write_object_from(twin, donor, mp, mode) c["objects_fixed"] = int(c["objects_fixed"]) + 1 # b) Zwillinglose ergänzen (aus welcher Seite auch immer — nichts gelöscht). for idx in (an["objects"]["singles_low"] as Array) + (an["objects"]["singles_high"] as Array): var res := ObjectOps.symmetry_place_twin(doc, int(idx), mode) if int(res["twin"]) >= 0: c["objects_added"] = int(c["objects_added"]) + 1 else: c["skipped"] = int(c["skipped"]) + 1 static func _apply_terrain(doc: Dictionary, mode: int, an: Dictionary, quelle_hoch: bool, c: Dictionary) -> void: _apply_materials(doc, mode, an, quelle_hoch, c) var cells: Array = an["terrain_cells"] if (cells as Array).is_empty(): return var terrain: Dictionary = doc.get("terrain", {}) var res := int(terrain.get("resolution", 0)) if res < 2: return var heights := SvmapIO.decode_heights(terrain) var hmin := float(terrain.get("min_height_m", 0.0)) var hmax := float(terrain.get("max_height_m", 1.0)) for cell in cells: var cc: Vector2i = cell var mc := SymmetryCore.mirror_cell(mode, cc.x, cc.y, res) var src := cc if not quelle_hoch else mc var dst := mc if not quelle_hoch else cc var si: int = src.y * res + src.x var di: int = dst.y * res + dst.x heights[di] = heights[si] c["terrain_cells"] = int(c["terrain_cells"]) + 1 terrain["heightmap_r16_b64"] = TerrainField.encode(heights, hmin, hmax) ## Material-Schicht angleichen (eigenes feines Raster, Karte 24.10.). static func _apply_materials(doc: Dictionary, mode: int, an: Dictionary, quelle_hoch: bool, c: Dictionary) -> void: var cells: Array = an.get("material_cells", []) if cells.is_empty(): return var terrain: Dictionary = doc.get("terrain", {}) var mres := SvmapIO.material_resolution(terrain) var mats := SvmapIO.decode_materials(terrain) if mres < 2 or mats.size() < mres * mres: return var mix := SvmapIO.decode_mix(terrain) var mb: PackedByteArray = mix[0] var mw: PackedByteArray = mix[1] for cell in cells: var cc: Vector2i = cell var mc := SymmetryCore.mirror_cell(mode, cc.x, cc.y, mres) var src := cc if not quelle_hoch else mc var dst := mc if not quelle_hoch else cc var di := dst.y * mres + dst.x var si := src.y * mres + src.x mats[di] = mats[si] mb[di] = mb[si] mw[di] = mw[si] c["terrain_cells"] = int(c["terrain_cells"]) + 1 terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) SvmapIO.encode_mix(terrain, mb, mw) static func _apply_spawns(doc: Dictionary, mode: int, an: Dictionary, quelle_hoch: bool, c: Dictionary) -> void: # a) Paare: verknüpfen (design_notes.spawn_mirror) + Quelle entscheidet. for p in (an["spawns"]["pairs"] as Array): var donor_team: int = int(p["high_team"]) if quelle_hoch else int(p["low_team"]) var donor_id: String = str(p["high_id"]) if quelle_hoch else str(p["low_id"]) var linked_d := _spawn_linked(doc, donor_id) var twin_id: String = str(p["low_id"]) if quelle_hoch else str(p["high_id"]) if linked_d != twin_id: # Beide Richtungen sauber neu setzen, alte Partner auskoppeln. _fresh_spawn_link(doc, donor_id, twin_id) c["spawns_linked"] = int(c["spawns_linked"]) + 1 var donor_point: Dictionary = p["low_point"] if not quelle_hoch else p["high_point"] var mp := SymmetryCore.mirror_point(mode, donor_point["pos"], _center_of(doc)) var twin_point: Dictionary = p["high_point"] if not quelle_hoch else p["low_point"] if Vector2(mp.x - twin_point["pos"].x, mp.z - twin_point["pos"].z).length() > POS_EPS_M: var msg := SpawnToolScript.spawn_mirror_move(doc, donor_team, donor_id, mode) if not msg.is_empty(): c["spawns_fixed"] = int(c["spawns_fixed"]) + 1 # b) Zwillinglose ergänzen (Team wechselt beim Spiegeln — M4-Regel). for s in (an["spawns"]["singles_low"] as Array) + (an["spawns"]["singles_high"] as Array): var res := SpawnToolScript.spawn_mirror_add(doc, int(s["team"]), str(s["id"]), mode) if bool(res["ok"]) and str(res["status"]).contains("gesetzt"): c["spawns_added"] = int(c["spawns_added"]) + 1 else: c["skipped"] = int(c["skipped"]) + 1