scopedvr-map-editor/tests/test_bauen.gd

507 lines
28 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_dach_teile()
_test_geschoss_fix()
_test_wand_ueberstand()
_test_ops_group_undo()
_test_duplicate_building()
_test_save_load()
_test_migration()
# ------------------------------------------------------------------ Tests ---
func _test_catalog() -> void:
print("Katalog")
_check(BauKatalog.KINDS.size() == 23, "23 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() >= 18, "%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.09), 0.0, kein, _flach(0.0))
_check(_nah(s["pos"], Vector3(1.0, 0.0, 0.09)) and float(s["rot"]) == 0.0, "Wand: Kante entlang X, Drehung 0, 9 cm in die Zelle gerückt (1 cm Überstand)")
s = BuildSnap.berechne("bau_wand", Vector3(0.09, 0.0, 1.0), 0.0, kein, _flach(0.0))
_check(_nah(s["pos"], Vector3(0.09, 0.0, 1.0)) and float(s["rot"]) == 90.0, "Wand: Kante entlang Z, Drehung 90, 9 cm in die Zelle gerückt (1 cm Überstand)")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.09), 90.0, kein, _flach(0.0))
_check(float(s["rot"]) == 180.0 and _nah(s["pos"], Vector3(1.0, 0.0, 0.09)), "R dreht die Wand um (180°), bleibt an ihrer Stelle")
s = BuildSnap.berechne("bau_wand", Vector3(0.09, 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: Wandmitte = 10 cm neben einer Kantenmitte (Wand liegt in der Zelle)
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 ex := absf(absf(wp.x - roundf(wp.x / 2.0) * 2.0) - 0.09) < 0.001 # X nahe einer geraden Linie
var ez := absf(absf(wp.z - roundf(wp.z / 2.0) * 2.0) - 0.09) < 0.001
var ox := absf(fposmod(wp.x, 2.0) - 1.0) < 0.001 # X auf Zellmitte
var oz := absf(fposmod(wp.z, 2.0) - 1.0) < 0.001
if not ((ex and oz) or (ez and ox)):
_check(false, "Wand-Mitte liegt in der Zelle an der Kante (%s)" % str(wp))
return
# und zwar in der Zelle, in der die Maus war (Außenseite bündig auf der Kante)
if (ez and absf(wp.z - p.z) > 1.01) or (ex and absf(wp.x - p.x) > 1.01):
_check(false, "Wand liegt auf der Maus-Seite (%s zu %s)" % [str(wp), str(p)])
return
_check(true, "60 Zufallsklicks: Wand liegt immer in der Zelle der Maus, bündig an einer Kante")
# Ecke: zwei Wände in einer Zellecke schließen sich ohne Lücke (äußere Ecke voll)
var wa := BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.3), 0.0, kein, _flach(0.0))
var wb := BuildSnap.berechne("bau_wand", Vector3(0.3, 0.0, 1.0), 0.0, kein, _flach(0.0))
var ra := BuildSnap.teil_rect({"oid": "bau_wand", "pos": wa["pos"], "rot": wa["rot"]})
var rb := BuildSnap.teil_rect({"oid": "bau_wand", "pos": wb["pos"], "rot": wb["rot"]})
_check(ra.has_point(Vector2(0.01, 0.01)) and rb.has_point(Vector2(0.01, 0.01)), "Ecke: äußere Ecke (0,0) ist von BEIDEN Wänden gedeckt")
_check(absf(ra.position.x) < 0.0111 and absf(ra.position.y) < 0.0111 and absf(rb.position.x) < 0.0111 and absf(rb.position.y) < 0.0111,
"Ecke: beide Wände beginnen bündig an der Außenkante (0,0)")
# Große Teile: Zellzahl bestimmt die Rasterung
s = BuildSnap.berechne("bau_dach_giebel_4", Vector3(3.2, 0.0, 3.2), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(4.0, 0.0, 4.0), "Satteldach 4×4: Mitte auf Zellecke (4, 4)")
s = BuildSnap.berechne("bau_dach_pyramide_6", Vector3(3.2, 0.0, 3.2), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(3.0, 0.0, 3.0), "Pyramidendach 6×6: Mitte einer Zelle (3, 3)")
s = BuildSnap.berechne("bau_giebelwand_4", Vector3(3.0, 0.0, 0.5), 0.0, kein, _flach(0.0))
_check(_nah(s["pos"], Vector3(4.0, 0.0, 0.09)), "Giebelwand 4 m: Mitte auf gerader Linie (4), in die Zelle gerückt")
# 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, 0.1), 0.0),
_teil(2, "bau_wand", Vector3(1.9, 0, 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.0, 0.1), 0.0, teile, _flach(0.0), true, Vector3(0, -0.5, 1))
_check(absf(float(s["ebene"]) - 3.0) < 0.001, "Boden nach Klick auf Wandoberkante = Decke auf 3,0")
_check(s["pos"] == Vector3(1.0, 3.0, 1.0), "…und in der Zelle, in der die Wand steht (1, 1) — die Decke liegt auf der Wand")
s = BuildSnap.berechne("bau_boden", Vector3(1.0, 1.0, 1.0), 0.0, [_teil(9, "bau_pfeiler", Vector3(2, 0, 2), 0.0)], _flach(0.0), true, Vector3(0, -0.5, 1))
_check(s["pos"].z == 1.0, "Treffer auf Nicht-Wand: Zelle der Kameraseite (Pfeiler an der Ecke)")
# Wand auf Wand (Stapeln): genau über der Wand darunter
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 3.0, 0.1), 0.0, teile, _flach(0.0), true)
_check(absf(float(s["ebene"]) - 3.0) < 0.001 and int(s["ersetze"]) == -1, "Wand stapelt sich auf die Oberkante (3,0)")
_check(s["pos"] == Vector3(1.0, 3.0, 0.1), "…und exakt über der Wand darunter (gleiche Lage)")
# Test-Befund 25.10.: obere Wände sitzen gleich hoch, egal ob auf der Nachbarwand
# eine Decke liegt oder nicht (Decke liegt AUF der Wand, Wände zählen ab Deckenunterkante).
var mit_decke: Array = teile.duplicate()
mit_decke.append(_teil(3, "bau_boden", Vector3(1, 3.0, -1), 0.0)) # Decke über der Wand vorn (Zelle −1)
var ohne := BuildSnap.berechne("bau_wand", Vector3(1.0, 3.0, 0.1), 0.0, mit_decke, _flach(0.0), true)
var mit := BuildSnap.berechne("bau_wand", Vector3(1.0, 3.2, -0.1), 0.0, mit_decke, _flach(0.0), true)
_check(absf(float(ohne["ebene"]) - 3.0) < 0.001, "Obergeschoss-Wand ohne Decke: Höhe 3,0 (%.2f)" % float(ohne["ebene"]))
_check(absf(float(mit["ebene"]) - 3.0) < 0.001, "Obergeschoss-Wand AUF der Decke: ebenfalls 3,0 (%.2f)" % float(mit["ebene"]))
# Ein Dach ist kein Träger: nichts darf auf der Dachspitze landen
teile.append(_teil(4, "bau_dach_giebel", Vector3(1, 3.0, 1), 0.0))
s = BuildSnap.berechne("bau_boden", Vector3(1.0, 4.0, 1.0), 0.0, teile, _flach(0.0))
_check(float(s["ebene"]) <= 3.0 + 0.001, "Dachspitze (4,0) 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, 0.1), 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")
# Klick direkt auf die Wand (Objekt-Treffer) übernimmt ihre Lage, auch von der anderen Seite
s = BuildSnap.berechne("bau_wand_tuer", Vector3(1.0, 1.5, 0.0), 0.0, teile, _flach(0.0), true, Vector3(0, 0, 1))
_check(bool(s["ok"]) and int(s["ersetze"]) == 1, "Tür: Klick auf die Außenseite der Wand ersetzt dieselbe Wand")
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.15), 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.15), 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) >= 13, "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")
func _nah(a: Vector3, b: Vector3) -> bool:
return a.distance_to(b) < 0.0005
## Dach-Einzelteile: massiv (geschlossen, Volumen > 0, Normalen nach außen), 45°.
func _test_dach_teile() -> void:
print("Dach aus Einzelteilen")
for oid in ["bau_dach_schraege", "bau_dach_ecke_aussen", "bau_dach_ecke_innen", "bau_dach_plateau", "bau_dach_giebel_ende"]:
var m := BauKatalog.mesh(oid)
var f := m.get_faces()
var vol := 0.0
for i in range(0, f.size() - 2, 3):
vol += f[i].dot(f[i + 1].cross(f[i + 2])) / 6.0
_check(vol < -0.05, "%s: geschlossener Körper, Volumen %.2f m³ (Godot: Vorderseite im Uhrzeigersinn → negativ = nach außen)" % [oid, vol])
# jede Kante kommt in genau zwei Dreiecken vor (wasserdicht)
var kanten := {}
for i in range(0, f.size() - 2, 3):
for k in 3:
var a := f[i + k]
var b := f[i + (k + 1) % 3]
var key := "%s|%s" % [str(a.snapped(Vector3.ONE * 0.001)), str(b.snapped(Vector3.ONE * 0.001))]
kanten[key] = int(kanten.get(key, 0)) + 1
var offen := 0
for key in kanten:
var teile: PackedStringArray = (key as String).split("|")
if not kanten.has("%s|%s" % [teile[1], teile[0]]):
offen += 1
_check(offen == 0, "%s: wasserdicht (keine offene Kante, %d)" % [oid, offen])
var sch := BauKatalog.groesse("bau_dach_schraege")
_check(absf(sch.x - 2.0) < 0.01 and absf(sch.y - 2.0) < 0.01 and absf(sch.z - 2.0) < 0.01, "Schräge 2×2 m Grundfläche, 2 m hoch = 45°")
# Haus 4 m breit aus zwei Schrägen + Gegenschräge: Firsthöhe 2 m über Wandkrone
var kein: Array = []
var s := BuildSnap.berechne("bau_dach_schraege", Vector3(1.2, 3.0, 1.1), 90.0, kein, _flach(0.0))
_check(bool(s["ok"]) and float(s["rot"]) == 90.0, "Schräge lässt sich in 90°-Schritten drehen")
## Geschoss-Wahl: feste Höhe, unabhängig von Wänden darunter (Decke mitten im Raum).
func _test_geschoss_fix() -> void:
print("Geschoss fest (Decke mitten im Raum)")
var kein: Array = []
# Raum 6×6: nur Außenwände, in der Mitte steht nichts. Klick auf dem Boden (y=0).
var s := BuildSnap.berechne("bau_boden", Vector3(3.0, 0.0, 3.0), 0.0, kein, _flach(0.0), false, Vector3.ZERO, 3.0)
_check(float(s["ebene"]) == 3.0 and _nah(s["pos"], Vector3(3.0, 3.0, 3.0)), "Decke auf 3 m mitten im Raum (kein Teil darunter)")
var ohne := BuildSnap.berechne("bau_boden", Vector3(3.0, 0.0, 3.0), 0.0, kein, _flach(0.0))
_check(float(ohne["ebene"]) == 0.0, "Automatik bleibt wie bisher (Gelände)")
var teile := [{"i": 0, "oid": "bau_fundament", "pos": Vector3(1, 0.5, 1), "rot": 0.0}, {"i": 1, "oid": "bau_wand", "pos": Vector3(1, 0.5, 0.09), "rot": 0.0}]
_check(is_equal_approx(BuildSnap.geschoss_basis(teile, Vector2(5, 5), _flach(0.0)), 0.5), "Basis = tiefstes Teil in der Nähe (0,5 m)")
_check(is_equal_approx(BuildSnap.geschoss_basis([], Vector2(5, 5), _flach(2.0)), 2.0), "Basis ohne Teile = Gelände")
## Wand-Außenseite steht 1 cm vor der Plattenkante (kein Z-Fighting durch deckungsgleiche Flächen).
func _test_wand_ueberstand() -> void:
print("Wand ragt 1 cm über die Plattenkante")
var kein: Array = []
var w := BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.3), 0.0, kein, _flach(0.0))
var b := BuildSnap.berechne("bau_fundament", Vector3(1.0, 0.0, 0.3), 0.0, kein, _flach(0.0))
var wr := BuildSnap.teil_rect({"oid": "bau_wand", "pos": w["pos"], "rot": w["rot"]})
var br := BuildSnap.teil_rect({"oid": "bau_fundament", "pos": b["pos"], "rot": b["rot"]})
_check(wr.position.y < br.position.y - 0.005, "Wand-Außenfläche liegt vor der Plattenkante (%.3f < %.3f)" % [wr.position.y, br.position.y])