diff --git a/src/editor/symmetry_ops.gd b/src/editor/symmetry_ops.gd new file mode 100644 index 0000000..03c54c6 --- /dev/null +++ b/src/editor/symmetry_ops.gd @@ -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 diff --git a/src/editor/symmetry_ops.gd.uid b/src/editor/symmetry_ops.gd.uid new file mode 100644 index 0000000..1ba1588 --- /dev/null +++ b/src/editor/symmetry_ops.gd.uid @@ -0,0 +1 @@ +uid://csb4dkvi508of diff --git a/src/ui/panels/registry.gd b/src/ui/panels/registry.gd index b3bd700..01a016a 100644 --- a/src/ui/panels/registry.gd +++ b/src/ui/panels/registry.gd @@ -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"}, diff --git a/src/ui/panels/symmetry_panel.gd b/src/ui/panels/symmetry_panel.gd new file mode 100644 index 0000000..e65a60e --- /dev/null +++ b/src/ui/panels/symmetry_panel.gd @@ -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) diff --git a/src/ui/panels/symmetry_panel.gd.uid b/src/ui/panels/symmetry_panel.gd.uid new file mode 100644 index 0000000..1c311e5 --- /dev/null +++ b/src/ui/panels/symmetry_panel.gd.uid @@ -0,0 +1 @@ +uid://ceu6y127v6lxc diff --git a/tests/probe_m5_symmetry_render.gd b/tests/probe_m5_symmetry_render.gd new file mode 100644 index 0000000..8d29542 --- /dev/null +++ b/tests/probe_m5_symmetry_render.gd @@ -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) diff --git a/tests/probe_m5_symmetry_render.gd.uid b/tests/probe_m5_symmetry_render.gd.uid new file mode 100644 index 0000000..0a53b13 --- /dev/null +++ b/tests/probe_m5_symmetry_render.gd.uid @@ -0,0 +1 @@ +uid://bc4d448ngu66c diff --git a/tests/test_symmetry_onclick.gd b/tests/test_symmetry_onclick.gd new file mode 100644 index 0000000..d82569b --- /dev/null +++ b/tests/test_symmetry_onclick.gd @@ -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() diff --git a/tests/test_symmetry_onclick.gd.uid b/tests/test_symmetry_onclick.gd.uid new file mode 100644 index 0000000..9abc3a7 --- /dev/null +++ b/tests/test_symmetry_onclick.gd.uid @@ -0,0 +1 @@ +uid://dm8vbx08thokj