scopedvr-map-editor/tests/test_ux.gd
Hermes (godot-vr) eca11bbf83 Objekt-Ops: Duplizieren (Strg+D) + Anheben/Absenken (Bild hoch/runter) + Spiegeln (X/Z)
Karte t_7567a06f. Hoehe wohnt direkt in object.pos.y — KEIN svmap-Format-
Feld noetig (V9 erlaubt freie y-Werte in bounds; Server-Loader geprueft).
Absenken stoppt am Terrain (nichts begraben), Anheben deckelt auf
bounds.max. Spiegeln am Objekt selbst (Position bleibt): X → −rot,
Z → 180°−rot selbstinvers. Alles über ActionHistory (Merge-Tags für
Lift-Serien), deutsche UI + Klartext-Status, dragt behält Terrain-Abstand.
test_ux.gd: 90 Checks (42 neu), Suite 5/5 grün, xvfb-Smoke UI-Bau ok.
2026-10-15 15:32:24 +00:00

370 lines
18 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
## Editor-Tests (headless): Karte t_40d855d0 — UX-Fundament (Undo/Redo-Historie,
## Terrain-Höhen-Abfrage, Platzhalter-Meshes).
## Ausführen: godot --headless --path . --script res://tests/test_ux.gd
## Exit 0 = alles grün. Läuft OHNE UI (ActionHistory/SvmapDocument/MeshShapes).
## GUI-Funktionen (Ghost-Preview sichtbar, Auswahl-Färbung) werden im
## Desktop-Smoke auf Slys PC verifiziert (Screenshot), nicht hier.
var failures := 0
var checks := 0
func _init() -> void:
_run_tests()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _obj(id: String, pos: Array, rot := 0.0) -> Dictionary:
return {"id": id, "pos": pos, "scale": 1.0, "rot_y_deg": float(rot)}
func _place(hist: ActionHistory, doc: Dictionary, id: String, pos: Array) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array).append(_obj(id, pos))
hist.commit("Test: %s platzieren" % id, before, hist.snapshot(["objects"]))
func _delete_at(hist: ActionHistory, doc: Dictionary, idx: int) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array).remove_at(idx)
hist.commit("Test: löschen", before, hist.snapshot(["objects"]))
func _move_obj(hist: ActionHistory, doc: Dictionary, idx: int, pos: Array) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array)[idx]["pos"] = pos
hist.commit("Test: verschieben", before, hist.snapshot(["objects"]), "move:%d" % idx)
func _rotate_obj(hist: ActionHistory, doc: Dictionary, idx: int, deg: float) -> void:
var before := hist.snapshot(["objects"])
(doc["objects"] as Array)[idx]["rot_y_deg"] = float(deg)
hist.commit("Test: drehen", before, hist.snapshot(["objects"]), "rot:%d" % idx)
func _run_tests() -> void:
_test_undo_roundtrip()
_test_merge_and_truncation()
_test_alias_safety()
_test_history_cap_and_clear()
_test_terrain_height()
_test_mesh_shapes()
_test_object_ops()
## Abnahme-Kriterium: Undo-Roundtrip (setzen → undo → redo) — und analog
## für löschen/verschieben/drehen.
func _test_undo_roundtrip() -> void:
print("Testgruppe: Undo/Redo-Roundtrip")
var doc := SvmapDocument.create_new("UX-Test", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
_check(objects.is_empty(), "Start: Objekt-Liste leer")
_place(hist, doc, "crate_wood", [10.0, 0.0, 10.0])
_check(objects.size() == 1, "setzen: 1 Objekt")
_check(hist.can_undo() and not hist.can_redo(), "History: undo möglich, redo nicht")
var desc := hist.undo()
_check(objects.is_empty(), "undo: Objekt weg (setzen→undo)")
_check(hist.can_redo() and not hist.can_undo(), "nach undo: redo möglich, undo verbraucht")
_check(desc == "Test: crate_wood platzieren", "undo-Desc stimmt")
desc = hist.redo()
_check(objects.size() == 1 and str(objects[0]["id"]) == "crate_wood", "redo: Objekt identisch zurück (setzen→undo→redo)")
_check(desc == "Test: crate_wood platzieren", "redo-Desc stimmt")
_check(hist.can_undo() and not hist.can_redo(), "nach redo: Zustand konsistent")
_delete_at(hist, doc, 0)
_check(objects.is_empty(), "löschen: Liste leer")
hist.undo()
_check(objects.size() == 1, "löschen→undo: Objekt zurück")
hist.redo()
_check(objects.is_empty(), "löschen→undo→redo: wieder weg")
_place(hist, doc, "crate_metal", [0.0, 0.0, 0.0])
_check(objects.size() == 1, "weiterbearbeiten: Platzieren nach fertigem Roundtrip klappt")
_move_obj(hist, doc, 0, [5.0, 0.0, 7.5])
_check(float(objects[0]["pos"][0]) == 5.0, "verschieben: neue Position im Doc")
hist.undo()
_check(objects.size() == 1 and float(objects[0]["pos"][0]) == 0.0, "verschieben→undo: zurück auf Platzierungsposition")
hist.redo()
_check(float(objects[0]["pos"][0]) == 5.0 and float(objects[0]["pos"][2]) == 7.5, "verschieben→undo→redo: Position wiederhergestellt")
_rotate_obj(hist, doc, 0, 90.0)
hist.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 0.0) < 0.0001, "drehen→undo: 0°")
hist.redo()
_check(absf(float(objects[0]["rot_y_deg"]) - 90.0) < 0.0001, "drehen→undo→redo: 90°")
## Mausrad/leichtes Drehen und Verschieben in Ticks dürfen die History NICHT
## fluten — gleicher merge_tag pro Objekt = EIN History-Eintrag.
func _test_merge_and_truncation() -> void:
print("Testgruppe: Merge-Tags (Mausrad-Ticks → 1 Schritt) + Redo-Ast")
var doc := SvmapDocument.create_new("Merge-Test", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
_place(hist, doc, "watchtower_wood", [0.0, 0.0, 0.0])
var entries_after_place := hist.entries.size()
for i in 8:
var before := hist.snapshot(["objects"])
objects[0]["rot_y_deg"] = float((i + 1) * 15)
hist.commit("Objekt gedreht", before, hist.snapshot(["objects"]), "rot:0")
_check(hist.entries.size() == entries_after_place + 1, "8 Dreh-Ticks → EIN zusätzlicher History-Eintrag (geflutet wären 8)")
_check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001, "Endposition 120° im Doc")
# innerster Zustand nach Merge-Undo = Zustand VOR der ersten Drehung (0°)
hist.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 0.0) < 0.0001, "Merge-Undo springt auf 0° (nicht auf Zwischenstufe 105°)")
hist.redo()
_check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001, "Merge-Redo wieder 120°")
# Neuer Schritt verwirft den Redo-Zweig
_place(hist, doc, "bush_small", [1.0, 0.0, 1.0])
_check(not hist.can_redo(), "neuer Schritt: Redo-Zweig verworfen")
hist.undo()
_check(objects.size() == 1, "undo nach Zweig: nur watchtower bleibt")
## Snapshot VOR Mutation darf nach Mutation unverändert bleiben (sonst
## „rettet“ undo versehentlich eine Mutation — Alias-Falle bei Godot-Dicts).
func _test_alias_safety() -> void:
print("Testgruppe: Alias-Sicherheit der Snapshots")
var doc := SvmapDocument.create_new("Alias", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var before := hist.snapshot(["objects"])
(doc["objects"] as Array).append(_obj("crate_metal", [1.0, 0.0, 2.0]))
hist.commit("alias-test", before, hist.snapshot(["objects"]))
var stored_before: Array = hist.entries[0]["before"]["objects"]
_check(stored_before.is_empty(), "before-Snapshot: leer")
# Kopie zum Gegenprobe-Mutieren: räumen wir DIESE leeren, muss das Doc
# unberührt bleiben — nur Beweis, dass Mutationen an Kopien nichts anrichten.
var probe_copy: Array = (hist.entries[0]["after"]["objects"] as Array).duplicate()
probe_copy.clear()
_check((doc["objects"] as Array).size() == 1, "Mutation an einer Kopie ändert Doc nicht")
# Doc nach dem commit mutieren (2 Objekte) — die History-Einträge müssen
# davon NICHT betroffen sein, sonst „rettet“ undo die Mutation.
var doc_objects: Array = doc["objects"]
doc_objects.append({"id": "fence_metal", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
hist.undo()
_check((doc["objects"] as Array).is_empty(), "undo trotz Doc-Mutation: sauber auf before (leer)")
hist.redo()
_check((doc["objects"] as Array).size() == 1, "redo: Snapshot-Zustand (1 Objekt), nicht der mutierte (2)")
func _test_history_cap_and_clear() -> void:
print("Testgruppe: Kapazität + begin() setzt zurück")
var doc := SvmapDocument.create_new("Cap", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
for i in ActionHistory.MAX_STEPS + 20:
_place(hist, doc, "crate_wood", [float(i), 0.0, 0.0])
_check(hist.entries.size() == ActionHistory.MAX_STEPS, "Cap: History hält sich an MAX_STEPS")
_check(objects.size() == ActionHistory.MAX_STEPS + 20, "Doc fuellt sich voll — Cap betrifft nur History-Tiefe")
hist.undo()
_check(objects.size() == ActionHistory.MAX_STEPS + 19, "auch nach Cap-Kippen funktioniert undo")
var doc2 := SvmapDocument.create_new("Frisch", 1024.0, "nacht")
hist.begin(doc2)
_check(not hist.can_undo() and not hist.can_redo(), "begin() mit neuer Map: History leer")
_check(hist.doc == doc2, "begin() hängt an der neuen Map-Referenz")
## Flache neue Map → Terrain-Höhe überall = min_height. Synthetischer Gradient →
## bilinear korrekt (roher uint16-Wert → Meter).
func _test_terrain_height() -> void:
print("Testgruppe: Terrain-Höhen-Abfrage")
var doc := SvmapDocument.create_new("Flach", 1024.0, "mittag")
_check(absf(SvmapDocument.terrain_height_at(doc, 0.0, 0.0) - 0.0) < 0.001, "flache Map: Höhe 0 m")
_check(absf(SvmapDocument.terrain_height_at(doc, 500.0, -500.0) - 0.0) < 0.001, "flache Map: auch am Rand 0 m")
# Synthetischer Gradient: res=17, size=160 → 10 m Raster; Raw-Wert = Zeile*655 (ROH skaliert)
var res := 17
var bytes := PackedByteArray()
bytes.resize(res * res * 2)
var i := 0
while i < res * res:
var row := i / res
var raw := int(round(float(row) / float(res - 1) * 65535.0))
bytes[i * 2] = raw & 0xFF
bytes[i * 2 + 1] = (raw >> 8) & 0xFF
i += 1
var slope := {
"type": "heightmap", "size_x_m": 160.0, "size_z_m": 160.0,
"min_height_m": 0.0, "max_height_m": 100.0, "resolution": res,
"heightmap_r16_b64": Marshalls.raw_to_base64(bytes),
}
var doc2 := {"terrain": slope}
_check(absf(SvmapDocument.terrain_height_at(doc2, 0.0, -80.0) - 0.0) < 0.5, "Gradient: Nordkante (z=-80) ≈ 0 m")
var mid := SvmapDocument.terrain_height_at(doc2, 0.0, 0.0)
_check(absf(mid - 50.0) < 1.0, "Gradient: Mitte (z=0) ≈ 50 m (ist %.1f)" % mid)
_check(absf(SvmapDocument.terrain_height_at(doc2, 0.0, 80.0) - 100.0) < 0.5, "Gradient: Südkante (z=+80) ≈ 100 m")
_check(absf(SvmapDocument.terrain_height_at(doc2, 0.0, 160.0) - 100.0) < 0.5, "Hinter der Kante wird geclampt (100 m)")
_check(absf(SvmapDocument.terrain_height_at(doc2, 5000.0, -900.0) - 0.0) < 0.6, "Außerhalb-Query wird geclampt (keine Abstürze)")
func _test_mesh_shapes() -> void:
print("Testgruppe: Platzhalter-Meshes (Bibliothek/Kollider)")
var b := MeshShapes.make_placeholder("box", {"size": [2.0, 4.0, 6.0]}, 1.0)
_check(b is BoxMesh, "box → BoxMesh")
_check((b as BoxMesh).size == Vector3(2, 4, 6), "box: Größe 1:1 aus dem Kollider")
var scaled := MeshShapes.make_placeholder("box", {"size": [2.0, 4.0, 6.0]}, 2.0)
_check((scaled as BoxMesh).size.is_equal_approx(Vector3(4, 8, 12)), "box: scale=2 verdoppelt alle Achsen")
var sp := MeshShapes.make_placeholder("sphere", {"radius": 1.5}, 1.0)
_check(sp is SphereMesh and absf((sp as SphereMesh).radius - 1.5) < 0.001, "sphere → SphereMesh mit Radius")
var cyl := MeshShapes.make_placeholder("cylinder", {"radius": 0.4, "height": 12.0}, 1.0)
_check(cyl is CylinderMesh and absf((cyl as CylinderMesh).height - 12.0) < 0.001, "cylinder → CylinderMesh mit Höhe")
_check(absf(MeshShapes.half_height(b) - 2.0) < 0.001, "half_height(box 4 m) = 2 m — Fußpunkt-Konvention")
_check(absf(MeshShapes.half_height(sp) - 1.5) < 0.001, "half_height(sphere) = radius")
_check(absf(MeshShapes.half_height(cyl) - 6.0) < 0.001, "half_height(cylinder 12 m) = 6 m")
# ----------------------------- Objekt-Ops: Duplizieren/Höhe/Spiegeln -------
## (Karte t_7567a06f — pure Funktionen in ObjectOps, UI-Wiring wird im
## Desktop-Smoke verifiziert; hier Headless-Beweis der Logik + Undo-Roundtrips.)
func _test_object_ops() -> void:
print("Testgruppe: Objekt-Ops (Duplizieren · Anheben/Absenken · Spiegeln)")
var doc := SvmapDocument.create_new("Objekt-Ops-Test", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var objects: Array = doc["objects"]
# --- Duplizieren:stelle diagonal versetzt, Raster-Snap, Undo fähig ---
_place(hist, doc, "crate_wood", [10.0, 0.0, 10.0])
objects[0]["rot_y_deg"] = 30.0
var new_idx := ObjectOps.duplicate_object(doc, 0, true)
_check(new_idx == 1, "duplizieren: neuer Index 1")
_check(objects.size() == 2, "duplizieren: 1 → 2 Objekte")
_check(str(objects[1]["id"]) == "crate_wood", "duplizieren: gleiche ID")
_check(float(objects[1]["pos"][0]) == 11.0 and float(objects[1]["pos"][2]) == 11.0,
"duplizieren: diagonal +1 m versetzt (Raster-Snap 1 m)")
_check(absf(float(objects[1]["rot_y_deg"]) - 30.0) < 0.0001, "duplizieren: Drehung übernommen")
_check(float(objects[1]["pos"][1]) == float(objects[0]["pos"][1]), "duplizieren: Höhe übernommen")
# Kopie ohne Raster: Snap aus → exakt +1/+1 auch auf 0,5-m-Fraktionen.
objects[0]["pos"] = [10.4, 0.0, 10.4]
var idx2 := ObjectOps.duplicate_object(doc, 0, false)
_check(absf(float(objects[idx2]["pos"][0]) - 11.4) < 0.0001, "duplizieren ohne Snap: Fraktion bleibt (+1.0)")
# Fraktionsobjekt wieder aufräumen für die History-Tests:
objects.remove_at(idx2)
# Duplizieren + Undo → Kopie weg; Redo → wieder da (EIN Undo-Schritt).
var before := hist.snapshot(["objects"])
var idx3 := ObjectOps.duplicate_object(doc, 0, true)
hist.commit("Test: duplizieren", before, hist.snapshot(["objects"]))
_check(objects.size() == 3, "duplizieren über History: Kopie hängt an (3 = Original + Raster-Kopie + neue Kopie)")
hist.undo()
_check(objects.size() == 2, "duplizieren→undo: Kopie weg (2 bleiben)")
hist.redo()
_check(objects.size() == 3, "duplizieren→undo→redo: Kopie wieder da")
_check(absf(float(objects[2]["pos"][0]) - 11.0) < 0.0001, "duplizieren→redo: Position identisch")
# Ungültige Indizes schaden nichts:
_check(ObjectOps.duplicate_object(doc, 99, true) == -1, "duplizieren: falscher Index → -1")
_check(objects.size() == 3, "duplizieren: falscher Index ändert nichts")
# --- Anheben/Absenken: 0,25-m-Schritte, Deckel = bounds.max, Boden-Stopp ---
var h0: float = SvmapDocument.terrain_height_at(doc, 10.0, 10.0)
var up := ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M)
_check(absf(up - ObjectOps.LIFT_STEP_M) < 0.0001, "anheben: 0,25 m angewendet")
_check(absf(float(objects[0]["pos"][1]) - (h0 + 0.25)) < 0.0001, "anheben: y = Boden + 0,25 m")
ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M)
_check(absf(float(objects[0]["pos"][1]) - (h0 + 0.5)) < 0.0001, "anheben x2: 0,5 m")
var down := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M)
_check(absf(down + 0.25) < 0.0001, "absenken: −0,25 m")
var stop := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M * 10)
_check(absf(stop + 0.25) < 0.0001, "absenken: stoppt genau am Terrain (kein Begraben)")
_check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "absenken am Boden: 0.0 (UI erklärt statt commit)")
_check(absf(float(objects[0]["pos"][1]) - h0) < 0.001, "nach Absenken-Stopp: exakt Boden")
# Deckel: bounds.max.y = 80 (Neue-Map) → Großsprung wird auf 80 gedeckelt.
var cap := ObjectOps.lift_object(doc, 0, 1000.0)
_check(absf(float(objects[0]["pos"][1]) - 80.0) < 0.0001, "anheben: Höchstgrenze bounds.max.y (80 m)")
_check(absf(cap - (80.0 - h0)) < 0.0001, "anheben: angewendet = Rest bis zum Deckel")
_check(ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M) == 0.0, "anheben am Deckel: 0.0")
# Im Boden versteckt (nach Terrain-Formen): Absenken bewegt NICHT,
# Anheben hebt schrittweise aus dem Boden.
objects[0]["pos"] = [10.0, -3.0, 10.0]
_check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "verborgen: absenken = 0.0")
var out := ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M)
_check(absf(out - ObjectOps.LIFT_STEP_M) < 0.0001, "versteckt: anheben hebt schrittweise (0,25 m ab −3 m)")
_check(absf(float(objects[0]["pos"][1]) + 2.75) < 0.0001, "versteckt: y steigt vom Untergrund aus (kein Sprung-Ruckel)")
# lift_offset (Offset der UI-Anzeige): angehoben → +0,25, am Boden → 0.
objects[0]["pos"] = [10.0, h0 + 0.25, 10.0]
_check(absf(ObjectOps.lift_offset(doc, 0) - 0.25) < 0.001, "lift_offset: +0,25 m sichtbar")
objects[0]["pos"] = [10.0, h0, 10.0]
_check(absf(ObjectOps.lift_offset(doc, 0)) < 0.001, "lift_offset: am Boden = 0")
# Merge-Tags wie beim Drehen: mehrere Lift-Ticks = EIN History-Eintrag.
var entries_before := hist.entries.size()
for i in 5:
var b := hist.snapshot(["objects"])
var y2 := float(objects[0]["pos"][1])
objects[0]["pos"] = [10.0, y2 + ObjectOps.LIFT_STEP_M, 10.0]
hist.commit("Objekt angehoben", b, hist.snapshot(["objects"]), "lift:0")
_check(hist.entries.size() == entries_before + 1, "5 Lift-Ticks → EIN History-Eintrag (Merge)")
hist.undo()
_check(absf(float(objects[0]["pos"][1]) - h0) < 0.001, "Merge-Undo: alle Stufen auf einmal weg")
# --- Spiegeln: Rotation wird mitgespiegelt, Position bleibt ---
objects[0]["rot_y_deg"] = 30.0
ObjectOps.mirror_object(objects[0], "x")
_check(absf(float(objects[0]["rot_y_deg"]) - 330.0) < 0.0001, "spiegeln X: 30° → 330° (−30°)")
ObjectOps.mirror_object(objects[0], "x")
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln X: selbstinvers (330° → 30°)")
_check(float(objects[0]["pos"][0]) == 10.0, "spiegeln: Position unverändert (am Objekt, nicht quer über die Map)")
objects[0]["rot_y_deg"] = 30.0
ObjectOps.mirror_object(objects[0], "z")
_check(absf(float(objects[0]["rot_y_deg"]) - 150.0) < 0.0001, "spiegeln Z: 30° → 150° (180° − rot)")
ObjectOps.mirror_object(objects[0], "z")
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln Z: selbstinvers (150° → 30°)")
# Spiegeln über History: ein Eintrag, Undo stellt die alte Richtung her.
objects[0]["rot_y_deg"] = 30.0
var before_m := hist.snapshot(["objects"])
ObjectOps.mirror_object(doc["objects"][0], "x")
hist.commit("Test: spiegeln", before_m, hist.snapshot(["objects"]))
hist.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln→undo: alte Richtung zurück")
# --- svmap-Roundtrip: Höhe + gespiegelte Rotation bleiben 1:1 erhalten ---
objects[0]["rot_y_deg"] = 210.0
objects[0]["pos"] = [5.0, h0 + 1.75, -6.0]
var path := "/tmp/t7567_roundtrip.svmap"
var res := SvmapIO.save_to_file(path, doc)
_check(res["ok"], "svmap-Roundtrip: angesenkte/gesp. Map speichert (V1–V14 ok)")
var loaded := SvmapIO.load_from_file(path)
_check(loaded["ok"], "svmap-Roundtrip: laden ok (Prüfsumme intakt)")
if loaded["ok"]:
var lo: Array = loaded["data"]["objects"]
_check(lo.size() == objects.size(), "svmap-Roundtrip: Objekt-Anzahl gleich")
_check(absf(float(lo[0]["pos"][1]) - (h0 + 1.75)) < 0.0001, "svmap-Roundtrip: angehobene Höhe bleibt (KEIN Extra-Format-Feld — pos.y ist frei)")
_check(absf(float(lo[0]["rot_y_deg"]) - 210.0) < 0.0001, "svmap-Roundtrip: gespiegelte Rotation bleibt")
_check(str(lo[1]["id"]) == "crate_wood", "svmap-Roundtrip: Kopie bleibt")