From 0adb1613869ad1e5fe2ba8b7e6b14f2ca8370070 Mon Sep 17 00:00:00 2001 From: "Hermes (scopedvr-dev)" Date: Tue, 20 Oct 2026 06:37:50 +0000 Subject: [PATCH] =?UTF-8?q?t=5Ff5935023:=20M1=20Symmetrie-Fundament=20(Spe?= =?UTF-8?q?c=20vc27,=20Sly=2020.10.)=20=E2=80=94=20SymmetryCore=20(Punkt/A?= =?UTF-8?q?chse=20X/Achse=20Z,=20Zell-Abbildung,=20Partner-Link=20sym,=20a?= =?UTF-8?q?lle=20statisch/pure,=20headless=20testbar)=20+=20Validator=20V1?= =?UTF-8?q?6=20in=20svmap=5Fio=20(sym=20muss=20String,=20nicht=20leer,=20?= =?UTF-8?q?=E2=89=A464=20Zeichen,=20genau=202=20pro=20Kennung)=20+=20Toolb?= =?UTF-8?q?ar-Schalter=20+=20Statuszeile-Anzeige=20in=20main.gd=20(Standar?= =?UTF-8?q?d=3DPunkt,=20Sly=20sieht=20Modus=20immer)=20+=20Suite=20test=5F?= =?UTF-8?q?symmetry=5Fcore=20(93=20Checks:=20Modus-Katalog,=20Kartenmitte?= =?UTF-8?q?=20aus=20bounds,=20zweimal-spiegeln=3DIdentity=20f=C3=BCr=20all?= =?UTF-8?q?e=20Modi,=20Achse/Zentrum-nicht-doppeln,=20Raster-Zell-Mapping?= =?UTF-8?q?=20inkl.=20gerader/ungerader=20res,=20Partner-Link-API,=20V16-V?= =?UTF-8?q?alidator,=20Roundtrip=20mit=20sym=20byte-identisch,=20Legacy-Ka?= =?UTF-8?q?rte=20ohne=20sym=20unver=C3=A4ndert).=2017/19=20Suiten=20gr?= =?UTF-8?q?=C3=BCn=20(test=5Fupdater=2015/75=20FAIL=20+=20test=5Fglb=5Fvie?= =?UTF-8?q?w=2015/25=20FAIL=20=3D=20PRE-EXISTING=20per=20git-stash-Verfahr?= =?UTF-8?q?en=20gegen=20437f6bf=20verifiziert,=20Ursache=20unabh=C3=A4ngig?= =?UTF-8?q?=20vom=20Diff).=20M1=20legt=20nur=20Mathe=20+=20Anzeige=20fest?= =?UTF-8?q?=20=E2=80=94=20gespiegeltes=20Bauen=20selbst=20kommt=20mit=20M2?= =?UTF-8?q?=20(Terrain),=20M3=20(Objekte),=20M4=20(Bau-Teile=20+=20Spawns)?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/symmetry_core.gd | 189 ++++++++++++++++++++ src/editor/symmetry_core.gd.uid | 1 + src/svmap/svmap_io.gd | 27 +++ src/ui/main.gd | 55 ++++++ tests/probe_cache_keys.gd.uid | 1 + tests/probe_camo_render.gd.uid | 1 + tests/probe_getimg.gd.uid | 1 + tests/test_symmetry_core.gd | 300 ++++++++++++++++++++++++++++++++ tests/test_symmetry_core.gd.uid | 1 + 9 files changed, 576 insertions(+) create mode 100644 src/editor/symmetry_core.gd create mode 100644 src/editor/symmetry_core.gd.uid create mode 100644 tests/probe_cache_keys.gd.uid create mode 100644 tests/probe_camo_render.gd.uid create mode 100644 tests/probe_getimg.gd.uid create mode 100644 tests/test_symmetry_core.gd create mode 100644 tests/test_symmetry_core.gd.uid diff --git a/src/editor/symmetry_core.gd b/src/editor/symmetry_core.gd new file mode 100644 index 0000000..4767eb1 --- /dev/null +++ b/src/editor/symmetry_core.gd @@ -0,0 +1,189 @@ +extends RefCounted +class_name SymmetryCore +## Symmetrie-Modus im Map Builder (Karte t_f5935023, Spec +## /opt/data/scopedvr/KARTEN_MIRROR_vc27.md, Entscheidung Sly 20.10.): +## gespiegeltes Bauen — Änderungen an einer Kartenseite landen automatisch +## auf der anderen. DIESE Datei ist M1 (Fundament): reine Mathe + Format- +## Helfer, KEIN Spiegeln von Aktionen — das kommt mit M2 (Terrain), +## M3 (Objekte) und M4 (Bau-Teile + Spawns). +## +## Spiegel-Mathematik (Spec vc27): +## Zentrum = Kartenmitte. Editor-Maps liegen zentriert (bounds +## −size/2 … +size/2), die Mitte ist also in Weltkoordinaten (0, 0) — +## map_center() rechnet sie trotzdem generell aus den bounds aus +## ((min + max) / 2), damit die Helfer auch bei asymmetrischen bounds +## korrekt bleiben. +## | Modus | Position | rot_y | +## |-------------|-----------------------------|--------------| +## | Punkt (180°)| x' = 2cx − x, z' = 2cz − z | rot + 180° | +## | Achse X | x' = 2cx − x, z' = z | −rot | +## | Achse Z | x' = x, z' = 2cz − z | −rot | +## y bleibt in ALLEN Modi unverändert (Höhe ist ein Skalar). +## Standard = Punktspiegelung (nur sie macht beide Hälften wirklich +## deckungsgleich — Achsenspiegelung dreht den Zwilling um 180°, siehe Spec). +## +## Terrain-Zell-Abbildung (Zelle (i,j), i läuft entlang x, j entlang z — +## siehe SvmapDocument.terrain_height_at): Punkt (res−1−i, res−1−j), +## Achse X (res−1−i, j), Achse Z (i, res−1−j). +## +## Partner-Link: ein Paar trägt denselben `sym`-Schlüssel (optionales Feld +## an Objekten UND Bau-Teilen). Validator-Regel (svmap_io.gd, V16): +## String, nicht leer, ≤ 64 Zeichen, höchstens 2 Objekte pro Kennung. +## Alte Karten ohne `sym` laden unverändert (Rückwärtskompatibilität). +## +## Alles static + reine Funktionen, ohne Node-Abhängigkeit → headless testbar. + +enum Mode { + OFF = 0, # Symmetrie aus + POINT = 1, # Punktspiegelung 180° um die Kartenmitte (Standard) + AXIS_X = 2, # Achsenspiegelung X (links ↔ rechts) + AXIS_Z = 3, # Achsenspiegelung Z (vorn ↔ hinten) +} + +## Toolbar-/Statuszeilen-Texte, Index = Mode-Wert. +const MODE_LABELS: Array[String] = ["Aus", "Punkt", "Achse X", "Achse Z"] + +## Toleranz in Metern für „liegt auf der Achse/im Zentrum“ — solche Elemente +## werden beim gespiegelten Bauen NICHT gedoppelt (Spec vc27). +const ON_AXIS_TOLERANCE_M := 0.05 + + +# ------------------------------------------------------------ Modus/Helfer -- + +static func mode_label(mode: int) -> String: + return MODE_LABELS[clampi(mode, 0, MODE_LABELS.size() - 1)] + + +## Kartenmitte aus den bounds ((min + max) / 2 in X und Z). Für zentrierte +## Editor-Maps = (0, 0), unabhängig von der Kartengröße — die Größe steckt +## in den bounds. Fehlen die bounds (defektes doc) → (0, 0). +static func map_center(doc: Dictionary) -> Vector2: + var bounds: Dictionary = doc.get("bounds", {}) + var mn: Array = bounds.get("min", []) + var mx: Array = bounds.get("max", []) + if (mn as Array).size() == 3 and (mx as Array).size() == 3: + return Vector2( + (float(mn[0]) + float(mx[0])) * 0.5, + (float(mn[2]) + float(mx[2])) * 0.5) + return Vector2.ZERO + + +# ----------------------------------------------------- Transform-Helfer ----- + +## Spiegelt die XZ-Position eines Objekts/Bau-Teils; Y (Höhe) bleibt gleich. +static func mirror_point(mode: int, p: Vector3, center: Vector2) -> Vector3: + match mode: + Mode.POINT: + return Vector3(2.0 * center.x - p.x, p.y, 2.0 * center.y - p.z) + Mode.AXIS_X: + return Vector3(2.0 * center.x - p.x, p.y, p.z) + Mode.AXIS_Z: + return Vector3(p.x, p.y, 2.0 * center.y - p.z) + _: + return p + + +## Spiegelt die Y-Drehung in Grad. Normalisiert auf [0, 360): zwei Anwendungen +## ergeben wieder die Ausgangsdrehung (modulo 360 — gleiche Orientierung). +## Punkt: +180°, Achsen: −180°-Spiegel = Vorzeichenwechsel. +static func mirror_rot_y(mode: int, rot_y_deg: float) -> float: + match mode: + Mode.POINT: + return fposmod(rot_y_deg + 180.0, 360.0) + Mode.AXIS_X, Mode.AXIS_Z: + return fposmod(-rot_y_deg, 360.0) + _: + return rot_y_deg + + +## true, wenn der Punkt unter diesem Modus auf sich selbst abgebildet wird +## (Zentrum bei Punktspiegelung, Spiegelachse bei Achsenspiegelung, ± Toleranz). +## Solche Elemente werden beim gespiegelten Bauen NICHT gedoppelt. +static func maps_to_self(mode: int, p: Vector3, center: Vector2, + threshold_m: float = ON_AXIS_TOLERANCE_M) -> bool: + match mode: + Mode.POINT: + return Vector2(p.x - center.x, p.z - center.y).length() < threshold_m + Mode.AXIS_X: + return absf(p.x - center.x) < threshold_m + Mode.AXIS_Z: + return absf(p.z - center.y) < threshold_m + _: + return true + + +# ------------------------------------------------ Zell-Abbildung (Terrain) -- + +## Rasterzelle (i, j) → gespiegelte Zelle. res = terrain.resolution. +## i läuft entlang x, j entlang z (wie terrain_height_at). OFF → unverändert. +static func mirror_cell(mode: int, i: int, j: int, res: int) -> Vector2i: + if res < 2: + return Vector2i(i, j) + var last := res - 1 + match mode: + Mode.POINT: + return Vector2i(last - i, last - j) + Mode.AXIS_X: + return Vector2i(last - i, j) + Mode.AXIS_Z: + return Vector2i(i, last - j) + _: + return Vector2i(i, j) + + +# ------------------------------------------------------------ Partner-Link -- + +## sym-Kennung eines Elements lesen (leer = kein Partner). Typsicher: nur +## nicht-leere Strings gelten; defekte Felder werden still als „kein Partner“ +## behandelt (Rückwärtskompatibilität mit alten Karten). +static func sym_of(obj: Dictionary) -> String: + var s = obj.get("sym", "") + return s if s is String else "" + + +## sym-Kennung setzen (mutiert das Element und gibt es zurück zum Verketten). +static func set_sym(obj: Dictionary, key: String) -> Dictionary: + obj["sym"] = key + return obj + + +## sym-Kennung entfernen (Feld komplett löschen — fehlt es, wie in alten Karten). +static func clear_sym(obj: Dictionary) -> Dictionary: + obj.erase("sym") + return obj + + +## Index des Partner-Elements in einer Liste (objects ODER gebaeude): +## das Element mit derselben sym-Kennung (index selbst ausgeschlossen). +## −1 = kein Partner gefunden. +static func find_partner_index(list: Array, key: String, exclude_index: int = -1) -> int: + if key.is_empty(): + return -1 + for i in list.size(): + if i == exclude_index or not (list[i] is Dictionary): + continue + if sym_of(list[i] as Dictionary) == key: + return i + return -1 + + +## Nächste freie sym-Kennung im Dokument ("sym_1", "sym_2", …): scannt +## Objekte UND Bau-Teile, deterministic (kein Zufall, keine Uhr). +static func next_sym_key(doc: Dictionary) -> String: + var used: Dictionary = {} + for block_key in ["objects", "gebaeude"]: + var block: Array = doc.get(block_key, []) + if not (block is Array): + continue + for item in block: + if not (item is Dictionary): + continue + var s := sym_of(item as Dictionary) + if s.begins_with("sym_"): + var tail := s.substr(4) + if tail.is_valid_int(): + used[int(tail)] = true + var n := 1 + while used.has(n): + n += 1 + return "sym_%d" % n diff --git a/src/editor/symmetry_core.gd.uid b/src/editor/symmetry_core.gd.uid new file mode 100644 index 0000000..51ee5f0 --- /dev/null +++ b/src/editor/symmetry_core.gd.uid @@ -0,0 +1 @@ +uid://e08hmo3p6ra3 diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index 68097b0..415521d 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -41,6 +41,7 @@ const MAX_HEIGHT_RANGE_M := 500.0 # V6 const MAX_BOUNDS_EXTENT_M := 8192.0 # V14 const BOUNDS_TOLERANCE_M := 1.0 # V9 const MIN_SPAWN_POINTS_PER_TEAM := 4 # V11 (Entscheidung 11) +const SYM_KEY_MAX_LEN := 64 # V16 (Symmetrie-Partner-Kennung) const MANIFEST_PATH := "res://library/objects.json" const JSON_INDENT := " " @@ -279,9 +280,35 @@ static func _validate_objects(objects: Array, bounds: Dictionary, manifest: Dict var rot = obj.get("rot_y_deg", 0.0) if not _is_finite_num(rot): errors.append("V9: objects[%d]: rot_y_deg muss endlich sein." % i) + # V16 (Karte t_f5935023): optionales `sym` (Symmetrie-Partner-Kennung). + # Editor-Format-Erweiterung: der Match-Server ignoriert unbekannte + # Felder, die Prüfsumme läuft über die ganze Payload → kompatibel. + # Fehlt das Feld (alte Karten) → keine Prüfung, alles unverändert. + var sym = obj.get("sym", null) + if sym != null: + if not (sym is String): + errors.append("V16: objects[%d]: sym muss ein String sein (ist %s)." % [i, str(sym)]) + elif (sym as String).strip_edges().is_empty(): + errors.append("V16: objects[%d]: sym darf nicht leer sein." % i) + elif (sym as String).length() > SYM_KEY_MAX_LEN: + errors.append("V16: objects[%d]: sym länger als %d Zeichen." % [i, SYM_KEY_MAX_LEN]) # V8b: Poly-Budget (× scale² — Flächen-/Kantenwachstum der Meshes) var poly = float(lib[oid].get("poly", 0)) total_poly += poly * float(scale) * float(scale) + # V16: eine sym-Kennung verbindet GENAU EIN Paar — mehr als 2 Objekte + # mit derselben Kennung sind ein Verkettungsfehler. + var sym_counts: Dictionary = {} + for obj2 in objects: + if not (obj2 is Dictionary): + continue + var s2 = (obj2 as Dictionary).get("sym", null) + if s2 is String and (s2 as String) != "": + var k := str(s2) + sym_counts[k] = int(sym_counts.get(k, 0)) + 1 + for k2 in sym_counts.keys(): + if int(sym_counts[k2]) > 2: + errors.append("V16: sym-Kennung '%s' verbindet %d Objekte — ein Paar hat genau 2." % [ + k2, int(sym_counts[k2])]) if total_poly > MAX_TOTAL_POLY: errors.append("V8: Poly-Budget überschritten (%.0f > %d)." % [total_poly, MAX_TOTAL_POLY]) return errors diff --git a/src/ui/main.gd b/src/ui/main.gd index 2896a41..6f34520 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -42,6 +42,13 @@ var spawn_tool # src/editor/spawn_tool.gd (Zonen, Karte t_c4162c5 var build_tool # src/editor/build_tool.gd (Bauen-Modus, Karte t_97ea59c5) var tool_mode := "objects" # "objects" | "terrain" | "spawns" | "bauen" (Modular-Builder) +# Symmetrie-Modus (Karte t_f5935023, M1 Fundament): Modus + Toolbar-Schalter +# + Statuszeilen-Anzeige. Standard = Punktspiegelung (Sly 20.10.). M1 legt nur +# Modus + Anzeige fest; das Spiegeln selbst kommt mit M2–M4. +var sym_menu: MenuButton +var sym_label: Label +var sym_mode: int = SymmetryCore.Mode.POINT + # UI-Referenzen var content_tree: Tree var stats_objects: Label @@ -182,6 +189,8 @@ func _ready() -> void: manifest = _load_manifest() _build_ui() _update_tool_label() + # Symmetrie-Anzeige initialisieren (Karte t_f5935023, M1): Standard = Punkt. + _update_sym_ui() # Fenster-Zustand (Karte t_297221b5): Default MAXIMIERT (kein Vollbild); # wer auf Fenstergröße umschaltet, bekommt die gemerkte Lage wieder. # Close-Request selbst behandeln, damit die Lage VOR dem Quit gemerkt wird. @@ -281,6 +290,20 @@ func _build_ui() -> void: contours_btn.toggled.connect(_on_contours_toggled) top_h.add_child(contours_btn) + # Symmetrie-Schalter (Karte t_f5935023, M1): Aus / Punkt / Achse X / Achse Z. + # Standard = Punktspiegelung (Sly 20.10.). M1 setzt nur Modus + Anzeige; + # das gespiegelte Bauen selbst kommt mit M2–M4. + sym_menu = MenuButton.new() + sym_menu.text = "Symmetrie: Punkt" + sym_menu.flat = false + sym_menu.custom_minimum_size = UIScale.v2(160, 40) + sym_menu.tooltip_text = "Gespiegeltes Bauen: Änderungen werden automatisch auf die andere Kartenseite übertragen. Aus / Punkt (180° um die Kartenmitte) / Achse X / Achse Z. Standard: Punkt." + var sym_popup := sym_menu.get_popup() + for i in SymmetryCore.MODE_LABELS.size(): + sym_popup.add_check_item(SymmetryCore.MODE_LABELS[i], i) + sym_popup.index_pressed.connect(_on_sym_mode_selected) + top_h.add_child(sym_menu) + # Map-Fertig-Check (Karte t_c4162c5d): Klartext-Liste VOR dem Export. check_btn = Button.new() check_btn.text = "✅ Karte prüfen" @@ -373,6 +396,14 @@ func _build_ui() -> void: tool_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS status_h.add_child(tool_label) + # Symmetrie-Anzeige (Karte t_f5935023, M1): Sly sieht in der Statuszeile + # immer, ob/wie gespiegelt wird (Standard: Punkt). + sym_label = Label.new() + sym_label.modulate = Color(0.55, 0.8, 1.0) + sym_label.clip_text = true + sym_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + status_h.add_child(sym_label) + stats_objects = Label.new() stats_objects.modulate = Color(1, 1, 1, 0.75) stats_objects.clip_text = true @@ -1847,6 +1878,30 @@ func _update_tool_label() -> void: tool_label.text = "Werkzeug: %s · Umschalt+F = Flugmodus" % str(names.get(tool_mode, tool_mode)) +## Symmetrie-Schalter (Karte t_f5935023, M1): Modus aus dem Toolbar-Menü übernehmen. +func _on_sym_mode_selected(index: int) -> void: + sym_mode = clampi(index, 0, SymmetryCore.MODE_LABELS.size() - 1) + _update_sym_ui() + if sym_mode == SymmetryCore.Mode.OFF: + _set_status("Symmetrie aus — die Karte wird nicht mehr automatisch gespiegelt.") + else: + _set_status("Symmetrie: %s — Änderungen an einer Seite werden auf die andere übertragen." % + SymmetryCore.mode_label(sym_mode)) + + +## Symmetrie-Status: Toolbar-Text + Haken + Statuszeilen-Label (M1 Fundament). +## Das tatsächliche Spiegeln von Änderungen kommt mit M2–M4. +func _update_sym_ui() -> void: + if sym_menu == null or sym_label == null: + return + var label := "Symmetrie: %s" % SymmetryCore.mode_label(sym_mode) + sym_menu.text = label + sym_label.text = label + var popup := sym_menu.get_popup() + for i in SymmetryCore.MODE_LABELS.size(): + popup.set_item_checked(i, i == sym_mode) + + # --------------------------------------------------------------- History ---- func _do_undo() -> void: diff --git a/tests/probe_cache_keys.gd.uid b/tests/probe_cache_keys.gd.uid new file mode 100644 index 0000000..c8b22f9 --- /dev/null +++ b/tests/probe_cache_keys.gd.uid @@ -0,0 +1 @@ +uid://bay7kp4kwlr5m diff --git a/tests/probe_camo_render.gd.uid b/tests/probe_camo_render.gd.uid new file mode 100644 index 0000000..1ebb0b5 --- /dev/null +++ b/tests/probe_camo_render.gd.uid @@ -0,0 +1 @@ +uid://ci6lucixnhbov diff --git a/tests/probe_getimg.gd.uid b/tests/probe_getimg.gd.uid new file mode 100644 index 0000000..363cd15 --- /dev/null +++ b/tests/probe_getimg.gd.uid @@ -0,0 +1 @@ +uid://dn7nv0olm65f5 diff --git a/tests/test_symmetry_core.gd b/tests/test_symmetry_core.gd new file mode 100644 index 0000000..b84f981 --- /dev/null +++ b/tests/test_symmetry_core.gd @@ -0,0 +1,300 @@ +extends SceneTree +## Editor-Tests (headless): Karte t_f5935023 — M1 Symmetrie-Fundament. +## Ausführen: godot --headless --path . --script res://tests/test_symmetry_core.gd +## Exit 0 = alles grün. Läuft OHNE UI (nur SymmetryCore/SvmapIO/SvmapDocument) — +## Toolbar/Statuszeile verifiziert der GUI-Smoke (--sym-demo kommt mit M2/M3). + +var failures := 0 +var checks := 0 + + +func _init() -> void: + # user:// persistiert über Godot-Läufe — vor UND nach dem Test aufräumen + # (Lektion aus t_5badf371: Stale-Reste maskierten sonst Verifikationen). + _cleanup() + _run_tests() + _cleanup() + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) + + +func _cleanup() -> void: + for path in ["user://test_sym.svmap", "user://test_sym_legacy.svmap"]: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +func _check(cond: bool, label: String) -> void: + checks += 1 + if cond: + print(" ok: %s" % label) + else: + failures += 1 + print(" FAIL: %s" % label) + + +func _errors_contain(errors: Array, needle: String) -> bool: + for e in errors: + if str(e).contains(needle): + return true + return false + + +func _run_tests() -> void: + _test_modes_and_labels() + _test_center() + _test_mirror_point_double_identity() + _test_mirror_rot_double_identity() + _test_maps_to_self() + _test_cell_mapping() + _test_partner_link() + _test_validator_sym() + _test_roundtrip_with_sym() + _test_legacy_map_unchanged() + + +# ------------------------------------------------------------------ Tests --- + +func _test_modes_and_labels() -> void: + print("== Modus-Katalog ==") + _check(SymmetryCore.Mode.OFF == 0, "OFF = 0") + _check(SymmetryCore.Mode.POINT == 1, "POINT = 1 (Standard, Sly 20.10.)") + _check(SymmetryCore.Mode.AXIS_X == 2, "AXIS_X = 2") + _check(SymmetryCore.Mode.AXIS_Z == 3, "AXIS_Z = 3") + _check(SymmetryCore.MODE_LABELS.size() == 4, "4 Modus-Labels (Aus/Punkt/Achse X/Achse Z)") + _check(SymmetryCore.mode_label(SymmetryCore.Mode.POINT) == "Punkt", "Label für POINT") + _check(SymmetryCore.mode_label(9) == "Achse Z", "Unbekannter Modus clampt auf letzten Label") + + +func _test_center() -> void: + print("== Kartenmitte ==") + var doc := SvmapDocument.create_new("SymTest", 1024.0, "mittag") + var c := SymmetryCore.map_center(doc) + _check(c.is_zero_approx(), "Zentrierte Map: Mitte = (0, 0) (bounds ±size/2)") + # Fehlende bounds → Fallback (0, 0), kein Crash. + _check(SymmetryCore.map_center({}).is_zero_approx(), "Defektes doc → Mitte (0, 0), kein Crash") + # Asymmetrische bounds: Mitte = (min + max) / 2. + var doc2 := doc.duplicate(true) + doc2["bounds"] = {"min": [-100.0, -10.0, -50.0], "max": [300.0, 80.0, 50.0]} + var c2 := SymmetryCore.map_center(doc2) + _check(absf(c2.x - 100.0) < 0.001 and absf(c2.y - 0.0) < 0.001, + "Asymmetrische bounds: Mitte (100, 0) korrekt") + + +func _test_mirror_point_double_identity() -> void: + print("== Zweimal spiegeln = Ausgangslage (Position, alle 3 Modi) ==") + var doc := SvmapDocument.create_new("SymTest", 1024.0, "mittag") + var center := SymmetryCore.map_center(doc) + var probe: Vector3 = Vector3(123.5, 42.0, -77.25) + for mode in [SymmetryCore.Mode.POINT, SymmetryCore.Mode.AXIS_X, SymmetryCore.Mode.AXIS_Z]: + var once := SymmetryCore.mirror_point(mode, probe, center) + var twice := SymmetryCore.mirror_point(mode, once, center) + _check(twice.distance_to(probe) < 0.0001, + "Modus %s: mirror(mirror(p)) == p (p=%s)" % [SymmetryCore.mode_label(mode), probe]) + _check(absf(once.y - probe.y) < 0.0001, + "Modus %s: y bleibt unverändert" % SymmetryCore.mode_label(mode)) + # Mitte bleibt Mitte (Punktspiegelung). + var mid := Vector3(0.0, 5.0, 0.0) + var mid_m := SymmetryCore.mirror_point(SymmetryCore.Mode.POINT, mid, center) + _check(mid_m.distance_to(mid) < 0.0001, "Mitte bleibt unter Punktspiegelung fix") + # Achse X: z unverändert; Achse Z: x unverändert. + var px := SymmetryCore.mirror_point(SymmetryCore.Mode.AXIS_X, probe, center) + _check(absf(px.z - probe.z) < 0.0001, "Achse X: z unverändert") + var pz := SymmetryCore.mirror_point(SymmetryCore.Mode.AXIS_Z, probe, center) + _check(absf(pz.x - probe.x) < 0.0001, "Achse Z: x unverändert") + # OFF = Identität. + _check(SymmetryCore.mirror_point(SymmetryCore.Mode.OFF, probe, center).distance_to(probe) < 0.0001, + "OFF: Position unverändert") + + +func _test_mirror_rot_double_identity() -> void: + print("== Zweimal spiegeln = Ausgangslage (Drehung, modulo 360) ==") + var rots := [0.0, 45.0, 90.0, 179.0, -90.0, 359.0, -359.0] + for mode in [SymmetryCore.Mode.POINT, SymmetryCore.Mode.AXIS_X, SymmetryCore.Mode.AXIS_Z]: + for r in rots: + var r_in: float = r + var once := SymmetryCore.mirror_rot_y(mode, r_in) + var twice := SymmetryCore.mirror_rot_y(mode, once) + var diff: float = absf(fposmod(twice - r_in + 180.0, 360.0) - 180.0) + _check(diff < 0.0001, "Modus %s: rot %.1f → zweimal → gleich (mod 360)" % [ + SymmetryCore.mode_label(mode), r_in]) + # Einzelfälle laut Spec: Punkt 0° → 180°, Achse 90° → −90° (≡ 270°). + _check(absf(SymmetryCore.mirror_rot_y(SymmetryCore.Mode.POINT, 0.0) - 180.0) < 0.0001, + "Spec: Punkt 0° → 180°") + _check(absf(fposmod(SymmetryCore.mirror_rot_y(SymmetryCore.Mode.AXIS_X, 90.0) + 90.0, 360.0)) < 0.0001, + "Spec: Achse X 90° → −90° (≡ 270°)") + _check(absf(SymmetryCore.mirror_rot_y(SymmetryCore.Mode.OFF, 42.0) - 42.0) < 0.0001, + "OFF: Drehung unverändert") + + +func _test_maps_to_self() -> void: + print("== Achse/Zentrum: nicht gedoppelt ==") + var center := Vector2.ZERO + _check(SymmetryCore.maps_to_self(SymmetryCore.Mode.POINT, Vector3(0.01, 5.0, 0.01), center), + "Punkt: Zentrum (±0.01 m) → maps_to_self") + _check(not SymmetryCore.maps_to_self(SymmetryCore.Mode.POINT, Vector3(10.0, 5.0, 0.0), center), + "Punkt: 10 m neben der Mitte → wird gedoppelt") + _check(SymmetryCore.maps_to_self(SymmetryCore.Mode.AXIS_X, Vector3(0.0, 5.0, 123.0), center), + "Achse X: auf der Achse (x = 0) → maps_to_self") + _check(not SymmetryCore.maps_to_self(SymmetryCore.Mode.AXIS_X, Vector3(7.0, 5.0, 123.0), center), + "Achse X: 7 m neben der Achse → wird gedoppelt") + _check(SymmetryCore.maps_to_self(SymmetryCore.Mode.AXIS_Z, Vector3(5.0, 5.0, 0.0), center), + "Achse Z: auf der Achse (z = 0) → maps_to_self") + _check(SymmetryCore.maps_to_self(SymmetryCore.Mode.OFF, Vector3(9.0, 5.0, 9.0), center), + "OFF: nichts wird gedoppelt → maps_to_self") + + +func _test_cell_mapping() -> void: + print("== Zell-Abbildung (Terrain-Raster) ==") + var res := 9 + # Punkt: (i,j) → (res−1−i, res−1−j); zweimal = Ausgangszelle. + var c := SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, 2, 7, res) + _check(c == Vector2i(6, 1), "Punkt: (2,7) → (6,1) bei res=9") + _check(SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, c.x, c.y, res) == Vector2i(2, 7), + "Punkt: zweimal spiegeln = Ausgangszelle") + # Achse X: nur i spiegelt; Achse Z: nur j spiegelt. + _check(SymmetryCore.mirror_cell(SymmetryCore.Mode.AXIS_X, 2, 7, res) == Vector2i(6, 7), + "Achse X: (2,7) → (6,7)") + _check(SymmetryCore.mirror_cell(SymmetryCore.Mode.AXIS_Z, 2, 7, res) == Vector2i(2, 1), + "Achse Z: (2,7) → (2,1)") + # Rastermitte (ungerade res): mittlere Zelle bleibt fix. + var mid_i := (res - 1) / 2 + _check(SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, mid_i, mid_i, res) == Vector2i(mid_i, mid_i), + "Rastermitte (ungerade res) bleibt unter Punktspiegelung fix") + _check(SymmetryCore.mirror_cell(SymmetryCore.Mode.AXIS_X, mid_i, 3, res) == Vector2i(mid_i, 3), + "Achse X: Mittelspalte bleibt fix") + _check(SymmetryCore.mirror_cell(SymmetryCore.Mode.AXIS_Z, 3, mid_i, res) == Vector2i(3, mid_i), + "Achse Z: Mittelzeile bleibt fix") + # Gerade res: keine fixe Zelle. + _check(SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, 3, 3, 8) == Vector2i(4, 4), + "Gerade res=8: (3,3) → (4,4)") + # OFF + Degenerate (res < 2) → unverändert, kein Crash. + _check(SymmetryCore.mirror_cell(SymmetryCore.Mode.OFF, 1, 2, 9) == Vector2i(1, 2), "OFF: Zelle unverändert") + _check(SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, 0, 0, 1) == Vector2i(0, 0), + "res=1: Degenerate-Fall, kein Crash") + _check(SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, 0, 0, 0) == Vector2i(0, 0), + "res=0: Degenerate-Fall, kein Crash") + + +func _test_partner_link() -> void: + print("== Partner-Link (sym-Feld) ==") + var obj := {"id": "baum", "pos": [1.0, 0.0, 2.0], "rot_y_deg": 0.0} + _check(SymmetryCore.sym_of(obj) == "", "Objekt ohne sym → leere Kennung") + _check(SymmetryCore.sym_of({"id": "x", "sym": 42}) == "", "Defektes sym (Zahl) → leer, kein Crash") + var key := SymmetryCore.next_sym_key({"objects": [], "gebaeude": []}) + _check(key == "sym_1", "Erste freie Kennung = sym_1") + SymmetryCore.set_sym(obj, key) + _check(SymmetryCore.sym_of(obj) == key, "set_sym setzt die Kennung") + _check(obj.has("sym"), "sym-Feld vorhanden") + SymmetryCore.clear_sym(obj) + _check(not obj.has("sym"), "clear_sym entfernt das Feld komplett (wie alte Karten)") + var doc := {"objects": [ + {"id": "a", "pos": [0.0, 0.0, 0.0], "sym": "sym_1"}, + {"id": "b", "pos": [0.0, 0.0, 0.0]}, + {"id": "c", "pos": [0.0, 0.0, 0.0], "sym": "sym_2"}, + ], "gebaeude": [ + {"id": "wand", "pos": [0.0, 0.0, 0.0], "sym": "sym_3"}, + ]} + _check(SymmetryCore.next_sym_key(doc) == "sym_4", + "next_sym_key überspringt belegte Kennungen über BEIDE Blöcke") + _check(SymmetryCore.find_partner_index(doc["objects"], "sym_2") == 2, + "find_partner_index findet den Partner") + _check(SymmetryCore.find_partner_index(doc["objects"], "sym_2", 2) == -1, + "find_partner_index schließt den Index selbst aus") + _check(SymmetryCore.find_partner_index(doc["gebaeude"], "sym_3") == 0, + "find_partner_index funktioniert auch im gebaeude-Block") + _check(SymmetryCore.find_partner_index(doc["objects"], "") == -1, + "Leere Kennung → kein Partner") + + +func _test_validator_sym() -> void: + print("== Validator V16 (sym-Feld an Objekten) ==") + var doc := SvmapDocument.create_new("SymTest", 1024.0, "mittag") + doc["objects"] = [{ + "id": "air_defense_001", "pos": [100.0, 0.0, 100.0], "rot_y_deg": 0.0, "scale": 1.0, + "sym": "sym_1", + }] + var errors := SvmapIO._validate_core(doc) + _check(errors.is_empty(), "Objekt mit gültiger sym-Kennung → keine Fehler (%s)" % [str(errors)]) + # sym = kein String → V16. + doc["objects"][0]["sym"] = 42 + errors = SvmapIO._validate_core(doc) + _check(_errors_contain(errors, "V16") and _errors_contain(errors, "muss ein String sein"), + "sym als Zahl → V16 'muss ein String sein'") + # sym leer → V16. + doc["objects"][0]["sym"] = " " + errors = SvmapIO._validate_core(doc) + _check(_errors_contain(errors, "V16") and _errors_contain(errors, "nicht leer"), + "sym leer/Whitespace → V16 'darf nicht leer sein'") + # sym zu lang → V16. + doc["objects"][0]["sym"] = "s".repeat(SvmapIO.SYM_KEY_MAX_LEN + 1) + errors = SvmapIO._validate_core(doc) + _check(_errors_contain(errors, "V16") and _errors_contain(errors, "länger"), + "sym > %d Zeichen → V16" % SvmapIO.SYM_KEY_MAX_LEN) + # Drei Objekte mit derselben Kennung → V16 (Paar = genau 2). + doc["objects"] = [ + {"id": "air_defense_001", "pos": [100.0, 0.0, 100.0], "rot_y_deg": 0.0, "scale": 1.0, "sym": "sym_9"}, + {"id": "air_defense_001", "pos": [-100.0, 0.0, -100.0], "rot_y_deg": 0.0, "scale": 1.0, "sym": "sym_9"}, + {"id": "air_defense_001", "pos": [0.0, 0.0, 100.0], "rot_y_deg": 0.0, "scale": 1.0, "sym": "sym_9"}, + ] + errors = SvmapIO._validate_core(doc) + _check(_errors_contain(errors, "V16") and _errors_contain(errors, "genau 2"), + "3 Objekte mit gleicher Kennung → V16 'genau 2'") + # Zwei Objekte (echtes Paar) → ok. + doc["objects"].remove_at(2) + errors = SvmapIO._validate_core(doc) + _check(errors.is_empty(), "2 Objekte mit gleicher Kennung (Paar) → keine Fehler") + + +func _test_roundtrip_with_sym() -> void: + print("== Roundtrip: speichern → laden → sym bleibt ==") + var doc := SvmapDocument.create_new("SymRT", 1024.0, "abend") + doc["objects"] = [ + {"id": "air_defense_001", "pos": [100.0, 0.0, 100.0], "rot_y_deg": 45.0, "scale": 1.0, "sym": "sym_1"}, + {"id": "air_defense_001", "pos": [-100.0, 0.0, -100.0], "rot_y_deg": 225.0, "scale": 1.0, "sym": "sym_1"}, + ] + doc["gebaeude"] = [ + {"id": "wand", "pos": [2.0, 0.0, 4.0], "rot": 90.0, "punkte": [], "sym": "sym_2"}, + {"id": "wand", "pos": [-2.0, 0.0, -4.0], "rot": 270.0, "punkte": [], "sym": "sym_2"}, + ] + var save := SvmapIO.save_to_file("user://test_sym.svmap", doc) + _check(save["ok"], "save_to_file mit sym ok: %s" % str(save["errors"])) + var load := SvmapIO.load_from_file("user://test_sym.svmap") + _check(load["ok"], "load_from_file mit sym ok: %s" % str(load["errors"])) + var doc2: Dictionary = load["data"] + _check((doc2["objects"] as Array).size() == 2, "2 Objekte im Roundtrip") + _check(str(doc2["objects"][0]["sym"]) == "sym_1", "Objekt-sym im Roundtrip erhalten") + _check(str(doc2["objects"][1]["sym"]) == "sym_1", "Partner-sym im Roundtrip erhalten") + _check((doc2["gebaeude"] as Array).size() == 2, "2 Bau-Teile im Roundtrip") + _check(str(doc2["gebaeude"][0]["sym"]) == "sym_2", "Bau-Teil-sym im Roundtrip erhalten") + # Re-Save → byte-identisch (Kanonisierung stabil). + var save2 := SvmapIO.save_to_file("user://test_sym2.svmap", doc2) + _check(save2["ok"], "Re-Save ok") + var t1 := FileAccess.get_file_as_string("user://test_sym.svmap") + var t2 := FileAccess.get_file_as_string("user://test_sym2.svmap") + _check(t1 == t2, "Re-Save ist byte-identisch (Prüfsumme über sym stabil)") + + +func _test_legacy_map_unchanged() -> void: + print("== Alte Karte (ohne sym) lädt unverändert ==") + var doc := SvmapDocument.create_new("Legacy", 512.0, "nacht") + doc["objects"] = [ + {"id": "air_defense_001", "pos": [50.0, 0.0, 50.0], "rot_y_deg": 10.0, "scale": 1.0}, + ] + var save := SvmapIO.save_to_file("user://test_sym_legacy.svmap", doc) + _check(save["ok"], "Legacy-save ok: %s" % str(save["errors"])) + var legacy_text := FileAccess.get_file_as_string("user://test_sym_legacy.svmap") + var load := SvmapIO.load_from_file("user://test_sym_legacy.svmap") + _check(load["ok"], "Legacy-load ok") + var doc2: Dictionary = load["data"] + _check((doc2["objects"] as Array).size() == 1, "Legacy: 1 Objekt") + _check(not doc2["objects"][0].has("sym"), "Legacy: kein sym-Feld entstanden") + # Roundtrip byte-identisch → alte Karten speichern sich unverändert weiter. + var save2 := SvmapIO.save_to_file("user://test_sym_legacy2.svmap", doc2) + _check(save2["ok"], "Legacy re-save ok") + var text2 := FileAccess.get_file_as_string("user://test_sym_legacy2.svmap") + _check(legacy_text == text2, "Legacy: speichern→laden→speichern byte-identisch") diff --git a/tests/test_symmetry_core.gd.uid b/tests/test_symmetry_core.gd.uid new file mode 100644 index 0000000..1f56928 --- /dev/null +++ b/tests/test_symmetry_core.gd.uid @@ -0,0 +1 @@ +uid://c5xqlmayrdfxn