647 lines
25 KiB
GDScript
647 lines
25 KiB
GDScript
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
|