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": "box_001", "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": "box_001", "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": "box_001", "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_008", "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_008", "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