scopedvr-map-editor/tests/test_platzierung.gd

194 lines
9.6 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 SceneTree
## Platzierungsfreiheit (Karte t_e166e656) — EDITOR-Seite, pure Funktionen.
## Run: godot4 --headless --path . -s res://tests/test_platzierung.gd
## Exit 0 = alle Tests grün.
##
## Abdeckung:
## - SvmapIO.objekt_rotation: Fallback rot_y_deg (Legacy), rot überschreibt
## - SvmapIO.rot_setzen: rot-Feld nur bei echtem Kippen (Legacy-sauber)
## - SvmapIO-Validierung: V17 (kaputtes rot abgelehnt), gute Map mit rot lädt
## - KolliderBau.hindernis_aabb: 8 gedrehte Ecken (45°-Handrechnung)
## - ObjectOps.lift_object: Versenken unter Terrainhöhe erlaubt (Stein-Testfall)
## - ObjectOps.mirror_object / SymmetryCore.mirror_rot_euler: reines Y bleibt
## exakt das alte Verhalten (330/210), Kippen wird mitgespiegelt
var _failures: Array[String] = []
var _checks: int = 0
func _initialize() -> void:
_run_all()
print("--------------------------------------------------")
print("CHECKS: %d FAILURES: %d" % [_checks, _failures.size()])
for f in _failures:
print("FAIL: " + f)
quit(0 if _failures.is_empty() else 1)
func _check(cond: bool, label: String) -> void:
_checks += 1
if cond:
print(" ok - " + label)
else:
_failures.append(label)
print(" FAIL - " + label)
func _map_mit(obj: Dictionary) -> Dictionary:
var res := 17
var samples := PackedByteArray()
samples.resize(res * res * 2)
for i in res * res:
samples.encode_u16(i * 2, 32768) # flaches Terrain auf halber Höhe (5.0 m)
return {
"format_version": 2,
"info": {"name": "Platzierung", "author": "t", "description": "",
"version": 1, "created_utc": "2026-10-20T12:00:00Z", "target_game_version": "0.1.0"},
"terrain": {"type": "heightmap", "size_x_m": 200.0, "size_z_m": 200.0,
"min_height_m": 0.0, "max_height_m": 10.0, "resolution": res,
"heightmap_r16_b64": Marshalls.raw_to_base64(samples)},
"objects": [obj],
"spawn_zones": {
"0": {"points": [{"id": "A1", "pos": [-80.0, 5.0, -80.0]},
{"id": "A2", "pos": [-84.0, 5.0, -80.0]}, {"id": "A3", "pos": [-80.0, 5.0, -84.0]},
{"id": "A4", "pos": [-84.0, 5.0, -84.0]}]},
"1": {"points": [{"id": "B1", "pos": [80.0, 5.0, 80.0]},
{"id": "B2", "pos": [84.0, 5.0, 80.0]}, {"id": "B3", "pos": [80.0, 5.0, 84.0]},
{"id": "B4", "pos": [84.0, 5.0, 84.0]}]},
},
"bounds": {"min": [-100.0, -20.0, -100.0], "max": [100.0, 60.0, 100.0]},
"weather": "mittag",
}
func _run_all() -> void:
print("== 1) SvmapIO.objekt_rotation (Fallback + Override) ==")
var legacy := {"id": "x", "pos": [0.0, 0.0, 0.0], "rot_y_deg": 25.0, "scale": 1.0}
var e := SvmapIO.objekt_rotation(legacy)
_check(absf(e.y - 25.0) < 0.0001 and absf(e.x) < 0.0001 and absf(e.z) < 0.0001,
"Fallback: rot_y_deg = 25 → Euler (0, 25, 0)")
var gekippt := {"id": "x", "pos": [0.0, 0.0, 0.0], "rot_y_deg": 25.0, "scale": 1.0,
"rot": [20.0, 90.0, 10.0]}
var e2 := SvmapIO.objekt_rotation(gekippt)
_check(absf(e2.x - 20.0) < 0.0001 and absf(e2.y - 90.0) < 0.0001 and absf(e2.z - 10.0) < 0.0001,
"rot present → Euler (20, 90, 10), rot_y_deg ignoriert")
print("== 2) SvmapIO.rot_setzen (Legacy-sauber) ==")
var obj := {"id": "x", "pos": [0.0, 0.0, 0.0], "rot_y_deg": 45.0, "scale": 1.0}
SvmapIO.rot_setzen(obj, Vector3(0.0, 30.0, 0.0))
_check(absf(float(obj["rot_y_deg"]) - 30.0) < 0.0001 and not obj.has("rot"),
"reines Y: rot_y_deg aktualisiert, KEIN rot-Feld")
SvmapIO.rot_setzen(obj, Vector3(20.0, 30.0, -5.0))
_check((obj["rot"] as Array) == [20.0, 30.0, -5.0]
and absf(float(obj["rot_y_deg"]) - 30.0) < 0.0001,
"Kippen: rot=[20,30,-5] geschrieben, rot_y_deg = Y-Komponente")
SvmapIO.rot_setzen(obj, Vector3(0.0, 10.0, 0.0))
_check(not obj.has("rot") and absf(float(obj["rot_y_deg"]) - 10.0) < 0.0001,
"zurück auf 0-Kippen: rot-Feld wieder ENTFERNT (legacy-sauber)")
print("== 3) Validierung V17 + Roundtrip ==")
var gut := _map_mit({"id": "crate_wood", "pos": [0.0, 5.0, 0.0], "rot_y_deg": 0.0,
"scale": 1.0, "rot": [20.0, 0.0, 10.0]})
SvmapIO.sign_map(gut)
_check(SvmapIO.validate_map(gut).is_empty(), "Map mit rot=[20,0,10] validiert")
for bad in [[0.0, 0.0], ["a", "b", "c"]]:
var schlecht := _map_mit({"id": "crate_wood", "pos": [0.0, 5.0, 0.0], "rot_y_deg": 0.0,
"scale": 1.0, "rot": bad})
var errs := SvmapIO.validate_map(schlecht)
var hat := false
for f in errs:
if str(f).contains("V17"):
hat = true
_check(hat, "V17: rot=%s abgelehnt" % str(bad))
var pfad := "user://test_platzierung_rot.svmap"
var save := SvmapIO.save_to_file(pfad, gut)
_check(bool(save.get("ok", false)), "save_to_file mit rot ok")
var load := SvmapIO.load_from_file(pfad)
_check(bool(load.get("ok", false)), "Map mit rot lädt zurück")
if bool(load.get("ok", false)):
var objs: Array = (load["data"] as Dictionary).get("objects", [])
_check((objs[0] as Dictionary).get("rot", []) == [20.0, 0.0, 10.0],
"rot überlebt Roundtrip unverändert")
# Alte Karte (mit scale ≠ 1) bleibt ladbar — Format-Kompatibilität.
var alt := _map_mit({"id": "crate_wood", "pos": [0.0, 5.0, 0.0], "rot_y_deg": 15.0, "scale": 2.5})
SvmapIO.sign_map(alt)
_check(SvmapIO.validate_map(alt).is_empty(),
"alte Karte mit scale=2.5 lädt unverändert (kein Datenverlust)")
print("== 4) KolliderBau.hindernis_aabb (8 gedrehte Ecken) ==")
var col := {"shape": "box", "size": [2.0, 2.0, 2.0]}
var obj45 := {"id": "x", "pos": [0.0, 5.0, 0.0], "rot": [0.0, 45.0, 0.0], "scale": 1.0}
var aabb45: AABB = KolliderBau.hindernis_aabb(obj45, col, 1.0)
var erwartet := 2.0 * sqrt(2.0)
_check(absf(aabb45.size.x - erwartet) < 0.01 and absf(aabb45.size.z - erwartet) < 0.01
and absf(aabb45.size.y - 2.0) < 0.01, "45°-Box: AABB 2·√2 × 2 × 2·√2")
_check(absf(aabb45.get_center().y - 6.0) < 0.01, "Fußpunkt-Konvention: Zentrum halbe Höhe über pos")
print("== 5) Versenken (Stein im Boden) ==")
var doc := _map_mit({"id": "rock_large", "pos": [10.0, 6.0, 10.0], "rot_y_deg": 0.0, "scale": 1.0})
var boden_hoehe: float = SvmapDocument.terrain_height_at(doc, 10.0, 10.0)
_check(absf(boden_hoehe - 5.0) < 0.01, "Terrain dekodiert (5.0 m am Probe-Punkt)")
var angewandt: float = ObjectOps.lift_object(doc, 0, -2.0)
var versunken: float = float(((doc["objects"] as Array)[0] as Dictionary)["pos"][1])
_check(absf(angewandt + 2.0) < 0.001, "Absenken wirkt ÜBER die Terrainhöhe HINAUS (Versenken)")
_check(versunken < boden_hoehe - 0.9, "Objekt steht 1 m unter der Terrainhöhe (versenkt)")
var offset: float = ObjectOps.lift_offset(doc, 0)
_check(offset < -0.9, "lift_offset meldet negative Tiefe (%.2f m versenkt)" % -offset)
# Untergrenze = bounds.min.y (−20): weiter absenken klemmt dort.
var grenze: float = ObjectOps.lift_object(doc, 0, -100.0)
var y_ende: float = float(((doc["objects"] as Array)[0] as Dictionary)["pos"][1])
_check(absf(y_ende - (-20.0)) < 0.001 and grenze < -0.1,
"Untergrenze = bounds.min.y (−20 m), nicht tiefer")
print("== 6) Spiegel: Legacy-Verhalten rein-Y + Kippen ==")
# Pure-Y-Objekte: exakt die alten Zahlenwerte (tests/test_object_mirror-Kontrakt).
var nur_y := {"id": "x", "pos": [0.0, 0.0, 0.0], "rot_y_deg": 30.0, "scale": 1.0}
SvmapIO.rot_setzen(nur_y, Vector3(0.0, 30.0, 0.0)) # ohne rot-Feld
ObjectOps.mirror_object(nur_y, "x")
_check(absf(float(nur_y["rot_y_deg"]) - 330.0) < 0.0001 and not nur_y.has("rot"),
"Spiegel X, pure-Y: 30 → 330 (legacy-identisch), kein rot-Feld")
ObjectOps.mirror_object(nur_y, "z")
_check(absf(float(nur_y["rot_y_deg"]) - 210.0) < 0.0001,
"Spiegel Z, pure-Y: 330 → 210 (180 − 330, legacy-identisch)")
# Kippen: X-Spiegel dreht Y- UND Z-Komponente, X-Kippen bleibt.
var gekippt2 := {"id": "x", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot": [20.0, 30.0, 10.0]}
ObjectOps.mirror_object(gekippt2, "x")
var mx := SvmapIO.objekt_rotation(gekippt2)
_check(absf(mx.x - 20.0) < 0.0001 and absf(mx.y - 330.0) < 0.0001 and absf(mx.z - 350.0) < 0.0001,
"Spiegel X mit Kippen: (20, 30, 10) → (20, 330, 350) — X-Kippen bleibt, Y/Z drehen Vorzeichen")
# Symmetrie-Kern: pure-Y-Kompatibilität.
var e30 := SymmetryCore.mirror_rot_euler(SymmetryCore.Mode.AXIS_X, Vector3(0.0, 30.0, 0.0))
_check(absf(e30.y - 330.0) < 0.0001 and absf(e30.x) < 0.0001 and absf(e30.z) < 0.0001,
"mirror_rot_euler AXIS_X, pure-Y: 30 → 330 (identisch zu mirror_rot_y)")
var e30p := SymmetryCore.mirror_rot_euler(SymmetryCore.Mode.POINT, Vector3(0.0, 30.0, 0.0))
_check(absf(e30p.y - 210.0) < 0.0001, "mirror_rot_euler POINT, pure-Y: 30 → 210")
# Achsenspiegelung ist selbstinvers: zweimal spiegeln = Ausgangslage.
var doppelt := SymmetryCore.mirror_rot_euler(SymmetryCore.Mode.AXIS_X,
SymmetryCore.mirror_rot_euler(SymmetryCore.Mode.AXIS_X, Vector3(20.0, 30.0, 10.0)))
_check(_euler_gleich(doppelt, Vector3(20.0, 30.0, 10.0)),
"AXIS_X-Spiegel ist selbstinvers (zweimal = Ausgangslage)")
print("== 7) Zwilling erbt gespiegeltes Kippen ==")
var doc2 := _map_mit({"id": "rock_large", "pos": [10.0, 5.0, 10.0], "scale": 1.0,
"rot": [20.0, 30.0, 10.0]})
var res: Dictionary = ObjectOps.symmetry_place_twin(doc2, 0, SymmetryCore.Mode.AXIS_X)
var twin_i := int(res.get("twin", -1))
_check(twin_i >= 0, "Zwilling angelegt")
if twin_i >= 0:
var twin: Dictionary = (doc2["objects"] as Array)[twin_i]
var tpos: Array = twin.get("pos", [])
_check(absf(float(tpos[0]) + 10.0) < 0.01, "Zwilling-Position gespiegelt (x → −x)")
var te := SvmapIO.objekt_rotation(twin)
var erwartet2 := SymmetryCore.mirror_rot_euler(SymmetryCore.Mode.AXIS_X, Vector3(20.0, 30.0, 10.0))
_check(_euler_gleich(te, erwartet2), "Zwilling-Kippen = gespiegelte Euler-Orientierung")
func _euler_gleich(a: Vector3, b: Vector3, tol := 0.01) -> bool:
var d := a - b
# Komponenten modulo 360 vergleichen (kürzester Weg).
for k in 3:
var w := fposmod(d[k] + 180.0, 360.0) - 180.0
if absf(w) > tol:
return false
return true