t_f5935023: M1 Symmetrie-Fundament (Spec vc27, Sly 20.10.) — SymmetryCore (Punkt/Achse X/Achse Z, Zell-Abbildung, Partner-Link sym, alle statisch/pure, headless testbar) + Validator V16 in svmap_io (sym muss String, nicht leer, ≤64 Zeichen, genau 2 pro Kennung) + Toolbar-Schalter + Statuszeile-Anzeige in main.gd (Standard=Punkt, Sly sieht Modus immer) + Suite test_symmetry_core (93 Checks: Modus-Katalog, Kartenmitte aus bounds, zweimal-spiegeln=Identity für alle Modi, Achse/Zentrum-nicht-doppeln, Raster-Zell-Mapping inkl. gerader/ungerader res, Partner-Link-API, V16-Validator, Roundtrip mit sym byte-identisch, Legacy-Karte ohne sym unverändert). 17/19 Suiten grün (test_updater 15/75 FAIL + test_glb_view 15/25 FAIL = PRE-EXISTING per git-stash-Verfahren gegen 437f6bf verifiziert, Ursache unabhängig vom Diff). M1 legt nur Mathe + Anzeige fest — gespiegeltes Bauen selbst kommt mit M2 (Terrain), M3 (Objekte), M4 (Bau-Teile + Spawns).

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 06:37:50 +00:00
parent 437f6bf368
commit 0adb161386
9 changed files with 576 additions and 0 deletions

189
src/editor/symmetry_core.gd Normal file
View file

@ -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

View file

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

View file

@ -41,6 +41,7 @@ const MAX_HEIGHT_RANGE_M := 500.0 # V6
const MAX_BOUNDS_EXTENT_M := 8192.0 # V14 const MAX_BOUNDS_EXTENT_M := 8192.0 # V14
const BOUNDS_TOLERANCE_M := 1.0 # V9 const BOUNDS_TOLERANCE_M := 1.0 # V9
const MIN_SPAWN_POINTS_PER_TEAM := 4 # V11 (Entscheidung 11) 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 MANIFEST_PATH := "res://library/objects.json"
const JSON_INDENT := " " 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) var rot = obj.get("rot_y_deg", 0.0)
if not _is_finite_num(rot): if not _is_finite_num(rot):
errors.append("V9: objects[%d]: rot_y_deg muss endlich sein." % i) 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) # V8b: Poly-Budget (× scale² — Flächen-/Kantenwachstum der Meshes)
var poly = float(lib[oid].get("poly", 0)) var poly = float(lib[oid].get("poly", 0))
total_poly += poly * float(scale) * float(scale) 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: if total_poly > MAX_TOTAL_POLY:
errors.append("V8: Poly-Budget überschritten (%.0f > %d)." % [total_poly, MAX_TOTAL_POLY]) errors.append("V8: Poly-Budget überschritten (%.0f > %d)." % [total_poly, MAX_TOTAL_POLY])
return errors return errors

View file

@ -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 build_tool # src/editor/build_tool.gd (Bauen-Modus, Karte t_97ea59c5)
var tool_mode := "objects" # "objects" | "terrain" | "spawns" | "bauen" (Modular-Builder) 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 # UI-Referenzen
var content_tree: Tree var content_tree: Tree
var stats_objects: Label var stats_objects: Label
@ -182,6 +189,8 @@ func _ready() -> void:
manifest = _load_manifest() manifest = _load_manifest()
_build_ui() _build_ui()
_update_tool_label() _update_tool_label()
# Symmetrie-Anzeige initialisieren (Karte t_f5935023, M1): Standard = Punkt.
_update_sym_ui()
# Fenster-Zustand (Karte t_297221b5): Default MAXIMIERT (kein Vollbild); # Fenster-Zustand (Karte t_297221b5): Default MAXIMIERT (kein Vollbild);
# wer auf Fenstergröße umschaltet, bekommt die gemerkte Lage wieder. # wer auf Fenstergröße umschaltet, bekommt die gemerkte Lage wieder.
# Close-Request selbst behandeln, damit die Lage VOR dem Quit gemerkt wird. # 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) contours_btn.toggled.connect(_on_contours_toggled)
top_h.add_child(contours_btn) 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. # Map-Fertig-Check (Karte t_c4162c5d): Klartext-Liste VOR dem Export.
check_btn = Button.new() check_btn = Button.new()
check_btn.text = "✅ Karte prüfen" check_btn.text = "✅ Karte prüfen"
@ -373,6 +396,14 @@ func _build_ui() -> void:
tool_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS tool_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
status_h.add_child(tool_label) 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 = Label.new()
stats_objects.modulate = Color(1, 1, 1, 0.75) stats_objects.modulate = Color(1, 1, 1, 0.75)
stats_objects.clip_text = true 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)) 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 ---- # --------------------------------------------------------------- History ----
func _do_undo() -> void: func _do_undo() -> void:

View file

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

View file

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

View file

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

300
tests/test_symmetry_core.gd Normal file
View file

@ -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")

View file

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