scopedvr-map-core/editor/symmetry_ops.gd
2026-11-01 19:31:33 +01:00

647 lines
25 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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