scopedvr-map-core/editor/symmetry_core.gd
slyphax 17a8927fec Map-Kern aus dem Editor übernommen (Stand e8ab954)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:30:18 +01:00

233 lines
8.8 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
## Spiegelt eine Blickrichtung (Grad, Blick = (-sin, 0, -cos)) exakt mit: anders als
## mirror_rot_y zeigt der Pfeil danach wirklich in die gespiegelte Richtung.
static func mirror_facing(mode: int, yaw_deg: float) -> float:
match mode:
Mode.POINT:
return fposmod(yaw_deg + 180.0, 360.0)
Mode.AXIS_X:
return fposmod(-yaw_deg, 360.0)
Mode.AXIS_Z:
return fposmod(180.0 - yaw_deg, 360.0)
_:
return yaw_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 (inkl. Bauteile), deterministic (kein Zufall, keine Uhr).
static func next_sym_key(doc: Dictionary) -> String:
var used: Dictionary = {}
var block: Array = doc.get("objects", [])
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
# ---------------------------------- Spiegel-Mathe 3 Achsen (t_e166e656) ------
## Spiegelt eine VOLLE Euler-Orientierung (Karte t_e166e656, Platzierungs-
## freiheit). Definition (dokumentiert in docs/map_format.md §objects.rot):
## - Achsenspiegelung (AXIS_X / AXIS_Z) = Konjugation der Basis mit der
## Spiegelmatrix: B' = M·B·M — für reines Y-Drehen exakt die bisherige
## Regel mirror_rot_y (−rot).
## - Punktspiegelung (POINT) = 180°-Drehung um Y: B' = Ry(180°)·B — für
## reines Y-Drehen exakt mirror_rot_y (+180°).
## Godot-Euler-Reihenfolge YXZ (Node3D-Rotation), Rückgabe in Grad.
static func mirror_rot_euler(mode: int, rot_deg: Vector3) -> Vector3:
var b := Basis.from_euler(Vector3(
deg_to_rad(rot_deg.x), deg_to_rad(rot_deg.y), deg_to_rad(rot_deg.z)))
var gespiegelt: Basis
match mode:
Mode.POINT:
gespiegelt = Basis.from_euler(Vector3(0.0, deg_to_rad(180.0), 0.0)) * b
Mode.AXIS_X:
var m := Basis.from_scale(Vector3(-1.0, 1.0, 1.0))
gespiegelt = m * b * m
Mode.AXIS_Z:
var m := Basis.from_scale(Vector3(1.0, 1.0, -1.0))
gespiegelt = m * b * m
_:
return rot_deg
# Normalisierung [0, 360) je Achse — gleiche Darstellung wie rot_y_deg
# bisher (tests/test_object_mirror.gd erwartet z. B. 330 statt −30).
return Vector3(
fposmod(gespiegelt.get_euler().x * (180.0 / PI), 360.0),
fposmod(gespiegelt.get_euler().y * (180.0 / PI), 360.0),
fposmod(gespiegelt.get_euler().z * (180.0 / PI), 360.0))