scopedvr-map-editor/tests/test_bauen.gd
slyphax a76c3ecae7 Bauen-Modus neu: Bauteile als normale Objekte (bau_*), Snap an Kante/Zelle/Ebene, Gebäude = Gruppe
- BauKatalog: 12 Teile (Fundament, Boden, Wände mit Tür/Fenster/Scharte, Brüstung, Pfeiler, Giebel, Dächer, Treppe) auf Basis der CC0-Geometrie des Modular Builder 0.2.0, Wandhöhe 3 m
- BuildSnap: Wände auf Zellkanten, Böden in Zellmitte, Höhe aus Ebenen statt Klickhöhe, Ersetzen (Tür in Wand)
- Gebäude kopieren/auswählen/löschen im Bauen-Tab, Kopien bekommen eigene Gruppe
- Kollision serverkonsistent über Trimesh-Dateien (Türen/Fenster echte Löcher), tools/gen_bauteile.gd erzeugt GLB, Kollision, Registry-Fragment
- Alte gebaeude-Sektion wird beim Laden migriert, altes Bauen-Werkzeug entfernt, Resize behält Gruppennamen
- Tests: test_bauen (258 Checks), Playtest läuft Treppe/Tür

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:30:15 +01:00

408 lines
20 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): Bauen-Modus (Umbau 25.10. — Bauteile sind normale
## Bibliotheks-Objekte `bau_*`, Gebäude = Gruppe).
## Ausführen: godot --headless --path . --script res://tests/test_bauen.gd
## Exit 0 = alles grün. Läuft OHNE UI (BauKatalog/BuildSnap/BauOps/SvmapIO/
## ActionHistory) — Ghost/Panel prüft der Desktop-Smoke (--bauen-demo).
var failures := 0
var checks := 0
func _init() -> void:
_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_bauen.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 _run_tests() -> void:
_test_catalog()
_test_registry()
_test_meshes()
_test_openings()
_test_stairs()
_test_snap_cells_and_edges()
_test_snap_levels()
_test_replace_and_stack()
_test_ops_group_undo()
_test_duplicate_building()
_test_save_load()
_test_migration()
# ------------------------------------------------------------------ Tests ---
func _test_catalog() -> void:
print("Katalog")
_check(BauKatalog.KINDS.size() == 12, "12 Bauteile im Katalog")
var in_gruppen := {}
for g in BauKatalog.GRUPPEN:
for oid in (g["oids"] as Array):
in_gruppen[oid] = true
_check(BauKatalog.KINDS.has(oid), "Gruppe verweist auf bekanntes Teil %s" % oid)
_check(in_gruppen.size() == BauKatalog.KINDS.size(), "jedes Teil steht in genau einer Gruppe (%d)" % in_gruppen.size())
for oid in BauKatalog.oids():
_check(str(oid).begins_with("bau_"), "%s: ID mit bau_-Präfix" % oid)
_check(BauKatalog.label(oid).length() > 3, "%s: deutscher Name" % oid)
_check(["zelle", "kante", "ecke", "treppe"].has(BauKatalog.snap_art(oid)), "%s: Snap-Art" % oid)
func _test_registry() -> void:
print("Registry / Dateien")
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://library/objects.json"))
var lib: Dictionary = (parsed as Dictionary).get("objects", {})
for oid in BauKatalog.oids():
_check(lib.has(oid), "%s steht in objects.json" % oid)
var e: Dictionary = lib.get(oid, {})
var soll := BauKatalog.registry_eintrag(oid)
var gleich := int(e.get("poly", -1)) == int(soll["poly"]) and str(e.get("glb", "")) == str(soll["glb"]) \
and str(e.get("name_de", "")) == str(soll["name_de"]) \
and str((e.get("collision", {}) as Dictionary).get("quelle", "")) == str((soll["collision"] as Dictionary)["quelle"])
var sz: Array = (e.get("collision", {}) as Dictionary).get("size", [])
var sz_soll: Array = (soll["collision"] as Dictionary)["size"]
for k in 3:
gleich = gleich and sz.size() == 3 and absf(float(sz[k]) - float(sz_soll[k])) < 0.002
_check(gleich, "%s: Registry = Katalog (sonst tools/gen_bauteile.gd neu laufen lassen)" % oid)
_check(str(e.get("kategorie", "")) == "bauteile", "%s: Kategorie bauteile" % oid)
var col: Dictionary = e.get("collision", {})
_check(str(col.get("shape", "")) == "trimesh", "%s: Trimesh-Kollision" % oid)
var path := "res://library/%s" % str(col.get("quelle", ""))
_check(FileAccess.file_exists(path), "%s: Kollisionsdatei vorhanden" % oid)
var kj: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
var verts: Array = (kj as Dictionary).get("vertices", []) if kj is Dictionary else []
_check(verts.size() == int(e.get("poly", -1)) * 3, "%s: Kollisions-Dreiecke = poly (%d)" % [oid, int(e.get("poly", -1))])
_check(int(e.get("poly", 0)) <= 260, "%s: Quest-Budget (≤ 260 Dreiecke)" % oid)
func _test_meshes() -> void:
print("Meshes / Fußpunkt-Normalisierung")
for oid in BauKatalog.oids():
var m := BauKatalog.mesh(oid)
_check(m != null and m.get_faces().size() >= 36, "%s: Mesh vorhanden" % oid)
var aabb := m.get_aabb()
_check(absf(aabb.position.y) < 0.001, "%s: Unterkante y = 0" % oid)
_check(absf(aabb.position.x + aabb.size.x * 0.5) < 0.001 and absf(aabb.position.z + aabb.size.z * 0.5) < 0.001,
"%s: XZ zentriert" % oid)
var arr := m.surface_get_arrays(0)
var verts: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
var nrms: PackedVector3Array = arr[Mesh.ARRAY_NORMAL]
var falsch := 0
for t in range(0, verts.size() - 2, 3):
if (verts[t + 2] - verts[t]).cross(verts[t + 1] - verts[t]).dot(nrms[t]) < 0.0:
falsch += 1
_check(falsch == 0, "%s: Wicklung passt zur Normale (kein Culling-Fehler)" % oid)
var mat := m.surface_get_material(0) as StandardMaterial3D
_check(mat != null and mat.albedo_color.a > 0.99 and mat.albedo_color != Color.WHITE, "%s: Grundfarbe im Material" % oid)
var w := BauKatalog.groesse("bau_wand")
_check(absf(w.x - 2.0) < 0.01 and absf(w.y - 3.0) < 0.01 and absf(w.z - 0.2) < 0.01, "Wand 2×3×0,2 m")
var f := BauKatalog.groesse("bau_fundament")
_check(absf(f.x - 2.0) < 0.01 and absf(f.y - 0.2) < 0.01 and absf(f.z - 2.0) < 0.01, "Fundament 2×0,2×2 m")
var b := BauKatalog.groesse("bau_bruestung")
_check(absf(b.y - 1.1) < 0.01, "Brüstung 1,1 m hoch")
## Strahl durch das Kollisions-Mesh: true = trifft (Wand), false = frei (Öffnung).
func _trifft(oid: String, origin: Vector3, dir: Vector3) -> bool:
var faces := BauKatalog.mesh(oid).get_faces()
for i in int(faces.size() / 3):
if SelectionMath.ray_tri(origin, dir, faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2]) >= 0.0:
return true
return false
func _test_openings() -> void:
print("Türen / Fenster / Scharte sind echte Löcher")
var quer := Vector3(0.0, 0.0, 1.0)
# Tür: Mitte (x=0) frei von Boden bis 2,2 m, daneben (x=0.8) massiv, über der Tür (y=2.6) massiv.
_check(not _trifft("bau_wand_tuer", Vector3(0.0, 0.5, -1.0), quer), "Tür: Durchgang knapp über dem Boden frei")
_check(not _trifft("bau_wand_tuer", Vector3(0.0, 2.0, -1.0), quer), "Tür: Durchgang auf 2,0 m frei")
_check(_trifft("bau_wand_tuer", Vector3(0.0, 2.6, -1.0), quer), "Tür: über der Tür ist Wand")
_check(_trifft("bau_wand_tuer", Vector3(0.8, 1.0, -1.0), quer), "Tür: neben dem Rahmen ist Wand")
# Fenster: 1,1–2,3 m offen.
_check(not _trifft("bau_wand_fenster", Vector3(0.0, 1.7, -1.0), quer), "Fenster: Öffnung auf 1,7 m frei")
_check(_trifft("bau_wand_fenster", Vector3(0.0, 0.5, -1.0), quer), "Fenster: unter der Brüstung ist Wand")
_check(_trifft("bau_wand_fenster", Vector3(0.0, 2.6, -1.0), quer), "Fenster: darüber ist Wand")
# Schießscharte: Schlitz auf 1,3–1,7 m.
_check(not _trifft("bau_wand_scharte", Vector3(0.0, 1.5, -1.0), quer), "Scharte: Schlitz auf 1,5 m frei")
_check(_trifft("bau_wand_scharte", Vector3(0.0, 1.0, -1.0), quer), "Scharte: darunter Wand")
_check(_trifft("bau_wand", Vector3(0.0, 1.5, -1.0), quer), "Volle Wand ist dicht")
func _test_stairs() -> void:
print("Treppe begehbar")
var faces := BauKatalog.mesh("bau_treppe").get_faces()
var g := BauKatalog.groesse("bau_treppe")
_check(absf(g.x - 4.0) < 0.01 and absf(g.y - 3.0) < 0.01 and absf(g.z - 2.0) < 0.01, "Treppe 4×3×2 m")
# Oberste Höhe unter x entlang der Mittellinie (z=0): steigt monoton, Stufen ≤ 22 cm.
var letzte := 0.0
var max_sprung := 0.0
var steigt := true
for k in range(1, 40):
var x := -2.0 + 4.0 * float(k) / 40.0
var top := -1.0
for i in int(faces.size() / 3):
var t := SelectionMath.ray_tri(Vector3(x, 10.0, 0.0), Vector3.DOWN,
faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2])
if t >= 0.0:
top = maxf(top, 10.0 - t)
if top < letzte - 0.001:
steigt = false
max_sprung = maxf(max_sprung, top - letzte)
letzte = top
_check(steigt, "Lauffläche steigt monoton")
_check(max_sprung <= 0.22, "Stufenhöhe ≤ 22 cm (größter Sprung %.3f)" % max_sprung)
_check(letzte > 2.7, "Oben ≥ 2,7 m erreicht (%.2f)" % letzte)
func _flach(h: float) -> Callable:
return func(_x: float, _z: float) -> float: return h
func _test_snap_cells_and_edges() -> void:
print("Snap: Zelle / Kante / Ecke / Treppe")
var kein: Array = []
var s := BuildSnap.berechne("bau_fundament", Vector3(0.3, 0.0, 0.4), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(1.0, 0.0, 1.0), "Fundament: Zellmitte (1, 1)")
s = BuildSnap.berechne("bau_fundament", Vector3(-0.3, 0.0, -2.9), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(-1.0, 0.0, -3.0), "Fundament: negative Zelle (−1, −3)")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.1), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(1.0, 0.0, 0.0) and float(s["rot"]) == 0.0, "Wand: Kante entlang X, Drehung 0")
s = BuildSnap.berechne("bau_wand", Vector3(0.1, 0.0, 1.0), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(0.0, 0.0, 1.0) and float(s["rot"]) == 90.0, "Wand: Kante entlang Z, Drehung 90")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.1), 90.0, kein, _flach(0.0))
_check(float(s["rot"]) == 180.0 and s["pos"] == Vector3(1.0, 0.0, 0.0), "R dreht die Wand um (180°), bleibt auf der Kante")
s = BuildSnap.berechne("bau_wand", Vector3(0.1, 0.0, 1.0), 90.0, kein, _flach(0.0))
_check(float(s["rot"]) == 270.0, "R: Z-Kante 90° → 270°")
s = BuildSnap.berechne("bau_pfeiler", Vector3(1.8, 0.0, 2.3), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(2.0, 0.0, 2.0), "Pfeiler: nächste Zellecke")
s = BuildSnap.berechne("bau_treppe", Vector3(3.3, 0.0, 1.2), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(4.0, 0.0, 1.0) and float(s["rot"]) == 0.0, "Treppe: 4×2-Fläche entlang X")
s = BuildSnap.berechne("bau_treppe", Vector3(1.2, 0.0, 3.3), 90.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(1.0, 0.0, 4.0) and float(s["rot"]) == 90.0, "Treppe: gedreht 90° → entlang Z")
# Alle Fälle: gerasterte Mitten (Wände auf Kanten = ein gerader + ein ungerader Meter)
for k in range(60):
var p := Vector3(-9.0 + 0.31 * k, 0.0, 7.0 - 0.27 * k)
var w := BuildSnap.berechne("bau_wand", p, 0.0, kein, _flach(0.0))
var wp: Vector3 = w["pos"]
var gerade_x := int(roundf(wp.x)) % 2 == 0
var gerade_z := int(roundf(wp.z)) % 2 == 0
if not (gerade_x != gerade_z):
_check(false, "Wand-Mitte liegt auf Kantenmitte (%s)" % str(wp))
return
_check(true, "60 Zufallsklicks: Wandmitte liegt immer auf einer Kantenmitte")
# Gelände: höchste Höhe unter der Grundfläche zählt
s = BuildSnap.berechne("bau_fundament", Vector3(1.0, 5.0, 1.0), 0.0, kein, _flach(5.0))
_check(absf(float(s["ebene"]) - 5.0) < 0.001, "Gelände-Höhe wird die Ebene (5,0)")
func _teil(i: int, oid: String, pos: Vector3, rot: float) -> Dictionary:
return {"i": i, "oid": oid, "pos": pos, "rot": rot}
func _test_snap_levels() -> void:
print("Snap: Ebenen statt Klickhöhe")
var teile: Array = [
_teil(0, "bau_fundament", Vector3(1, 0, 1), 0.0),
_teil(1, "bau_wand", Vector3(1, 0.2, 0), 0.0),
_teil(2, "bau_wand", Vector3(2, 0.2, 1), 90.0),
]
# Klick mitten auf die Seite der Wand (y=1,5): Boden darf NICHT in der Luft landen
var s := BuildSnap.berechne("bau_boden", Vector3(0.9, 1.5, 0.6), 0.0, teile, _flach(0.0))
_check(absf(float(s["ebene"]) - 0.2) < 0.001, "Boden nach Klick auf Wandseite liegt auf dem Fundament (0,2), nicht bei 1,5")
# Klick auf die Oberkante der Wand: nächste Geschoss-Ebene
s = BuildSnap.berechne("bau_boden", Vector3(1.0, 3.2, 0.1), 0.0, teile, _flach(0.0), true, Vector3(0, -0.5, 1))
_check(absf(float(s["ebene"]) - 3.2) < 0.001, "Boden nach Klick auf Wandoberkante = Decke auf 3,2")
_check(s["pos"] == Vector3(1.0, 3.2, -1.0), "…und in der Zelle auf der Kameraseite (1, −1)")
# Wand auf Wand (Stapeln)
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 3.2, 0.0), 0.0, teile, _flach(0.0))
_check(absf(float(s["ebene"]) - 3.2) < 0.001 and int(s["ersetze"]) == -1, "Wand stapelt sich auf die Oberkante")
# Ein Dach ist kein Träger: nichts darf auf der Dachspitze landen
teile.append(_teil(3, "bau_dach_giebel", Vector3(1, 3.2, 1), 0.0))
s = BuildSnap.berechne("bau_boden", Vector3(1.0, 4.2, 1.0), 0.0, teile, _flach(0.0))
_check(float(s["ebene"]) <= 3.2 + 0.001, "Dachspitze (4,2) ist keine Ebene (Ebene %.2f)" % float(s["ebene"]))
func _test_replace_and_stack() -> void:
print("Ersetzen / doppelt")
var teile: Array = [
_teil(0, "bau_fundament", Vector3(1, 0, 1), 0.0),
_teil(1, "bau_wand", Vector3(1, 0.2, 0), 0.0),
]
var s := BuildSnap.berechne("bau_wand_tuer", Vector3(1.0, 1.5, 0.1), 0.0, teile, _flach(0.0))
_check(bool(s["ok"]) and int(s["ersetze"]) == 1, "Tür in vorhandene Wand → ersetzt Teil 1")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 1.5, 0.1), 0.0, teile, _flach(0.0))
_check(not bool(s["ok"]) and str(s["grund"]).contains("schon"), "gleiche Wand nochmal → abgelehnt mit Erklärung")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 1.5, 0.1), 90.0, teile, _flach(0.0))
_check(bool(s["ok"]) and int(s["ersetze"]) == 1, "Wand von der anderen Seite (R) ersetzt = dreht um")
s = BuildSnap.berechne("bau_wand_tuer", Vector3(3.0, 0.0, 0.1), 0.0, teile, _flach(0.0))
_check(bool(s["ok"]) and int(s["ersetze"]) == -1, "Nachbarkante bleibt frei (kein Ersetzen)")
func _neue_doc() -> Dictionary:
return SvmapDocument.create_new("Bautest", 256.0, "mittag")
func _test_ops_group_undo() -> void:
print("BauOps: Gruppe, Undo, Ersetzen")
var doc := _neue_doc()
var hist := ActionHistory.new()
hist.begin(doc)
var snap := BuildSnap.berechne("bau_fundament", Vector3(1, 0, 1), 0.0, [], _flach(0.0))
var before := hist.snapshot(["objects", "group_names"])
if not before.has("group_names"):
before["group_names"] = null
var gid := BauOps.gruppe_neu(doc)
_check(GroupState.group_name(doc, gid) == "Gebäude 1", "Gebäude-Gruppe heißt „Gebäude 1“")
var r := BauOps.setze_teil(doc, "bau_fundament", snap, gid, SymmetryCore.Mode.OFF)
hist.commit("Fundament bauen", before, hist.snapshot(["objects", "group_names"]))
var objs: Array = doc["objects"]
var n0 := objs.size()
_check(n0 >= 1 and int(r["index"]) == n0 - 1, "Teil als Objekt angehängt")
_check(GroupOps.group_id(objs[n0 - 1]) == gid, "Teil gehört zum Gebäude (Gruppe %d)" % gid)
_check(BauOps.teile_zahl(doc) == 1, "teile_zahl = 1")
# Wand setzen, dann Tür ersetzt dieselbe Stelle (Index bleibt)
var teile := BuildSnap.sammle_teile(doc)
var ws := BuildSnap.berechne("bau_wand", Vector3(1, 0.2, 0.05), 0.0, teile, _flach(0.0))
var before2 := hist.snapshot(["objects", "group_names"])
var wr := BauOps.setze_teil(doc, "bau_wand", ws, gid, SymmetryCore.Mode.OFF)
hist.commit("Wand bauen", before2, hist.snapshot(["objects", "group_names"]))
var widx := int(wr["index"])
teile = BuildSnap.sammle_teile(doc)
var ts := BuildSnap.berechne("bau_wand_tuer", Vector3(1, 1.5, 0.05), 0.0, teile, _flach(0.0))
_check(int(ts["ersetze"]) == widx, "Tür-Klick findet die Wand")
var before3 := hist.snapshot(["objects", "group_names"])
var tr := BauOps.setze_teil(doc, "bau_wand_tuer", ts, gid, SymmetryCore.Mode.OFF)
hist.commit("Tür einsetzen", before3, hist.snapshot(["objects", "group_names"]))
_check(bool(tr["ersetzt"]) and (doc["objects"] as Array).size() == n0 + 1, "Ersetzen fügt kein zusätzliches Objekt hinzu")
_check(str((doc["objects"] as Array)[widx]["id"]) == "bau_wand_tuer", "Objekt ist jetzt die Tür-Wand")
_check(GroupOps.group_id((doc["objects"] as Array)[widx]) == gid, "Gruppe bleibt beim Ersetzen")
hist.undo()
_check(str((doc["objects"] as Array)[widx]["id"]) == "bau_wand", "Undo: wieder normale Wand")
hist.undo()
hist.undo()
_check((doc["objects"] as Array).is_empty(), "Undo ×3: alles weg")
_check(not doc.has("group_names") or (doc["group_names"] as Dictionary).is_empty(), "Undo entfernt auch den Gebäude-Namen")
hist.redo()
hist.redo()
hist.redo()
_check(BauOps.teile_zahl(doc) == 2, "Redo ×3 stellt Fundament + Tür-Wand wieder her")
# Gebäude-Info
var geb := BauOps.gebaeude_gruppen(doc)
_check(geb.size() == 1 and (geb[0]["indizes"] as Array).size() == 2, "gebaeude_gruppen: 1 Gebäude mit 2 Teilen")
_check(BauOps.gebaeude_von(doc, 0).size() == 2, "gebaeude_von liefert alle Mitglieder")
# Haus komplett
var d2 := _neue_doc()
var gid2 := BauOps.haus_demo(d2, null, Vector3(4.0, 0.0, 4.0))
_check(BauOps.teile_zahl(d2) >= 16, "Demo-Haus: %d Teile über die echte Snap-Logik" % BauOps.teile_zahl(d2))
_check(BauOps.gebaeude_von(d2, 0).size() == BauOps.teile_zahl(d2), "alle Teile des Hauses in einer Gruppe (%d)" % gid2)
var doppelt := {}
var hat_doppelte := false
for t in BuildSnap.sammle_teile(d2):
var key := "%s|%s|%s" % [str((t["pos"] as Vector3).snapped(Vector3(0.01, 0.01, 0.01))), str(roundf(float(t["rot"]))), BauKatalog.snap_art(str(t["oid"]))]
if doppelt.has(key):
hat_doppelte = true
doppelt[key] = true
_check(not hat_doppelte, "Haus hat keine doppelt gesetzten Teile")
func _test_duplicate_building() -> void:
print("Gebäude kopieren / löschen")
var doc := _neue_doc()
var gid := BauOps.haus_demo(doc, null, Vector3(4.0, 0.0, 4.0))
var anzahl := (doc["objects"] as Array).size()
var alle := BauOps.gebaeude_von(doc, 0)
var neue := ObjectOps.duplicate_objects(doc, alle, false, 8.0)
_check(neue.size() == anzahl, "alle %d Teile kopiert" % anzahl)
var gid_kopie := GroupOps.group_id((doc["objects"] as Array)[int(neue[0])])
_check(gid_kopie != gid and gid_kopie != GroupOps.UNGROUPED, "Kopie bekommt eine EIGENE Gruppe (%d ≠ %d)" % [gid_kopie, gid])
_check(GroupOps.members_of(doc, gid).size() == anzahl and GroupOps.members_of(doc, gid_kopie).size() == anzahl,
"Original und Kopie haben je %d Teile" % anzahl)
_check(GroupState.display_name(doc, gid_kopie).contains("Kopie"), "Kopie heißt „… (Kopie)“")
_check(BauOps.gebaeude_gruppen(doc).size() == 2, "zwei Gebäude in der Map")
var o0: Dictionary = (doc["objects"] as Array)[0]
var k0: Dictionary = (doc["objects"] as Array)[int(neue[0])]
_check(absf(float(k0["pos"][0]) - float(o0["pos"][0]) - 8.0) < 0.001, "Kopie um 8 m versetzt")
# Löschen: Gebäude ohne Teile verliert den Namen
var objects: Array = doc["objects"]
var del := GroupOps.members_of(doc, gid_kopie)
del.sort()
del.reverse()
for i in del:
objects.remove_at(i)
GroupState.prune_orphans(doc)
_check(BauOps.gebaeude_gruppen(doc).size() == 1, "nach Löschen: 1 Gebäude")
_check(not GroupState.names_dict(doc).has(str(gid_kopie)), "Gruppenname der gelöschten Kopie ist weg")
func _test_save_load() -> void:
print("Speichern / Laden")
var doc := _neue_doc()
BauOps.haus_demo(doc, null, Vector3(4.0, 0.0, 4.0))
var n := (doc["objects"] as Array).size()
var res := SvmapIO.save_to_file("user://test_bauen.svmap", doc)
_check(bool(res["ok"]), "Map mit Haus speichern (Validierung V1–V17 grün)")
var back := SvmapIO.load_from_file("user://test_bauen.svmap")
_check(bool(back["ok"]), "…und wieder laden")
if bool(back["ok"]):
var d: Dictionary = back["data"]
_check((d["objects"] as Array).size() == n, "alle %d Teile nach dem Laden da" % n)
_check(BauOps.gebaeude_gruppen(d).size() == 1, "Gebäude-Gruppe erhalten")
_check(GroupState.display_name(d, 0).begins_with("Gebäude"), "Gebäude-Name erhalten")
_check(not d.has("gebaeude"), "keine alte gebaeude-Sektion mehr")
# Poly-Rechnung: Bauteile zählen im Budget wie jedes Objekt
var manifest: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://library/objects.json"))
var poly := SvmapDocument.poly_usage(doc, manifest as Dictionary)
_check(absf(poly - float(BauOps.dreiecke_gesamt(doc))) < 0.5, "Poly-Budget = Summe der Bauteil-Dreiecke (%d)" % int(poly))
func _test_migration() -> void:
print("Migration alte gebaeude-Sektion")
var doc := _neue_doc()
doc["gebaeude"] = [
{"id": "fundament", "pos": [3.0, 0.0, 3.0], "rot": 0.0, "punkte": []},
{"id": "wand", "pos": [2.0, 0.2, 2.0], "rot": 0.0, "punkte": []},
{"id": "tuer", "pos": [4.0, 0.2, 4.0], "rot": 90.0, "punkte": []},
{"id": "unbekannt", "pos": [0.0, 0.0, 0.0], "rot": 0.0},
]
var n := BauOps.migriere_gebaeude(doc)
_check(n == 3, "3 bekannte Teile übernommen (unbekannte ignoriert)")
_check(not doc.has("gebaeude"), "alte Sektion entfernt")
var objs: Array = doc["objects"]
var wand: Dictionary = {}
for o in objs:
if str(o["id"]) == "bau_wand":
wand = o
_check(not wand.is_empty() and absf(float(wand["pos"][0]) - 3.0) < 0.001 and absf(float(wand["pos"][2]) - 2.0) < 0.001,
"Wand-Ursprung wandert in die Wandmitte (2,2 → 3,2)")
var tuer: Dictionary = {}
for o in objs:
if str(o["id"]) == "bau_wand_tuer":
tuer = o
_check(not tuer.is_empty() and absf(float(tuer["pos"][0]) - 4.0) < 0.001 and absf(float(tuer["pos"][2]) - 3.0) < 0.001,
"gedrehte Tür-Wand (90°): Mitte (4, 3)")
_check(BauOps.gebaeude_gruppen(doc).size() == 1, "alle Teile in einem Gebäude")
_check(BauOps.migriere_gebaeude(doc) == 0, "zweiter Lauf tut nichts")
var leer := _neue_doc()
leer["gebaeude"] = []
_check(BauOps.migriere_gebaeude(leer) == 0 and not leer.has("gebaeude"), "leere Sektion wird still entfernt")