M5 (t_60014669): Ein-Klick „Karte symmetrisch machen“ + Symmetrie-Check mit Klartext-Bericht (Spec vc27). Neu: symmetry_ops.gd (analyze/apply/check — Paarung über Spiegel-Position + sym-Links, Ergänzen ohne Löschen, Widersprüche zählen und der gewählten Quelle zusprechen, Terrain zellgenau Höhen+Material, Spawn-Zwillinge im Gegner-Team via M4-Mechanik, frische sym-Kopplungen ohne V16-Waisen) + Symmetrie-Panel über Panel-Registry (main.gd unberührt; Widerspruchs-Dialog nennt die Zahlen VOR dem Anwenden, Quell-Hälfte links/rechts bzw. vorn/hinten, EIN Undo-Schritt). Suite test_symmetry_onclick (66 Checks: 0 Abweichungen nach Klick, Zellgleichheit, Quell-Flip, Legacy ohne sym, EIN Undo stellt alles her, Modus AUS no-op) + Renderprobe probe_m5_symmetry_render (Vorher/Nachher).

This commit is contained in:
Slyphax 2026-10-20 10:55:06 +00:00
parent 835a7f7363
commit 0e1406a63e
9 changed files with 1412 additions and 0 deletions

677
src/editor/symmetry_ops.gd Normal file
View file

@ -0,0 +1,677 @@
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 Bau-Teilen aus BuildOps 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://src/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},
"gebaeude": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0},
"spawns": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0},
"terrain_cells": [],
"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_gebaeude(doc, mode, an)
_analyze_spawns(doc, mode, an)
_analyze_terrain(doc, mode, an)
an["conflicts"] = int(an["objects"]["diff"]) + int(an["gebaeude"]["diff"]) \
+ int(an["spawns"]["diff"]) + (an["terrain_cells"] as Array).size()
an["adds"] = (an["objects"]["singles_low"] as Array).size() \
+ (an["objects"]["singles_high"] as Array).size() \
+ (an["gebaeude"]["singles_low"] as Array).size() \
+ (an["gebaeude"]["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
var trot := fposmod(float(twin.get("rot_y_deg", 0.0))
- SymmetryCore.mirror_rot_y(mode, float(src.get("rot_y_deg", 0.0))), 360.0)
var rotd := minf(trot, 360.0 - trot)
if rotd > 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))
static func _teil_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
var trot := fposmod(float(twin.get("rot", 0.0))
- SymmetryCore.mirror_rot_y(mode, float(src.get("rot", 0.0))), 360.0)
var rotd := minf(trot, 360.0 - trot)
if rotd > ROT_EPS_DEG:
return true
return str(twin.get("id", "")) != str(src.get("id", ""))
static func _analyze_gebaeude(doc: Dictionary, mode: int, an: Dictionary) -> void:
var list: Array = doc.get("gebaeude", [])
var items: Array = []
for i in list.size():
var teil = list[i]
if not (teil is Dictionary):
continue
var pos: Array = teil.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(list, low_i) == high_i
var high_ok: bool = _valid_link(list, high_i) == low_i
if not (low_ok and high_ok):
an["links"] = int(an["links"]) + 1
if _teil_differs(list[low_i], list[high_i], p["mirror"], mode):
diff_n += 1
_push_dev(an, "Bau-Teil #%d" % low_i)
an["gebaeude"]["pairs"] = pairs
an["gebaeude"]["singles_low"] = paired["singles_low"]
an["gebaeude"]["singles_high"] = paired["singles_high"]
an["gebaeude"]["diff"] = diff_n
for idx in (paired["singles_low"] as Array) + (paired["singles_high"] as Array):
_push_dev(an, "Bau-Teil #%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 mats := SvmapIO.decode_materials(terrain)
var cells: Array = an["terrain_cells"]
var eps := HEIGHT_EPS_M # absolut in Metern — Material als Byte exakt vergleichen
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])
var dm: bool = mats[ia] != mats[ib]
if dh > eps or dm:
cells.append(Vector2i(i, j))
if cells.size() <= MAX_EXAMPLES:
_push_dev(an, "Boden (%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,
"parts_added": 0, "parts_fixed": 0, "parts_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_gebaeude(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"], ["parts_added", "Bau-Teile ergänzt"],
["spawns_added", "Spawn-Punkte ergänzt"], ["terrain_cells", "Bodenstellen angeglichen"],
["objects_fixed", "Objekt-Widersprüche"], ["parts_fixed", "Bau-Teil-Widersprüche"],
["spawns_fixed", "Spawn-Widersprüche"], ["objects_linked", "Objekt-Paare verknüpft"],
["parts_linked", "Bau-Teil-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["parts_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]
twin["rot_y_deg"] = SymmetryCore.mirror_rot_y(mode, float(donor.get("rot_y_deg", 0.0)))
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 _write_teil_from(twin: Dictionary, donor: Dictionary, mp: Vector3,
mode: int) -> void:
twin["pos"] = [mp.x, mp.y, mp.z]
twin["rot"] = SymmetryCore.mirror_rot_y(mode, float(donor.get("rot", 0.0)))
static func _apply_gebaeude(doc: Dictionary, mode: int, an: Dictionary,
quelle_hoch: bool, c: Dictionary) -> void:
var list: Array = doc.get("gebaeude", [])
if (list as Array).is_empty():
return
for p in (an["gebaeude"]["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 = list[donor_i]
var twin: Dictionary = list[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, list, donor_i, twin_i)
c["parts_linked"] = int(c["parts_linked"]) + 1
var mp := SymmetryCore.mirror_point(mode, _pos_of(donor.get("pos", [])), _center_of(doc))
if _teil_differs(donor, twin, mp, mode):
_write_teil_from(twin, donor, mp, mode)
c["parts_fixed"] = int(c["parts_fixed"]) + 1
for idx in (an["gebaeude"]["singles_low"] as Array) + (an["gebaeude"]["singles_high"] as Array):
var msg := BuildOps.place_mirrored(doc, list[int(idx)], mode)
if not msg.contains("V9") and not msg.contains("nicht gedoppelt"):
c["parts_added"] = int(c["parts_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:
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 mats := SvmapIO.decode_materials(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]
mats[di] = mats[si]
c["terrain_cells"] = int(c["terrain_cells"]) + 1
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, hmin, hmax)
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
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

View file

@ -0,0 +1 @@
uid://csb4dkvi508of

View file

@ -42,6 +42,10 @@ const PANELS: Array = [
# statt main.gd (Operator-Vorgabe 20.10.).
{"id": "einstellungen", "titel": "Einstellungen", "gruppe": "rechts", "order": 70,
"skript": "res://src/ui/panels/settings_panel.gd"},
# M5 (Karte t_60014669): Ein-Klick „Karte symmetrisch machen“ + Check mit
# Klartext-Bericht — ebenfalls Registry-Weg, main.gd bleibt unangetastet.
{"id": "symmetrie", "titel": "Symmetrie", "gruppe": "rechts", "order": 80,
"skript": "res://src/ui/panels/symmetry_panel.gd"},
# --- links: Vorbereitet für U2 (Szenen-Baum); Bibliothek ist UNTEN ---
{"id": "szene", "titel": "Map-Inhalt", "gruppe": "links", "order": 10,
"skript": "res://src/ui/panels/scene_tree_panel.gd"},

View file

@ -0,0 +1,233 @@
extends "res://src/ui/panels/editor_panel.gd"
## Symmetrie-Panel (M5, Karte t_60014669 — Spec /opt/data/scopedvr/
## KARTEN_MIRROR_vc27.md): Ein-Klick „Karte symmetrisch machen“ + Symmetrie-
## Check mit Klartext-Bericht. Erlaubnis-Regel des Operators (20.10.):
## main.gd NICHT anfassen — dieses Modul meldet sich über die Panel-Registry
## an (registry.gd, Eintrag) und greift wie alle Module über `main` auf
## Dokument/History zu. Die Spiegel-Arbeit lebt KOMPLETT in
## src/editor/symmetry_ops.gd (statisch, headless testbar).
##
## Ablauf „Karte symmetrisch machen“:
## 1. Symmetrie-Modus AUS → Hinweis, nichts passiert (Abnahme „Modus AUS
## ändert nichts“).
## 2. Analyse: nichts zu tun → Klartext „schon symmetrisch“, OHNE Undo.
## 3. Widersprüche? → Dialog nennt die ZAHL und lässt die Quell-Hälfte
## wählen (Sly entscheidet, bevor irgendwas überschrieben wird).
## 4. Anwenden = EIN Undo-Schritt (ActionHistory snapshotet das Dokument).
const SymmetryOps := preload("res://src/editor/symmetry_ops.gd")
var mode_label: Label
var half_label: Label
var half_option: OptionButton
var apply_btn: Button
var check_btn: Button
var report_label: Label
var conflict_dialog: ConfirmationDialog
var _pending_high := false # Quell-Wahl des laufenden Widerspruchs-Dialogs
static func spec() -> Dictionary:
return {"titel": "Symmetrie", "gruppe": "rechts", "order": 75}
func panel_title() -> String:
return "Symmetrie"
func build() -> void:
add_child(_header_label("Symmetrie"))
add_child(_muted_label(
"Macht die GANZE Karte mit einem Klick symmetrisch: Objekte, "
+ "Bau-Teile, Spawn-Punkte und Boden (Höhen + Material) werden "
+ "von der Quell-Hälfte auf die andere Seite ERGÄNZT. Es wird "
+ "nichts gelöscht — Widersprüche werden vorher gemeldet."))
mode_label = _field_label(_mode_text())
add_child(mode_label)
half_label = _field_label("Quell-Hälfte:")
add_child(half_label)
half_option = OptionButton.new()
half_option.custom_minimum_size = UIScale.v2(0, 36)
half_option.item_selected.connect(_on_half_selected)
add_child(half_option)
_refresh_half_option()
apply_btn = Button.new()
apply_btn.text = "🗺️ Karte symmetrisch machen"
apply_btn.custom_minimum_size = UIScale.v2(0, 48)
apply_btn.tooltip_text = "Ergänzt die andere Kartenseite (EIN Strg+Z macht alles rückgängig)"
apply_btn.pressed.connect(_on_apply_pressed)
add_child(apply_btn)
check_btn = Button.new()
check_btn.text = "🔍 Symmetrie prüfen"
check_btn.custom_minimum_size = UIScale.v2(0, 40)
check_btn.tooltip_text = "Ehrlicher Bericht: Wo weicht die Karte von der Spiegelung ab?"
check_btn.pressed.connect(_on_check_pressed)
add_child(check_btn)
report_label = _muted_label("")
report_label.custom_minimum_size = UIScale.v2(0, 96)
add_child(report_label)
add_child(_muted_label(
"Der Modus steht oben in der Leiste (Punkt/Achse X/Achse Z). "
+ "Paare, die schon passen, werden mit Spiegel-Kennungen "
+ "verkoppelt — auch bei alten Karten ohne diese Kennungen."))
conflict_dialog = ConfirmationDialog.new()
conflict_dialog.title = "Widersprüche — welche Seite gewinnt?"
conflict_dialog.dialog_close_on_escape = true
conflict_dialog.get_ok_button().text = "Anwenden"
conflict_dialog.confirmed.connect(_on_conflict_confirmed)
conflict_dialog.add_button("Andere Hälfte als Quelle", true, "other_half")
conflict_dialog.custom_action.connect(_on_conflict_other)
add_child(conflict_dialog)
# ------------------------------------------------------------- Zustands-Helfer
func _current_mode() -> int:
## Defensiv wie spawn_tool._sym_mode(): Mock-Mains ohne Feld → OFF.
var v: Variant = main.get("sym_mode") if main != null else null
return SymmetryCore.Mode.OFF if v == null else int(v)
func _mode_text() -> String:
var mode := _current_mode()
return "Modus: %s" % SymmetryCore.mode_label(mode)
func _source_high() -> bool:
## false = erste Hälfte (links/vorn), true = zweite (rechts/hinten).
return half_option.selected == 1
func _refresh_half_option() -> void:
var labels: Array = SymmetryOps.side_labels(_current_mode())
half_option.clear()
half_option.add_item("Quelle: %s" % str(labels[0]))
half_option.add_item("Quelle: %s" % str(labels[1]))
half_option.selected = 0
func _on_half_selected(_index: int) -> void:
_refresh_mode_line()
func _refresh_mode_line() -> void:
mode_label.text = _mode_text()
# ------------------------------------------------------------ Karte spiegeln --
func _on_apply_pressed() -> void:
var mode := _current_mode()
if mode == SymmetryCore.Mode.OFF:
_say("Der Symmetrie-Modus ist Aus — oben in der Leiste erst Punkt oder eine Achse wählen.")
return
if main.doc.is_empty():
_say("Keine Karte offen — erst eine Map anlegen oder laden.", true)
return
_refresh_mode_line()
_refresh_half_option()
var an := SymmetryOps.analyze(main.doc, mode)
if int(an["total_changes"]) == 0:
_say("Nichts zu tun — die Karte ist schon symmetrisch ✓")
return
if int(an["conflicts"]) == 0:
_apply(_source_high())
return
_pending_high = _source_high()
conflict_dialog.dialog_text = _conflict_text(an)
conflict_dialog.get_ok_button().text = "Anwenden — %s gewinnt" % _half_name(_pending_high)
conflict_dialog.popup_centered(Vector2i(520, 260))
## Klartext für den Widerspruchs-Dialog: ZAHL erst nennen, DANN anwenden
## (Spec: „Nichts wird gelöscht, ohne dass die Zahl vorher genannt wurde“).
func _conflict_text(an: Dictionary) -> String:
var parts: Array = []
var obj_n := int(an["objects"]["diff"])
var teil_n := int(an["gebaeude"]["diff"])
var spawn_n := int(an["spawns"]["diff"])
var boden_n := (an["terrain_cells"] as Array).size()
if obj_n > 0:
parts.append("%d Objekt-Paare unterscheiden sich" % obj_n)
if teil_n > 0:
parts.append("%d Bau-Teil-Paare unterscheiden sich" % teil_n)
if spawn_n > 0:
parts.append("%d Spawn-Punkt-Paare unterscheiden sich" % spawn_n)
if boden_n > 0:
parts.append("%d Bodenstellen sind unterschiedlich" % boden_n)
var adds := int(an["adds"])
var text := ", ".join(PackedStringArray(parts)) + ".\n\n"
text += "Beim Anwenden gewinnt die gewählte Quell-Hälfte (die andere Seite wird angepasst).\n"
if adds > 0:
text += "Zusätzlich werden %d fehlende Zwillinge ergänzt.\n" % adds
text += "Es wird NICHTS gelöscht. Ein Strg+Z macht alles rückgängig."
return text
func _half_name(high: bool) -> String:
var labels: Array = SymmetryOps.side_labels(_current_mode())
return str(labels[1]) if high else str(labels[0])
func _on_conflict_confirmed() -> void:
# OK = die im Auswahlfeld gewählte Quelle behalten (Dialog-Text nennt sie).
_apply(_pending_high)
func _on_conflict_other(_action: String) -> void:
conflict_dialog.hide()
_pending_high = not _pending_high
_apply(_pending_high)
## Snapshot über ALLE Blöcke; fehlt design_notes (alte Karte), wird es als
## null geseedet — sonst könnte das Undo den neu angelegten Block nicht
## entfernen (U3-Lektion: fehlernde Blöcke explizit als null snapshoten).
func _snapshot_all() -> Dictionary:
var snap: Dictionary = main.history.snapshot(main.doc.keys())
if not snap.has("design_notes") and not main.doc.has("design_notes"):
snap["design_notes"] = null
return snap
func _apply(quelle_hoch: bool) -> void:
var mode := _current_mode()
var before: Dictionary = _snapshot_all()
var res := SymmetryOps.apply(main.doc, mode, quelle_hoch)
main.history.commit("Karte symmetrisch machen (%s → %s)" % [
_half_name(quelle_hoch), _half_name(not quelle_hoch)],
before, main.history.snapshot(main.doc.keys()))
main.call("_after_structure_change")
var report := str(res["report"])
report_label.text = report
report_label.modulate = Color(0.55, 0.9, 0.55)
main.call("_set_status", report.split(".")[0] + ".")
# ---------------------------------------------------------- Symmetrie-Check --
func _on_check_pressed() -> void:
var mode := _current_mode()
if mode == SymmetryCore.Mode.OFF:
_say("Der Symmetrie-Modus ist Aus — erst Punkt oder eine Achse wählen, dann prüfen.")
return
_refresh_mode_line()
var rep := SymmetryOps.check(main.doc, mode)
var text := str(rep["text"])
report_label.text = text
report_label.modulate = Color(0.55, 0.9, 0.55) if bool(rep["ok"]) else Color(1.0, 0.75, 0.35)
main.call("_set_status", text)
func _say(text: String, is_error := false) -> void:
report_label.text = text
report_label.modulate = Color(1.0, 0.75, 0.35) if is_error else Color(0.55, 0.9, 0.55)
main.call("_set_status", text, is_error)

View file

@ -0,0 +1 @@
uid://ceu6y127v6lxc

View file

@ -0,0 +1,123 @@
extends SceneTree
## M5-Beweis (t_60014669): Ein-Klick „Karte symmetrisch machen“ an einer echten
## Testkarte — VORHER/NACHHER aus der Vogelperspektive mit Symmetrie-Achse.
## Karte asymmetrisch: Hügel + Sandfleck nur links, Objekte nur links (plus
## ein widersprüchliches rechts), Bau-Teil nur links, Solo-Spawn links.
## Nach EINEM Klick (SymmetryOps.apply, Quelle links): beide Seiten decken.
##
## xvfb-run -a /opt/data/home/bin/godot4 --rendering-driver opengl3 --path . \
## --resolution 1600x900 -s res://tests/probe_m5_symmetry_render.gd
##
## Ausgabe: /tmp/m5_vorher.png, /tmp/m5_nachher.png + Prüfzeilen.
const SpawnToolScript := preload("res://src/editor/spawn_tool.gd")
const POINT := SymmetryCore.Mode.POINT
func _initialize() -> void:
var manifest: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://library/objects.json"))
var doc: Dictionary = SvmapDocument.create_new("M5-Symmetrie-Beweis", 256.0, "mittag")
_fill_asymmetric(doc)
# --- VORHER rendern --------------------------------------------------------
var bauer: Node3D = load("res://src/viewport/map_view_builder.gd").new()
root.add_child(bauer)
await process_frame
bauer.rebuild(doc, manifest)
await process_frame
var cam := Camera3D.new()
cam.fov = 50.0
root.add_child(cam)
cam.make_current()
var achse := MeshInstance3D.new()
var achse_mesh := BoxMesh.new()
achse_mesh.size = Vector3(0.4, 3.0, 120.0)
achse.mesh = achse_mesh
var achse_mat := StandardMaterial3D.new()
achse_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
achse_mat.albedo_color = Color(1.0, 0.95, 0.2, 0.55)
achse_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
achse.material_override = achse_mat
achse.position = Vector3(0.0, 1.5, 0.0)
root.add_child(achse)
await _shot(cam, "/tmp/m5_vorher.png", Vector3(0.0, 95.0, 105.0))
# --- Prüfzeilen VORHER (echte Werte) ---------------------------------------
var rep_v := SymmetryOps.check(doc, POINT)
print("VORHER-CHECK: %s" % str(rep_v["text"]))
print("VORHER-BODEN: Hügel Süd-West %.2f m | Spiegelpunkt Nord-Ost %.2f m" % [
SvmapDocument.terrain_height_at(doc, -90.0, 50.0),
SvmapDocument.terrain_height_at(doc, 90.0, -50.0)])
print("VORHER-OBJEKTE: %d | BAU-TEILE: %d" % [
(doc["objects"] as Array).size(), BuildOps.piece_count(doc)])
# --- EIN Klick: ganze Karte angleichen (Quelle links) ----------------------
var res := SymmetryOps.apply(doc, POINT, false)
print("APPLY-BERICHT: %s" % str(res["report"]))
bauer.rebuild(doc, manifest)
await _shot(cam, "/tmp/m5_nachher.png", Vector3(0.0, 95.0, 105.0))
# --- Prüfzeilen NACHHER ----------------------------------------------------
var rep_n := SymmetryOps.check(doc, POINT)
print("NACHHER-CHECK: %s" % str(rep_n["text"]))
print("NACHHER-BODEN: Süd-West %.2f m | Nord-Ost %.2f m" % [
SvmapDocument.terrain_height_at(doc, -90.0, 50.0),
SvmapDocument.terrain_height_at(doc, 90.0, -50.0)])
print("NACHHER-OBJEKTE: %d | BAU-TEILE: %d | Spawn-Partner A9=%s" % [
(doc["objects"] as Array).size(), BuildOps.piece_count(doc),
SpawnToolScript.spawn_mirror_partner(doc, "A9")])
var fehler: Array = []
_check(bool(rep_n["ok"]), "Check nach dem Klick: 0 Abweichungen", fehler)
_check(absf(SvmapDocument.terrain_height_at(doc, -90.0, 50.0)
- SvmapDocument.terrain_height_at(doc, 90.0, -50.0)) < 0.001,
"Boden: Hügelhöhe am Spiegelpunkt identisch", fehler)
for f in fehler:
print(" FEHLER: %s" % f)
print("PROBE_M5_%s" % ("FAIL" if not fehler.is_empty() else "OK"))
quit(1 if not fehler.is_empty() else 0)
func _fill_asymmetric(doc: Dictionary) -> void:
# Layout-Kohärenz: Hügelregion Süd-West (i<60, j 82..96) und ihr
# Punkt-Spiegel Nord-Ost. Objekte/Bauteil sitzen AUF dem Hügel (y=6) —
# nach dem Angleichen sitzen ihre Zwillinge exakt (M3-Konvention: y 1:1).
# Spawns liegen außerhalb der Hügelzone (y 0.2 bleibt sichtbar).
var terrain: Dictionary = doc["terrain"]
var res := int(terrain["resolution"])
var heights := SvmapIO.decode_heights(terrain)
for j in range(82, 97):
for i in 60:
heights[j * res + i] = 6.0 # Hügel nur Süd-West
terrain["heightmap_r16_b64"] = TerrainField.encode(heights,
float(terrain["min_height_m"]), float(terrain["max_height_m"]))
var mats := SvmapIO.decode_materials(terrain)
for j in range(84, 93):
for i in range(20, 30):
mats[j * res + i] = 1 # Sandfleck nur Süd-West
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
var objs: Array = doc["objects"]
objs.append({"id": "fass", "pos": [-90.0, 6.0, 50.0], "scale": 1.0, "rot_y_deg": 30.0}) # Hügel
objs.append({"id": "fass", "pos": [90.0, 6.0, -50.0], "scale": 1.0, "rot_y_deg": 10.0}) # Widerspruch (Spiegelpunkt)
objs.append({"id": "fass", "pos": [110.0, 6.0, -60.0], "scale": 1.0, "rot_y_deg": 0.0}) # solo rechts
var fund := BuildOps.make_piece("fundament", Vector3(-90.0, 6.0, 40.0), 0.0)
BuildOps.ensure_block(doc).append(fund) # nur links (auf dem Hügel), kein Zwilling
SpawnOps.remove_point(doc, 1, "B1") # roter Punkt weg → A4 solo
var zones: Dictionary = doc["spawn_zones"]
((zones["0"] as Dictionary)["points"] as Array).append({"id": "A9", "pos": [-60.0, 6.0, 40.0]})
func _shot(cam: Camera3D, path: String, pos: Vector3) -> void:
cam.position = pos
await process_frame
cam.look_at(Vector3(0.0, 0.0, -5.0), Vector3(0.0, 0.0, -1.0))
await process_frame
await process_frame
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png(path)
print("BILD: %s" % path)
func _check(cond: bool, label: String, fehler: Array) -> void:
print(" %s: %s" % ["ok" if cond else "FEHLER", label])
if not cond:
fehler.append(label)

View file

@ -0,0 +1 @@
uid://bc4d448ngu66c

View file

@ -0,0 +1,371 @@
extends SceneTree
## M5-Tests (headless): Karte t_60014669 — Ein-Klick „Karte symmetrisch machen“
## + Symmetrie-Check mit Klartext-Bericht. Ausführen:
## godot --headless --path . --script res://tests/test_symmetry_onclick.gd
## Exit 0 = alles grün; zusätzlich nach Parse Error/SCRIPT ERROR greifen.
## Die Arbeit lebt in SymmetryOps (statisch); das Panel (Registry-Weg) wird
## mit einem Mock-main gefahren — kein echtes UI nötig.
var failures := 0
var checks := 0
## spawn_tool.gd bewusst OHNE class_name (M4-Entscheidung) → preload.
const SpawnToolScript := preload("res://src/editor/spawn_tool.gd")
func _init() -> void:
_test_analyze_asym_map()
_test_check_klartext()
_test_apply_and_check_zero()
_test_halves_cell_equal()
_test_source_flip()
_test_legacy_without_sym()
_test_second_apply_nothing()
_test_undo_single_step()
_test_mode_off()
_after_frames()
func _after_frames() -> void:
await process_frame
await process_frame
_test_panel_flow()
_test_panel_off_and_choice()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
# ------------------------------------------------------------ Test-Karten ---
func _obj(id: String, pos: Array, rot := 0.0) -> Dictionary:
return {"id": id, "pos": pos, "scale": 1.0, "rot_y_deg": float(rot)}
## Synthetische, bewusst ASYMMETRISCHE Karte (Modus Punkt):
## - Hügel + Sandfleck nur auf der linken Hälfte,
## - Objekt A links ohne Zwilling, Objekt B rechts (Widerspruch: falsche Drehung),
## - Objekt C rechts OHNE Gegenstück (wird zurückgespiegelt),
## - Default-Spawns sind punkt-symmetrisch; ein roter Punkt fehlt (B1) und
## ein zusätzlicher blauer Punkt sitzt solo links.
func _asym_doc() -> Dictionary:
var doc := SvmapDocument.create_new("M5Test", 1024.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var res := int(terrain["resolution"])
var heights := SvmapIO.decode_heights(terrain)
for j in res:
for i in 60:
heights[j * res + i] = 8.0 # Hügel nur links (i < 60 < Mitte 64)
terrain["heightmap_r16_b64"] = TerrainField.encode(heights,
float(terrain["min_height_m"]), float(terrain["max_height_m"]))
var mats := SvmapIO.decode_materials(terrain)
for j in range(60, 70):
for i in range(10, 20):
mats[j * res + i] = 1 # Sandfleck nur links
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
(doc["objects"] as Array).append(_obj("crate_wood", [-100.0, 0.0, 40.0], 30.0)) # A (0)
(doc["objects"] as Array).append(_obj("crate_wood", [100.0, 0.0, -40.0], 10.0)) # B (1) Widerspruch
(doc["objects"] as Array).append(_obj("crate_wood", [200.0, 0.0, 0.0], 0.0)) # C (2) solo rechts
SpawnOps.remove_point(doc, 1, "B1") # roter Punkt weg → blauer A4 solo
var zones: Dictionary = doc["spawn_zones"]
((zones["0"] as Dictionary)["points"] as Array).append(
{"id": "A9", "pos": [-150.0, 0.0, 100.0]}) # extra blau solo links
return doc
func _mirror_cell_equal(doc: Dictionary, mode: int) -> bool:
var terrain: Dictionary = doc.get("terrain", {})
var res := int(terrain.get("resolution", 0))
if res < 2:
return true
var heights := SvmapIO.decode_heights(terrain)
var mats := SvmapIO.decode_materials(terrain)
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
if absf(heights[j * res + i] - heights[mc.y * res + mc.x]) > 0.0001:
return false
if mats[j * res + i] != mats[mc.y * res + mc.x]:
return false
return true
func _hill_at(doc: Dictionary, x: float, z: float) -> float:
return SvmapDocument.terrain_height_at(doc, x, z)
func _doc_str(doc: Dictionary) -> String:
return var_to_str(doc)
# ---------------------------------------------------------------- Analyse ---
func _test_analyze_asym_map() -> void:
print("== Analyse der asymmetrischen Testkarte ==")
var doc := _asym_doc()
var an := SymmetryOps.analyze(doc, SymmetryCore.Mode.POINT)
var objects: Dictionary = an["objects"]
_check((objects["pairs"] as Array).size() == 1, "Objekte: 1 Paar gefunden (A↔B)")
_check(int(objects["diff"]) == 1, "Objekte: das Paar widerspricht (Drehung 30° vs 10°)")
_check((objects["singles_high"] as Array) == [2], "Objekte: C (rechts) ist zwillinglos")
_check((objects["singles_low"] as Array).is_empty(), "Objekte: links kein Zwillingloser")
var spawns: Dictionary = an["spawns"]
_check((spawns["pairs"] as Array).size() == 3, "Spawns: 3 Default-Paare (A2↔B3, A3↔B2, A1↔B4)")
_check(int(spawns["diff"]) == 0, "Spawns: Default-Paare passen exakt")
_check((spawns["singles_low"] as Array).size() == 2, "Spawns: 2 Solo-Punkte links (A4 + A9)")
_check((an["terrain_cells"] as Array).size() > 0, "Boden: abweichende Zellen erkannt")
_check(int(an["conflicts"]) == int(objects["diff"]) + int(spawns["diff"])
+ (an["terrain_cells"] as Array).size(), "Widerspruchs-Zahl = Objekt- + Spawn- + Boden-Widersprüche")
_check(int(an["adds"]) == 3, "3 fehlende Zwillinge (C, A4, A9)")
_check(int(an["total_deviations"]) == int(an["_dev_total"]),
"Abweichungs-Zähler konsistent")
func _test_check_klartext() -> void:
print("== Klartext-Bericht des Checks ==")
var doc := _asym_doc()
var rep := SymmetryOps.check(doc, SymmetryCore.Mode.POINT)
_check(not bool(rep["ok"]), "asymmetrische Karte: Check meldet nicht ok")
var text := str(rep["text"])
_check(text.contains("Stellen weichen ab: "),
"Bericht nennt die GESAMT-Zahl zuerst (ist: %s …)" % text.left(24))
_check(int(rep["analyze"]["total_deviations"]) > 3,
"Hügel allein erzeugt viele Boden-Abweichungen (ehrlich gezählt)")
_check(text.contains("Objekt #0"), "Bericht nennt das widersprechende Objekt (Objekt #0)")
_check(text.contains("Boden ("), "Bericht nennt Boden-Zellen mit Koordinaten")
_check(text.contains("hat keinen Zwilling"), "Bericht nennt fehlende Zwillinge")
_check(text.length() < 600, "Bericht bleibt kurz (Beispiele gekappt)")
# ------------------------------------------------------------ Anwenden ------
func _test_apply_and_check_zero() -> void:
print("== Ein-Klick: asymmetrische Karte → 0 Abweichungen ==")
var doc := _asym_doc()
var res := SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
_check(bool(res["ok"]), "apply läuft durch")
var c: Dictionary = res["counts"]
_check(int(c["objects_fixed"]) == 1, "1 Objekt-Widerspruch zugunsten links entschieden")
_check(int(c["objects_added"]) == 1, "1 Objekt ergänzt (C zurückgespiegelt)")
_check(int(c["spawns_added"]) == 2, "2 Spawn-Punkte ergänzt (A4, A9)")
_check(int(c["terrain_cells"]) > 0, "Bodenstellen angeglichen")
var report := str(res["report"])
_check(report.contains("Fertig ✓"), "Klartext-Erfolgsmeldung (Fertig ✓)")
_check(report.contains("links gewonnen"), "Bericht nennt die gewinnende Hälfte")
_check(report.contains("Strg+Z"), "Bericht nennt das Rückgängig-Machen")
var rep := SymmetryOps.check(doc, SymmetryCore.Mode.POINT)
_check(bool(rep["ok"]), "Check NACH dem Anwenden: 0 Abweichungen")
_check(str(rep["text"]) == "Alles symmetrisch ✓", "Klartext: „Alles symmetrisch ✓“")
# Widerspruch zugunsten der Quelle: B trägt jetzt den exakten Spiegel von A.
var objs: Array = doc["objects"]
_check(absf(float((objs[1] as Dictionary)["rot_y_deg"]) - 210.0) < 0.0001,
"B wurde angepasst: Drehung 210° = Spiegel von A (30°)")
_check(str((objs[1] as Dictionary)["id"]) == "crate_wood", "B behält Slot (nichts gelöscht)")
_check(SymmetryCore.sym_of(objs[0]) != "" and SymmetryCore.sym_of(objs[0]) == SymmetryCore.sym_of(objs[1]),
"A + B tragen jetzt dieselbe sym-Kennung")
_check(SymmetryCore.sym_of(objs[2]) != "" and SymmetryCore.sym_of(objs[2]) != "",
"C + sein neuer Zwilling sind verkoppelt")
_check((objs as Array).size() == 4, "4 Objekte (A, B, C + C-Zwilling)")
# C-Zwilling: exakter Spiegel (-200, 0, 0).
_check(absf(float((objs[3] as Dictionary)["pos"][0]) + 200.0) < 0.0001
and absf(float((objs[3] as Dictionary)["pos"][2])) < 0.0001,
"C-Zwilling liegt exakt gespiegelt bei (-200, 0)")
# Spawn-Paarung: A4 ↔ neuer roter Punkt, A9 ↔ neuer roter Punkt.
var m1 := SpawnToolScript.spawn_mirror_partner(doc, "A4")
var m2 := SpawnToolScript.spawn_mirror_partner(doc, "A9")
_check(m1 != "" and m2 != "" and m1 != m2, "A4 und A9 haben eigene Spawn-Partner")
# Validator zufrieden (V16: eine Kennung = genau 2 Elemente).
var errors: PackedStringArray = SvmapIO.validate_map(doc, {})
var sym_errors: Array = []
for e in errors:
if str(e).contains("V16") or str(e).contains("sym"):
sym_errors.append(e)
_check(sym_errors.is_empty(), "V16/Symmetrie-Validator: keine Fehler")
func _test_halves_cell_equal() -> void:
print("== Boden: beide Hälften zellgenau gleich ==")
var doc := _asym_doc()
SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
_check(_mirror_cell_equal(doc, SymmetryCore.Mode.POINT),
"Höhen UND Material paarweise identisch (alle Zell-Paare)")
_check(absf(_hill_at(doc, -300.0, 0.0) - _hill_at(doc, 300.0, 0.0)) < 0.001,
"Weltprobe: Hügelhöhe links == rechts")
# Auch im Achsen-Modus X zellgenau.
var docx := SvmapDocument.create_new("M5AxX", 512.0, "mittag")
var terr: Dictionary = docx["terrain"]
var res := int(terr["resolution"])
var hs := SvmapIO.decode_heights(terr)
for j in res:
for i in 100:
hs[j * res + i] = 5.0
terr["heightmap_r16_b64"] = TerrainField.encode(hs,
float(terr["min_height_m"]), float(terr["max_height_m"]))
SymmetryOps.apply(docx, SymmetryCore.Mode.AXIS_X, false)
_check(_mirror_cell_equal(docx, SymmetryCore.Mode.AXIS_X), "Achse X: Zellgleichheit")
func _test_source_flip() -> void:
print("== Quell-Hälfte rechts gewinnt bei Widerspruch ==")
var doc := _asym_doc()
var res := SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, true)
_check(bool(res["ok"]), "apply (rechts gewinnt) läuft")
var objs: Array = doc["objects"]
# Quelle = rechts: A bekommt den Spiegel von B (Punkt-Modus: 10° + 180° = 190°).
_check(absf(float((objs[0] as Dictionary)["rot_y_deg"]) - 190.0) < 0.0001,
"A wurde angepasst: Spiegel von B (190°) — rechts gewinnt")
var rep := SymmetryOps.check(doc, SymmetryCore.Mode.POINT)
_check(bool(rep["ok"]), "Check danach trotzdem 0 Abweichungen")
# Der Hügel links wird zugunsten der rechten (flachen) Seite geglättet.
_check(_hill_at(doc, -300.0, 0.0) < 0.5, "Boden: rechts gewinnt → Hügel links geglättet")
func _test_legacy_without_sym() -> void:
print("== Alte Karte ohne sym-Feld: läuft und verknüpft ==")
var doc := SvmapDocument.create_new("M5Alt", 512.0, "mittag")
(doc["objects"] as Array).append(_obj("crate_wood", [-80.0, 0.0, 20.0], 45.0))
(doc["objects"] as Array).append(_obj("crate_wood", [80.0, 0.0, -20.0], 225.0))
var an := SymmetryOps.analyze(doc, SymmetryCore.Mode.POINT)
_check((an["objects"]["pairs"] as Array).size() == 1
and int(an["objects"]["diff"]) == 0, "Spiegelndes Paar erkannt, kein Widerspruch")
var res := SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
_check(int(res["counts"]["objects_linked"]) == 1, "Paar ohne sym wurde verknüpft")
_check(SymmetryCore.sym_of((doc["objects"] as Array)[0])
== SymmetryCore.sym_of((doc["objects"] as Array)[1]), "beide tragen dieselbe Kennung")
var rep := SymmetryOps.check(doc, SymmetryCore.Mode.POINT)
_check(bool(rep["ok"]), "alte Karte: danach symmetrisch ✓")
func _test_second_apply_nothing() -> void:
print("== Zweiter Klick: nichts zu tun, keine unnötige Änderung ==")
var doc := _asym_doc()
SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
var before := _doc_str(doc)
var res := SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
_check(str(res["report"]).contains("Nichts zu tun"), "Bericht: nichts zu tun")
_check(_doc_str(doc) == before, "doc dabei UNVERÄNDERT (byte-gleich)")
func _test_undo_single_step() -> void:
print("== EIN Undo-Schritt stellt den Ausgangszustand VOLLSTÄNDIG her ==")
var doc := _asym_doc()
var hist := ActionHistory.new()
hist.begin(doc)
var pre := _doc_str(doc)
var before: Dictionary = hist.snapshot(doc.keys())
# design_notes fehlt evtl. (alte Karte) — als null seeden, damit das Undo
# den vom Vorgang neu angelegten Block auch wieder ENTFERNT (U3-Lektion).
if not before.has("design_notes"):
before["design_notes"] = null
SymmetryOps.apply(doc, SymmetryCore.Mode.POINT, false)
hist.commit("Karte symmetrisch machen", before, hist.snapshot(doc.keys()))
_check(hist.entries.size() == 1, "EIN Historie-Eintrag für den ganzen Vorgang")
_check(_doc_str(doc) != pre, "doc hat sich geändert (Sinnprobe)")
hist.undo()
_check(_doc_str(doc) == pre, "Ein Strg+Z: doc VOLLSTÄNDIG wie vorher (alle Blöcke)")
func _test_mode_off() -> void:
print("== Modus AUS: nichts ändert sich ==")
var doc := _asym_doc()
var before := _doc_str(doc)
var res := SymmetryOps.apply(doc, SymmetryCore.Mode.OFF, false)
_check(not bool(res["ok"]), "apply verweigert bei Modus AUS")
_check(_doc_str(doc) == before, "Modus AUS: doc unverändert")
var rep := SymmetryOps.check(doc, SymmetryCore.Mode.OFF)
_check(not bool(rep["ok"]) and str(rep["text"]).contains("Aus"),
"Check bei AUS: ehrlicher Hinweis statt „symmetrisch“")
# --------------------------------------------------------- Panel (Registry) --
## Mock-main: Panel braucht doc/sym_mode/history + Status-Rufe.
class ProbeMain:
var doc: Dictionary = {}
var manifest: Dictionary = {}
var sym_mode: int = SymmetryCore.Mode.POINT
var history: ActionHistory = ActionHistory.new()
var dirty := false
var last_status := ""
func _set_status(msg: String, _err := false) -> void:
last_status = msg
func _after_structure_change() -> void:
dirty = true
func _make_panel(doc: Dictionary, mode: int) -> VBoxContainer:
var probe := ProbeMain.new()
probe.doc = doc
probe.sym_mode = mode
probe.history.begin(doc)
var panel := VBoxContainer.new()
panel.set_script(load("res://src/ui/panels/symmetry_panel.gd"))
root.add_child(panel)
panel.setup(probe)
panel.call("build")
return panel
func _test_panel_flow() -> void:
print("== Panel: Ein-Klick-Ablauf über die Registry ==")
var doc := _asym_doc()
var panel := _make_panel(doc, SymmetryCore.Mode.POINT)
panel.call("_on_apply_pressed")
var dlg: ConfirmationDialog = panel.get("conflict_dialog")
var report: Label = panel.get("report_label")
_check(dlg.dialog_text.contains("unterscheiden sich") or dlg.dialog_text.contains("Bodenstellen"),
"Widerspruchs-Dialog nennt die Zahlen (Klartext)")
_check(str(dlg.dialog_text).contains("NICHTS gelöscht"),
"Dialog sagt: nichts wird gelöscht")
# Sly bestätigt mit der gewählten Quelle (links) → anwenden.
panel.call("_on_conflict_confirmed")
_check((panel.get("main") as ProbeMain).history.entries.size() == 1,
"ganzer Vorgang = EIN Undo-Schritt (Panel-Pfad)")
_check(str(report.text).contains("Fertig ✓"), "Bericht-Label zeigt den Klartext-Erfolg")
_check(str((panel.get("main") as ProbeMain).last_status).length() > 0,
"Statuszeile wurde bedient")
# Check-Knopf: danach „Alles symmetrisch ✓“.
panel.call("_on_check_pressed")
_check(str(report.text) == "Alles symmetrisch ✓", "Check-Bericht im Panel: Alles symmetrisch ✓")
panel.free() # sofort frei geben: sonst hängt der exklusive Dialog im Fenster
func _test_panel_off_and_choice() -> void:
print("== Panel: Modus AUS tut nichts; Dialog erlaubt Seitenwechsel ==")
var doc := _asym_doc()
var before := _doc_str(doc)
var panel := _make_panel(doc, SymmetryCore.Mode.OFF)
panel.call("_on_apply_pressed")
_check(str((panel.get("main") as ProbeMain).last_status).contains("Aus"),
"Modus AUS: Klartext-Hinweis statt Aktion")
_check(_doc_str(doc) == before, "Modus AUS: doc unverändert")
_check((panel.get("main") as ProbeMain).history.entries.size() == 0,
"Modus AUS: kein Historie-Eintrag")
panel.free()
# Dialog: „Andere Hälfte als Quelle“ → rechts gewinnt.
var doc2 := _asym_doc()
var panel2 := _make_panel(doc2, SymmetryCore.Mode.POINT)
panel2.call("_on_apply_pressed")
panel2.call("_on_conflict_other", "other_half")
var objs: Array = doc2["objects"]
_check(absf(float((objs[0] as Dictionary)["rot_y_deg"]) - 190.0) < 0.0001,
"Dialog-Wechsel: Quelle rechts hat gewonnen (A = 190°)")
_check((panel2.get("main") as ProbeMain).history.entries.size() == 1,
"Seitenwechsel-Pfad: ebenfalls EIN Undo-Schritt")
panel2.free()

View file

@ -0,0 +1 @@
uid://dm8vbx08thokj