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:
parent
835a7f7363
commit
0e1406a63e
9 changed files with 1412 additions and 0 deletions
677
src/editor/symmetry_ops.gd
Normal file
677
src/editor/symmetry_ops.gd
Normal 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
|
||||
1
src/editor/symmetry_ops.gd.uid
Normal file
1
src/editor/symmetry_ops.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://csb4dkvi508of
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
233
src/ui/panels/symmetry_panel.gd
Normal file
233
src/ui/panels/symmetry_panel.gd
Normal 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)
|
||||
1
src/ui/panels/symmetry_panel.gd.uid
Normal file
1
src/ui/panels/symmetry_panel.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ceu6y127v6lxc
|
||||
123
tests/probe_m5_symmetry_render.gd
Normal file
123
tests/probe_m5_symmetry_render.gd
Normal 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)
|
||||
1
tests/probe_m5_symmetry_render.gd.uid
Normal file
1
tests/probe_m5_symmetry_render.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bc4d448ngu66c
|
||||
371
tests/test_symmetry_onclick.gd
Normal file
371
tests/test_symmetry_onclick.gd
Normal 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()
|
||||
1
tests/test_symmetry_onclick.gd.uid
Normal file
1
tests/test_symmetry_onclick.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dm8vbx08thokj
|
||||
Loading…
Reference in a new issue